Status: Proposed — not yet implemented Author: Claude Code session, 2026-05-16 Decision input: Confirmed by product owner — the same course will be sold both as a one-time product AND included in a subscription plan (overlap is intended).
enrollments carries two unrelated concerns in one row:
- Access grant — "user may open course X" (
status) - Billing attribution — "...because of product P or subscription S" (
product_id/subscription_id+ thevalid_enrollmentCHECK)
The CHECK constraint:
CHECK ( (product_id IS NOT NULL AND subscription_id IS NULL)
OR (product_id IS NULL AND subscription_id IS NOT NULL) )forces exactly one access source per (user, course). The unique index enrollments_user_id_course_id_key (user_id, course_id) allows only one row per (user, course).
Because a course can be reached via a product and a plan (intended), a user can legitimately hold two access sources for one course. The schema cannot represent this. Symptoms:
- HTTP 500 on plan purchase —
handle_new_subscription'sON CONFLICT (user_id, course_id) DO UPDATEsetssubscription_idon a row that already hasproduct_id→ both non-null →valid_enrollmentviolation → transaction rolls back. Verified 2026-05-16 on the Code Academy tenant. - Silent access loss (worse than the 500) — if the upsert is "fixed" by nulling the other column, a subscription lapse can revoke a course the user bought to own permanently via a product, because the product attribution was overwritten.
Separate entitlement (why a user has access) from enrollment (learning progress). Allow multiple concurrent access sources per (user, course). Make subscription lapse revoke only subscription-derived access.
- No change to
transactions,subscriptions,products,plans,payment_requestsschemas. - No change to the checkout UI.
- No change to Stripe integration surface (webhook handlers change internally only).
- Lesson/exam content RLS stays tenant-scoped (it already is — see §4.5). Enrollment-based content RLS is noted as optional hardening, out of scope.
One row per access source per (user, course).
CREATE TYPE entitlement_source AS ENUM ('product', 'subscription', 'free', 'admin_grant');
CREATE TYPE entitlement_status AS ENUM ('active', 'revoked', 'expired');
CREATE TABLE entitlements (
entitlement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
course_id integer NOT NULL REFERENCES courses(course_id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
source_type entitlement_source NOT NULL,
source_id integer, -- product_id | subscription_id; NULL for free / admin_grant
status entitlement_status NOT NULL DEFAULT 'active',
granted_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz, -- NULL = perpetual (products, free); set for subscriptions
revoked_at timestamptz,
CONSTRAINT entitlements_source_id_shape CHECK (
(source_type IN ('product','subscription') AND source_id IS NOT NULL)
OR (source_type IN ('free','admin_grant') AND source_id IS NULL)
)
);
-- one entitlement per (user, course, source). NULLS NOT DISTINCT (PG15+) so that
-- two 'free' rows (source_id NULL) for the same course also collide.
CREATE UNIQUE INDEX entitlements_unique_source
ON entitlements (user_id, course_id, source_type, source_id) NULLS NOT DISTINCT;
CREATE INDEX idx_entitlements_access ON entitlements (user_id, course_id, status);
CREATE INDEX idx_entitlements_tenant ON entitlements (tenant_id);
CREATE INDEX idx_entitlements_source ON entitlements (source_type, source_id); -- revoke-by-subscriptionNotes
bigintidentity PK (project elsewhere uses_idinteger serials; identity is the modern equivalent and avoids sequence ownership quirks).expires_at: products & free =NULL(perpetual); subscription entitlements = the subscriptionend_date. Access check treatsexpires_at <= now()as no access even before a status sweep runs.source_idis intentionally not a real FK — it points at eitherproducts.product_idorsubscriptions.subscription_iddepending onsource_type. A polymorphic reference; integrity is maintained by the RPCs. (Alternative: two nullable FK columns. Rejected — reintroduces the XOR-shaped smell.)
Becomes a pure learning record: "user has this course in their workspace / progress."
- Keep:
enrollment_id,user_id,course_id,tenant_id,status,enrollment_date. statussemantics narrow to learning only:active(in progress) |completed. Thedisabledvalue is no longer set by billing logic (access is gated byentitlements).disabledmay remain in the enum unused, or be dropped in Phase 3.- Drop (Phase 3):
product_id,subscription_id, both FKs, thevalid_enrollmentCHECK. - Unique index
(user_id, course_id)stays — still one learning record per course.
An enrollments row is created by the RPCs at grant time (as today), so existing dashboard/progress queries keep working with minimal change.
A single source of truth, callable from SQL (RLS, RPCs) and reportable to the app.
CREATE OR REPLACE FUNCTION has_course_access(_user_id uuid, _course_id integer)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM entitlements e
WHERE e.user_id = _user_id
AND e.course_id = _course_id
AND e.status = 'active'
AND (e.expires_at IS NULL OR e.expires_at > now())
);
$$;All three functions live in a new migration; they currently sit in supabase/migrations/20260316000000_fix_enrollment_rpcs.sql.
For each course in product_courses for the product:
- Upsert
entitlements—source_type='product',source_id=_product_id,status='active',expires_at=NULL.ON CONFLICT (user_id, course_id, source_type, source_id) DO UPDATE SET status='active', revoked_at=NULL. - Upsert
enrollments— learning record only:(user_id, course_id, tenant_id, status='active'),ON CONFLICT (user_id, course_id) DO UPDATE SET status = CASE WHEN enrollments.status='completed' THEN 'completed' ELSE 'active' END. Noproduct_id/subscription_idwritten (Phase 3); in Phase 1 still written for dual-write — see §5.
Never touches subscription entitlements. The overlap collision is gone by construction.
- Upsert
subscriptions(unchanged). - For each course in
plan_courses:- Upsert
entitlements—source_type='subscription',source_id=<subscription_id>,status='active',expires_at=<subscription end_date>. - Upsert
enrollmentslearning record (same as §3.1 step 2).
- Upsert
Never touches product entitlements.
Current behaviour: on status → canceled/expired, sets enrollments.status='disabled' for that subscription_id; on → active, re-enables.
New behaviour — operate on entitlements:
- →
canceled/expired:UPDATE entitlements SET status='expired', revoked_at=now() WHERE source_type='subscription' AND source_id=<subscription_id>. - →
active/renewed:UPDATE entitlements SET status='active', expires_at=<new end_date>, revoked_at=NULL WHERE source_type='subscription' AND source_id=<subscription_id>. - Never touches product or free entitlements. A user who also owns a course outright keeps access. This is the core correctness win.
handle_student_subscription_expiry() (daily cron) is unchanged — it flips subscriptions.subscription_status, the trigger cascades.
Replaces the fabricated $0 product workaround in enrollFree. Upserts entitlements (source_type='free', source_id=NULL) + the enrollments learning record. Removes the need to create synthetic products / product_courses rows.
The original note here assumed revenue_splits was a per-sale ledger written by
the Stripe webhook. It is not — revenue_splits is a per-tenant config row
(platform_percentage, school_percentage, applies_to_providers, default
['stripe']), with the rate synced from the school's platform plan
(app/actions/admin/billing.ts).
The real gap was in getRevenueOverview (app/actions/admin/revenue.ts): it
computed platformFees = totalRevenue × platform_percentage over every
successful transaction, ignoring applies_to_providers. Stripe Connect collects
the platform fee via application_fee_amount; manual/offline sales settle
directly to the school, so charging them a platform fee in the dashboard
overstated fees and understated the school's net revenue.
Fixed: getRevenueOverview now derives each transaction's provider
(stripe_payment_intent_id IS NOT NULL → stripe, else manual) and applies
the platform fee only to revenue from providers in applies_to_providers. Also
fixed a latent bug in the same function — it selected a non-existent
created_at column (the column is transaction_date), which broke the monthly
trend.
Touch points from a full repo sweep. Grouped; Phase column says when each change lands (see §5).
lib/services/enrollment-service.ts — CourseAccess, EnrollmentData types and determineAccessStatus(). This is the single best place to absorb the model change. Rework it to read entitlements:
getCourseAccess(userId, courseId)→ queryentitlements, return{ hasAccess, sources: [...], isExpired, soonestExpiry }.accessTypebecomesaccessTypes: ('product'|'subscription'|'free'|'admin_grant')[](a course can now have several).- Keep a thin back-compat shim during Phase 2 so callers migrate incrementally.
Route as many of the callers below through this service as possible instead of hand-rolled .from('enrollments') access checks. Phase 2.
| File | Change | Phase |
|---|---|---|
app/[locale]/(public)/checkout/actions.ts — enrollUser (product + plan branches) |
No signature change; RPCs do the new work. Remove the direct enroll_user RPC call in the product branch — the after_transaction_insert trigger already enrolls (see §6 double-path). Keep transaction insert. |
2 |
app/[locale]/(public)/checkout/actions.ts — enrollFree |
Replace fabricated-product logic with a call to grant_free_entitlement. Delete the admin-client products/product_courses insert block. |
2 |
app/actions/payment-requests.ts — completeAndEnroll |
Same: rely on the transaction trigger; drop the explicit enroll_user/handle_new_subscription calls. |
2 |
| File | Change | Phase |
|---|---|---|
lib/hooks/use-enrollment.ts |
Currently client-side INSERT into enrollments with subscription_id. Replace with an RPC call self_enroll_subscription_course(course_id) (new, SECURITY DEFINER) that verifies an active subscription covers the course, then upserts a subscription entitlement + learning record. Client-side INSERT of a billing row is itself a smell — moving it server-side is a bonus hardening. |
2 |
app/[locale]/dashboard/student/browse/page.tsx |
"already enrolled" set: query entitlements (active) instead of enrollments. |
2 |
components/student/browse-course-card.tsx |
EnrollmentStatus type + enroll handler follow the hook change. |
2 |
All switch from select enrollment where status='active' to has_course_access() / the enrollment service.
| File | Phase |
|---|---|
app/[locale]/dashboard/student/courses/[courseId]/page.tsx (course overview gate) |
2 |
app/[locale]/dashboard/student/courses/[courseId]/exams/[examId]/page.tsx (exam gate) |
2 |
app/[locale]/dashboard/student/courses/[courseId]/lessons/[lessonId]/page.tsx |
2 |
app/[locale]/dashboard/student/courses/[courseId]/community/page.tsx |
2 |
app/actions/community.ts (enrolled-before-post check) |
2 |
app/actions/teacher/lesson-resources.ts (joins enrollments.product_id = pc.product_id) |
2 |
app/api/chat/aristotle/route.ts, app/api/exercises/artifact/evaluate/route.ts, app/api/exercises/media/upload-url/route.ts |
2 |
app/api/certificates/generate/route.ts, app/api/certificates/issue/route.ts |
2 |
Current enrollments RLS (SELECT own / teacher+admin tenant / super admin; INSERT own; UPDATE admin) — keep, entitlements gets a parallel policy set:
SELECT "Students view own entitlements" : auth.uid() = user_id AND tenant_id = get_tenant_id()
SELECT "Teachers/admins view tenant entitlements"
SELECT "Super admins view all entitlements"
-- No INSERT/UPDATE policy for end users: all writes go through SECURITY DEFINER RPCs.
lessons / exams SELECT RLS today is tenant-scoped only (any tenant user can read) — access is gated in page components, not RLS. No RLS change required. Optional hardening (separate ticket): add has_course_access() to lessons/exam_questions SELECT policies for defence-in-depth.
These read enrollments.product_id / subscription_id to show "lifetime vs expires":
| File | Change | Phase |
|---|---|---|
app/[locale]/dashboard/student/courses/page.tsx (in-progress/completed list, lines ~30-178) |
Join entitlements instead of product_id/subscription_id; a course may now show multiple badges. |
2 |
components/student/enrolled-course-card.tsx |
Render from entitlements sources. |
2 |
app/[locale]/dashboard/student/page.tsx, .../progress/page.tsx |
Mostly progress %; verify they don't read billing columns. | 2 |
app/[locale]/dashboard/admin/enrollments/page.tsx + admin/teacher/analytics pages |
Listing — switch billing display to entitlements; counts that use raw enrollments are fine. |
2 |
app/api/stripe/webhook/route.ts — charge.refunded currently does UPDATE enrollments SET status='disabled' WHERE product_id=.... Change to UPDATE entitlements SET status='revoked', revoked_at=now() WHERE source_type='product' AND source_id=<product_id>. Plan refunds already go via subscription status → trigger. Phase 2.
- Regenerate Supabase TypeScript types after Phase 1 (adds
entitlements) and after Phase 3 (drops columns):supabase gen types. supabase/seed.sql,tests/demos/seed-demo-data.sql— addentitlementsrows; dropenrollments.product_idinserts in Phase 3.- Migration
20260310000000_lesson_resources_sequential_scheduling.sqlcontains a JOINenrollments e ON e.product_id = pc.product_id— that historical migration file is not edited (already applied); but any view or function it created with that join must be found and rewritten. Audit during Phase 2.
tests/playwright/enrollment-flows.spec.ts, tests/student/student-flow.spec.ts, tests/playwright/student-exams.spec.ts, tests/purchase-flow.spec.ts — update enrollment setup/teardown to entitlements. Add a new spec: plan purchase for a course already owned via product (the bug this whole plan exists for).
Each phase is independently shippable and reversible.
- Migration
A: createentitlement_source/entitlement_statusenums,entitlementstable, indexes, RLS,has_course_access(). - Migration
B: backfill — for each existingenrollmentsrow, insert the matchingentitlementsrow:product_idset →('product', product_id, expires_at=NULL)subscription_idset →('subscription', subscription_id, expires_at=<sub end_date>)- Current data: 7 rows, all product-based, 0 subscription, 0 orphan — backfill is trivial and safe.
- Migration
C: drop thevalid_enrollmentCHECK + rewriteenroll_user,handle_new_subscription,handle_subscription_status_change, addgrant_free_entitlement— dual-write: they writeentitlements(new) and keep writing the legacyenrollmentsbilling columns.- The
valid_enrollmentCHECK (product_idXORsubscription_id) is what raised the HTTP 500. Dropping it is the minimal, lossless crash fix: the columns stay and are still dual-written, a legacy row may now legitimately carry both ids, and that legacy attribution is simply no longer read once Phase 2 lands. (This supersedes the earlier "null the opposing column" stopgap idea — dropping the CHECK keeps both attributions instead of destroying one.)
- The
- App: no code change. (No generated Supabase types file exists in this repo — types are hand-written, see §4.1 — so there is nothing to regenerate.)
Outcome: plan purchase works again, including the product+plan overlap case; entitlements is populated and kept in sync; nothing reads it yet. Fully reversible (drop table + restore the CHECK + revert functions).
Status: Phase 1 applied 2026-05-16. Migrations
20260516120000_entitlements_table.sql,20260516120001_entitlements_backfill.sql,20260516120002_entitlements_dual_write_rpcs.sql. Backfilled 7 product entitlements. Verified end-to-end: a plan purchase for courses already owned via products now succeeds and produces coexistingproduct(perpetual) +subscription(expiring) entitlements on the same course. Note:enrollement_statusenum (sic — typo in the original schema) has onlyactive/disabled, nocompleted— the RPCs writestatus = 'active'on upsert.
- Rework
lib/services/enrollment-service.ts(§4.1). - Migrate every access-gate, enrichment read, the
useEnrollmenthook, browse, Stripe webhook, RPC callers (§4.2–4.7) toentitlements/has_course_access(). - Remove the direct RPC calls from
checkout/actions.tsandpayment-requests.ts; let theafter_transaction_inserttrigger be the single enrollment path (§6). - Update E2E tests; add the overlap regression test.
- Ship. Legacy
enrollments.product_id/subscription_idare now written but never read.
Outcome: correct behaviour live — overlapping access works, subscription lapse spares owned courses. Reversible by reverting the app deploy (DB still dual-written).
Status: Phase 2 applied 2026-05-16. Migration
20260516130000_phase2_self_enroll_rpc.sql(self_enroll_subscription_courseRPC). Reworkedlib/services/enrollment-service.ts(entitlement-basedcomputeCourseAccess) + newlib/services/course-access.ts(hasCourseAccess,fetchCourseAccess,fetchAccessibleCourseIds,fetchCourseAccessMap). Converted:use-course-access/use-enrollmenthooks; access gates (course overview, exam, community pages); gating API routes (aristotle, artifact evaluate, media upload-url, certificates/generate) + server actions (community.ts,teacher/lesson-resources.ts); browse page +enrolled-course-card/ student courses page (entitlement badges); Stripe refund webhook (revokes product entitlements).npm run buildgreen; browser-verified — course gate viahas_course_access, My Courses shows entitlement-derived badges, the product+subscription overlap course correctly reads "Lifetime Access".Phase 2 follow-up — deferrals closed 2026-05-16. Double-enrollment path removed: the explicit
enroll_user/handle_new_subscriptionRPC calls are gone fromcheckout/actions.ts(enrollUser) andpayment-requests.ts(completeAndEnroll) — theafter_transaction_inserttrigger is now the single enrollment path.enrollFreerewritten to callgrant_free_entitlement(no more fabricated $0 products). E2E specs updated —student-exams.spec.tsseeds via theenroll_userRPC,enrollment-flows.spec.tscleans entitlements; newtests/playwright/entitlements-overlap.spec.tsregression test (passes). Browser-verified: paid purchase enrolls via the trigger only; free enroll produces a cleanfreeentitlement.Also fixed a real bug surfaced while closing the deferrals — migration
20260516140000_phase2_fix_renewal_entitlements.sql: the trigger's subscription renewal path extendedsubscriptions.end_datebut never the entitlements'expires_at, so a renewed subscription would still read as lapsed.handle_new_subscriptionis now renewal-aware (extends from the existing end_date) and the trigger always delegates to it. (The trigger is not SECURITY DEFINER, so it must not writeentitlementsdirectly — all entitlement writes route through the definer RPC.)Still genuinely out of scope: the lesson page remains gateless (it was gateless before this migration too — noted in §4.4 as optional hardening).
After Phase 2 has been stable in production for a chosen soak period (suggest ≥1 billing cycle):
- Migration
D: stop dual-writing in the RPCs;ALTER TABLE enrollments DROP COLUMN product_id, DROP COLUMN subscription_id;(drops FKs +valid_enrollmentCHECK with them). - Optionally drop
'disabled'from the enrollment status enum if fully unused. - Delete dead code: fabricated-product block already removed in Phase 2; remove any remaining shims.
- Regenerate types; update seeds.
Outcome: schema clean, single model. Rollback after this point requires a restore — hence the soak period.
Status: Phase 3 applied 2026-05-16. Migration
20260516150000_phase3_drop_legacy_enrollment_columns.sql. Soak period waived — the app is a pre-launch POC with no real users (owner's call). The RPCs (enroll_user,handle_new_subscription,self_enroll_subscription_course,handle_subscription_status_change) no longer write the legacy columns;enrollments.product_id/subscription_idand their FKs are dropped.enrollmentsis nowenrollment_id, user_id, course_id, enrollment_date, status, tenant_id— a pure learning record. Thelesson_resources_selectRLS policy (which joinedenrollments.product_id) was rewritten to usehas_course_access(). App code that read the columns updated (student/courses/page.tsxenrichment, Stripe refund webhook); test specs +tests/demos/seed-demo-data.sqlmigrated to entitlements.npm run buildgreen; overlap regression test passes; browser-verified. The'disabled'value of theenrollement_statusenum is now unused but left in place (harmless).
Today: enrollUser() inserts a transactions row → after_transaction_insert trigger → trigger_manage_transactions calls enroll_user/handle_new_subscription; and the server action also calls the RPC directly. completeAndEnroll (manual flow) does the same. Two paths, same effect, idempotent — but fragile and confusing.
Resolution: the transaction trigger is the single source of truth. Server actions only insert the transactions row. Remove the direct RPC calls. The trigger already covers the Stripe webhook path uniformly, so this also removes a behavioural inconsistency between mock / manual / Stripe.
(The trigger fires on INSERT and UPDATE — confirm a pending → successful UPDATE re-running enrollment is harmless. With idempotent entitlement upserts, it is.)
| Risk | Mitigation |
|---|---|
Reads switched before entitlements fully populated |
Phase 1 backfill + dual-write guarantee parity before Phase 2 reads anything. |
source_id polymorphic (no FK) drifts |
All writes via SECURITY DEFINER RPCs; add a periodic integrity check query (orphaned source_id) to monitoring. |
| Trigger fires twice (INSERT + UPDATE) | Entitlement upserts are idempotent; verify with a test that flips a transaction pending→successful. |
useEnrollment moving server-side changes client behaviour |
Keep the hook's external API identical; only its body changes (now calls an RPC). |
| Soak period too short before Phase 3 | Require ≥1 billing cycle + a clean run of the overlap regression test in prod-like data. |
Historical migration with enrollments.product_id JOIN |
Phase 2 audits for any view/function created by 20260310000000_*; the migration file itself is left untouched. |
| Phase | Scope | Rough size |
|---|---|---|
| 1 | 3 migrations, backfill, RPC rewrite (dual-write), type regen | Small–Medium — isolated, no app code |
| 2 | enrollment-service rework + ~20 file edits + tests + double-path cleanup | Medium–Large — the bulk of the work |
| 3 | 1 migration, dead-code delete, type regen | Small |
Not an afternoon. Phase 1 alone unblocks the 500 quickly and safely; Phases 2–3 are the real refactor.
- Soak period before Phase 3 — 1 billing cycle, or a fixed calendar window?
— resolved 2026-05-16, see §3.5.revenue_splitsfor non-Stripe sales (§3.5)- Optional RLS hardening — add
has_course_access()tolessons/exam_questionsSELECT policies, or leave content RLS tenant-scoped and keep gating in pages? - When a user holds both a product (perpetual) and subscription (expiring) entitlement for a course, the dashboard should show — "Owned" (product wins for display), or both badges? Affects §4.6 only.