Trial Period Management

A trial is the riskiest part of the subscription lifecycle to get wrong because nothing is charged yet, so bugs are silent: entitlements that never expire, conversions that fire twice, or reminders that arrive after the customer is already billed. This page is part of Subscription Billing Architecture & Pricing Models and treats trial management as a small, deterministic slice of the broader subscription lifecycle states: a clock-driven deadline, an idempotent conversion charge, and a reminder schedule, all anchored to a single immutable UTC timestamp. Whether you collect a card up front is a product decision with engineering consequences β€” covered in depth in Card Required Vs No Card Trials Conversion Tradeoffs.

Prerequisites

Architecture & Data Flow

The trial subsystem has one input that matters β€” the passage of time toward trial_ends_at β€” plus optional customer actions (cancel, add card). A scheduler scans for upcoming and past deadlines, emits reminder and conversion events, and the FSM transitions the subscription. Entitlement and email side-effects flow through the outbox so they never fire without the state change committing.

Trial lifecycle and conversion flow A trial moves from start through reminders to a conversion charge that yields active or expired. trial starts deadline set reminders D-7 / D-3 / D-1 conversion charge (idemp) active expired paid declined / no card
Reminders and the conversion charge are all anchored to one immutable trial-end timestamp.

Implementation Walkthrough

1. Start the trial and anchor the deadline

Compute trial_ends_at once, in UTC, and never recompute it from calendar days later. Everything downstream references this single value.

from datetime import timedelta
from django.utils import timezone

def start_trial(customer_id: str, price_id: str, trial_days: int) -> str:
    now = timezone.now()  # tz-aware UTC
    sub = Subscription.objects.create(
        customer_id=customer_id,
        price_id=price_id,
        current_state="trialing",
        trial_ends_at=now + timedelta(days=trial_days),  # immutable anchor
    )
    Outbox.objects.create(
        aggregate_id=sub.id,
        event_type="entitlement.granted",
        payload={"customer_id": customer_id, "price_id": price_id},
    )
    return sub.id

2. Scan deadlines on a clock, not on webhooks

A periodic job finds trials that are nearing or past their deadline. Using the clock as the trigger means a missing trial_will_end webhook never strands a subscription in trialing forever.

-- Trials due for conversion now (claimed in batches by parallel workers)
SELECT id, customer_id, price_id, trial_ends_at
FROM subscriptions
WHERE current_state = 'trialing'
  AND trial_ends_at <= now()
FOR UPDATE SKIP LOCKED
LIMIT 200;

3. Schedule reminders before the deadline

Reminder timing is a fixed offset from the immutable deadline. Emit them through the outbox so a reminder is never sent for a trial that was already canceled in the same transaction.

REMINDER_OFFSETS_DAYS = [7, 3, 1]  # D-7, D-3, D-1 before trial_ends_at

def schedule_reminders(sub) -> None:
    for days_before in REMINDER_OFFSETS_DAYS:
        send_at = sub.trial_ends_at - timedelta(days=days_before)
        if send_at <= timezone.now():
            continue  # ⚠️ skip reminders already in the past for short trials
        ReminderJob.objects.create(
            subscription_id=sub.id, send_at=send_at, kind=f"trial_d{days_before}"
        )

4. Run the conversion charge idempotently

The charge that converts the trial must be exactly-once. Derive the idempotency key from the subscription id and the immutable trial-end instant so retries and concurrent triggers collapse to one charge. The detailed friction-free flow lives in Handling Free Trial Conversions Without Payment Friction.

async function convertTrial(sub: TrialSub): Promise<'active' | 'expired'> {
  if (!sub.paymentMethodId) return 'expired';  // βœ— no-card trial with no card added

  const idempotencyKey = `convert:${sub.id}:${sub.trialEndsAt}`; // immutable anchor
  try {
    await psp.charges.create(
      {
        amount: sub.amountCents,       // integer minor units
        currency: sub.currency,
        payment_method: sub.paymentMethodId,
        off_session: true,
        confirm: true,
      },
      { idempotencyKey }
    );
    return 'active';                   // βœ… converted
  } catch (e) {
    await enqueueDunning(sub.id);      // ⚠️ retry path before giving up
    return 'expired';
  }
}

Edge Cases & Failure Modes

Scenario Why it breaks Mitigation
trial_ends_at recomputed from β€œnow + N days” on edit Customer’s deadline silently shifts; reminders misfire Treat the anchor as immutable; only an explicit extension event may change it
Conversion charge fires twice (cron + webhook) Double-charge Idempotency key derived from (subscription_id, trial_ends_at)
Payment method expires mid-trial Silent conversion failure at the deadline Account-updater refresh + a D-7 prompt to update the card
Reminder scheduled in the past for a 2-day trial Email never sends or sends late Skip offsets that fall before now(); clamp to the start
Trial-expiry webhook lost Row stuck in trialing, entitlements leak Clock-driven scan converts on deadline regardless of webhooks
Same email starts many trials Trial abuse, free usage forever Fingerprint + email-normalization checks at trial start

Performance & Scale

The conversion scan is the only query that touches every trialing row, so index subscriptions(current_state, trial_ends_at) and let workers claim batches with FOR UPDATE SKIP LOCKED to parallelize without lock contention. Reminders fan out from a reminder_jobs table indexed on (send_at) partial to undelivered rows; a single worker draining due jobs handles tens of thousands of trials per minute. Keep the conversion charge off the request path entirely β€” it is a background job β€” so a slow gateway never blocks a user. At high volume, batch entitlement-grant outbox rows by customer to reduce downstream write amplification.

Testing Strategy

Inject a mock clock so trial-start, reminder, and conversion tests are deterministic rather than dependent on wall time. Run the conversion job twice for the same subscription and assert exactly one charge and one state transition. Test the short-trial path where some reminder offsets fall in the past and assert they are skipped, not sent late. Simulate a lost expiry webhook by never delivering it and assert the clock-driven scan still converts the trial on its deadline. For anti-abuse, assert that a normalized duplicate email is flagged before a second trial provisions.

Frequently Asked Questions

Should the conversion charge run synchronously when the trial ends? No. Run it as an idempotent background job keyed on the trial-end instant. Synchronous charging on the request path couples user latency to gateway latency and makes retries awkward; a background job converts cleanly and lets dunning take over on decline without blocking anyone.

How do I prevent a customer from getting unlimited free trials? Normalize and fingerprint at trial start β€” lowercase and strip plus-addressing from emails, hash the payment instrument if one is collected, and rate-limit trials per fingerprint. The full anti-abuse trade-off, including when card-up-front is the right lever, is in Card Required Vs No Card Trials Conversion Tradeoffs.

Do trial periods need ledger entries even though nothing is charged? Record them as zero-amount, non-revenue line items with a distinct GL code so acquisition cost is traceable and tax engines log the 0.00 transaction, but never as recognized revenue. This keeps ASC 606 / IFRS 15 reporting clean and separates trial cohorts from paid ones.

What is the right reminder schedule? A D-7 / D-3 / D-1 cadence covers most SaaS: early enough to update a failing card, late enough to catch intent. For short trials (under 7 days), collapse to D-3 / D-1 and skip any offset that lands before the trial started.