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.
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.