SevyDevy

Guide · System design

Subscription Billing System Design: Proration, Dunning and Grace Periods

22 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Subscription billing system design is about one job: charge the right amount on the right date, again and again, and keep access in step with payment.
  • A plan is what you sell, a subscription is one customer's copy of it, and an entitlement is the list of features that customer may use right now.
  • Proration means charging only for the part of a billing period used. An upgrade with 20 of 30 days left credits the unused part of the old plan and charges the new plan for those 20 days.
  • Dunning is the process of retrying a failed renewal and reminding the customer. A grace period is the time access continues while those retries run.
  • Beginners can stop after the grace period section. Engineers then get how the 2026 RBI e-mandate rules change renewals and retries for Indian cards, and safe webhook handling. The proration section also shows a tested one-paisa rounding trap.

What goes wrong when a subscription renewal fails or a plan changes mid-month?

Money and access drift apart, which is the problem subscription billing system design exists to solve. A customer who upgraded gets billed twice, a customer whose card failed keeps using paid features for months, or a loyal customer loses access over one bank glitch.

Picture a common setup. A project-management app sold in India has two monthly plans: Basic at ₹499 and Pro at ₹999, prices before tax, paid by card. The app is made up, but every rule and number below is real or computed.

Three things happen every month. Thousands of subscriptions renew on the same morning. A few customers switch plans halfway through. Some cards decline, because of low balance, an expired card or a bank outage.

A naive design handles each with a quick fix: a cron job (a script a timer runs) that charges everyone, a full charge on upgrade, and instant lockout on failure. Each fix breaks something, and this guide replaces them one by one.

Which words do you need before designing subscription billing?

You need eight words. Each gets a plain meaning here, tied to the project-management app.

  • Plan (or price): what you sell, such as Pro at ₹999 a month. It has an amount, a currency and an interval, the length of one billing period.
  • Subscription: one customer's ongoing agreement to a plan. It stores the plan, the status and the current billing period's start and end.
  • Billing period: the stretch of time one charge pays for, here 1 November to 1 December.
  • Invoice: the bill for one period. It lists line items, the separate amounts added or credited, and has its own status such as open or paid.
  • Entitlement: a feature the customer may use right now, such as "Gantt charts" or "50 projects".
  • Proration: charging or crediting for only the part of a period that was used.
  • Dunning: the process of retrying a failed payment and reminding the customer to fix it.
  • Grace period: how long a customer keeps access after a failed renewal while dunning runs.

What is subscription billing system design?

Subscription billing system design is the plan for a service that bills customers on a schedule and keeps their access correct. It sits between your app and a payment processor, the company that actually moves money from a card.

It differs from a one-time checkout. A checkout charges a card once while the customer watches. A billing system charges with nobody watching, every period, for years, and must remember what was promised in between.

The guide on payment gateway API design covers the single charge. This one covers what wraps around it: the schedule, the plan changes, failed-payment recovery and the link between payment state and feature access.

Parts of a subscription billing system

Notice that the app never asks the processor whether a customer paid. It reads entitlements, which only the billing side updates, after it hears from the processor.

Diagram connections:

  • Subscription service to Plan catalogue (reads plan)
  • Billing scheduler to Invoice service (period ended)
  • Invoice service to Payment processor (charge with key)
  • Payment processor to Webhook receiver (payment events)
  • Webhook receiver to Event queue (store, then enqueue)
  • Event queue to Subscription service (update status)
  • Subscription service to Entitlement store (grant or revoke)
  • Your app to Entitlement store (may I show this?)
  • Event queue to Notifications (send notice)
  • Your app: Checks entitlements before showing a feature
  • Plan catalogue: Plans, prices, intervals, features per plan
  • Subscription service: Creates subscriptions, handles plan changes
  • Billing scheduler: Finds subscriptions due for renewal
  • Invoice service: Builds invoices and proration lines
  • Payment processor: Charges the customer's saved card
  • Webhook receiver: Verifies and stores processor events
  • Event queue: Lets workers handle events at their pace
  • Entitlement store: Features each customer may use now
  • Notifications: Receipts, failure emails, reminders

How do plans, subscriptions and entitlements fit together?

Keep them as three separate records. The plan catalogue says what each plan includes. The subscription says which plan a customer is on and whether they are paid up. Entitlements are worked out from both.

Why not check the plan name in code? Because "if plan == 'pro'" ends up copied into many files. When you add a yearly Pro or a festival offer, every check must change.

With entitlements, the code asks one question: does this customer have the "gantt_charts" feature? Stripe, a payment processor, builds its billing product this way. Its docs describe an entitlement as a customer's access to a feature.

