Every subscription business eventually builds the same five pieces of billing logic: proration when a customer changes plans mid-cycle, retry logic when a card gets declined, invoice generation that matches what was actually charged, webhook handling that keeps your database in sync with the payment processor, and reporting that reconciles the two. Get any one of these wrong and you either leak revenue or annoy customers with billing errors. I've built this stack around Stripe a few times now, and the failure modes are almost always the same: someone assumes billing is a solved problem because "we're just calling an API," then discovers three months later that half the failed-payment recoveries never happened because the retry job silently stopped running.
Proration is arithmetic, not guesswork
Proration means charging or crediting a customer for the unused portion of a billing period when they upgrade or downgrade mid-cycle. Stripe computes this for you if you let it — the mistake teams make is trying to reimplement the math themselves "to be safe," and getting the day-count convention wrong. Let the billing platform own the calculation; your job is deciding when to invoice immediately versus rolling the credit into the next cycle.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
async function changePlan(subscriptionId, newPriceId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
return stripe.subscriptions.update(subscriptionId, {
items: [{
id: subscription.items.data[0].id,
price: newPriceId,
}],
proration_behavior: 'create_prorations', // 'none' or 'always_invoice' are the alternatives
billing_cycle_anchor: 'unchanged',
});
}
// 'create_prorations' adds a line item to the NEXT invoice.
// 'always_invoice' bills the proration immediately — use this for upgrades
// where you want the cash now instead of waiting for the renewal.
Dunning: the failed-payment retry schedule
Dunning is the process of retrying a failed card charge on a schedule instead of giving up after one attempt. Cards fail for boring reasons — insufficient funds on the day, a bank's fraud filter, an expired card the customer hasn't updated yet — and a fixed percentage of those will succeed on retry without you doing anything except waiting and trying again. Stripe's Smart Retries handles this natively via subscription settings, but if you're building your own dunning logic (common when billing runs through a custom invoicing service instead of Stripe subscriptions directly), the schedule matters more than the mechanism.
Retrying a declined card within minutes almost never works — the failure reason (insufficient funds, temporary hold) usually hasn't changed. Spacing retries across days gives the underlying cause time to resolve and avoids getting your merchant account flagged for excessive retry attempts.
// A typical dunning cadence: retry on day 1, 3, 5, then cancel or downgrade.
const RETRY_SCHEDULE_DAYS = [1, 3, 5];
async function handleFailedInvoice(invoice) {
const attempt = invoice.attempt_count; // Stripe increments this per retry
if (attempt > RETRY_SCHEDULE_DAYS.length) {
// All retries exhausted — cancel or move to a restricted plan.
await stripe.subscriptions.update(invoice.subscription, {
pause_collection: { behavior: 'void' },
});
await notifyCustomer(invoice.customer_email, 'subscription_paused');
return;
}
// Stripe's automatic retry schedule (Smart Retries) handles the actual
// re-attempt timing. Your job on each `invoice.payment_failed` webhook
// is just to notify the customer and log where they are in the cycle.
await notifyCustomer(invoice.customer_email, 'payment_failed', { attempt });
}
Webhooks are the source of truth, your DB is a cache
The most common bug in homegrown billing systems: the app updates its own "subscription active" flag at checkout time, then never listens for what happens afterward. A payment can fail three days later during renewal, and the app has no idea because it never subscribed to `invoice.payment_failed` or `customer.subscription.updated`. Treat your local subscription status as a cache of what Stripe (or whichever processor) tells you via webhook, not as the primary record. Verify webhook signatures, and make the handler idempotent — Stripe retries webhook delivery, so the same event can arrive twice.
Pass an idempotency key on every subscription-mutating API call. If your job to "invoice everyone whose trial ended today" gets retried after a timeout, the key stops Stripe from creating a duplicate charge for the same customer.
Invoice generation that matches reality
An invoice should be generated from what was actually charged, not from what the plan's list price says. This sounds obvious until you have coupons, usage-based add-ons, and mid-cycle proration all landing on the same invoice. Pull invoice line items from Stripe's `invoice.finalized` event rather than reconstructing them from your own pricing table — the platform already did the reconciliation, and re-deriving it independently is how invoice totals drift from what customers were actually billed.
Wrapping up
Subscription billing automation isn't hard because the individual pieces are complex — proration is arithmetic, retries are a cron schedule, invoices are a template. It's hard because each piece depends on treating the billing processor as the source of truth and your own database as a reflection of it. The moment you start maintaining parallel state — recalculating proration yourself, tracking payment status without listening to webhooks — you've created two systems that can disagree, and one of them is wrong. Lean on Stripe's (or your processor's) built-in retry and proration logic, make your webhook handlers idempotent, and reconcile against the processor's records rather than your own assumptions.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.