Skip to content

fix(products): create a real $0 product for free offerings - #562

Merged
guillermoscript merged 3 commits into
masterfrom
fix/free-offering-creates-product
Jul 26, 2026
Merged

fix(products): create a real $0 product for free offerings#562
guillermoscript merged 3 commits into
masterfrom
fix/free-offering-creates-product

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

The bug

Creating a free product in the admin wizard created a course and no products row.

saveProductCreationWizard's free branch called the RPC with _product: null and returned productId: null; save_product_creation_wizard returned right after the course insert. Because the wizard then redirects to /dashboard/admin/products — a page that lists products rows — the offering the admin had just created was simply not there.

Two more consequences of the same assumption:

  • No edit route. /dashboard/admin/products/[productId]/edit is keyed by product_id, which a free offering never had. And that page hardcoded pricing: { mode: 'paid' }, so a free offering opened there would have been re-published as a paid offering priced at 0.
  • Free offerings could never appear anywhere driven by 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, manual provider, 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:

  • enrollFree rejects only a linked product whose price is non-zero;
  • the public checkout takes the paid path only for 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

  • New unit test pins the action's RPC contract. Its 4 behavioural cases fail against the previous code and pass now.
  • Migration verified against the local DB in a BEGIN/ROLLBACK transaction: free create, free→paid, paid→free, and draft-free.
  • E2E: this spec was already broken on master — openWizard pointed 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/i name 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.
3 passed, 1 skipped   (the 4th self-skips when the tenant has no unlinked course)
469/469 unit · typecheck clean · build clean · lint clean on changed files

QA

  1. Admin → Products → Create Product → title, leave pricing on Free → Create & publish.
  2. You land on Products and the offering is there, priced Free. (Before: empty list.)
  3. Open it — the editor reopens on Free, not Paid.
  4. As a student, open the course publicly: Enroll for Free, one click, no checkout.

Note

The migration is applied locally only — it has not been applied to cloud.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KZVsJNvmfUsbT4WY83ePKE

guillermoscript and others added 3 commits July 26, 2026 20:32
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
@guillermoscript

Copy link
Copy Markdown
Owner Author

Visual verification

Captured against the app running locally on the Code Academy tenant, with the migration applied.

Before vs after — the admin Products list

Both 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.

Before and after the fix

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 flow

Admin 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.

Free offering flow

Student side

Student free enrollment

The 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 entitlements row with source_type: free, status: active.

Please check visually before merging

Worth your eyes specifically:

  1. A free offering now really is a products row at price 0 on the manual provider. If you would rather free offerings stayed out of the products table, this is the decision to push back on — everything else follows from it.
  2. It reopens on Free in the editor (before this branch, free offerings had no edit route at all, and that page hardcoded mode: 'paid').
  3. Free is still free everywhere: enrollFree rejects only a non-zero linked product, and checkout takes the paid path only for price > 0.

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.

@guillermoscript
guillermoscript marked this pull request as ready for review July 26, 2026 18:53
@guillermoscript
guillermoscript merged commit 9fd240f into master Jul 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant