Modeling Subscription Pause and Resume

Pause-and-resume is the retention lever between “keep paying for something you are not using” and “cancel entirely” — a customer who can pause for two months is a customer who does not churn. But adding it to a billing engine is deceptively tricky: pause must suspend entitlements and billing without losing the billing anchor, resume must decide whether the paused time is free or shifts the cycle, and paused subscriptions must drop out of MRR while staying recoverable. This page is a concrete extension of Subscription Lifecycle States: the FSM changes, the anchor handling, and the resume-time proration that keep the ledger honest.

You reach for this when churn-survey data shows people cancelling for temporary reasons — a seasonal business, a parental leave, a budget freeze — that a pause would have retained. The engineering is a new state and a set of guards, done carefully so a paused subscription is a first-class citizen, not a boolean bolted onto active.

The distinction that trips teams up is that pause is not a lightweight cancellation. A cancel tears down the entitlement grant and lets the row settle into a terminal state; the customer’s payment method, plan reference, and cycle history become read-only history. A pause has to preserve every one of those so resume is a cheap reactivation rather than a fresh signup. That means the subscription_id never changes, the customer_id stays attached to the same payment instrument, and the plan reference and price version are held exactly as they were at pause time. If your resume path re-reads the current catalogue price instead of the version pinned on the row, a customer who paused a legacy plan comes back paying today’s rate — a silent price increase that generates support tickets and, in some jurisdictions, a consent problem. Pin the price version on the subscription row and read it back verbatim on resume.

Trade-offs

The core policy choice is what resume does to the cycle: extend the period by the paused duration (paused time is free), or resume immediately on the original anchor (paused time is forfeited). The map contrasts the options and their revenue and fairness implications.

Pause/resume policies Extend-the-period gives paused time free and shifts the anchor, resume-on-anchor forfeits paused time, and a capped pause limits the free window. Extend period paused time free anchor shifts forward customer-friendly Resume on anchor paused time forfeited anchor unchanged simplest accounting Capped pause max N months free auto-resume after cap bounds the exposure
A capped extend-the-period pause is the common default — customer-friendly, but bounded so a subscription cannot pause indefinitely.

Most SaaS teams pick extend-the-period with a cap: the paused months are free, the anchor shifts forward, and an auto-resume deadline stops a subscription from sitting paused forever. That bounds the revenue exposure while giving the retention benefit.

The extend-versus-forfeit choice is really a prepaid-versus-postpaid choice

Which policy is even coherent depends on when the money moved. On a prepaid annual plan, the customer has already paid for the full term; forfeiting paused time means they lose days they bought, which is indefensible and will show up as chargebacks. Extend-the-period is the only honest option there — you owe them the service days regardless of when they take them. On a postpaid or monthly-in-arrears plan, nothing has been charged for the paused window yet, so resume-on-anchor simply skips billing for the gap and re-bills from the original day-of-month; the customer never paid for the pause, so there is nothing to forfeit. A subtle bug is mixing the two: a monthly plan that shifts the anchor by 18 paused days drifts the billing day from, say, the 3rd to the 21st of the month, and after a few pauses the customer’s charge date has wandered somewhere they no longer recognise. If preserving the day-of-month matters more than crediting the exact paused seconds, resume-on-anchor plus a skipped cycle is cleaner for monthly plans, and extend-the-period is reserved for prepaid terms where you genuinely owe service days.

Bounding the exposure with a cap and a pause budget

The cap does double duty: it prevents a subscription from parking in paused as a permanent free tier, and it puts a ceiling on the deferred-revenue liability you carry on prepaid plans. A 90-day cap on a plan billed at 9900 minor units per month bounds the maximum free window to roughly three cycles of foregone revenue per subscription. Teams that see abuse — customers pausing every month to skip payment while keeping their spot — add a per-year pause budget on top of the per-pause cap: for example, at most 60 paused days in any rolling 12-month window, tracked as a running sum of paused_duration on the customer record. The budget is enforced at the pause guard, before the state transition, so an over-budget pause request is rejected with a clear reason rather than half-applied. Model both the per-pause cap and the annual budget explicitly; a single global cap is usually too blunt to satisfy both retention and finance.

Step-by-Step Implementation

The five steps add the paused state and guards, freeze the anchor and suspend entitlements, schedule a clock-driven auto-resume, prorate on resume, and adjust MRR. The FSM diagram shows where paused slots into the lifecycle.

FSM with paused state Active transitions to paused on pause and back to active on resume; paused can also transition to canceled, and an auto-resume deadline forces the return. active billing runs paused no billing, no access canceled auto-resume pause resume
The paused state sits between active and canceled, with a clock-driven auto-resume so a pause always ends.

1. Add the paused state and its guards

Extend the transition table; paused is reachable from active and returns to active or cancels. Everything else is rejected.

