fix(products): create a real $0 product for free offerings - #562
Conversation
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
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
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
Visual verificationCaptured against the app running locally on the Code Academy tenant, with the migration applied. Before vs after — the admin Products listBoth panels show the products list immediately after publishing a free offering. Left is the pre-fix RPC (restored temporarily to reproduce it), right is this branch. Left: 3 products, and the offering just published is not among them — the course was created, the product was not. Right: 4 products, the new offering listed as Free. The whole flowAdmin fills quick-create with pricing on Free → Create & publish → the offering appears on the Products list → a student opens it publicly and enrolls in one click. Student sideThe public course page offers Enroll for Free with no checkout, and the student lands inside the course. The database confirms a real grant rather than just a redirect: an Please check visually before mergingWorth your eyes specifically:
Reproduce it yourself with the QA steps in the PR description. Note: the migration is applied to local only — it has not been applied to cloud. |



The bug
Creating a free product in the admin wizard created a course and no
productsrow.saveProductCreationWizard's free branch called the RPC with_product: nulland returnedproductId: null;save_product_creation_wizardreturned right after the course insert. Because the wizard then redirects to/dashboard/admin/products— a page that listsproductsrows — the offering the admin had just created was simply not there.Two more consequences of the same assumption:
/dashboard/admin/products/[productId]/editis keyed byproduct_id, which a free offering never had. And that page hardcodedpricing: { mode: 'paid' }, so a free offering opened there would have been re-published as a paid offering priced at 0.products(landing-page pricing blocks, product analytics).The fix
Free offerings now persist course + product + product_courses like paid ones. Both paths share one upsert; only the product payload differs, resolved up front — price
0,manualprovider, no external catalog ids. That shape is 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 rather than sellable:
enrollFreerejects only a linked product whose price is non-zero;price > 0, otherwise it redirects to one-click free enrollment.Verified: the public course page for a $0 product renders "Enroll for Free" and no paid CTA.
A paid offering switched to free now keeps its identity (same
product_id, same course link) instead of being archived and detached. The wizard copy ("Publish the course without creating a product row", "No product created") was updated to match what it now does, and the products list shows Free/Gratis instead of $0.00.Tests
BEGIN/ROLLBACKtransaction: free create, free→paid, paid→free, and draft-free.openWizardpointed at/products/new, which renders the quick-create screen now that the wizard sits behind?advanced=1, so every test timed out on step one. Repaired, along with three locators that matched step-nav buttons instead of the controls they meant. Most notably a/publish/iname fallback matched the step-5 nav item ("Review — check readiness and choose draft or publish"), 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 path — the exact reported flow.QA
Note
The migration is applied locally only — it has not been applied to cloud.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KZVsJNvmfUsbT4WY83ePKE