Raxx · internal docs

internal · gated ↑ index

Founders Grace Window + Paid-Tier Transition

Status: Draft Owner: software-architect Last updated: 2026-04-23 Parent card: #210 — Implement grace window and paid-tier transition at Founders trial expiration Parent epic: #204 — Founders Promo Related ADRs: 0003, 0016, 0019 Cross-references: founders-trial-engine.md (state machine, FounderTrialService, scheduler), founders-referral-service.md (Stripe webhook handler), auth.md §3 (audit_log schema)


1. Context

When a Founder's trial window closes (expires_at passes), they enter a 5-business-day grace window. During grace, read access is preserved and an upgrade CTA is shown on every authenticated page. If the Founder converts to a paid subscription during grace, they keep the Founders pricing ($19/mo, locked). If they do not convert by the end of 5 business days, their account downgrades to free-tier posture — data is not deleted, but access is restricted.

This design extends the state machine in founders-trial-engine.md. The trial engine doc defines active, warning_*, grace_window, converted_to_paid, and lapsed states. This doc refines the grace_windowconverted / expired transitions, the business-day computation, the banner rendering contract, and the downgrade mechanics. Cross-references to the trial engine doc are noted throughout; nothing in this doc supersedes or duplicates the trial engine design.

Terminology alignment: the trial engine doc uses the state name grace_window; #210 uses grace. This doc uses grace_window to match the canonical state machine in founders-trial-engine.md. The display copy uses "grace period" (human-readable).


2. Invariants that apply

  1. No stored credentials. No new credential surfaces. ADR 0002 CI grep applies.
  2. GDPR by default (ADR 0003). Grace-window timing data (grace_ends_at) is metadata on the founder_trial row, which cascades on users.id delete. Downgrade is an access-level change, not a deletion event. audit_log rows for grace transitions: 7-year retention.
  3. Audit trail for every state change. Every grace-entry, grace-conversion, and grace-expiration writes an audit_log row. Action namespaces: founder.trial.status_transition (as defined in the trial engine doc).
  4. No deletion on downgrade. Paper-trading history older than 90 days is filtered at the API layer; rows are not deleted. Paid strategies become read-only; they are not deleted. This is a hard invariant — test assertions must confirm no delete jobs fire on the expired transition.
  5. Paper-first gating. New paper-trade submissions are blocked during grace and after expiration. This is enforced at the API layer in Raptor. The MBT live-trading gate is unchanged.
  6. Flag gating. FLAG_FOUNDERS_GRACE_V1=off by default. Phased rollout per cohort. The flag is checked at grace-entry (scheduler), banner API, and email dispatch. Existing non-Founders code paths are unaffected when the flag is off.
  7. Business-day calendar is configurable. The 5-business-day window uses a US federal holiday calendar. The specific library and holiday list are decided in ADR 0019. The calendar source is configurable via env (not hard-coded).

3. State machine extension

This section extends the state machine in founders-trial-engine.md §5. The states active, warning_*, grace_window, converted_to_paid, and lapsed are defined there. This doc adds precision to the grace transitions.

State table (grace-specific)

