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.