Why most SaaS MVPs break at billing logic
Common pitfalls in trial-to-paid flows, proration, and dunning—and how to avoid them before they become technical debt.
11 min read
Billing logic is where MVPs accumulate technical debt fastest. Early shortcuts—hardcoded plans, manual status checks, no retry logic—become painful at scale. Here are the failure modes we see most often and how to avoid them before they become technical debt.
Trial-to-paid transitions
Trials expire; users convert or churn. The failure: deriving status from a calculated end date instead of Stripe's subscription state. If you compute "trial ends in 14 days" locally and gate access on that, you'll miss conversions (user paid early), cancellations (user churned before trial end), and past_due (payment failed at conversion). Stripe knows the real state. Your app should read it.
The trial_will_end event fires three days before trial end. Use it for reminder emails. When the trial actually ends, Stripe attempts to charge the card. Success → subscription.updated with status active. Failure → subscription.updated with status past_due and invoice.payment_failed. Your webhook handler must handle both; don't assume trial end always means conversion.
Use subscription.status (active, trialing, past_due, canceled, unpaid) and subscription.trial_end from webhooks. Sync these to your DB. For access checks: active or trialing = access. past_due = access with a banner (grace period). canceled/unpaid = no access after period end.
// Access check: use synced status, not local calculation
function hasAccess(subscription: Subscription) {
if (['active', 'trialing'].includes(subscription.status)) return true;
if (subscription.status === 'past_due') {
// Grace period: e.g. 7 days after first failure
return isWithinGracePeriod(subscription);
}
if (['canceled', 'unpaid'].includes(subscription.status)) {
return subscription.current_period_end > new Date();
}
return false;
}Proration and plan changes
Upgrades, downgrades, and mid-cycle changes require proration. Stripe handles this when you use the subscription update API correctly. The mistake: building custom proration logic or recalculating invoices yourself. You will get rounding errors, edge cases (leap years, timezones), and support tickets. Let Stripe compute credits and charges.
// Upgrade: Stripe prorates automatically
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations'
});
// Downgrade: credit for unused time, new price at next period
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: lowerPriceId }],
proration_behavior: 'create_prorations',
billing_cycle_anchor: 'unchanged' // or 'now' for immediate
});
// Optional: preview before applying
const preview = await stripe.invoices.retrieveUpcoming({
customer: customerId,
subscription: subscriptionId,
subscription_items: [{ id: itemId, price: newPriceId }]
});For downgrades, decide: apply at period end (no refund, simpler) or immediately (prorated credit). Stripe supports both. Document the behavior for support.
Dunning and failed payments
Cards fail. Industry recovery rates for first retry are often 40–60%. The failure: no retry, or retrying without updating the user. Stripe retries automatically (configurable in Dashboard). You must: handle invoice.payment_failed and customer.subscription.updated, update local status to past_due, send a clear "update payment method" email, and optionally show an in-app banner.
Don't block access immediately. Stripe retries over several days. Give a grace period (e.g. 7 days) before restricting access. After final failure, subscription moves to unpaid/canceled—then revoke access. Align your grace period with Stripe's retry schedule so you don't cut off users before Stripe has given up.
// invoice.payment_failed handler
async function handlePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
await db.subscriptions.update({
where: { stripeSubscriptionId: invoice.subscription },
data: { status: 'past_due', lastPaymentFailedAt: new Date() }
});
await sendEmail(invoice.customer_email, 'payment-failed', {
updatePaymentUrl: await getBillingPortalUrl(invoice.customer)
});
}Idempotency
Webhooks can be delivered more than once. Network issues, timeouts, or Stripe's retry logic can cause duplicates. If your handler creates a subscription record or applies a credit on every delivery, you get duplicate rows or double credits. Store the webhook event ID before processing. If it exists, return 200 and do nothing. The first write wins.
// Idempotency: check before any side effects
const existing = await db.webhookEvents.findUnique({
where: { id: event.id }
});
if (existing) return res.status(200).send('OK');
// Insert event ID in same transaction as business logic
await db.$transaction(async (tx) => {
await tx.webhookEvents.create({ data: { id: event.id } });
await tx.subscriptions.upsert({ ... });
});Use a unique constraint on the event ID so concurrent deliveries cannot both insert. One will succeed; the other will fail and can be retried (idempotently).
Cancel-at-period-end
When a user cancels, set cancel_at_period_end: true. The subscription remains active until current_period_end. On that date, Stripe sends customer.subscription.deleted. Store cancel_at_period_end so your UI can show "Cancels on March 15" and offer a reactivation flow before the period ends.
Quantity-based and seat billing
For per-seat or quantity-based pricing, use Stripe's quantity on the subscription item. When a user adds a seat, update the quantity. Stripe prorates automatically. Store the quantity in your DB for display and feature gating. Sync from webhooks—subscription.updated includes the new quantity.
// Add 2 seats to subscription
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscriptionItemId, quantity: currentQuantity + 2 }],
proration_behavior: 'create_prorations'
});Tax handling
Stripe Tax calculates tax based on customer location and product tax codes. Enable it in Dashboard or via API. For manual tax: setautomatic_tax: { enabled: false } and add tax as a separate invoice line item. Stripe Tax is simpler and stays current with regulations. Pass customer address at checkout for accurate calculation.
Refunds and credits
Refunds go through Stripe; sync the result via charge.refunded or credit_note.created. Customer balance credits (from overpayment or goodwill) apply to future invoices automatically. Don't build custom credit logic—Stripe handles application order and expiry.
Stripe retry schedule
Stripe retries failed invoices on a schedule: typically immediately, then 3 days, 5 days, 7 days (configurable in Dashboard). Your grace period should extend past the last retry. If you revoke access after 3 days but Stripe retries at day 5, a user who fixes their card at day 4 will be charged—but you've already locked them out. Set grace period to 7–14 days or align with your Stripe retry config.
Start simple, but start right
You don't need a full billing system on day one. You do need: webhooks as the source of truth, idempotent handlers, and Stripe doing the math. Get those right early. Add dunning emails, usage metering, and annual plans later—the foundation will hold.
Checklist: (1) Webhook signature verification. (2) Event ID idempotency. (3) Sync subscription status from webhooks, not API calls. (4) Use Stripe for proration. (5) Handle payment_failed and update status. (6) Grace period aligned with Stripe retries. (7) Cancel-at-period-end for churn. (8) Tax via Stripe Tax or manual line items.
When to escalate
Some edge cases need manual handling: disputed charges, partial refunds, custom payment plans. Build a simple admin view to inspect subscription and invoice state. Link to Stripe Dashboard for disputes and detailed history. Don't automate everything—support needs tools to fix one-off situations.
Implementing the grace period
Store last_payment_failed_at when you receive invoice.payment_failed. Your access check: if status is past_due and last_payment_failed_at is within the grace window (e.g. 7 days), allow access but show a banner. After the window, deny access. When invoice.paid arrives, clear last_payment_failed_at and set status back to active. The grace period is business logic— Stripe doesn't enforce it; you do.
function isWithinGracePeriod(sub: Subscription, days = 7): boolean {
if (!sub.lastPaymentFailedAt) return true;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return sub.lastPaymentFailedAt > cutoff;
}Testing billing flows
Use Stripe test cards: 4242... for success, 4000 0000 0000 0341 for requiring authentication, 4000 0000 0000 9995 for decline. Test the full flow: signup, trial, conversion, upgrade, downgrade, cancel, payment failure, retry success. Automate the critical path: create subscription via API, trigger webhooks, assert DB state. Manual testing for edge cases. Stripe's test clock lets you fast-forward time for trial expiration and period rollover.
Monitoring and alerts
Monitor webhook failure rate, idempotency conflicts, and subscription status distribution. Alert on webhook endpoint errors (5xx), high past_due count, or orphaned subscriptions (Stripe has it but your DB doesn't). A nightly job can reconcile your subscriptions table against Stripe for drift detection. Fix discrepancies manually at first; automate once you understand the failure modes.