Stripe's docs, read on 26 October 2026, say it sends an event each time a customer's active entitlements change, for example on subscribe, upgrade, downgrade or cancel. They also recommend storing those entitlements in your own system for faster checks.

Store a feature list per plan in your catalogue, then copy the result into a fast store keyed by customer. The app reads only that store, so a billing outage does not slow every page.

How does proration work when a customer upgrades mid-cycle?

Proration credits the unused part of the old plan and charges the new plan for the time left. On the app, a Basic customer upgrades to Pro at midnight IST (Indian Standard Time) on 11 November, with 20 of 30 days left.

The fraction left is 20 divided by 30, about 0.667. So the customer gets back 20/30 of ₹499 and pays 20/30 of ₹999. The table shows the lines our script computed.

Upgrade from Basic to Pro at 00:00 IST on 11 November 2026, in a 1 November to 1 December period. Computed in tests/billing_maths.py.
Invoice lineWorkingAmount
Unused time on Basic (credit)-499 x 20/30-₹332.67
Remaining time on Pro (charge)999 x 20/30₹666.00
Net for the upgradeSum of the two lines₹333.33
Next renewal on 1 DecemberFull Pro price₹999.00

Stripe's proration page shows the same shape. Its example upgrades a 10 USD plan to 20 USD halfway through, giving a 5 USD credit, a 10 USD charge and 5 USD net. Our script reproduces that exactly. The same page says positive prorations are not billed at once by default. You choose whether to invoice them now or at the next renewal.

Pause and predict: if the same upgrade happened on 11 December instead, would the net be the same? December has 31 days, so 21 of 31 days are left. The net becomes ₹338.71, not ₹333.33.

What just happened? The fraction depends on the real length of the period, not on a fixed 30 days. That is why billing systems store each period's start and end as exact timestamps.

Proration to the second

Stripe's docs, as of 26 October 2026, say it calculates prorations down to the second by default. If the upgrade happens at 14:30 IST on 11 November, the time left is 19 days and 9.5 hours.

That is 16,75,800 seconds out of 25,92,000, a fraction of 0.646528. The credit becomes ₹322.62 and the charge ₹645.88, so the net is ₹323.26.

Seconds create a new problem. The preview a customer sees and the actual change happen seconds or minutes apart, so the amounts can differ. Stripe's docs suggest passing one fixed proration_date, a timestamp, to both the preview and the update.

Here is the core calculation in Python. It holds money in paise, the smallest unit of the rupee, so no floating-point error creeps in. It ran on Python 3.13.

python
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo

IST = ZoneInfo("Asia/Kolkata")

def prorate(price_paise, start, end, change_at):
    left = (end - change_at).total_seconds()
    total = (end - start).total_seconds()
    exact = Decimal(price_paise) * Decimal(left) / Decimal(total)
    return int(exact.quantize(Decimal(1), rounding=ROUND_HALF_UP))

start = datetime(2026, 11, 1, tzinfo=IST)
end = datetime(2026, 12, 1, tzinfo=IST)
at = datetime(2026, 11, 11, tzinfo=IST)

credit = -prorate(49900, start, end, at)   # unused Basic time
debit = prorate(99900, start, end, at)     # remaining Pro time
print(credit, debit, credit + debit)       # -33267 66600 33333

The one-paisa rounding trap

Round each invoice line on its own, then add the lines. Do not round the difference once. They can disagree.

Take an upgrade at 01:00 IST on 10 November. Rounded line by line, the credit is ₹348.61 and the charge ₹697.91, so the lines sum to ₹349.30. Rounding (999 minus 499) times the fraction once gives ₹349.31.

One paisa sounds harmless. But the invoice shows lines, and the customer adds them up. If your total does not equal the sum of the printed lines, finance teams and auditors will flag it. Store the rounded lines and derive the total from them.

What should happen when a customer downgrades?

You have two sane choices: prorate now and keep the credit, or switch at the end of the period. Many products pick the second for downgrades, because the customer already paid for the higher plan.

If you prorate a Pro to Basic switch on 11 November, the lines are a ₹666.00 credit and a ₹332.67 charge. The customer is owed ₹333.33.

Stripe's docs say negative prorations are not refunded automatically. A common design keeps the credit on the customer's account to reduce the next invoice. In our example the December Basic renewal of ₹499 drops to ₹165.67.

Switching at period end is simpler. The customer keeps Pro features until 1 December, and the next invoice is plain Basic. Stripe's docs point to a feature called subscription schedules for changes timed to the end of a period.

