Skip to content

Commit 9fd240f

Browse files
fix(products): create a real $0 product for free offerings (#562)
* fix(products): create a real $0 product for free offerings Creating a "free product" in the admin wizard created a course and no `products` row: the free branch of `saveProductCreationWizard` called the RPC with `_product: null` and returned `productId: null`, and the RPC returned right after the course insert. Since the wizard redirects to /dashboard/admin/products — which lists `products` rows — the offering the admin had just created was nowhere to be seen. It also had no edit route (that route is keyed by product_id) and could never appear anywhere driven by `products`. Free offerings now persist course + product + product_courses like paid ones. The two paths share one upsert; only the product payload differs, resolved up front: price 0, `manual` provider, no external catalog ids — pinned inside the RPC, so no caller can smuggle in a price. Free stays free. Every consumer already treats a 0-price product as free: `enrollFree` only rejects a linked product whose price is non-zero, and the public checkout only takes the paid path for price > 0 (otherwise it redirects to one-click free enrollment). Verified end-to-end: the public course page for a $0 product renders "Enroll for Free" and no paid CTA. Also fixed, both consequences of the same assumption: - the edit route hardcoded `mode: 'paid'`, so reopening a free offering would have re-published it as a paid one priced at 0; - the wizard's own copy ("Publish the course without creating a product row", "No product created") no longer matched what it does. The products list renders 0-price offerings as Free/Gratis rather than $0.00. Tests: - new unit test pins the action's RPC contract; its 4 behavioural cases fail against the previous code and pass now - the E2E spec was already broken on master: `openWizard` pointed at /products/new, which renders the quick-create screen since the wizard moved behind ?advanced=1, so every test timed out on step one. Repaired, plus three locators that matched step-nav buttons instead of the controls they meant (a /publish/i name matched the step-5 "Review … choose draft or publish" nav item, so the old free test never saved anything and still passed on the word "publish" in that description). Added a test for the quick-create free flow — the exact reported path. The migration is applied locally only; it has NOT been applied to cloud. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZVsJNvmfUsbT4WY83ePKE * docs(evidence): before/after screenshots and flow GIF for #562 Visual proof for the free-offering fix: the products list before (the published free offering is absent) and after, the public one-click free enrollment, and a recording of the whole admin→student flow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZVsJNvmfUsbT4WY83ePKE * docs(evidence): add before/after comparison stills for #562 PNGs are gitignored repo-wide (temporary screenshots), so the paired before/after stills ship as GIFs alongside the flow recording. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZVsJNvmfUsbT4WY83ePKE --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2753c26 commit 9fd240f

12 files changed

Lines changed: 421 additions & 53 deletions

File tree

app/[locale]/dashboard/admin/products/[productId]/edit/page.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,17 @@ export default async function EditProductPage({ params }: PageProps) {
132132
thumbnailUrl: linkedCourse?.thumbnail_url || product.image || '',
133133
categoryId: linkedCourse?.category_id || null,
134134
},
135-
pricing: {
136-
mode: 'paid',
137-
price: Number(product.price),
138-
currency: product.currency === 'eur' ? 'eur' : 'usd',
139-
paymentProvider: getSupportedPaymentProvider(product.payment_provider),
140-
},
135+
// A 0-price product IS the free offering (see save_product_creation_wizard),
136+
// so the editor must reopen on "free" — hardcoding 'paid' here re-published
137+
// it as a paid offering with a price of 0.
138+
pricing: Number(product.price) === 0
139+
? { mode: 'free', currency: product.currency === 'eur' ? 'eur' : 'usd' }
140+
: {
141+
mode: 'paid',
142+
price: Number(product.price),
143+
currency: product.currency === 'eur' ? 'eur' : 'usd',
144+
paymentProvider: getSupportedPaymentProvider(product.payment_provider),
145+
},
141146
postRegistrationSteps: ((postRegistrationSteps || []) as PostRegistrationRow[]).map((step, index) => ({
142147
id: step.id,
143148
type: step.type,

app/[locale]/dashboard/admin/products/page.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,19 @@ export default async function AdminProductsPage() {
188188
{product.description || t('card.noDescription')}
189189
</p>
190190
<div className="mb-4">
191-
<p className="text-2xl font-bold tracking-tight tabular-nums">
192-
{product.currency === 'usd' ? '$' : '€'}
193-
{product.price.toFixed(2)}
194-
</p>
195-
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
196-
{product.currency}
197-
</p>
191+
{Number(product.price) === 0 ? (
192+
<p className="text-2xl font-bold tracking-tight">{t('card.free')}</p>
193+
) : (
194+
<>
195+
<p className="text-2xl font-bold tracking-tight tabular-nums">
196+
{product.currency === 'usd' ? '$' : '€'}
197+
{product.price.toFixed(2)}
198+
</p>
199+
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
200+
{product.currency}
201+
</p>
202+
</>
203+
)}
198204
</div>
199205

200206
{/* Course List */}

app/actions/admin/products.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,9 @@ export async function updateProduct(
391391

392392
/**
393393
* Saves the guided admin offering flow.
394-
* Free offerings persist only a course. Paid offerings persist course + product + product_courses + post-registration steps.
394+
* Both modes persist course + product + product_courses + post-registration steps.
395+
* Free offerings differ only in the product they get: price 0 on the `manual`
396+
* provider, with no external catalog objects to create.
395397
*/
396398
export async function saveProductCreationWizard(
397399
input: ProductCreationWizardInput
@@ -440,9 +442,14 @@ export async function saveProductCreationWizard(
440442

441443
const productId = input.productId ?? null
442444

443-
// ---- FREE: no payment provider work, single-tx RPC handles cleanup ------
445+
// ---- FREE: a real $0 product, no payment provider work -------------------
446+
// A free offering still persists course + product + product_courses, so it
447+
// is listable and editable like any other. The RPC pins price 0 and the
448+
// `manual` provider, and every consumer treats a 0-price product as free
449+
// (enrollFree only rejects a *non-zero* linked product; the public checkout
450+
// only takes the paid path for price > 0), so it can never be charged for.
444451
if (input.pricing.mode === 'free') {
445-
// The RPC soft-archives the product row but cannot touch the external
452+
// The RPC clears the provider columns but cannot touch the external
446453
// provider, so archive its objects here first (no-op for manual).
447454
if (productId) {
448455
const { data: productToArchive } = await adminClient
@@ -476,20 +483,24 @@ export async function saveProductCreationWizard(
476483
_course: coursePayload,
477484
_pricing_mode: 'free',
478485
_product_id: productId,
479-
_product: null,
480-
_steps: null,
486+
// Only the display currency is honoured for a free offering — the RPC
487+
// fixes price/provider itself so no caller can smuggle in a price.
488+
_product: { currency: input.pricing.currency ?? 'usd' },
489+
_steps: buildPostRegistrationStepRows(input.postRegistrationSteps),
481490
}
482491
)
483492

484493
if (rpcError) throw new Error(rpcError.message)
485494

495+
const freeResult = rpcResult as { course_id: number; product_id: number }
496+
486497
revalidateOfferingPaths()
487498

488499
return {
489500
success: true,
490501
data: {
491-
courseId: (rpcResult as { course_id: number }).course_id,
492-
productId: null,
502+
courseId: freeResult.course_id,
503+
productId: freeResult.product_id,
493504
pricingMode: 'free',
494505
published: input.intent === 'publish',
495506
},

components/admin/product-creation-wizard.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ export function ProductCreationWizard({
277277
...current,
278278
pricing:
279279
mode === 'free'
280-
? { mode: 'free' }
280+
? { mode: 'free', currency: current.pricing.currency }
281281
: {
282282
mode: 'paid',
283283
price: current.pricing.price || 0,
@@ -581,7 +581,7 @@ export function ProductCreationWizard({
581581
<RadioGroupItem value="free" disabled={isSaving} />
582582
<FieldContent>
583583
<span className="text-sm font-medium">Free</span>
584-
<FieldDescription>Publish the course without creating a product row.</FieldDescription>
584+
<FieldDescription>Create a $0 product students enroll in with one click.</FieldDescription>
585585
</FieldContent>
586586
</Field>
587587
</FieldLabel>
@@ -747,7 +747,7 @@ export function ProductCreationWizard({
747747
<dd className="mt-1 text-xs text-muted-foreground">
748748
{input.pricing.mode === 'paid'
749749
? input.pricing.paymentProvider || 'No provider'
750-
: 'No product created'}
750+
: 'No payment required'}
751751
</dd>
752752
</div>
753753
<div className="bg-card p-3">
83.2 KB
Loading
1.77 MB
Loading
68.6 KB
Loading

messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3116,6 +3116,7 @@
31163116
"card": {
31173117
"course": "course",
31183118
"courses": "courses",
3119+
"free": "Free",
31193120
"noDescription": "No description",
31203121
"includedCourses": "Included Courses:",
31213122
"more": "+{count} more",

messages/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2937,6 +2937,7 @@
29372937
"card": {
29382938
"course": "curso",
29392939
"courses": "cursos",
2940+
"free": "Gratis",
29402941
"noDescription": "Sin descripción",
29412942
"includedCourses": "Cursos Incluidos:",
29422943
"more": "+{count} más",
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
-- Free offerings get a real product row (price 0, manual provider).
2+
--
3+
-- Before this migration the `_pricing_mode = 'free'` branch returned right after
4+
-- the course insert with `product_id => NULL`, so "create a free product" in the
5+
-- admin wizard created a course and nothing else. Consequences:
6+
-- * the wizard redirects to /dashboard/admin/products, which lists `products`
7+
-- rows only — the offering the admin just created was invisible there;
8+
-- * the offering had no edit route at all (that route is keyed by product_id);
9+
-- * free offerings could never appear anywhere driven by `products`
10+
-- (landing-page pricing blocks, product analytics).
11+
--
12+
-- Free stays free: price is forced to 0 and the provider to `manual`, and every
13+
-- consumer already treats a 0-price product as free rather than sellable —
14+
-- `enrollFree` only rejects a linked product whose price is non-zero, and the
15+
-- public checkout only enters the paid flow for a linked product with price > 0
16+
-- (otherwise it redirects to the one-click free-enrollment page). So a 0-price
17+
-- row can never be charged for; it just makes the offering a first-class,
18+
-- listable, editable object.
19+
--
20+
-- The free and paid paths now share ONE upsert: the only difference is the
21+
-- product payload, resolved up front. Everything still commits in a single
22+
-- transaction, and tenant ownership is still re-verified inside the function.
23+
24+
CREATE OR REPLACE FUNCTION public.save_product_creation_wizard(
25+
_tenant_id uuid,
26+
_author_id uuid,
27+
_intent text, -- 'draft' | 'publish'
28+
_source_mode text, -- 'new' | 'existing'
29+
_existing_course_id integer, -- nullable
30+
_course jsonb, -- { title, description, thumbnail_url, category_id }
31+
_pricing_mode text, -- 'free' | 'paid'
32+
_product_id integer, -- nullable (edit)
33+
_product jsonb, -- paid: { price, currency, payment_provider, provider_product_id, provider_price_id }; free: optional { currency }
34+
_steps jsonb -- array of { type, title, description, url, sort_order, is_active }
35+
)
36+
RETURNS jsonb
37+
LANGUAGE plpgsql
38+
SECURITY DEFINER
39+
SET search_path TO 'public'
40+
AS $function$
41+
DECLARE
42+
_course_id integer;
43+
_new_product integer;
44+
_course_status text := CASE WHEN _intent = 'publish' THEN 'published' ELSE 'draft' END;
45+
_product_status text := CASE WHEN _intent = 'publish' THEN 'active' ELSE 'inactive' END;
46+
_effective jsonb;
47+
_step jsonb;
48+
BEGIN
49+
-- 1. Course -----------------------------------------------------------------
50+
IF _source_mode = 'existing' THEN
51+
SELECT course_id INTO _course_id
52+
FROM courses
53+
WHERE course_id = _existing_course_id
54+
AND tenant_id = _tenant_id
55+
FOR UPDATE;
56+
57+
IF _course_id IS NULL THEN
58+
RAISE EXCEPTION 'Course % not found or access denied', _existing_course_id
59+
USING ERRCODE = 'P0002';
60+
END IF;
61+
62+
UPDATE courses SET
63+
title = _course->>'title',
64+
description = NULLIF(_course->>'description', ''),
65+
thumbnail_url = NULLIF(_course->>'thumbnail_url', ''),
66+
category_id = NULLIF(_course->>'category_id', '')::integer,
67+
status = _course_status::status
68+
WHERE course_id = _course_id
69+
AND tenant_id = _tenant_id;
70+
ELSE
71+
INSERT INTO courses (title, description, thumbnail_url, category_id, status, tenant_id, author_id)
72+
VALUES (
73+
_course->>'title',
74+
NULLIF(_course->>'description', ''),
75+
NULLIF(_course->>'thumbnail_url', ''),
76+
NULLIF(_course->>'category_id', '')::integer,
77+
_course_status::status,
78+
_tenant_id,
79+
_author_id
80+
)
81+
RETURNING course_id INTO _course_id;
82+
END IF;
83+
84+
-- 2. Resolve the product payload -------------------------------------------
85+
-- Free is a fixed shape the caller cannot influence beyond the display
86+
-- currency: price 0, `manual` provider, no external catalog ids. A paid
87+
-- offering later switched to free therefore keeps its identity (same
88+
-- product_id, same course link) instead of being archived and detached — the
89+
-- server action archives its Stripe/PayPal objects before calling this.
90+
IF _pricing_mode = 'free' THEN
91+
_effective := jsonb_build_object(
92+
'price', 0,
93+
'currency', COALESCE(NULLIF(_product->>'currency', ''), 'usd'),
94+
'payment_provider', 'manual',
95+
'provider_product_id', NULL,
96+
'provider_price_id', NULL
97+
);
98+
ELSE
99+
_effective := _product;
100+
END IF;
101+
102+
-- 3. Upsert the product -----------------------------------------------------
103+
IF _product_id IS NOT NULL THEN
104+
PERFORM 1 FROM products
105+
WHERE product_id = _product_id AND tenant_id = _tenant_id;
106+
IF NOT FOUND THEN
107+
RAISE EXCEPTION 'Product % not found or access denied', _product_id
108+
USING ERRCODE = 'P0002';
109+
END IF;
110+
111+
UPDATE products SET
112+
name = _course->>'title',
113+
description = NULLIF(_course->>'description', ''),
114+
price = (_effective->>'price')::numeric,
115+
currency = (_effective->>'currency')::currency_type,
116+
image = NULLIF(_course->>'thumbnail_url', ''),
117+
payment_provider = _effective->>'payment_provider',
118+
provider_product_id = NULLIF(_effective->>'provider_product_id', ''),
119+
provider_price_id = NULLIF(_effective->>'provider_price_id', ''),
120+
status = _product_status
121+
WHERE product_id = _product_id AND tenant_id = _tenant_id;
122+
123+
_new_product := _product_id;
124+
ELSE
125+
INSERT INTO products (
126+
tenant_id, name, description, price, currency, image,
127+
payment_provider, provider_product_id, provider_price_id, status
128+
)
129+
VALUES (
130+
_tenant_id,
131+
_course->>'title',
132+
NULLIF(_course->>'description', ''),
133+
(_effective->>'price')::numeric,
134+
(_effective->>'currency')::currency_type,
135+
NULLIF(_course->>'thumbnail_url', ''),
136+
_effective->>'payment_provider',
137+
NULLIF(_effective->>'provider_product_id', ''),
138+
NULLIF(_effective->>'provider_price_id', ''),
139+
_product_status
140+
)
141+
RETURNING product_id INTO _new_product;
142+
END IF;
143+
144+
-- 4. Re-link product → course (single course per offering) ------------------
145+
DELETE FROM product_courses
146+
WHERE product_id = _new_product AND tenant_id = _tenant_id;
147+
INSERT INTO product_courses (tenant_id, product_id, course_id)
148+
VALUES (_tenant_id, _new_product, _course_id);
149+
150+
-- 5. Replace post-registration steps ---------------------------------------
151+
DELETE FROM product_post_registration_steps
152+
WHERE product_id = _new_product AND tenant_id = _tenant_id;
153+
154+
IF _steps IS NOT NULL AND jsonb_typeof(_steps) = 'array' THEN
155+
FOR _step IN SELECT * FROM jsonb_array_elements(_steps)
156+
LOOP
157+
INSERT INTO product_post_registration_steps (
158+
tenant_id, product_id, type, title, description, url, sort_order, is_active
159+
)
160+
VALUES (
161+
_tenant_id,
162+
_new_product,
163+
_step->>'type',
164+
_step->>'title',
165+
NULLIF(_step->>'description', ''),
166+
NULLIF(_step->>'url', ''),
167+
COALESCE((_step->>'sort_order')::integer, 0),
168+
COALESCE((_step->>'is_active')::boolean, true)
169+
);
170+
END LOOP;
171+
END IF;
172+
173+
RETURN jsonb_build_object('course_id', _course_id, 'product_id', _new_product);
174+
END;
175+
$function$;

0 commit comments

Comments
 (0)