A subscription pause feature lets users temporarily halt their billing and access without cancelling. Common in SaaS and subscription boxes: users going on vacation, experiencing financial difficulty, or trying a competitor. Key challenges: accurately prorating credits for the paused period, resuming exactly on schedule, integrating with the billing provider (Stripe), and handling pause limits (max 3 months/year).
Core Data Model
CREATE TYPE subscription_status AS ENUM ('active','paused','cancelled','past_due');
CREATE TABLE Subscription (
subscription_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE,
plan_id TEXT NOT NULL,
status subscription_status NOT NULL DEFAULT 'active',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
stripe_subscription_id TEXT UNIQUE,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE SubscriptionPause (
pause_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL REFERENCES Subscription(subscription_id),
paused_at TIMESTAMPTZ NOT NULL,
resume_at TIMESTAMPTZ NOT NULL, -- scheduled auto-resume
resumed_at TIMESTAMPTZ, -- actual resume time (NULL if not yet resumed)
pause_reason TEXT,
pause_duration_days INT NOT NULL,
prorate_credit_cents INT NOT NULL DEFAULT 0, -- credit issued for unused period
billing_anchor_adjusted BOOLEAN NOT NULL DEFAULT FALSE,
created_by UUID NOT NULL -- user or support agent
);
CREATE INDEX idx_pause_subscription ON SubscriptionPause (subscription_id, paused_at DESC);
CREATE INDEX idx_pause_resume ON SubscriptionPause (resume_at) WHERE resumed_at IS NULL;
-- Track total pause days per subscription per year for limit enforcement
CREATE VIEW PauseUsageThisYear AS
SELECT
subscription_id,
SUM(pause_duration_days) AS total_pause_days,
COUNT(*) AS pause_count
FROM SubscriptionPause
WHERE paused_at >= date_trunc('year', NOW())
GROUP BY subscription_id;
Pausing a Subscription
from datetime import datetime, timezone, timedelta
import stripe, psycopg2
stripe.api_key = "sk_..."
MAX_PAUSE_DAYS_PER_YEAR = 90
MAX_PAUSE_DURATION_DAYS = 30
def pause_subscription(
conn,
subscription_id: str,
actor_id: str,
pause_days: int,
reason: str | None = None
) -> dict:
if not (1 <= pause_days MAX_PAUSE_DAYS_PER_YEAR:
remaining = MAX_PAUSE_DAYS_PER_YEAR - used_days
raise ValueError(f"Pause limit: only {remaining} days remaining this year")
now = datetime.now(timezone.utc)
resume_at = now + timedelta(days=pause_days)
# Calculate prorate credit: days remaining in current period × daily rate
days_remaining = (period_end - now).days
plan_price_cents = get_plan_price_cents(conn, subscription_id)
period_days = 30 # approximate; use actual period length in production
daily_rate_cents = plan_price_cents // period_days
prorate_credit_cents = max(0, days_remaining * daily_rate_cents)
# Pause Stripe subscription: set billing_cycle_anchor and pause_collection
stripe.Subscription.modify(
stripe_sub_id,
pause_collection={"behavior": "void"}, # void invoices during pause
)
# Issue Stripe credit note or balance credit
if prorate_credit_cents > 0:
stripe.Customer.create_balance_transaction(
get_stripe_customer_id(conn, subscription_id),
amount=-prorate_credit_cents, # negative = credit
currency="usd",
description=f"Pause credit for {pause_days}-day pause"
)
with conn.cursor() as cur:
cur.execute("UPDATE Subscription SET status = 'paused' WHERE subscription_id = %s", (subscription_id,))
cur.execute("""
INSERT INTO SubscriptionPause
(subscription_id, paused_at, resume_at, pause_reason, pause_duration_days, prorate_credit_cents, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING pause_id
""", (subscription_id, now, resume_at, reason, pause_days, prorate_credit_cents, actor_id))
pause_id = cur.fetchone()[0]
conn.commit()
return {"pause_id": str(pause_id), "resume_at": resume_at.isoformat(), "credit_cents": prorate_credit_cents}
Auto-Resume Worker
def run_resume_worker(conn):
"""
Polling worker: find subscriptions whose pause window has elapsed and resume them.
Runs every minute.
"""
import time
while True:
resume_due_subscriptions(conn)
time.sleep(60)
def resume_due_subscriptions(conn):
now = datetime.now(timezone.utc)
with conn.cursor() as cur:
# Find pauses whose resume_at has passed and haven't been resumed yet
cur.execute("""
SELECT p.pause_id, p.subscription_id, s.stripe_subscription_id
FROM SubscriptionPause p
JOIN Subscription s USING (subscription_id)
WHERE p.resume_at <= %s
AND p.resumed_at IS NULL
AND s.status = 'paused'
FOR UPDATE SKIP LOCKED
""", (now,))
due = cur.fetchall()
for pause_id, subscription_id, stripe_sub_id in due:
try:
resume_subscription(conn, subscription_id, pause_id, stripe_sub_id)
except Exception as e:
import logging
logging.error(f"Failed to resume {subscription_id}: {e}")
def resume_subscription(conn, subscription_id: str, pause_id: str, stripe_sub_id: str):
# Re-enable billing collection in Stripe
stripe.Subscription.modify(
stripe_sub_id,
pause_collection="" # empty string removes the pause
)
with conn.cursor() as cur:
cur.execute("UPDATE Subscription SET status = 'active' WHERE subscription_id = %s", (subscription_id,))
cur.execute("UPDATE SubscriptionPause SET resumed_at = NOW() WHERE pause_id = %s", (pause_id,))
conn.commit()
Key Interview Points
- Stripe pause_collection: Setting pause_collection={“behavior”:”void”} stops Stripe from generating and charging invoices during the pause period. On resume, Stripe resumes the billing cycle from where it left off (next period end is extended by the pause duration if you set trial_end). Always test in Stripe test mode — the interaction between prorations, credits, and pause is complex.
- Proration approach: Two options: (1) credit the unused portion back to the customer’s Stripe balance (used above), which applies automatically on their next invoice; (2) extend the current period end by the pause duration (no credit issued, just delay billing). Option 1 is cleaner for mid-cycle pauses; option 2 is simpler for same-day pauses. Document clearly in your billing policy.
- Annual pause limit enforcement: The PauseUsageThisYear view aggregates by calendar year. Alternatively, enforce a rolling 12-month window: SUM(pause_duration_days) WHERE paused_at >= NOW() – interval ’12 months’. The VIEW approach is fast because it’s computed on query; for very high read volume, materialize it with a nightly refresh.
- Re-activation on payment: A paused subscription should not go past_due. Handle the Stripe webhook customer.subscription.updated — if status transitions from paused to past_due (missed payment before pause took effect), set subscription status to past_due and notify the user. Do not auto-resume a past_due subscription.
- User-facing state machine: active → paused (on pause request) → active (on scheduled resume or manual resume). active → cancelled (on cancel request). paused → cancelled (allowed — user can cancel during pause). paused → past_due: treat as cancelled. Never allow paused → active without going through the resume path — the billing anchor must be updated.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How does Stripe pause_collection work and what are the billing implications?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Setting pause_collection={"behavior": "void"} on a Stripe subscription tells Stripe to void (not charge) any invoices generated during the pause period. The subscription remains in Stripe’s system as "active" but generates no revenue. When you remove pause_collection (setting it to an empty string or null), Stripe resumes normal billing from the next period boundary. The billing cycle anchor is preserved — if the subscription normally bills on the 15th, it still bills on the 15th after resumption. This means the customer may be charged for a partial month immediately after resumption. To avoid this: set trial_end = resume_at when removing the pause, which extends the current free period until resume_at before billing. Always test pause/resume flows in Stripe test mode with webhook forwarding to catch edge cases.”}},{“@type”:”Question”,”name”:”How do you prevent subscription abuse where users repeatedly pause to avoid billing?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Abuse pattern: pause 30 days before each billing cycle, resume after, effectively using the service for free. Mitigations: (1) Maximum annual pause days (MAX_PAUSE_DAYS_PER_YEAR = 90) — once used up, pause requests are rejected. (2) Minimum active period before pause eligibility: require at least 30 days of active subscription before first pause. (3) Maximum pauses per year (e.g., 2 pauses per year) — even if under the day limit. (4) Billing cycle anchor awareness: if the current period ends within 7 days, deny pause (abusive pattern). Implement these checks in the pause_subscription() function before calling Stripe. Expose the remaining pause allowance in the subscription management UI so honest users understand their limits.”}},{“@type”:”Question”,”name”:”How do you handle proration when a user resumes early?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”If a user pauses for 30 days but resumes on day 10 (manually), they used only 10 pause days. The credit issued at pause time was for 30 days. Options: (1) Keep the full credit applied — simpler, rewards early resumers, customer-friendly. (2) Recalculate credit at resume time: void the original credit (create a positive balance transaction to cancel it), issue a new credit for only 10 days. This is more accurate for accounting but adds complexity. Option 1 is used by most consumer SaaS products. Option 2 is used in enterprise billing where accuracy matters for revenue recognition. In both cases, update SubscriptionPause.resumed_at = NOW() when the user resumes early, so usage reports are accurate.”}},{“@type”:”Question”,”name”:”How do you notify users before their pause period ends?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Send a reminder notification 48 hours before scheduled resume. Schedule the notification at pause creation time using a ScheduledNotification: send_at_utc = resume_at – 48 hours. Message: "Your subscription resumes on {resume_at}. Your card ending in {last4} will be charged {plan_price}." Also send a confirmation email immediately after pausing. If the user doesn’t want to resume (they decided to cancel), the reminder gives them time to cancel before being charged. Track notification delivery in the audit log. This reduces involuntary churn caused by users who forgot they paused and are surprised by a charge.”}},{“@type”:”Question”,”name”:”How does the auto-resume worker handle failures during resumption?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The resume worker fetches due pauses and calls stripe.Subscription.modify() + DB updates. Stripe API calls can fail (network timeout, 500 from Stripe). Error handling: (1) On failure, log the error and leave status = "paused" — the worker will retry on its next tick (every 60 seconds). (2) After 10 consecutive failures, alert on-call: "Resume of subscription {id} has been failing for 10 minutes." (3) Add a retry_count column to SubscriptionPause to track attempts. (4) Use SKIP LOCKED in the worker query so multiple worker instances do not attempt to resume the same subscription simultaneously. Most Stripe failures are transient — the subscription resumes within a few minutes without manual intervention.”}}]}
Subscription pause and billing hold system design is discussed in Stripe system design interview questions.
Subscription pause and merchant billing design is covered in Shopify system design interview preparation.
Subscription pause and host account management design is discussed in Airbnb system design interview guide.