You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(subscriptions): make cancel_at a real schedule signal and harden the plan-change state machine (#545) (#558)
`subscriptions.cancel_at` shipped as NOT NULL DEFAULT now() with no writer
ever advancing it, so every row was born with a cancel date permanently in
the past. Three bugs fell out of that:
1. The Solana crank ORed `cancel_at <= now()` into its cancel decision, so
it cancelled EVERY subscription at its first rollover.
2. `change_subscription_plan` writes `cancel_at = NULL` on the reactivate
branch, which the NOT NULL column rejected with 23502 — every A -> B -> A
plan switch failed.
3. Nothing gated price: a student on a free plan could switch to a paid one,
and a self-managed (non-Stripe/LS) subscriber could upgrade, with no
payment taken.
Schema (20260726120000): `cancel_at` becomes nullable with no default,
`cancel_at_period_end` becomes NOT NULL DEFAULT false, and a CHECK
(`subscriptions_cancel_at_requires_schedule`) pins the contract — a cancel
date may exist only while the flag is set. `cancel_at_period_end` is now the
ONLY signal that a cancel is scheduled; `cancel_at` is informational.
`canceled_at`/`ended_at` lose their now() defaults too, and live rows are
backfilled clean.
handle_new_subscription (20260726120100): the parallel-subscription backstop
now counts `renewed` as live, and a resubscribe clears the stale cancel
fields instead of inheriting them.
change_subscription_plan (20260726120200): takes a per-user advisory lock so
a double-submit cannot build two subscriptions, writes `cancel_at = NULL`
alongside the flag, refuses an upgrade that would not settle natively
(`upgrade_requires_payment`), and carries a pending cancel across the swap
instead of silently dropping or resurrecting it.
Application side:
- solana-pull-decision reads only `cancel_at_period_end`; a stale date no
longer cancels a healthy subscription.
- `renewed` added to BLOCKING_SUBSCRIPTION_STATUSES (it bills and grants
access, so it must block a parallel checkout).
- The `subscription.past_due` webhook branch now actually writes the status
instead of dropping the event. (Refund handling is deliberately untouched.)
- Cancelling never IMPROVES a status: a past_due student who cancels stays
past_due rather than being rewritten as healthy. Reactivating clears
`cancel_at` with the flag.
- Billing UI treats past_due as live (it was falling through to the "no
subscription" empty state mid-dunning), shows a dunning notice, and only
offers same-or-cheaper plans when the provider cannot settle the swap.
Tests: new `subscription-plan-change-rpc.spec.ts` drives the RPC against a
real DB as an authenticated student (A -> B, A -> B -> A, cancel-then-switch,
same_plan, cross-tenant, the price gate, the advisory lock), asserting the
entitlement delta per course via `has_course_access`. plan-change.spec.ts
previously CLAIMED that coverage while nothing exercised the RPC at all —
which is how bugs 2 and 3 shipped; its header now points at the real spec.
Each fix was proven load-bearing by restoring the old schema/function and
watching the new tests go red (A -> B -> A reproduced the exact 23502).
Unit: 427/427 (411 on master). Playwright plan-change + entitlement batch
19/19.
Closes#545
Claude-Session: https://claude.ai/code/session_01WiXe7s6snHsso7q9LE9gCq
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
-**`subscriptions.cancel_at_period_end` is the ONLY signal that a cancel is scheduled** (since `20260726120000`, issue #545). `cancel_at` is nullable and purely informational — the CHECK `subscriptions_cancel_at_requires_schedule` allows it to be non-NULL only while the flag is set, so clear both together (both reactivate actions do). It shipped as `NOT NULL DEFAULT now()` with no writer setting it, which made the Solana crank cancel every subscription at its first rollover. `subscription_status` is `active`/`canceled`/`expired`/`renewed`/`past_due`; `renewed` and `past_due` both count as LIVE (parallel-subscription guards, billing UI, plan change), and cancelling must never *improve* a status
108
109
-**Access control lives in `entitlements`, not `enrollments`** (since migration `20260516150000`): `entitlements` (`user_id`, `course_id`, `tenant_id`, `source_type`, `source_id`, `status`, `expires_at`) is the polymorphic source of truth for product/subscription access. `enrollments.product_id`/`subscription_id` and their old CHECK constraint were dropped — `enrollments` is now a learning-progress record only (`user_id`, `course_id`, `status`, `tenant_id`, `enrollment_date`)
109
110
-`enroll_user()` RPC loops through ALL courses for a product (a product can map to multiple courses via `product_courses`) and writes to `entitlements`
110
111
-**Subscriptions grant access, not auto-enrollment** — students self-enroll via `/dashboard/student/browse` (`useEnrollment()` hook); `plan_courses` defines which courses a plan covers
|`cancel_at`| TIMESTAMPTZ |NULLABLE. When the subscription terminates, or NULL when no cancel is scheduled — informational, never a signal. CHECK `subscriptions_cancel_at_requires_schedule`: non-NULL only while `cancel_at_period_end` (#545)|
529
+
|`cancel_at_period_end`| BOOLEAN |NOT NULL DEFAULT false. **The single source of truth for "a cancel is scheduled"** — read by the Solana crank, the expiry crons and the billing UI. It used to be OR'd with `cancel_at <= now()`, and since `cancel_at` defaulted to `now()` on every INSERT, that canceled every `solana_subs` subscription at its first rollover (#545)|
530
530
|`canceled_at` / `ended_at`| TIMESTAMPTZ ||
531
531
|`superseded_by`| INTEGER | Set on plan change — points at the replacement subscription |
0 commit comments