Skip to content

Commit 6e286ae

Browse files
feat(enrollment): entitlements model for course access
Introduces the entitlements model to fix a structural flaw in the billing/enrollment architecture: the old `enrollments` table doubled as both an access-grant and a billing-attribution record, causing plan purchases over product-owned courses to 500 (valid_enrollment CHECK violation) and subscription lapses to silently revoke perpetually-owned courses. Key changes: - New `entitlements` table (one row per access source: product/subscription/free/admin_grant) - `has_course_access()` SECURITY DEFINER RPC as single access-gate truth - Drop enrollments.product_id / subscription_id (Phase 3) - Renewal-aware handle_new_subscription (extends expires_at correctly) - Revenue splits honour applies_to_providers (platform fee only on Stripe sales) - Stripe webhook charge.refunded: fix status missing from select + remove non-existent refunded_amount column All flows browser-verified: sub lapse, perpetual product survival, Stripe refund, free enrollment, renewal, exam/community gates. Two regression specs added.
1 parent a85e056 commit 6e286ae

36 files changed

Lines changed: 1951 additions & 649 deletions

app/[locale]/(public)/checkout/actions.ts

Lines changed: 78 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,53 @@
22

33
import { createClient as createServerClient } from '@/lib/supabase/server';
44
import { revalidatePath } from 'next/cache';
5-
import { getCurrentUserId } from '@/lib/supabase/tenant'
5+
import { getCurrentUserId, getCurrentTenantId } from '@/lib/supabase/tenant'
66

