Yesterday I argued a point over four turns with the author of a revenue-recognition tool, under his post about recognizing Stripe annual subscriptions with the actual journal entries. He conceded the gap was real and shipped a fix into his product about two hours later. The argument is worth writing down, because the shape of the bug is common and the usual trigger people wire it to is the wrong one.
First, my position. We do not run a deferred-revenue ledger. We build affiliate software for SaaS on Stripe, so everything below comes from the subscription and webhook side, from watching what Stripe actually emits and when. I said that to the author mid-thread, and I think it is why the advice landed. Where the reversal lands in your books is your accountant's call, not mine.
The model that works right up until it doesn't
A customer pays $1,200 for an annual plan. You create a deferred revenue schedule keyed to that invoice and release $100 a month for twelve months. Clean. Auditable. Every textbook draws it this way.
That model holds exactly as long as nothing changes.
Month five
The customer upgrades. Or downgrades, or moves from 8 seats to 14. Stripe prorates, and on that date two things move at once: what the customer is entitled to for the rest of the term, and what you still owe them in service.
Your schedule knows about neither. It keeps releasing $100 a month against a contract that no longer exists in that form. You are recognizing revenue on terms nobody agreed to anymore, and the error compounds every month until the term runs out.
Mid-term cancellations and refunds have the same shape. The remaining deferred balance has to come off, not continue.
Close out and reopen
The fix is to close the original schedule at the change date and open a new one for the amended terms. Two other options look reasonable and both are worse.
Leaving the old schedule running and adding a second one for the delta. This is the one that bites, and it is the most common instinct because the proration genuinely is a delta. The original schedule keeps releasing revenue against a term that ended in month five. Adding a correct schedule next to an incorrect one gets you a wrong total.
Mutating the original schedule in place. You end up with an accurate present and no record of what was contracted first. When someone asks in month nine what the customer originally bought, the answer is gone.
Close-out plus a new schedule matches the shape Stripe already hands you. Proration arrives as new invoice line items, not as an edit to the original invoice. The original invoice is immutable once finalised; Stripe writes credit and debit lines with their own period.start and period.end covering the remaining term. Modelling your ledger the same way keeps it aligned with the source system instead of drifting from it every time someone changes plan.
The practical payoff is provability. Each schedule's deferred balance resolves to zero on its own, over its own window:
schedule_A: original terms, Jan 1 -> May 14 (closed out, balance 0)
schedule_B: amended terms, May 14 -> Dec 31 (releasing)
A schedule amended twice in place never resolves cleanly. The author had described chasing a stranded four cents that would not clear months later, which is the exact symptom this prevents. Rounding residue on a partially-consumed period has nowhere to go when the schedule that created it has been overwritten. Give it its own close-out and the four cents close out with it.
Cancellation is the same shape, and the trigger is where people get it wrong
Close out at the cancellation date so the unearned remainder comes off. Straightforward.
Then comes the part nobody writes about. Do not trigger the close-out off a credit note existing.
Walk the actual paths. Cancelling at period end, which is Stripe's default and the normal SaaS flow, produces no proration and no credit note at all. The subscription sets cancel_at_period_end, the customer runs out the term they already paid for, and at the end Stripe flips the status. Nothing gets credited because nothing needs crediting. Cancelling immediately with proration generally gives you a credit balance on the customer or a proration line on the next invoice, depending on proration_behavior and whether an invoice gets raised. A refund is its own object again, against the charge or payment intent.
Credit notes mainly turn up when you credit an invoice that is already finalised.
So a close-out that fires on credit_note.created misses the most common cancellation path entirely. Not an edge case. The default one. Every customer who cancels normally keeps releasing deferred revenue on your books until someone notices.
Key it off the subscription state instead:
// customer.subscription.updated / customer.subscription.deleted
const endsAt = sub.cancel_at
|| sub.canceled_at
|| currentPeriodEnd(sub); // period fields moved onto
// subscription items in recent
// API versions, check yours
if (sub.cancel_at_period_end || sub.status === "canceled") {
closeOutSchedule(sub, { at: endsAt });
}
// credit notes: reconcile against, do not trigger on
Treat a credit note as a second signal, useful for reconciling the amount, never as the thing that starts the process.
One nuance worth keeping. On a period-end cancellation you do not reverse the months you already earned. The customer consumed that service and you keep it. Close out only the future portion, which on a clean period-end cancellation is often zero anyway, since the term runs to its natural end. The value there is that the schedule stops rather than rolling into a renewal that never happens.
The general shape, not a guarantee
A lot of this is configurable. proration_behavior alone changes what lands on the invoice, whether an invoice gets raised at all, and whether the money moves through the customer balance. Field locations move between API versions, subscription period fields being the recent example. Test against your own account, on your own version, with a real upgrade and a real cancellation, and read what actually arrives on the webhook rather than what the docs imply should.
The durable part is the principle. Anything that changes the term changes the schedule, so close the old one and open a new one, and derive your triggers from the subscription object rather than from an artifact that only shows up on some of the paths.
Top comments (0)