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
| 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.
Step-by-Step Implementation
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.
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.
-- 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.
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
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
Gotchas & Production Pitfalls
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.