Note what is deliberately absent from the table. There is no past_due -> paused edge: a subscription with an open unpaid invoice should not be allowed to pause its way out of a dunning cycle, or a customer could dodge collection indefinitely by pausing the moment a charge fails. Resolve the delinquency first, then pause from active. Equally there is no paused -> past_due: while paused, no invoice is generated, so there is no charge to fail and no dunning to enter. Keeping these edges out of LEGAL means the guard rejects them structurally, and you never have to reason about the meaning of a paused-and-past-due subscription because the state is simply unreachable. Every pause and resume call should also carry an idempotency_key so a retried request — the classic double-click on a “Pause” button — collapses to a single transition instead of pausing, then immediately being seen as already-paused and erroring.

LEGAL = {
    "active":   {"paused", "past_due", "canceled"},
    "paused":   {"active", "canceled"},          # resume or cancel while paused
    "past_due": {"active", "canceled"},
    "canceled": set(),
}

def assert_transition(current: str, target: str) -> None:
    if target not in LEGAL[current]:
        raise ValueError(f"illegal transition {current} -> {target}")   # ✗ reject

2. Freeze the anchor and suspend entitlements on pause

Record the pause instant and the remaining days in the current cycle. Suspend access through the entitlements service; do not delete the subscription.

Storing remaining_period_seconds rather than only paused_at is a deliberate choice: it captures the state of the cycle at the moment of pause, so resume does not have to reconstruct it from a chain of subsequent events. If you stored only the pause timestamp and recomputed the remaining time on resume, any cycle boundary that would have fallen during the paused window becomes ambiguous — did the cycle roll over while paused or not? Freezing the seconds sidesteps that entirely: the cycle is suspended mid-flight and resumes with exactly the time that was left. The WHERE ... AND status = 'active' clause is not decoration; it makes the update a compare-and-swap so two concurrent pause requests cannot both succeed. The second one matches zero rows, and your application code should treat a zero-row result as “already paused” and return the existing state rather than raising.

The entitlement suspension must be driven from the outbox, not written inline in the same transaction as an HTTP call to the entitlements service. If you call the service synchronously and it times out after the row is already flipped to paused, you have a paused subscription that still grants access — the worst failure mode, because the customer keeps consuming the product for free. Emitting a subscription.paused row into the outbox inside the same transaction as the status flip, then letting a relay deliver it with at-least-once semantics, guarantees the revocation eventually lands and can be safely retried. The entitlements consumer must be idempotent on subscription_id plus the pause event id so a redelivered revocation is a no-op.

UPDATE subscriptions
SET status = 'paused',
    paused_at = now(),
    remaining_period_seconds = EXTRACT(EPOCH FROM current_period_end - now())::BIGINT
WHERE subscription_id = $1 AND status = 'active';
-- entitlements service consumes subscription.paused from the outbox and revokes access

3. Schedule a clock-driven auto-resume

A cap means a pause always ends. Drive it from a reconciliation job, not a webhook, so a missing signal never strands a paused subscription.

The reason to poll a clock rather than trust a scheduled webhook or a delayed message is durability. A webhook or a queue message that says “resume subscription_id at T+90d” is a single point of failure: if it is dropped, filtered by a downstream outage, or lost in a queue purge, the subscription silently overstays its cap and keeps accruing free time with nothing to correct it. A reconciliation query that runs every few minutes and asks “which paused subscriptions are now past their cap?” is self-healing — it recomputes the truth from durable state on every pass, so a missed run just means the resume happens on the next tick, not never. The FOR UPDATE SKIP LOCKED clause lets several workers run the sweep concurrently without contending on the same rows or double-resuming a subscription; each worker claims a disjoint batch, processes it, and the locks release on commit. Bound the batch with LIMIT 500 so a backlog of expired pauses is drained over several passes instead of one worker taking a table-wide lock and blocking pause requests. Store the cap window as a configurable per-plan value rather than the hard-coded INTERVAL '90 days' shown here, so enterprise plans can carry a longer allowance without a code change.

-- Paused subscriptions past their maximum pause window, resumed by the clock.
SELECT subscription_id
FROM subscriptions
WHERE status = 'paused'
  AND paused_at <= now() - INTERVAL '90 days'   -- max pause cap
FOR UPDATE SKIP LOCKED
LIMIT 500;

4. Prorate or shift the cycle on resume

On resume, restore the frozen remaining days by shifting the period end forward by the paused duration (extend-the-period policy), so the customer keeps the time they paid for.