State Entry condition Exit conditions
grace_window expires_at passed (scheduler sweep) converted_to_paid: Stripe webhook (paid subscription). → lapsed: grace_ends_at passed (scheduler sweep).
converted_to_paid Stripe webhook OR admin action during grace or warning period Terminal.
lapsed grace_ends_at passed with no payment Terminal. lapsed = free-tier posture in this design. (Note: #210 uses expired; the trial engine uses lapsed. They are the same terminal state. The canonical name is lapsed per the trial engine doc.)

Illegal transitions

Transition Reason
grace_windowactive Terminal entry into grace. No reversal.
grace_windowwarning_* Grace is downstream of warnings; regression is illegal.
lapsed → any Terminal state.
converted_to_paid → any Terminal state.

Grace-entry event

The daily scheduler (founders.daily_sweep) is responsible for the active|warning_* → grace_window transition (per the trial engine doc). At grace-entry:

  1. Set founder_trial.status = 'grace_window', grace_ends_at = compute_grace_end(expires_at).
  2. Write audit_log row.
  3. Emit founders.grace_entered event (consumed by email dispatcher — see §7).

grace_ends_at computation

compute_grace_end(expires_at: datetime_utc) -> datetime_utc:
    # Advance by 5 business days from expires_at (UTC date)
    # Business day = Mon–Fri excluding US federal holidays
    # Returns the end of the 5th business day at 23:59:59 UTC
    # Implementation delegates to the calendar library chosen in ADR 0019

grace_ends_at is stored in UTC on the founder_trial row. Display to the user is in their local timezone (derived from browser locale or user profile — see §5 banner contract).

Idempotency

The transition_to(user_id, target_state, actor, reason) method (defined in the trial engine doc) is the single entry point for all transitions. It checks: - Current state is a legal predecessor of target_state. - A transition to target_state has not already occurred (idempotent keyed on (user_id, target_state)).

The scheduler's grace-entry sweep is catch-up capable (per ADR 0016 / trial engine §8): if a row's expires_at is in the past and its status is not yet grace_window, the sweep transitions it regardless of how many days were missed.

stateDiagram-v2
    [*] --> active : trial init
    active --> warning_30d : days_remaining <= 30
    warning_30d --> warning_14d : days_remaining <= 14
    warning_14d --> warning_7d : days_remaining <= 7
    warning_7d --> warning_1d : days_remaining <= 1
    warning_1d --> grace_window : expires_at passed
    active --> grace_window : expires_at passed (catch-up)
    warning_30d --> grace_window : expires_at passed (catch-up)
    warning_14d --> grace_window : expires_at passed (catch-up)
    warning_7d --> grace_window : expires_at passed (catch-up)
    grace_window --> converted_to_paid : Stripe webhook (paid, monetized)
    grace_window --> lapsed : grace_ends_at passed
    active --> converted_to_paid : early conversion (any time)
    warning_30d --> converted_to_paid : early conversion
    warning_14d --> converted_to_paid : early conversion
    warning_7d --> converted_to_paid : early conversion
    warning_1d --> converted_to_paid : early conversion
    lapsed --> [*]
    converted_to_paid --> [*]

4. Business-day calculation

Definition: 5 business days = 5 calendar days that are Monday–Friday AND not US federal holidays.

Timezone handling: - grace_ends_at is computed and stored in UTC. - The expires_at anchor for business-day counting is the UTC date of expiration. - Display to users converts UTC to their local timezone for day-count messaging (see §5). - Business-day counting does NOT use the user's timezone — it uses UTC dates. This is a simplification. For v1, a user whose trial expires at 23:59 UTC Thursday, and whose local timezone is UTC+9 (Friday), sees "5 business days" computed from UTC Friday. This is acceptable for v1.

Library choice: ADR 0019 records the decision. Candidates: - pandas_market_calendars (has USFederalHolidayCalendar and NYSE calendar). Heavy dependency. - workalendar (lightweight, has UnitedStatesCalendar). Preferred for weight. - Custom: enumerate federal holidays from FOUNDERS_HOLIDAY_LIST env var (JSON array of ISO dates). Simplest, most controllable, but requires ops to update annually.

See ADR 0019 for the final decision and license evaluation. This doc does not pre-decide the library.

Holiday calendar source is configurable via env var (FOUNDERS_HOLIDAY_CALENDAR=us_federal or similar). Rotatable and overrideable without redeploy for testing.


5. Banner rendering contract

Antlers reads a single endpoint to determine banner state. This avoids Antlers needing to implement business-day logic.

Endpoint

GET /api/founders/trial/banner

Response (for grace_window state):

{
  "status": "grace_window",
  "variant": "grace",
  "expires_at_utc": "2026-05-10T23:59:59Z",
  "grace_ends_at_utc": "2026-05-17T23:59:59Z",
  "business_days_remaining": 3,
  "copy_key": "founders.grace.banner.n_days",
  "cta_url": "/billing",
  "dismissible": false
}

Response (for lapsed state):

{
  "status": "lapsed",
  "variant": "expired",
  "copy_key": "founders.expired.banner",
  "cta_url": "/billing",
  "dismissible": false
}

Response (for active / warning states):

{
  "status": "warning_7d",
  "variant": "warning",
  "days_remaining": 7,
  "expires_at_utc": "2026-05-17T23:59:59Z",
  "copy_key": "founders.warning.banner.7d",
  "cta_url": "/billing",
  "dismissible": false
}

Response (for converted_to_paid):

{
  "status": "converted_to_paid",
  "variant": null
}

Copy keys are resolved by Antlers against the copy dictionary (attorney-reviewed copy per #197). The API returns keys, not copy strings — this keeps the banner text updatable without a backend deploy.

dismissible: false for grace_window and lapsed — the banner is not session-dismissible for these states. warning_* banners may be dismissible (Antlers decision; the API signals dismissible: false for grace/lapsed; dismissible: true for warnings — to be confirmed by ux-polisher).

business_days_remaining is computed fresh on each API call from grace_ends_at and the current UTC datetime using the same calendar library as compute_grace_end. Antlers does not recompute this.


6. Free-tier downgrade mechanics

These mechanics activate when status = 'lapsed'. The enforcement is read-time filtering, not deletion.

Paper-trading history

Paper-trade order submission

Access enforcement pattern

All enforcement is a middleware check on the founder_trial.status field. A new Raptor middleware (FounderAccessMiddleware) reads the trial status from the session JWT claim (or DB lookup on cache miss) and attaches it to the request context. Endpoint handlers check the context, not the DB directly.


7. Email coordination

Two new warning email types are added to the founder_warning_sent table (defined in the trial engine doc §3 — the table is referenced there as part of the scheduler's idempotency mechanism). The canonical definition of founder_warning_sent lives in the trial engine doc; this doc only adds new warning_type values.

Warning type Trigger Idempotency key
grace_d1 1st business day of grace window (within 24h of grace_ends_at - 4 business days) (user_id, 'grace_d1')
grace_d_final Final business day of grace window (within 24h of grace_ends_at) (user_id, 'grace_d_final')

Dispatch: The daily scheduler sweep detects when a grace_window row's business-day count matches the threshold, checks founder_warning_sent for the idempotency key, and if absent, emits founders.warning_triggered {trial_id, warning_type, grace_ends_at}. The email dispatcher (separate card, #209 extended) consumes this event.

Email content: - grace_d1: "Your Founders trial just ended — here's your 5-business-day window to add payment. $19/mo locked for Founders." + CTA to /billing. - grace_d_final: "Last chance — your Founders trial access expires today. Add payment to keep your history and strategies." + CTA to /billing. All copy blocks attorney-reviewed per #197.


8. Stripe subscription transition flow

When a Founder converts during grace:

  1. Stripe fires customer.subscription.created with a paid, monetized subscription.
  2. The existing Stripe webhook dispatcher (in Raptor) routes to the conversion handler.
  3. The conversion handler calls FounderTrialService.transition_to(user_id, 'converted_to_paid', actor='stripe', reason=subscription_id).
  4. FounderTrialService validates the transition is legal (from grace_window, active, or warning_*).
  5. founder_trial.converted_at = now, founder_trial.status = 'converted_to_paid'. audit_log written.
  6. Antlers reads GET /api/founders/trial/banner{status: 'converted_to_paid', variant: null} → banner removed.
  7. Full paper-trading access restored immediately (middleware reads new status).

Founders pricing persistence: The $19/mo pricing is a Stripe product/price ID. When the Founder subscribes at /billing, the billing page presents this price. Once subscribed, the Stripe subscription carries the price — the Raptor trial engine does not enforce pricing. The pricing lock is in the Stripe product configuration.

Proration: A Founder who converts during grace starts a new subscription at the billing cycle start. There is no partial-month credit for the unused grace window. This is a product decision (locked per #210); this design does not add proration logic.

Failed payment during grace: A payment attempt that fails does NOT transition the state. The Founder remains in grace_window. Raptor shows the payment failure inline on the billing page (existing billing error handling). The scheduler does not accelerate grace_ends_at on payment failure.


9. Sequence diagrams

9.1 Grace entry → banner render

sequenceDiagram
    participant CEL as Celery beat (founders.daily_sweep)
    participant FTS as FounderTrialService
    participant DB as DB (founder_trial)
    participant AL as audit_log
    participant EVTS as Internal event bus

    CEL->>FTS: run_daily_sweep()
    FTS->>DB: SELECT rows WHERE expires_at <= now AND status NOT IN ('grace_window','converted_to_paid','lapsed')
    loop each expired row
        FTS->>FTS: compute_grace_end(expires_at)
        FTS->>DB: UPDATE founder_trial SET status='grace_window', grace_ends_at=computed
        FTS->>AL: INSERT audit_log (action='founder.trial.status_transition', old=prev, new='grace_window')
        FTS->>EVTS: emit founders.grace_entered {trial_id, grace_ends_at}
    end

    Note over EVTS: Email dispatcher consumes founders.grace_entered -> sends grace_d1 email
sequenceDiagram
    participant ANT as Antlers (React)
    participant RAP as Raptor /api/founders/trial/banner
    participant DB as DB (founder_trial)

    ANT->>RAP: GET /api/founders/trial/banner
    RAP->>DB: SELECT founder_trial WHERE user_id=caller
    RAP->>RAP: compute business_days_remaining from grace_ends_at
    RAP-->>ANT: {status:'grace_window', business_days_remaining:4, cta_url:'/billing', dismissible:false}
    ANT->>ANT: render persistent red grace banner

9.2 Conversion during grace (happy path)

sequenceDiagram
    participant F as Founder (browser)
    participant ANT as Antlers
    participant STR as Stripe
    participant RWH as Raptor webhook handler
    participant FTS as FounderTrialService
    participant DB as DB (founder_trial)
    participant AL as audit_log

    F->>ANT: clicks "Add payment" CTA on grace banner
    ANT->>STR: Stripe.js creates subscription at $19/mo Founders price
    STR-->>F: payment confirmed
    STR->>RWH: POST /webhooks/stripe (customer.subscription.created, status=active)
    RWH->>FTS: transition_to(user_id, 'converted_to_paid', actor='stripe', reason=subscription_id)
    FTS->>DB: UPDATE founder_trial SET status='converted_to_paid', converted_at=now
    FTS->>AL: INSERT audit_log (action='founder.trial.status_transition', new='converted_to_paid')
    RWH-->>STR: 200
    F->>ANT: next page load
    ANT->>RWH: GET /api/founders/trial/banner
    RWH-->>ANT: {status:'converted_to_paid', variant:null}
    ANT->>ANT: banner removed; full access restored

9.3 Grace expiration (sad path)

sequenceDiagram
    participant CEL as Celery beat (founders.daily_sweep)
    participant FTS as FounderTrialService
    participant DB as DB (founder_trial)
    participant AL as audit_log
    participant EVTS as Internal event bus

    CEL->>FTS: run_daily_sweep()
    FTS->>DB: SELECT rows WHERE status='grace_window' AND grace_ends_at <= now
    loop each expired grace row
        FTS->>DB: UPDATE founder_trial SET status='lapsed', lapsed_at=now
        FTS->>AL: INSERT audit_log (action='founder.trial.status_transition', new='lapsed')
        FTS->>EVTS: emit founders.trial_lapsed {trial_id}
    end

    Note over EVTS: Antlers reads banner endpoint -> {status:'lapsed', variant:'expired'}\n90-day history filter applies; paid strategies read-only

10. Migration path

Schema changes: No new tables. Changes to founder_trial: - grace_ends_at column already defined in founders-trial-engine.md §3. No new column. - founder_warning_sent table: add warning_type values grace_d1 and grace_d_final. This is a data change (new enum values), not a schema change. The existing CHECK constraint on warning_type must be updated in the migration.

Middleware: FounderAccessMiddleware is a new Raptor middleware. It does not modify existing middleware. It is registered conditionally: only when FLAG_FOUNDERS_GRACE_V1=on.

API: /api/founders/trial/banner is a new endpoint. Does not modify existing routes.

Rollout steps:

  1. Schema migration (NNNN_founders_grace_warning_types.sql): adds new CHECK values to founder_warning_sent.warning_type. Additive only.
  2. Middleware + banner API + access enforcement land behind FLAG_FOUNDERS_GRACE_V1=off.
  3. Scheduler grace-entry logic activated when FLAG_FOUNDERS_GRACE_V1=on (per cohort, phased rollout). Grace-entry and lapse transitions are part of the daily sweep.
  4. Email dispatcher wired to founders.grace_entered event.
  5. Flag flip per cohort: earliest cohort first.

Rollback: Set FLAG_FOUNDERS_GRACE_V1=off. Scheduler ignores grace transitions. Banner API returns empty response or non-grace status. Access enforcement middleware is inactive. No data is deleted on rollback.


11. Security + GDPR checklist

Question Answer
What PII does this collect? No new PII. Grace-window timing data (grace_ends_at, lapsed_at) is metadata on the founder_trial row. founder_trial already cascades on users.id delete.
Retention period? grace_ends_at, lapsed_at: retained for account lifetime then cascade-deleted per ADR 0003. audit_log rows for grace transitions: 7-year retention.
Deletion on DSR? Covered by ON DELETE CASCADE on users.id for founder_trial. No new tables; no new DSR surface.
Audit logging? Every grace-entry, grace-conversion, and lapse writes an audit_log row. No PII in context field beyond user_id and subscription_id.
Stored credential risk? None.
Breach response? Covered by ADR 0003 breach pipeline. No new sensitive data surface.
Where are secrets? FLAG_FOUNDERS_GRACE_V1, FOUNDERS_GRACE_WINDOW_DAYS (default 5), FOUNDERS_HOLIDAY_CALENDAR — all env vars. Rotatable without redeploy.
Kill switch? FLAG_FOUNDERS_GRACE_V1=off disables the grace mechanics entirely. Per-user admin override is in scope for #212 (admin dashboard).

No-deletion invariant test: The integration test suite MUST include a named assertion: test_lapse_does_not_delete_history_rows and test_lapse_does_not_delete_strategies. These are not optional.


12. Open questions — decisions needed before sub-cards can be claimed

  1. Business-day library choice. Three candidates are identified in §4 (pandas_market_calendars, workalendar, custom enum). License, weight, and maintenance profile need evaluation. ADR 0019 will record the decision. Needs: Kristerpher confirmation + ADR 0019 filed before sub-card covering compute_grace_end can be claimed.

  2. Holiday calendar update procedure. US federal holidays change (observed dates shift). Who updates FOUNDERS_HOLIDAY_CALENDAR or the library's holiday list? Is there an annual ops runbook entry? Needs: Kristerpher to assign ops ownership.

  3. Strategy read-only scope. "Paid strategies become read-only" — are ALL strategies owned by the Founder read-only at lapse, or only strategies that required a paid feature to create (e.g., strategies using premium data sources)? If the Founder had free-tier-eligible strategies, should those remain runnable? Needs: product-manager clarification before sub-card covering lapse enforcement is claimable.

  4. Warning email sender for grace emails. The grace_d1 and grace_d_final emails are dispatched via the email service. Is the email dispatcher from #209 already designed and ready as a dependency, or does this card need to define its own email trigger path? Needs: dependency confirmation with #209 feature-developer before sub-card covering grace email dispatch can be claimed.

  5. Billing page Founders pricing display. The billing page must show $19/mo with a struck-through standard Pro price. Is the existing billing page (/billing) owned by a specific sub-card already? What is the integration point for the Founders price display? Needs: Kristerpher to assign ownership or confirm it is in scope for the frontend sub-card of this design.


End of doc. See ADR 0019 for the business-day calendar library decision.