Handling Downgrade Credits & Proration Refunds
A mid-cycle downgrade is the mirror image of an upgrade, and the part teams most often get wrong. The customer has already paid for the higher plan through the end of the period; when they drop to a cheaper plan on day 11, you owe them the value of the unused days at the difference between the two rates. The hard questions are not the arithmetic — they are what to do with that credit: park it as a balance, cut a cash refund, or emit a negative invoice, and how each choice ripples into the ledger and the tax you already remitted. This page handles those decisions as a concrete case of Proration Logic & Calculations, and assumes the period-boundary and ledger conventions from Subscription Billing Architecture & Pricing Models.
The default that keeps you out of trouble is a credit balance applied to the next invoice. Cash refunds reverse a captured payment, which reopens settled tax and triggers gateway refund fees; reach for them only when policy or law demands. The sections below make that trade-off explicit and then show the exact postings for each path.
Trade-offs
The three ways to return value to a downgrading customer differ mostly in what they touch downstream — cash, settled tax, and gateway fees. The map below shows which downstream systems each path disturbs.
| Approach | Credit balance | Cash refund | Negative invoice |
|---|---|---|---|
| Customer cash returned | No (applied to next bill) | Yes (to card) | Yes, once settled |
| Reverses settled tax | No | Yes — file tax adjustment | Yes |
| Gateway fee impact | None | Refund fee, often non-refundable | Same as refund on payout |
| Cash-flow impact | Neutral | Immediate outflow | Outflow at settlement |
| Audit complexity | Low (one credit posting) | Medium (refund + tax reversal) | High (sequential negative doc) |
| Best when | Customer stays subscribed | Customer churns / legally required | Jurisdiction mandates a credit note |
Prefer a credit balance for retained customers; use a refund or a sequential negative invoice (a credit note) only when the customer is leaving or local invoicing law requires a corrective document.
The reason the credit balance wins so often is that it defers every side effect until the next scheduled invoice run, where it is netted against a fresh charge and settles inside a document you were going to emit anyway. Nothing leaves the business, no gateway round-trip happens, and the tax on the credited amount is handled implicitly because the next invoice recomputes tax on a smaller net taxable base. A cash refund, by contrast, is an out-of-band event: it fires a separate POST /refunds call to the processor, waits on an asynchronous webhook to confirm settlement, and only then can the ledger close the liability. That asynchrony is the source of most bugs — the credit is owed the instant the customer downgrades, but the cash does not move for hours or days, so the liability account must carry the balance in the interim rather than the code assuming an instant refund.
Credit expiry and stranded balances
A credit balance is a liability you carry indefinitely unless policy says otherwise, and a large aging balance is both an accounting nuisance and a customer-trust problem. Decide up front whether credits expire, and if so, encode the expiry as a timestamp on the credit-liability record rather than a background job that mutates balances. When a customer with a subscription_id that has been dormant for six months holds a 4,200-cent credit, an auditor will ask why revenue was reduced for value never returned; an explicit expires_at and a documented forfeiture policy answer that question. Where consumer-protection law forbids expiry — several EU jurisdictions treat prepaid credit as the customer’s money — you cannot expire it at all, and the balance must survive account closure as a genuine payable. Model the expiry decision as data, not as a hardcoded branch, because it varies by jurisdiction and by the origin of the credit: a goodwill credit and a proration credit are frequently treated differently.
Multi-currency and cross-plan complications
Downgrade credits get subtle the moment the higher and lower plan are priced in different currencies, or the customer paid in one currency and now bills in another. Never convert a credit balance at today’s FX rate — that leaks or creates value on every rate move. Hold the credit in the currency it was collected in, tag the 2200_CUSTOMER_CREDIT posting with that currency_code, and only apply it against a future invoice in the same currency. If the downgrade also crosses a billing-interval boundary — an annual plan dropping to a monthly one — the unused-days math must run against the annual period the customer actually paid for, not the monthly cadence of the new plan, or the credit will be understated by an order of magnitude.
Step-by-Step Implementation
The four steps compute the capped credit, choose a disposition, post the balanced ledger movement, and — only if required — emit a corrective document. The disposition branch is the pivot: everything after it depends on whether you chose a balance or a refund.
1. Compute the downgrade credit
The credit is the unused days valued at the rate difference. On a downgrade the net is negative — that magnitude is what you owe, capped so you never credit more than the customer actually paid this cycle.
Two details in the computation cause real production incidents. The first is the choice of total_days: it must be the length of the specific period the customer paid for, so a February downgrade uses 28 (or 29) and a 31-day month uses 31. Hardcoding 30 introduces a systematic error of up to 3.3 percent on every credit — small per transaction, but it accumulates into a reconciliation gap that finance eventually notices. The second is the boundary treatment of days_remaining. Decide once whether the downgrade day itself belongs to the old plan or the new plan, and apply that convention everywhere; an off-by-one here means the sum of the upgrade and downgrade halves of a same-day plan swap does not reconcile to the customer’s actual paid amount. Compute both daily rates before subtracting, and keep the subtraction in Decimal space so you round exactly once, at the end, rather than compounding two independent roundings.
A worth-stating edge case: when new_price_cents exceeds old_price_cents the function is being called for the wrong direction — that is an upgrade, and raw goes negative. The cap against paid_this_cycle_cents will not catch it because the value is already below zero, so guard the disposition step (below) to treat any non-positive credit as NO_CREDIT and route genuine upgrades through the upgrade path instead.
from decimal import Decimal, ROUND_HALF_UP
def downgrade_credit_cents(old_price_cents: int, new_price_cents: int,
days_remaining: int, total_days: int,
paid_this_cycle_cents: int) -> int:
daily_old = Decimal(old_price_cents) / Decimal(total_days)
daily_new = Decimal(new_price_cents) / Decimal(total_days)
raw = (daily_old - daily_new) * Decimal(days_remaining) # positive on a downgrade
credit = raw.quantize(Decimal('1'), rounding=ROUND_HALF_UP)
# ✅ never credit more than was actually collected this cycle
return int(min(credit, Decimal(paid_this_cycle_cents)))
2. Decide credit balance vs refund
Branch on customer state and policy. Staying subscribed means a balance; churning or a legal requirement means a refund.
def resolve_credit_disposition(credit_cents: int, customer_churning: bool,
refund_required_by_law: bool) -> str:
if credit_cents <= 0:
return "NO_CREDIT"
if customer_churning or refund_required_by_law:
return "CASH_REFUND" # ⚠️ reverses settled tax + gateway fee
return "CREDIT_BALANCE" # ✅ default: apply to next invoice
3. Post the credit to the double-entry ledger
A credit balance moves money out of recognized revenue into a customer-credit liability — never delete or edit the original charge. Every posting is balanced and carries an idempotency key.
The account choice on the debit side deserves care. Debiting 4000_REVENUE is correct when the revenue was recognized on receipt, but if your recognition schedule defers the higher-plan revenue over the period, the unused portion may still be sitting in a 2400_DEFERRED_REVENUE contract-liability account rather than recognized revenue. In that case the credit reduces deferred revenue, not recognized revenue, and posting against 4000_REVENUE would understate the period’s earnings and misstate the deferred balance. Read the recognition state of the original charge before choosing the debit account; the safe default for month-to-month plans billed and recognized at period start is 4000_REVENUE, but any annual plan with straight-line recognition needs the deferred account instead.
The idempotency_key here is doing more than deduplicating a retry. Because the credit posting and any downstream refund share a causal chain, derive the key deterministically from the downgrade event — for example a hash of subscription_id, the effective date, and the old and new plan identifiers — so a replayed webhook or a re-run of the billing job produces the identical key and the ON CONFLICT clause absorbs it silently. A random UUID per attempt defeats the guard and lets a retry double-post the credit.
-- Credit-balance path: reduce revenue, raise a customer-credit liability
INSERT INTO ledger_entries (
customer_id, account_code, direction, amount, currency_code, idempotency_key
) VALUES
($1, '4000_REVENUE', 'debit', $2, 'USD', $3), -- reverse recognized revenue
($1, '2200_CUSTOMER_CREDIT','credit', $2, 'USD', $3) -- liability owed to customer
ON CONFLICT (idempotency_key, account_code, direction) DO NOTHING;
For a cash refund, the liability is settled against cash when the gateway confirms:
-- Cash-refund path: settle the credit liability against cash on gateway confirmation
INSERT INTO ledger_entries (
customer_id, account_code, direction, amount, currency_code, idempotency_key
) VALUES
($1, '2200_CUSTOMER_CREDIT','debit', $2, 'USD', $4), -- clear the liability
($1, '1000_CASH', 'credit', $2, 'USD', $4) -- cash leaves the business
ON CONFLICT (idempotency_key, account_code, direction) DO NOTHING;
4. Handle the negative invoice and tax adjustment
If the jurisdiction requires a corrective document, emit a sequential negative invoice (credit note) rather than mutating the original. Reverse the proportional tax you already remitted, and snapshot the original rate — never apply today’s rate to a past charge.
The reason the original rate matters is that tax is a fact about the moment the sale happened, not about the moment you correct it. If the standard VAT rate moved from 19 to 20 percent between the original invoice_id and the downgrade, reversing at 20 percent would refund the customer tax you never collected and leave your filing out of balance by the difference. Store the applied rate on the original invoice line at the time it was issued and read it back here; treat original_invoice["tax_rate"] as immutable historical data. The same principle governs the taxability decision itself — if the original charge was zero-rated because the customer supplied a valid VAT number, the credit note is zero-rated too, regardless of the customer’s current tax status.
Sequencing is the other hard constraint. A gapless credit-note number must be allocated inside the same database transaction that posts the ledger movement, drawn from a dedicated sequence that is distinct from your invoice sequence in most jurisdictions. If the transaction rolls back after you have handed out number 4,096, that number must never be reused or skipped — which is exactly why the allocation and the posting share one transaction rather than the number being minted optimistically before the write. Reference the original document by references_invoice_id on the credit note so the corrective relationship is explicit and auditable, and carry the customer’s customer_id onto the note so it appears on their account statement alongside the invoice it corrects.
Applying the balance to the next invoice
A credit balance is inert until the next invoice run consumes it. When that run assembles the upcoming invoice for the subscription_id, it should draw down 2200_CUSTOMER_CREDIT before charging the card, applying the smaller of the outstanding credit and the invoice total. Post the drawdown as a debit to the liability and a credit to accounts receivable on that invoice, so the liability shrinks by exactly the amount applied and any remainder carries forward. Two failure modes appear here: applying more credit than the invoice total (which would push the invoice negative and re-create the refund problem you were avoiding), and applying the same credit twice because two invoice runs raced. Guard both by clamping the applied amount to the invoice total and by making the drawdown conditional on the current liability balance inside the transaction that finalizes the invoice.
def build_credit_note(original_invoice, credit_base_cents: int) -> dict:
# Tax reverses at the ORIGINAL rate, applied to the credited base only
tax_reversed = round(credit_base_cents * original_invoice["tax_rate"])
return {
"doc_type": "credit_note",
"references_invoice_id": original_invoice["invoice_id"],
"sequential_number": next_credit_note_number(), # gapless sequence
"base_credit_cents": -credit_base_cents,
"tax_credit_cents": -tax_reversed, # ⚠️ adjust filed tax
"total_cents": -(credit_base_cents + tax_reversed),
"currency": original_invoice["currency"],
}
Verification & Testing
The invariants worth asserting after every downgrade form a short checklist: the credit is capped, the ledger balances, the refund liability nets to zero, and tax reverses at the original rate. The panel below is the assertion set.
Assert the credit never exceeds the amount paid this cycle, and that on a same-price plan change it is exactly zero. After every disposition, assert ledger debits equal credits per currency — a non-zero sum is a correctness bug. For the refund path, assert the 2200_CUSTOMER_CREDIT liability nets to zero once the gateway confirms. Verify tax is reversed at the original snapshotted rate, not the current one. Reconcile credit notes:
-- A credit note must never credit more tax than the original invoice charged
SELECT cn.sequential_number, cn.tax_credit_cents, inv.tax_applied_cents
FROM credit_notes cn
JOIN invoices inv ON inv.invoice_id = cn.references_invoice_id
WHERE ABS(cn.tax_credit_cents) > inv.tax_applied_cents; -- any row is a bug
Property-based and round-trip tests
Example-based tests catch the cases you thought of; property-based tests catch the ones you did not. The single most valuable property for this page is conservation: for any plan pair and any downgrade day, the sum of what the customer was charged and every credit posted against a cycle must never leave the customer having paid for value they did not receive, nor the business having refunded value it did not collect. Generate random old and new prices, random day-of-cycle, and random month lengths, and assert the credit stays within [0, paid_this_cycle_cents] for every draw. A second property covers the round trip: upgrade then immediately downgrade back to the original plan on the same day should net to zero credit, because no time elapsed on either plan. If your day-boundary convention is inconsistent between the two paths, this property fails and points straight at the off-by-one.
Reconciliation tests belong in the suite too, not only in production dashboards. Sum every posting to 2200_CUSTOMER_CREDIT for a customer_id and assert it equals the difference between credits granted and credits consumed by later invoices and refunds — a drift of even one cent means a posting escaped the double-entry discipline. Run this assertion across a synthetic account that has churned through several upgrades, downgrades, and a partial refund, because the compound cases are where single-transaction tests give false confidence. Finally, test idempotency explicitly by replaying the same downgrade event twice and asserting the ledger is byte-identical after the second run; a passing replay is your proof that a retried webhook cannot double-credit.
Gotchas & Production Pitfalls
The five pitfalls below share a theme: they treat a settled financial fact as mutable. The diagram groups them by the fact they wrongly touch — the amount, the original document, the tax, the numbering, or the customer relationship.
Crediting more than was collected. Stacked downgrades or a credit computed against list price rather than amount paid can exceed the original payment. Cap the credit at paid_this_cycle_cents and assert it in tests.
Editing the original invoice to “fix” the amount. Mutating a settled invoice destroys the audit trail and breaks tax filings. Always post a new dated reversing entry or a sequential credit note; the original stays immutable.
Forgetting tax on the refund. Refunding only the base leaves remitted tax stranded — you over-paid the tax authority and under-refunded the customer. Reverse tax proportionally at the original rate and file the adjustment.
Non-gapless credit note numbering. Many jurisdictions require sequential, gapless corrective-document numbers. Generate the number inside the same transaction that posts the credit, from a dedicated sequence, so a rollback never leaves a hole.
Auto-issuing cash refunds for retained customers. Refunds incur gateway fees and reopen settled tax for no benefit when the customer is staying. Default to a credit balance and reserve refunds for churn or legal mandates.
Assuming the refund settled the instant you called the gateway. The POST /refunds call returns a pending object, not a completed one. If you close the 2200_CUSTOMER_CREDIT liability against 1000_CASH on the API response rather than on the settlement webhook, a later refund failure — insufficient processor balance, a closed card, a dispute — leaves your books showing cash gone that never left. Post the cash movement only when the confirmation webhook arrives, and keep the liability open until then so a failed refund is visible as an unsettled balance rather than a silent discrepancy.
Crediting against a charge that was itself disputed or already refunded. A customer who filed a chargeback on the original invoice and then downgrades should not also receive a proration credit for the same days — that double-returns the value. Before posting, check that the original charge tied to the invoice_id is still settled and not the subject of an open dispute or prior refund; if the money already came back through another channel, the downgrade credit is zero. This guard is easy to omit because the two events flow through different subsystems, and it surfaces only when a support agent notices a customer was made whole twice.
When to reverse a credit
Occasionally a credit must be clawed back — a downgrade is reversed within the same cycle, or a credit was granted in error. The rule mirrors the rest of this page: never delete the original credit posting. Post a new, dated, balanced reversing entry with its own idempotency_key that debits 2200_CUSTOMER_CREDIT and credits back the account the original credit reduced. If any portion of the credit was already consumed by an intervening invoice, you can only reverse the unconsumed remainder without re-billing; attempting to reverse consumed credit means issuing a fresh charge, which is a new billing event with its own tax treatment, not a bookkeeping undo.
Frequently Asked Questions
Should a downgrade produce cash back or an account credit? An account credit in almost every case. Refunding cash for a plan the customer chose to leave invites abuse through repeated up-and-down switching, and a credit applied to the next invoice is what customers actually expect.
Does an unused credit ever expire? Only if you say so at the time it is issued, and consumer-protection rules in some jurisdictions constrain it. The safer default is that credits persist until consumed or the account closes.
What happens if the credit exceeds the next invoice? It carries forward as a remaining balance rather than triggering a payout. Show the balance on the billing page so the customer can see the money is still theirs.
How should the credit appear on the invoice? As an explicit applied-credit line with the reference of the credit that produced it, above the amount due. A silently reduced total is the most common cause of “why is this invoice different?” contacts.