Payment processing and subscription management integration patterns. Covers Stripe, payment lifecycle, webhooks, dunning, and billing system architecture.
Provides patterns for integrating payment processing and subscription management into SaaS applications. Use when building billing systems, handling Stripe webhooks, or designing subscription lifecycles with dunning and usage-based billing.
/plugin marketplace add melodic-software/claude-code-plugins/plugin install saas-patterns@melodic-softwareThis skill is limited to using the following tools:
Patterns for integrating payment processing and subscription management into SaaS applications.
Use this skill when:
Billing integration connects your application to payment processors (Stripe, etc.) for subscription management, payment collection, and revenue operations.
+------------------------------------------------------------------+
| SaaS Billing Architecture |
+------------------------------------------------------------------+
| |
| +----------------+ +-----------------+ +----------------+ |
| | Your App |--->| Billing Service |--->| Stripe/Payment | |
| | (Subscriptions)| | (Orchestration) | | Provider | |
| +----------------+ +-----------------+ +----------------+ |
| | | | |
| v v v |
| +----------------+ +-----------------+ +----------------+ |
| | Entitlements | | Usage Metering | | Webhooks | |
| | (Feature Flags)| | (Consumption) | | (Events) | |
| +----------------+ +-----------------+ +----------------+ |
| |
+------------------------------------------------------------------+
Pattern: Stripe owns subscription state
Your App: Syncs state via webhooks
Benefit: Stripe handles complexity (proration, dunning, taxes)
Caveat: Need reliable webhook processing
Pattern: Your DB + Stripe in sync
Your App: Owns business logic, uses Stripe for payments
Benefit: More control, faster reads
Caveat: Must keep in sync (eventual consistency)
// Your internal representation (synced from Stripe)
public sealed record Subscription
{
public required Guid TenantId { get; init; }
public required string StripeSubscriptionId { get; init; }
public required string StripePriceId { get; init; }
public required SubscriptionStatus Status { get; init; }
public required DateTimeOffset CurrentPeriodStart { get; init; }
public required DateTimeOffset CurrentPeriodEnd { get; init; }
public DateTimeOffset? CancelAt { get; init; }
public DateTimeOffset? TrialEnd { get; init; }
public required int Quantity { get; init; } // Seats
}
public enum SubscriptionStatus
{
Trialing,
Active,
PastDue,
Canceled,
Unpaid,
Incomplete,
IncompleteExpired,
Paused
}
public sealed record BillingCustomer
{
public required Guid TenantId { get; init; }
public required string StripeCustomerId { get; init; }
public required string Email { get; init; }
public string? DefaultPaymentMethodId { get; init; }
public required string Currency { get; init; }
public TaxInfo? TaxInfo { get; init; }
}
// Program.cs - Register Stripe client
builder.Services.AddSingleton<IStripeClient>(sp =>
{
var apiKey = builder.Configuration["Stripe:SecretKey"];
return new StripeClient(apiKey);
});
// Register services
builder.Services.AddScoped<CustomerService>();
builder.Services.AddScoped<SubscriptionService>();
builder.Services.AddScoped<InvoiceService>();
builder.Services.AddScoped<PaymentMethodService>();
builder.Services.AddScoped<UsageRecordService>();
public sealed class BillingService(
CustomerService customerService,
SubscriptionService subscriptionService,
IBillingRepository repository)
{
public async Task<BillingCustomer> CreateCustomerAsync(
Guid tenantId,
string email,
string? name = null,
CancellationToken ct = default)
{
var options = new CustomerCreateOptions
{
Email = email,
Name = name,
Metadata = new Dictionary<string, string>
{
["tenant_id"] = tenantId.ToString()
}
};
var stripeCustomer = await customerService.CreateAsync(options, cancellationToken: ct);
var customer = new BillingCustomer
{
TenantId = tenantId,
StripeCustomerId = stripeCustomer.Id,
Email = email,
Currency = "usd"
};
await repository.SaveCustomerAsync(customer, ct);
return customer;
}
}
public async Task<Subscription> CreateSubscriptionAsync(
Guid tenantId,
string priceId,
int quantity = 1,
bool startTrial = false,
CancellationToken ct = default)
{
var customer = await repository.GetCustomerAsync(tenantId, ct)
?? throw new InvalidOperationException("Customer not found");
var options = new SubscriptionCreateOptions
{
Customer = customer.StripeCustomerId,
Items =
[
new SubscriptionItemOptions
{
Price = priceId,
Quantity = quantity
}
],
PaymentBehavior = "default_incomplete",
PaymentSettings = new SubscriptionPaymentSettingsOptions
{
SaveDefaultPaymentMethod = "on_subscription"
},
Metadata = new Dictionary<string, string>
{
["tenant_id"] = tenantId.ToString()
}
};
if (startTrial)
{
options.TrialPeriodDays = 14;
}
var stripeSub = await subscriptionService.CreateAsync(options, cancellationToken: ct);
return MapToSubscription(tenantId, stripeSub);
}
[ApiController]
[Route("webhooks/stripe")]
public class StripeWebhookController(
IBillingWebhookHandler handler,
IConfiguration config,
ILogger<StripeWebhookController> logger) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> HandleWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].ToString();
var webhookSecret = config["Stripe:WebhookSecret"];
try
{
var stripeEvent = EventUtility.ConstructEvent(
json,
signature,
webhookSecret);
logger.LogInformation(
"Processing Stripe webhook: {EventType} {EventId}",
stripeEvent.Type,
stripeEvent.Id);
await handler.HandleEventAsync(stripeEvent);
return Ok();
}
catch (StripeException ex)
{
logger.LogError(ex, "Stripe webhook signature verification failed");
return BadRequest();
}
}
}
public sealed class BillingWebhookHandler(
IBillingRepository repository,
IEntitlementService entitlements,
ILogger<BillingWebhookHandler> logger) : IBillingWebhookHandler
{
public async Task HandleEventAsync(Event stripeEvent)
{
switch (stripeEvent.Type)
{
case Events.CustomerSubscriptionCreated:
case Events.CustomerSubscriptionUpdated:
await HandleSubscriptionChangeAsync(
(Stripe.Subscription)stripeEvent.Data.Object);
break;
case Events.CustomerSubscriptionDeleted:
await HandleSubscriptionDeletedAsync(
(Stripe.Subscription)stripeEvent.Data.Object);
break;
case Events.InvoicePaid:
await HandleInvoicePaidAsync(
(Invoice)stripeEvent.Data.Object);
break;
case Events.InvoicePaymentFailed:
await HandlePaymentFailedAsync(
(Invoice)stripeEvent.Data.Object);
break;
case Events.CustomerSubscriptionTrialWillEnd:
await HandleTrialEndingAsync(
(Stripe.Subscription)stripeEvent.Data.Object);
break;
default:
logger.LogDebug("Unhandled event type: {Type}", stripeEvent.Type);
break;
}
}
private async Task HandleSubscriptionChangeAsync(Stripe.Subscription stripeSub)
{
var tenantId = Guid.Parse(stripeSub.Metadata["tenant_id"]);
var subscription = new Subscription
{
TenantId = tenantId,
StripeSubscriptionId = stripeSub.Id,
StripePriceId = stripeSub.Items.Data[0].Price.Id,
Status = ParseStatus(stripeSub.Status),
CurrentPeriodStart = stripeSub.CurrentPeriodStart,
CurrentPeriodEnd = stripeSub.CurrentPeriodEnd,
CancelAt = stripeSub.CancelAt,
TrialEnd = stripeSub.TrialEnd,
Quantity = (int)stripeSub.Items.Data[0].Quantity
};
await repository.SaveSubscriptionAsync(subscription);
// Update entitlements based on new subscription state
await entitlements.SyncEntitlementsAsync(tenantId);
}
}
| Event | Action Required |
|---|---|
customer.subscription.created | Provision tenant, set entitlements |
customer.subscription.updated | Update entitlements (plan change) |
customer.subscription.deleted | Revoke access, cleanup |
invoice.paid | Confirm payment, extend access |
invoice.payment_failed | Notify, start dunning |
customer.subscription.trial_will_end | Notify user (3 days before) |
Lifecycle Flow:
+-------------------+
| |
v |
+----------+ +----------+ +--------+ +-------------+
| Trialing |--->| Active |--->| PastDue|--->| Canceled |
+----------+ +----------+ +--------+ +-------------+
| | | ^
| v v |
| +---------+ +---------+ |
+-------->| Canceled| | Unpaid |-----------+
+---------+ +---------+
Key Transitions:
- Trialing -> Active: First successful payment
- Active -> PastDue: Payment failed (retry in progress)
- PastDue -> Active: Payment succeeded on retry
- PastDue -> Canceled: All retries exhausted
- Any -> Canceled: User or admin cancellation
public sealed class DunningService(
IEmailService email,
IBillingRepository repository)
{
public async Task HandlePaymentFailedAsync(
Guid tenantId,
string invoiceId,
int attemptCount,
DateTimeOffset nextRetry)
{
var customer = await repository.GetCustomerAsync(tenantId);
// Progressive messaging based on attempt
var message = attemptCount switch
{
1 => "We couldn't process your payment. We'll retry automatically.",
2 => "Second payment attempt failed. Please update your payment method.",
3 => "Final payment attempt upcoming. Update your payment to avoid service interruption.",
_ => "Your subscription is at risk. Immediate action required."
};
await email.SendAsync(new PaymentFailedEmail
{
To = customer.Email,
Subject = $"Payment Issue - Attempt {attemptCount}",
Message = message,
UpdatePaymentUrl = GenerateUpdatePaymentUrl(tenantId),
NextRetryDate = nextRetry
});
// Log for support visibility
await repository.LogDunningEventAsync(tenantId, invoiceId, attemptCount);
}
}
public sealed class UsageReporter(
UsageRecordService usageRecordService,
IUsageMeteringService metering,
IBillingRepository repository)
{
public async Task ReportDailyUsageAsync(DateOnly date, CancellationToken ct)
{
var subscriptions = await repository.GetMeteredSubscriptionsAsync(ct);
foreach (var sub in subscriptions)
{
var usage = await metering.GetDailyUsageAsync(
sub.TenantId,
date,
ct);
foreach (var metric in usage)
{
var subscriptionItemId = sub.GetSubscriptionItemId(metric.MetricName);
await usageRecordService.CreateAsync(new UsageRecordCreateOptions
{
SubscriptionItem = subscriptionItemId,
Quantity = (long)metric.TotalQuantity,
Timestamp = date.ToDateTime(TimeOnly.MinValue),
Action = "set" // "set" replaces, "increment" adds
}, cancellationToken: ct);
}
}
}
}
public async Task<string> CreateCheckoutSessionAsync(
Guid tenantId,
string priceId,
string successUrl,
string cancelUrl,
CancellationToken ct = default)
{
var customer = await repository.GetCustomerAsync(tenantId, ct);
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(new SessionCreateOptions
{
Customer = customer?.StripeCustomerId,
CustomerEmail = customer?.Email,
Mode = "subscription",
LineItems =
[
new SessionLineItemOptions
{
Price = priceId,
Quantity = 1
}
],
SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}",
CancelUrl = cancelUrl,
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new Dictionary<string, string>
{
["tenant_id"] = tenantId.ToString()
}
},
AllowPromotionCodes = true
}, cancellationToken: ct);
return session.Url;
}
public async Task<string> CreatePortalSessionAsync(
Guid tenantId,
string returnUrl,
CancellationToken ct = default)
{
var customer = await repository.GetCustomerAsync(tenantId, ct)
?? throw new InvalidOperationException("Customer not found");
var portalService = new Stripe.BillingPortal.SessionService();
var session = await portalService.CreateAsync(new SessionCreateOptions
{
Customer = customer.StripeCustomerId,
ReturnUrl = returnUrl
}, cancellationToken: ct);
return session.Url;
}
// Store processed webhook event IDs
public async Task<bool> TryProcessWebhookAsync(string eventId)
{
// Check if already processed (idempotency)
if (await repository.WebhookAlreadyProcessedAsync(eventId))
{
logger.LogInformation("Webhook {EventId} already processed", eventId);
return false;
}
// Mark as processing (with TTL for cleanup)
await repository.MarkWebhookProcessingAsync(eventId);
return true;
}
Recommendations:
1. Return 200 quickly (process async if needed)
2. Store raw event for replay/debugging
3. Implement idempotency (track event IDs)
4. Handle out-of-order events gracefully
5. Set up webhook endpoint monitoring
6. Use Stripe CLI for local development
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Sync API calls only | Miss events, state drift | Use webhooks as source of truth |
| No idempotency | Duplicate processing | Track event IDs |
| Blocking webhook handlers | Timeouts, retries | Process async, return 200 fast |
| Hardcoded prices | Painful to change | Use Stripe price IDs, sync from Stripe |
| No retry handling | Lost events | Implement retry with backoff |
Reference documentation for detailed implementation patterns (load on demand):
usage-metering skill - Consumption tracking for usage-based billingsubscription-models skill - Pricing tier design and feature bundlingentitlements-management skill - Feature gating based on subscriptionFor Stripe-specific patterns, use MCP research:
perplexity: "Stripe .NET SDK billing integration patterns"
context7: "Stripe.net" (for SDK documentation)
subscription-models - Pricing tier designusage-metering - Consumption tracking for usage-based billingentitlements-management - Feature gating based on subscriptionFor current billing integration patterns:
perplexity: "Stripe .NET SDK 2024" "SaaS billing integration patterns"
context7: "Stripe.net" (for SDK documentation)
microsoft-learn: "Azure SaaS billing" "subscription management patterns"
Last Updated: 2025-12-29
Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.