The arithmetic looks trivial but has two edges worth pinning down. First, compute paused_duration from the stored paused_at and the actual resume instant, and clamp it to the cap — if a reconciliation lag means the clock fires at 90 days plus a few minutes, you do not want to grant the extra minutes as a matter of policy drift; clamp to the exact cap so extend-the-period and auto-resume agree on the boundary. Second, whether resume triggers an immediate charge depends on where in the cycle the pause happened. If the customer paused mid-cycle on a prepaid plan, resume grants the remaining paid days and the next charge is simply pushed out by paused_duration — no money moves at resume. If the customer paused exactly at a renewal boundary, resume should generate the renewal invoice that was deferred, using the same subscription_id and a fresh invoice_id, so the cycle restarts cleanly. Deciding this from remaining_period_seconds — zero or near-zero means a boundary pause that needs a renewal charge, a positive value means mid-cycle days to hand back — keeps the branch honest and testable. Never issue the resume charge before the state flip commits; emit it through the same outbox so a failed charge attempt cannot leave the subscription active-but-unbilled.

from datetime import timedelta

def resume(sub, now) -> None:
    paused_duration = now - sub.paused_at
    sub.current_period_end = sub.current_period_end + paused_duration   # ✅ anchor shifts
    sub.status = "active"
    sub.paused_at = None
    # entitlements restored via subscription.active outbox event

5. Exclude paused subscriptions from MRR

A paused subscription accrues no revenue, so it must drop out of active MRR while staying recoverable in cohort reports.

The nuance is that “excluded from MRR” is not the same as “churned.” A paused subscription is contracted revenue that is temporarily not billing, which is materially different from a cancelled one that is gone. If your churn metric counts a pause as a cancellation, you understate retention and overstate churn, and every retention experiment that pause was supposed to help will look like it made things worse. The clean model is a dedicated paused_mrr line alongside active_mrr: the paused amount leaves active MRR the moment the subscription pauses and rejoins it on resume, and the net movement between the two is reported as a reversible contraction rather than logo churn. That distinction matters to finance because paused MRR is a leading indicator of recovery — a rising paused balance with healthy resume rates is very different from the same balance with pauses that never come back. Keep the paused subscriptions queryable by customer_id and paused_at so the cohort view can show resume rate by pause length, which is the number that tells you whether your cap is set sensibly.

SELECT SUM(monthly_amount_minor) AS active_mrr
FROM subscriptions
WHERE status = 'active';   -- paused explicitly excluded

Verification & Testing

The tests prove the anchor survives a pause, entitlements toggle correctly, the auto-resume fires on the cap, and MRR excludes paused. Use a mock clock to pause for 30 days and assert the period end shifts by exactly 30 days on resume. Assert access is revoked on pause and restored on resume. Drive the clock past the 90-day cap and assert auto-resume fires. The panel lists them.

Pause/resume tests The anchor shifts by the paused duration, entitlements revoke and restore, the cap forces auto-resume, and MRR excludes paused subscriptions. Anchor pause 30d shifts 30d Entitlements revoke on pause restore on resume Cap past 90d auto-resume MRR paused excluded recoverable
The anchor-shift test is the one that matters — a pause must never quietly re-bill the customer early or bill them for paused time.

Gotchas & Production Pitfalls

The pitfalls are overloading active with a flag, losing the anchor, forgetting the cap, and mis-counting MRR. The map groups them.

Pause pitfalls A boolean pause flag reintroduces illegal states, a lost anchor mis-bills on resume, no cap strands a subscription, and MRR double-counts paused subs. Boolean flag is_paused on active → first-class state Lost anchor recompute on resume → freeze remaining No cap paused forever → auto-resume MRR counts paused → exclude explicitly
Four pitfalls — modeling pause as a boolean on active is the root of most of them.
  • Modeling pause as a boolean on active. An is_paused flag on active reintroduces the illegal combinations the FSM exists to prevent, and every MRR and entitlement query must remember to check it. Make paused a first-class state.
  • Losing the billing anchor. If you recompute the period from now() on resume, the customer is billed early or late. Freeze the remaining seconds at pause and restore them on resume.
  • No auto-resume cap. Without a cap, a subscription can sit paused indefinitely, indefinitely free. A clock-driven auto-resume bounds the exposure.
  • MRR counting paused subscriptions. A paused sub accrues no revenue; include it in active MRR and your metrics overstate. Exclude it explicitly and keep it in the recoverable cohort.

Frequently Asked Questions

Should a pause have a maximum length? Yes. An open-ended pause is a free account with extra steps, and a bounded pause of one to three months with an automatic resume date is both easier to reason about and more likely to convert back.

What happens to the billing anchor after a resume? Either the anchor holds and the first period after resume is short, or it shifts to the resume date. Pick one, state it at pause time, and apply it consistently — the ambiguity is what confuses customers.

Does a paused subscription count as churn? It is contraction to zero rather than churn, provided the definition says so and is applied to both sides of any retention calculation. Whatever you choose, apply it consistently.

Should paused accounts keep their data? Yes, entirely. Preserving the workspace is the reason a pause outperforms a cancellation as a retention offer, and removing anything undermines the whole mechanism.