One trap from Stripe's docs is worth knowing. If a customer changes plan while an invoice is still unpaid, proration may credit time they never paid for. The docs suggest turning proration off in that case.

How do invoices and the renewal job avoid double charging?

Make every step safe to repeat. A renewal job can crash halfway and run again, and a network call can time out after the processor already charged the card.

Two guards cover most cases. First, a unique constraint, a database rule that rejects duplicate rows, refuses a second invoice for the same subscription and period. Second, every charge request carries an idempotency key, a unique ID the processor uses to spot a repeat.

Stripe's API docs, read on 26 October 2026, say that for its v1 API a retry with the same key returns the saved first result, even a 500 error. Keys can be removed once they are at least 24 hours old, and the key can be up to 255 characters.

If your own scheduler calls the processor, build the key from the invoice ID and the attempt number, such as INVOICE_ID plus "attempt-2". A crash and re-run of attempt 2 within the key's lifetime reuses the key and cannot charge twice. Retry 3, a real new attempt, gets a new key.

The idempotency key explained guide goes deeper into how servers store these keys. Here is the database half, run on Python 3.13 with SQLite 3.53. The same unique constraint works in PostgreSQL.

python
import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE invoices (
  id INTEGER PRIMARY KEY,
  subscription_id TEXT NOT NULL,
  period_start TEXT NOT NULL,
  amount_paise INTEGER NOT NULL,
  UNIQUE (subscription_id, period_start)
);
CREATE TABLE processed_events (event_id TEXT PRIMARY KEY);
""")

def create_renewal_invoice(sub_id, period_start, amount):
    cur = db.execute(
        "INSERT INTO invoices (subscription_id, period_start, amount_paise) "
        "VALUES (?, ?, ?) ON CONFLICT DO NOTHING",
        (sub_id, period_start, amount))
    return cur.rowcount  # 1 = created, 0 = already existed

def handle_event(event_id):
    cur = db.execute(
        "INSERT INTO processed_events VALUES (?) ON CONFLICT DO NOTHING",
        (event_id,))
    return "processed" if cur.rowcount else "duplicate, skipped"

print(create_renewal_invoice("sub_42", "2026-12-01", 99900))  # 1
print(create_renewal_invoice("sub_42", "2026-12-01", 99900))  # 0
print(handle_event("evt_001"))  # processed
print(handle_event("evt_001"))  # duplicate, skipped

Running it prints 1, then 0, then "processed", then "duplicate, skipped". The second invoice insert and the second event are ignored, so a crashed job can simply run again.

What are dunning retries and how should you schedule them?

Dunning retries are planned repeat attempts to charge a failed renewal, spaced over days, with emails asking the customer to update their card. Stripe's docs note that many failed payments are recoverable. A low balance that is topped up before the next try is a typical case.

Stripe's retry docs, as of 26 October 2026, recommend a default of 8 tries within 2 weeks. A custom schedule there allows up to three retries, each a set number of days after the previous attempt.

Soft declines are temporary refusals worth retrying. Hard declines should not be retried. Stripe lists hard decline codes, such as lost_card, stolen_card and incorrect_number. For these, the docs say a payment executes only after a new payment method is added.

Here is a made-up custom schedule for the app's 1 December renewal: retry 1, 3 and 5 days after each previous attempt. Our script turned that into real dates. Indian cards change this picture, as the RBI section later shows.

One failed renewal, step by step

Notice the gaps grow: 1 day, then 3, then 5. Retry dates are counted from the previous attempt, so the last retry lands on day 9, not day 5.

Diagram connections:

  • Day 0: 1 Dec, 10:00 to Day 1: 2 Dec (wait 1 day)
  • Day 1: 2 Dec to Day 4: 5 Dec (wait 3 days)
  • Day 4: 5 Dec to Day 9: 10 Dec (wait 5 days)
  • Day 9: 10 Dec to Paid by day 9? (check result)
  • Paid by day 9? to Active again (yes)
  • Paid by day 9? to Grace ends: day 10 (no)
  • Day 0: 1 Dec, 10:00: First attempt declines; email the customer
  • Day 1: 2 Dec: Retry 1, one day after the first attempt
  • Day 4: 5 Dec: Retry 2, three days after retry 1
  • Day 9: 10 Dec: Retry 3, five days after retry 2
  • Paid by day 9?: Any success returns the subscription to active
  • Active again: Entitlements stay; send a receipt
  • Grace ends: day 10: Cancel, or move to unpaid and remove features

Send an email on each failure with a link to update the card. A new card should trigger an immediate attempt, not wait for the next slot.

How long should a grace period last, and what access does it keep?

A grace period should last at least until your last retry has had its chance. If grace ends before the final retry, you lock out customers who were about to pay.

That mistake is easy to make. In the schedule above, a 7-day grace period ends on 8 December, but the last retry runs on 10 December. Our script flags it, and a 10-day grace period fixes it.

The subscription's status should drive access. Stripe's statuses are a good model, and the table maps each to an access decision. The statuses come from Stripe's docs; the access column is our recommendation.

Subscription statuses from Stripe's docs (read 26 October 2026) and a suggested access rule for each
StatusWhat it meansSuggested access
trialingIn a trial period, before the first paymentFull access
activeIn good standingFull access
incompleteFirst payment not yet made; 23 hours to payNo access yet
past_dueLatest invoice failed or was not attemptedFull access during grace, then reduced
unpaidRetries finished, invoices unsettled, no more payment attemptsRemove paid features
canceledEnded; cannot be updatedRemove paid features

Subscription status changes

Notice that past_due is a waiting room, not an end. Paying the invoice returns it to active, while running out of retries moves it to unpaid or canceled, or leaves it past_due, depending on your settings.

Diagram connections:

  • trialing to active (trial ends, payment works)
  • active to past_due (renewal fails)
  • past_due to active (retry or new card pays)
  • past_due to unpaid (retries end (if set))
  • past_due to canceled (retries end (if set))
  • unpaid to active (open invoice paid)
  • trialing: Trial running, no payment yet
  • active: Paid up; full entitlements
  • past_due: Renewal failed; dunning and grace
  • unpaid: Retries over; invoices stay open
  • canceled: Final state; resubscribe to return

Reduced access is often kinder than a hard lockout. For example, let the customer read projects but not create new ones. They can still see what they are about to lose, which nudges them to pay.

Beginners can stop here. You now know the parts, the proration maths, retries and grace. The rest covers Indian card rules, webhooks and the interview answer.

How do the 2026 RBI e-mandate rules change recurring card billing in India?

They add a customer-controlled step before every automatic debit. An e-mandate is a standing permission, registered once with the card issuer, the bank that gave out the card.

The current rules are the RBI's Digital Payments – E-mandate Framework, 2026, issued on 21 April 2026 and effective immediately. They repealed eight earlier sets of instructions, including the original card e-mandate circular of 21 August 2019.

Key e-mandate rules for recurring card payments, from the RBI directions dated 21 April 2026 (as of 26 October 2026)
RuleWhat the directions say
RegistrationOne-time registration, valid only after an additional factor of authentication (AFA), such as an OTP
First debitThe first transaction under a mandate needs AFA
Later debits without AFAUp to ₹15,000 per transaction; above that, AFA each time
Higher limit categoriesInsurance premiums, mutual fund subscriptions and credit card bills: up to ₹1,00,000 (1 lakh) without AFA
Pre-debit noticeIssuer notifies the customer at least 24 hours before the debit
Notice contentMerchant name, amount, debit date and time, mandate reference, reason
Opt-outCustomer can skip one debit or the whole mandate, with AFA
ChargesNo charge to the customer for using the e-mandate facility

Three design changes follow for the app. First, a renewal charge cannot happen the moment you ask for it. Stripe's India docs say it waits 26 hours after a payment request before charging a card, a buffer on top of the 24-hour rule.

Second, think about off-session charges, the ones made while the customer is not present. Because of the 24-hour notice, an off-session upgrade charge cannot be collected at once. You can add the prorated amount to the next renewal, or take it as a normal payment while they are on the page.

Third, watch the amount. One Pro seat at ₹999 sits well under ₹15,000. A team on 20 Pro seats renews at ₹19,980, so each renewal needs AFA. Stripe's India docs say the pre-debit notice then carries a link for that step.

What the directions do not say about retries

The 2026 directions do not mention retries of failed debits. Our reading is that each retry is a new debit, so it needs its own notice at least 24 hours ahead. Check your processor's behaviour before relying on this.

Stripe's retry docs, as of 26 October 2026, say it does not retry payments automatically when the card is India-issued. So dunning for Indian cards leans on emails and an easy way to pay the open invoice by hand.

If you run your own retries, the notice lead time sets the earliest retry. A failure at 10:00 on 1 December allows a retry no sooner than 10:00 on 2 December, or 12:00 with a 26-hour buffer.

How should your system handle billing webhooks?

Treat every webhook as a hint to fetch the truth, not as the truth itself. A webhook is an HTTP request the processor sends to your server when something happens, such as a paid or failed invoice.

Stripe's webhook docs, read on 26 October 2026, make four points that are good practice with any processor. Verify the signature before acting. Return a 2xx success code quickly, before slow work. Expect duplicates. Do not expect events in order.

Stripe retries failed deliveries for up to three days in live mode, waiting longer between each try, so your endpoint may see the same event again. Log each event ID you have processed and skip repeats, as the earlier code does.

Ordering matters for billing. If invoice.paid arrives before the event that created the subscription, a naive handler fails. Stripe's docs suggest fetching the latest invoice and subscription from the API instead.

The docs also warn about the first of the month, when many subscriptions renew at once. A queue between the webhook receiver and the workers absorbs that spike. The webhook design guide covers retries and signatures in more depth.

  1. Verify the signature

    Check the signature header against the raw request body and your signing secret. Reject the request if it fails.

  2. Store the event and return 2xx

    Insert the event ID into a processed-events table and push the event onto a queue. Respond within a few seconds.

  3. Skip duplicates

    If the event ID is already stored, do nothing. The unique key makes this a single database insert.

  4. Fetch the current state

    Load the invoice and subscription from the processor's API, so a late or out-of-order event cannot roll status back.

  5. Update status, then entitlements

    Write the new status, recompute entitlements and send the matching email.

Run a nightly reconciliation job too. It compares your subscription statuses with the processor's records and fixes any drift a missed webhook left behind.

How do you explain subscription billing system design in a system design interview?

Lead with the core tension: money moves without the customer present, so every step must be repeatable and every status change must reach access control. Then walk one renewal through the system.

  1. State the goal

    Charge the right amount each period, recover failed payments and keep feature access in step with payment.

  2. Name the entities

    Plan catalogue, subscriptions with exact period timestamps, invoices with line items, and an entitlement store.

  3. Walk through a renewal

    A scheduler finds due subscriptions, the invoice service builds one invoice per period, and the charge carries an idempotency key.

  4. Show a plan change

    Prorate to the second with a fixed timestamp, round each line, and defer downgrades to the period end.

  5. Handle failure

    Retry soft declines on a widening schedule, stop on hard declines and keep a grace period longer than the last retry.

  6. Mention webhooks and India

    Deduplicate events by ID and fetch current state. For Indian cards, allow for the 24-hour pre-debit notice and AFA above ₹15,000.

Questions people ask

How do you calculate proration for a subscription upgrade?
Multiply each plan's price by the fraction of the billing period left. Credit that share of the old plan and charge that share of the new one. For a ₹499 to ₹999 upgrade with 20 of 30 days left, the credit is ₹332.67, the charge ₹666.00 and the net ₹333.33. Use the period's real length and round each line separately.
What is dunning in subscription billing?
Dunning is the process of recovering a failed recurring payment. It combines planned retries of the charge with emails asking the customer to update their card or pay the open invoice. Good dunning retries temporary declines over days or weeks, stops retrying hard declines such as a stolen card, and ends with a clear rule for removing access.
How long should a subscription grace period be?
Long enough to cover your last retry, plus a little slack. If retries run until day 9 after a failed renewal, a 7-day grace period cuts off customers who were about to pay. Set grace to end after the final retry, and consider reduced access rather than a hard lockout while it runs.
Should a downgrade take effect immediately?
Usually not. Many products switch downgrades at the end of the paid period, because the customer already paid for the higher plan. If you prorate immediately, the unused amount becomes a credit. Stripe's docs say negative prorations are not refunded automatically, so a common design uses the credit to reduce the next invoice instead.
How do you stop a billing system from charging a customer twice?
Use two guards. A unique constraint on subscription and period start stops a second invoice for the same period. An idempotency key on each charge, built from the invoice ID and attempt number, lets the processor return the first result when a timed-out request is sent again. Deduplicate webhook events by their event ID too.
What are the RBI rules for recurring card payments in India?
As of 26 October 2026, the RBI directions of 21 April 2026 apply. A mandate is registered once with extra authentication, and the first debit needs it too. Later debits up to ₹15,000 need no extra authentication, or ₹1 lakh for insurance, mutual funds and credit card bills. The issuer must notify the customer at least 24 hours before each debit.
Why use entitlements instead of checking the plan name?
Because plan names change and multiply. Yearly plans, trials, discounts and custom deals all grant features differently. If product code checks one feature name, such as gantt_charts, you can change pricing without touching that code. The billing side works out entitlements from the plan and status and stores them where the app can read them fast.

Sources and further reading

Practise this with Lala

Practise