77
/**
8-
* Mock enrollment for testing checkout flow
9-
* In production, this should be called by Stripe webhook after successful payment
10-
*
11-
* This action:
12-
* 1. Creates a transaction record
13-
* 2. Calls enroll_user RPC (for products) or handle_new_subscription (for plans)
14-
* 3. Enrolls user in associated courses
8+
* Mock checkout — creates a successful transaction.
9+
*
10+
* Enrollment is handled by the `after_transaction_insert` DB trigger
11+
* (`trigger_manage_transactions`), which calls `enroll_user` /
12+
* `handle_new_subscription` — the same single path the Stripe webhook uses.
13+
* In production this action is replaced by the Stripe webhook.
1514
*/
1615
export async function enrollUser(courseId?: string, planId?: string, paymentMethod: string = 'mock_test') {
17-
// 1. Authenticate user
16+
// 1. Authenticate user + resolve tenant
1817
const supabase = await createServerClient();
1918
const userId = await getCurrentUserId()
2019
if (!userId) {
2120
throw new Error("You must be logged in to enroll.");
2221
}
22+
const tenantId = await getCurrentTenantId();
2323

2424
try {
2525
if (courseId) {
26-
// Get product for this course (pick first match)
26+
// Get product for this course (pick first match, scoped to tenant)
2727
const { data: productCourses } = await supabase
2828
.from('product_courses')
2929
.select('product_id, product:products(*)')
3030
.eq('course_id', parseInt(courseId))
31+
.eq('tenant_id', tenantId)
3132
.limit(1);
3233

3334
if (!productCourses || productCourses.length === 0) {
3435
throw new Error("No product found for this course.");
3536
}
3637

37-
const product = productCourses[0].product as any;
38+
const product = productCourses[0].product as unknown as {
39+
product_id: number;
40+
price: number | string;
41+
currency: string;
42+
};
3843

39-
// Create transaction record (mock payment)
44+
// Create the transaction. The after_transaction_insert trigger
45+
// enrolls the user (entitlements + enrollment record) — no explicit
46+
// RPC call here, the trigger is the single enrollment path.
4047
const { data: transaction, error: txError } = await supabase
4148
.from('transactions')
4249
.insert({
4350
user_id: userId,
51+
tenant_id: tenantId,
4452
product_id: product.product_id,
4553
amount: product.price,
4654
currency: product.currency,
@@ -55,38 +63,30 @@ export async function enrollUser(courseId?: string, planId?: string, paymentMeth
5563
throw new Error("Failed to create transaction: " + txError.message);
5664
}
5765

58-
// Call enroll_user RPC function
59-
// This function checks for transaction and enrolls in linked courses
60-
const { error: enrollError } = await supabase.rpc('enroll_user', {
61-
_user_id: userId,
62-
_product_id: product.product_id
63-
});
64-
65-
if (enrollError) {
66-
console.error("Enrollment error:", enrollError);
67-
throw new Error("Failed to enroll: " + enrollError.message);
68-
}
69-
7066
revalidatePath('/dashboard/student');
7167
return { success: true, transaction_id: transaction.transaction_id };
7268

7369
} else if (planId) {
74-
// Get plan details
70+
// Get plan details (scoped to tenant)
7571
const { data: plan, error: planError } = await supabase
7672
.from('plans')
7773
.select('*')
7874
.eq('plan_id', parseInt(planId))
75+
.eq('tenant_id', tenantId)
7976
.single();
8077

8178
if (planError || !plan) {
8279
throw new Error("Plan not found.");
8380
}
8481

85-
// Create transaction for plan
82+
// Create the transaction. The after_transaction_insert trigger
83+
// creates the subscription + entitlements (or extends an existing
84+
// subscription on renewal).
8685
const { data: transaction, error: txError } = await supabase
8786
.from('transactions')
8887
.insert({
8988
user_id: userId,
89+
tenant_id: tenantId,
9090
plan_id: plan.plan_id,
9191
amount: plan.price,
9292
currency: plan.currency,
@@ -101,18 +101,6 @@ export async function enrollUser(courseId?: string, planId?: string, paymentMeth
101101
throw new Error("Failed to create transaction: " + txError.message);
102102
}
103103

104-
// Call handle_new_subscription RPC function
105-
const { error: subError } = await supabase.rpc('handle_new_subscription', {
106-
_user_id: userId,
107-
_plan_id: plan.plan_id,
108-
_transaction_id: transaction.transaction_id
109-
});
110-
111-
if (subError) {
112-
console.error("Subscription error:", subError);
113-
throw new Error("Failed to create subscription: " + subError.message);
114-
}
115-
116104
revalidatePath('/dashboard/student');
117105
return { success: true, transaction_id: transaction.transaction_id };
118106
}
@@ -126,85 +114,72 @@ export async function enrollUser(courseId?: string, planId?: string, paymentMeth
126114
}
127115

128116
/**
129-
* Enroll user in a free course or plan
117+
* Enroll the current user in a free course.
118+
*
119+
* Grants a `free` entitlement via the grant_free_entitlement RPC
120+
* (SECURITY DEFINER, idempotent). No product/transaction is fabricated.
121+
* See docs/ENTITLEMENTS_MIGRATION_PLAN.md.
130122
*/
131-
export async function enrollFree(courseId?: string, planId?: string) {
123+
export async function enrollFree(courseId?: string) {
132124
const supabase = await createServerClient();
133125
const userId = await getCurrentUserId()
134126
if (!userId) {
135127
throw new Error("You must be logged in to enroll.");
136128
}
129+
const tenantId = await getCurrentTenantId();
137130

138131
try {
139-
if (courseId) {
140-
// Check if the course is actually free
141-
const { data: productCourses } = await supabase
142-
.from('product_courses')
143-
.select('product:products(product_id, price)')
144-
.eq('course_id', parseInt(courseId))
145-
.limit(1);
132+
if (!courseId) {
133+
throw new Error("Free enrollment only supported for courses currently.");
134+
}
146135

147-
const courseProduct = productCourses?.[0]?.product as any;
148-
const isFree = courseProduct ? parseFloat(courseProduct.price) === 0 : true;
136+
const numericCourseId = parseInt(courseId);
149137

150-
if (!isFree) {
151-
throw new Error("This course is not free. Please use the paid enrollment flow.");
152-
}
138+
// 1. Validate the course: it must belong to this tenant and be published.
139+
// Prevents enrolling in another tenant's course or an unpublished draft
140+
// via a crafted checkout URL.
141+
const { data: course, error: courseError } = await supabase
142+
.from('courses')
143+
.select('course_id, status, tenant_id')
144+
.eq('course_id', numericCourseId)
145+
.eq('tenant_id', tenantId)
146+
.single();
153147

154-
// For free courses, we need a product_id to satisfy the constraint
155-
let productId: number;
156-
157-
if (courseProduct?.product_id) {
158-
productId = courseProduct.product_id;
159-
} else {
160-
// Create a free product if one doesn't exist
161-
const { data: freeProduct, error: createError } = await supabase
162-
.from('products')
163-
.insert({
164-
name: `Free Access - ${courseId}`,
165-
price: 0,
166-
currency: 'usd',
167-
status: 'active',
168-
payment_provider: 'stripe'
169-
})
170-
.select('product_id')
171-
.single();
172-
173-
if (createError) throw createError;
174-
175-
// Link product to course
176-
await supabase
177-
.from('product_courses')
178-
.insert({
179-
product_id: freeProduct.product_id,
180-
course_id: parseInt(courseId)
181-
});
182-
183-
productId = freeProduct.product_id;
184-
}
148+
if (courseError || !course) {
149+
throw new Error("Course not found.");
150+
}
151+
if (course.status !== 'published') {
152+
throw new Error("This course is not available for enrollment.");
153+
}
185154

186-
// Create enrollment with product_id
187-
const { error } = await supabase
188-
.from('enrollments')
189-
.insert({
190-
user_id: userId,
191-
course_id: parseInt(courseId),
192-
product_id: productId,
193-
status: 'active',
194-
enrollment_date: new Date().toISOString()
195-
});
196-
197-
if (error) {
198-
// If already enrolled, that's fine
199-
if (error.code === '23505') return { success: true, alreadyEnrolled: true };
200-
throw error;
201-
}
155+
// 2. Guard: reject if a paid product is linked to this course.
156+
const { data: productCourses } = await supabase
157+
.from('product_courses')
158+
.select('product:products(price)')
159+
.eq('course_id', numericCourseId)
160+
.eq('tenant_id', tenantId)
161+
.limit(1);
202162

203-
revalidatePath('/dashboard/student');
204-
return { success: true };
163+
const linkedProduct = productCourses?.[0]?.product as unknown as {
164+
price: number | string;
165+
} | undefined;
166+
167+
if (linkedProduct && Number(linkedProduct.price) !== 0) {
168+
throw new Error("This course is not free. Please use the paid enrollment flow.");
169+
}
170+
171+
// 3. Grant a free entitlement (entitlements model). Idempotent.
172+
const { error: grantError } = await supabase.rpc('grant_free_entitlement', {
173+
_user_id: userId,
174+
_course_id: numericCourseId,
175+
});
176+
177+
if (grantError) {
178+
throw new Error("Failed to enroll: " + grantError.message);
205179
}
206-
207-
throw new Error("Free enrollment only supported for courses currently.");
180+
181+
revalidatePath('/dashboard/student');
182+
return { success: true };
208183
} catch (error) {
209184
console.error("Free enrollment failed:", error);
210185
throw error;

app/[locale]/(public)/courses/[id]/page.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import { Card, CardContent } from "@/components/ui/card";
2424
import { getTranslations } from 'next-intl/server';
2525
import { getCurrentUserId } from '@/lib/supabase/tenant'
26+
import { hasCourseAccess } from '@/lib/services/course-access'
2627

2728
export const dynamic = 'force-dynamic';
2829

@@ -80,20 +81,10 @@ export default async function CourseDetailsPage(props: { params: Promise<{ id: s
8081
const estimatedHours = Math.floor(totalLessons * 10 / 60);
8182
const estimatedMinutes = (totalLessons * 10) % 60;
8283

83-
// Check user's enrollment
84+
// Check user's access (entitlements model)
8485
let hasAccess = false;
8586
if (userId) {
86-
const { data: enrollment } = await supabase
87-
.from('enrollments')
88-
.select('enrollment_id')
89-
.eq('user_id', userId)
90-
.eq('course_id', parseInt(params.id))
91-
.eq('status', 'active')
92-
.maybeSingle();
93-
94-
if (enrollment) {
95-
hasAccess = true;
96-
}
87+
hasAccess = await hasCourseAccess(supabase, userId, parseInt(params.id));
9788
}
9889

9990
// Fetch product for pricing

app/[locale]/dashboard/student/browse/page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
88
import Link from 'next/link'
99
import { IconAlertCircle, IconSparkles, IconTrophy, IconSearch } from '@tabler/icons-react'
1010
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
11+
import { fetchAccessibleCourseIds } from '@/lib/services/course-access'
1112

1213
export default async function BrowseCoursesPage({
1314
searchParams,
@@ -53,7 +54,7 @@ export default async function BrowseCoursesPage({
5354
}
5455

5556
// Parallelize 4 independent queries
56-
const [{ data: subscriptions }, { data: categories }, { data: courses }, { data: enrollments }] = await Promise.all([
57+
const [{ data: subscriptions }, { data: categories }, { data: courses }, accessibleCourseIds] = await Promise.all([
5758
supabase.from('subscriptions').select(`
5859
subscription_id,
5960
subscription_status,
@@ -71,8 +72,8 @@ export default async function BrowseCoursesPage({
7172
supabase.from('course_categories').select('id, name')
7273
.eq('tenant_id', tenantId).order('name'),
7374
query,
74-
supabase.from('enrollments').select('course_id')
75-
.eq('user_id', userId).eq('tenant_id', tenantId).eq('status', 'active'),
75+
// "Already enrolled" set — entitlements model (any active access source).
76+
fetchAccessibleCourseIds(supabase, userId),
7677
])
7778

7879
const activeSubscription = subscriptions?.[0]
@@ -98,7 +99,7 @@ export default async function BrowseCoursesPage({
9899
const tCourses = await getTranslations('dashboard.student.courses')
99100
const tSearch = await getTranslations('courseSearch')
100101

101-
const enrolledCourseIds = new Set(enrollments?.map(e => e.course_id) || [])
102+
const enrolledCourseIds = accessibleCourseIds
102103

103104
const hasActiveFilters = sanitizedSearch || category
104105

app/[locale]/dashboard/student/courses/[courseId]/community/page.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createAdminClient } from '@/lib/supabase/admin'
22
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
33
import { getUserRole } from '@/lib/supabase/get-user-role'
4+
import { hasCourseAccess } from '@/lib/services/course-access'
45
import { redirect, notFound } from 'next/navigation'
56
import { getTranslations } from 'next-intl/server'
67
import { CommunityFeed } from '@/components/community/community-feed'
@@ -27,16 +28,9 @@ export default async function StudentCourseCommunityPage({ params }: PageProps)
2728

2829
const adminClient = createAdminClient()
2930

30-
// Verify enrollment and fetch course in parallel
31-
const [{ data: enrollment }, { data: course }] = await Promise.all([
32-
adminClient
33-
.from('enrollments')
34-
.select('enrollment_id')
35-
.eq('user_id', userId)
36-
.eq('course_id', numericCourseId)
37-
.eq('status', 'active')
38-
.eq('tenant_id', tenantId)
39-
.single(),
31+
// Verify access (entitlements model) and fetch course in parallel
32+
const [hasAccess, { data: course }] = await Promise.all([
33+
hasCourseAccess(adminClient, userId, numericCourseId),
4034
adminClient
4135
.from('courses')
4236
.select('course_id, title')
@@ -45,7 +39,7 @@ export default async function StudentCourseCommunityPage({ params }: PageProps)
4539
.single(),
4640
])
4741

48-
if (!enrollment) {
42+
if (!hasAccess) {
4943
redirect('/dashboard/student')
5044
}
5145

0 commit comments

Comments
 (0)