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)
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_window → converted / 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).
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.audit_log row. Action namespaces: founder.trial.status_transition (as defined in the trial engine doc).expired transition.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.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 | 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.) |
| Transition | Reason |
|---|---|
grace_window → active |
Terminal entry into grace. No reversal. |
grace_window → warning_* |
Grace is downstream of warnings; regression is illegal. |
lapsed → any |
Terminal state. |
converted_to_paid → any |
Terminal state. |
The daily scheduler (founders.daily_sweep) is responsible for the active|warning_* → grace_window transition (per the trial engine doc). At grace-entry:
founder_trial.status = 'grace_window', grace_ends_at = compute_grace_end(expires_at).audit_log row.founders.grace_entered event (consumed by email dispatcher — see §7).grace_ends_at computationcompute_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).
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 --> [*]
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.
Antlers reads a single endpoint to determine banner state. This avoids Antlers needing to implement business-day logic.
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.
These mechanics activate when status = 'lapsed'. The enforcement is read-time filtering, not deletion.
GET /api/trading/history (and related history endpoints): filter response to rows where created_at >= utcnow() - 90 days.DELETE statement fires in the downgrade path. This must be a named test assertion in the integration test suite.lapsed Founder are readable (GET /api/strategies/{id} works).POST /api/strategies/{id}/run returns HTTP 403 with error code STRATEGY_READ_ONLY_LAPSED_FOUNDER).PUT /api/strategies/{id} returns HTTP 403 with error code STRATEGY_READ_ONLY_LAPSED_FOUNDER).POST /api/trading/orders returns HTTP 403 with error code PAPER_TRADE_BLOCKED_GRACE during grace_window.PAPER_TRADE_BLOCKED_LAPSED during lapsed.cta_url: "/billing" field in the error response body for Antlers to render an inline explanation.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.
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.
When a Founder converts during grace:
customer.subscription.created with a paid, monetized subscription.FounderTrialService.transition_to(user_id, 'converted_to_paid', actor='stripe', reason=subscription_id).FounderTrialService validates the transition is legal (from grace_window, active, or warning_*).founder_trial.converted_at = now, founder_trial.status = 'converted_to_paid'. audit_log written.GET /api/founders/trial/banner → {status: 'converted_to_paid', variant: null} → banner removed.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.
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
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
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
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:
NNNN_founders_grace_warning_types.sql): adds new CHECK values to founder_warning_sent.warning_type. Additive only.FLAG_FOUNDERS_GRACE_V1=off.FLAG_FOUNDERS_GRACE_V1=on (per cohort, phased rollout). Grace-entry and lapse transitions are part of the daily sweep.founders.grace_entered event.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.
| 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.
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.
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.
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.
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.
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.