feat(compete): registration add-ons (merch) gated by entitlement#511
feat(compete): registration add-ons (merch) gated by entitlement#511zacjones93 wants to merge 6 commits into
Conversation
Implements Option A from the merch add-ons research memo: in-flow registration add-ons with a platform-owned catalog, sold inside the existing Stripe Checkout Session. - New registration_addons feature entitlement; platform admins enable merch per organizing team via /admin/entitlements. Server fns enforce the gate (CRUD throws, public catalog returns empty, checkout rejects addOns input for unentitled teams). - Catalog schema: competition_products + competition_product_variants (sizes, optional per-variant stock); commerce_purchases gains variantId + quantity. - initiateRegistrationPaymentFn accepts addOns[]: validates entitlement, availability (order-by deadline, end-of-day in comp timezone), variants, per-athlete caps, soft stock; appends per-unit all-in line items (percentage platform fee only, no $2 fixed fee on merch). Free division + paid shirt routes through Stripe; coupons never discount merch. - Checkout workflow ADDON branch: atomic variant stock claim with auto-refund on oversell (reverse_transfer), group refund when every registration in the session failed, PAYMENT_COMPLETED ledger events. - Athlete UI: Event merch order-bump section in both registration form variants with fee summary lines. - Organizer UI: Merch page (catalog CRUD, locked state when not entitled, counts-by-variant print-shop report, pickup list) + sidebar entry. - Tests: availability/fee-math units, entitlement-gate server fn tests, workflow ADDON branch coverage. lat.md docs updated. Schema changes apply via pnpm db:push (no MySQL migration journal exists yet); production needs the feat_registration_addons feature row inserted once, mirroring the seed. https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi
Post-implementation code review (7 finder angles + verification) of the registration add-ons commit surfaced five confirmed issues, all fixed: - Capacity: competition-wide pending-purchase counts (submit-time check and workflow auto-refund re-check) now exclude ADDON purchases via isNotNull(divisionId) — a pending shirt purchase could previously occupy an athlete spot and even trigger a wrongful competition-full auto-refund of the buyer's own registration. - Stock idempotency: variant stock claim and the PENDING→COMPLETED purchase flip now run in one transaction with the conditional status update as the gate, so a workflow step retry or concurrent run can never double-claim stock. - Invites: an invited FREE division that routes through the Stripe path because add-ons were selected now settles the invite inline (shared settleInviteForFreeRegistration helper, same allocation re-check) — previously the invite stayed pending forever. - Fee summary: free divisions now report as $0 instead of absent, so add-on lines and the order total render for free-division + merch checkouts. - Revenue stats: ADDON purchases excluded from the per-division revenue rollup (no more phantom "Unknown" division); merch revenue lives on the Merch page report. - Merch dialog: friendly validation for non-numeric max-per-athlete. lat.md updated to match (capacity exclusion, transactional stock claim, revenue scope). https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi
- New how-to guide: selling merch during registration (prerequisites including the account-level entitlement, product setup, deadline vs stock availability, athlete experience, fulfillment reports, refunds). Distinguishes paid merch from included-shirt registration questions. - First-competition athlete tutorial mentions the optional Event merch section in the registration form. Docs build passes (validates internal links). https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi
Stripe rejects Checkout Sessions whose application_fee_amount exceeds the amount actually charged. Fees are computed from undiscounted totals (coupons are organizer-funded), so a large coupon combined with organizer-absorbed fees could exceed the post-discount charge and abort checkout at session creation — newly reachable with add-ons, since a 100% registration coupon plus a paid add-on now produces a session where the customer pays only the add-on. calculateApplicationFeeCents clamps the fee to what the customer pays and logs when the clamp binds (for reconciliation). All previously working checkouts are byte-identical — the clamp only binds in the case that previously hard-failed. The broader fee-vs-discount policy question stays open and unchanged. https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi
WalkthroughThis pull request implements a complete in-flow registration add-ons (merch) feature, allowing organizers to define and sell purchasable products alongside competition registration through Stripe Checkout, with athletes selecting items during registration and fulfillment tracked via pickup lists and variant sales reports. ChangesRegistration Add-ons (Merch) Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~65 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa0fc33fba
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Same null-divisionId exclusion already applied to competition revenue stats and capacity counts: getSeriesRevenueStatsFn aggregated every COMPLETED purchase for the series, so ADDON (merch) purchases would surface as an Unknown division and inflate series gross/net totals. Addresses the Codex review finding on PR #511. https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi
There was a problem hiding this comment.
5 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/wodsmith-start/src/server-fns/competition-addon-fns.ts">
<violation number="1" location="apps/wodsmith-start/src/server-fns/competition-addon-fns.ts:376">
P2: Archive mutation bypasses the registration_addons entitlement gate. Unentitled teams can still change catalog state by archiving products.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| const session = await requireVerifiedEmail() | ||
| assertTeamManageAccess(session, input.teamId) | ||
| await requireAddonEntitlement(input.teamId) | ||
| const product = await getOwnedAddon(input.productId, input.teamId) |
There was a problem hiding this comment.
P2: Archive mutation bypasses the registration_addons entitlement gate. Unentitled teams can still change catalog state by archiving products.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/wodsmith-start/src/server-fns/competition-addon-fns.ts, line 376:
<comment>Archive mutation bypasses the registration_addons entitlement gate. Unentitled teams can still change catalog state by archiving products.</comment>
<file context>
@@ -0,0 +1,750 @@
+ const session = await requireVerifiedEmail()
+ assertTeamManageAccess(session, input.teamId)
+ await requireAddonEntitlement(input.teamId)
+ const product = await getOwnedAddon(input.productId, input.teamId)
+
+ getEvlog()?.set({
</file context>
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
apps/wodsmith-start/test/server/commerce/application-fee.test.ts (1)
1-69: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAdd
@lat:comments to link tests to specifications.Each test case should have a
@lat:comment referencing the relevant specification section. This file contains 5 test cases with no LAT references.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/wodsmith-start/test/server/commerce/application-fee.test.ts` around lines 1 - 69, Add a `@lat`: comment to each test case in the calculateApplicationFeeCents suite linking to the relevant specification section; locate the describe("calculateApplicationFeeCents", ...) and add a single-line comment (e.g. // `@lat`: <spec-id>) above each it(...) block so all five it tests reference the correct LAT spec IDs.Source: Coding guidelines
apps/wodsmith-start/test/server/commerce/addons.test.ts (1)
1-72: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAdd
@lat:comments to link tests to specifications.According to coding guidelines, each test should reference its spec with exactly one
@lat:comment placed next to the relevant test. None of the test cases in this file have LAT references.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/wodsmith-start/test/server/commerce/addons.test.ts` around lines 1 - 72, Add a single `@lat:` comment to each test case pointing to its LAT spec: for the tests inside describe("buildAddonFeeConfig") add one `@lat:` next to the it("drops only the fixed platform fee") test; for describe("getAddonUnitBreakdown") add `@lat:` comments to both it("charges the percentage platform fee without the $2 fixed fee") and it("follows the competition's stripe pass-through setting"); and for describe("multiplyFeeBreakdown") add `@lat:` to both it("scales every cents field with zero rounding drift") and it("is identity for quantity 1"). Place exactly one `@lat:` comment adjacent to each respective `it` (inline or immediately above) with the correct LAT identifier for that spec.Source: Coding guidelines
apps/wodsmith-start/test/utils/addon-availability.test.ts (1)
1-109: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAdd
@lat:comments to link tests to specifications.As per coding guidelines, test files should include
@lat:comments next to each test case to reference the relevant specification. This file has 8 test cases without LAT references.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/wodsmith-start/test/utils/addon-availability.test.ts` around lines 1 - 109, The tests in addon-availability.test.ts are missing required `@lat`: spec references next to each test case; update every it(...) block within the describe blocks ("isAddonPurchasable", "variant stock helpers", "getMaxSelectableQuantity") to include an inline or directly preceding comment of the form // `@lat`: <SPEC_ID> (use the correct spec IDs for each behavior being tested) so each of the 8 it(...) cases references its specification; ensure comments are added for tests that check isAddonPurchasable, getVariantRemaining, isVariantSoldOut, canFulfillQuantity, and getMaxSelectableQuantity.Source: Coding guidelines
apps/wodsmith-start/src/server-fns/competition-divisions-fns.ts (1)
655-673: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winPublic competition capacity still counts merch holds.
getCompetitionSpotsAvailableFn()now excludes pending rows with a nulldivisionId, but this query still groups all pending purchases andcompetitionCapacitysums every bucket. A merch-only checkout will therefore reduce the public competition-wide spots count even though it doesn't reserve a registration slot. Add the sameisNotNull(commercePurchaseTable.divisionId)filter here so both capacity paths stay aligned.Suggested fix
db .select({ divisionId: commercePurchaseTable.divisionId, pendingCount: sql<number>`cast(count(*) as unsigned)`, }) .from(commercePurchaseTable) .where( and( eq(commercePurchaseTable.competitionId, data.competitionId), eq(commercePurchaseTable.status, COMMERCE_PURCHASE_STATUS.PENDING), + isNotNull(commercePurchaseTable.divisionId), gt( commercePurchaseTable.createdAt, new Date( Date.now() - PENDING_PURCHASE_MAX_AGE_MINUTES * 60 * 1000, ),Also applies to: 706-719
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/wodsmith-start/src/server-fns/competition-divisions-fns.ts` around lines 655 - 673, The grouped pending-purchases query is still including rows with null divisionId and thus reduces public competition capacity incorrectly; update the where clause used in the db.select(...) that builds pendingCount (the block referencing commercePurchaseTable, COMMERCE_PURCHASE_STATUS.PENDING and PENDING_PURCHASE_MAX_AGE_MINUTES) to also include isNotNull(commercePurchaseTable.divisionId). Apply the same change to the equivalent grouped-pending query later in the file (the second block around the other selection) so both paths align with getCompetitionSpotsAvailableFn's exclusion of null divisionId.apps/wodsmith-start/src/server-fns/registration-fns.ts (1)
913-969: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftDon't finalize zero-dollar registrations before the paid checkout exists.
Line 916 creates the registration, and Lines 925-941 mark/email it as complete before Line 1201 can still fail. In a mixed checkout, any Stripe error or user abandonment leaves a committed free registration behind even though the paid add-on flow never finished, and the next retry will hit the "already registered" guard. Keep these zero-fee divisions pending until the paid checkout succeeds, or add an explicit rollback path when session creation/cancellation fails.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/wodsmith-start/src/server-fns/registration-fns.ts` around lines 913 - 969, The code currently creates and immediately finalizes zero-dollar registrations inside the itemFees loop by calling registerForCompetition, updating paymentStatus to COMMERCE_PAYMENT_STATUS.FREE, calling storeRegistrationAnswers, notifyRegistrationConfirmed and settleInviteForFreeRegistration; this must be changed so that free divisions in a mixed checkout are not marked complete until the paid checkout/session creation succeeds (or there is an explicit rollback on failure). Modify the flow around registerForCompetition so you create tentative/pending registrations (e.g., leave paymentStatus as PENDING or do not call notifyRegistrationConfirmed/storeRegistrationAnswers/settleInviteForFreeRegistration) and only perform the update to COMMERCE_PAYMENT_STATUS.FREE, call storeRegistrationAnswers, notifyRegistrationConfirmed and settleInviteForFreeRegistration after the paid Stripe session creation completes successfully (or implement a compensating rollback path that deletes/marks as cancelled any registration created if session creation or payment fails), focusing changes on the code paths that call registerForCompetition, the db.update(...) that sets paymentStatus, storeRegistrationAnswers, notifyRegistrationConfirmed, and settleInviteForFreeRegistration so that finalization is atomic with successful paid checkout.
🧹 Nitpick comments (1)
apps/wodsmith-start/src/routes/compete/organizer/$competitionId/merch.tsx (1)
236-299: 📐 Maintainability & Code Quality | 💤 Low valueConsider migrating to React Hook Form with Zod validation.
The form currently uses controlled inputs with manual validation in
handleSave(). While functionally correct, the coding guidelines specify using React Hook Form with Zod validation for forms. This would provide:
- Consistent validation patterns across the codebase
- Better field-level error states
- Reuse of existing Zod schemas from the server functions
The current approach works but diverges from project conventions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/wodsmith-start/src/routes/compete/organizer/`$competitionId/merch.tsx around lines 236 - 299, The handleSave function is doing manual validation on draft-controlled inputs; migrate this form to use React Hook Form with Zod by replacing the controlled draft state and handleSave validation logic with a RHF form that uses a Zod schema (reusing or mirroring the server-side schema), validate fields (name, priceDollars -> priceCents, maxPerAthlete, variants.stock, availableUntil, status) via Zod, surface field errors through RHF error state instead of toast checks, and then call createAddon or updateAddon with the same shared payload construction (priceCents, maxPerAthlete, variants mapping) inside the RHF onSubmit handler while retaining setIsSaving, refresh, and toast notifications.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/wodsmith-start/scripts/seed/seeders/02-billing.ts`:
- Around line 200-211: The new feature record with id "feat_registration_addons"
/ key "registration_addons" is missing the required source-trace comment; add a
nearby single-line comment like // `@lat`: [[registration_addons]] immediately
above (or adjacent to) the object literal so the TS source updater can anchor
this seed entry; ensure the comment uses the exact // `@lat`: [[section-id]]
format and place it next to the record for "Registration Add-ons" (id:
"feat_registration_addons").
In `@apps/wodsmith-start/src/server-fns/registration-fns.ts`:
- Around line 759-783: The update against competitionInvitesTable that sets
status to COMPETITION_INVITE_STATUS.ACCEPTED_PAID must check the number of rows
matched and treat zero matches as a failed claim: after calling
db.update(...).where(...), inspect the result (e.g., returned rowCount or
affectedRows) and throw or return an error if it is 0 so the free-invite flow
fails instead of continuing; only call
notifyRegistrationConfirmed(claimedRegistrationId, ...) after
settleInviteForFreeRegistration(...) has succeeded and the invite update was
confirmed; apply the same change to the other identical invite-claim blocks (the
ones that update status from COMPETITION_INVITE_STATUS.PENDING and then call
notifyRegistrationConfirmed/settleInviteForFreeRegistration) so all free-invite
branches validate the update matched before sending confirmations.
In `@apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts`:
- Around line 230-249: The current logic in stripe-checkout-workflow.ts refunds
the add-on if all purchases for the session are FAILED at the instant of
checking (sessionRegistrationPurchases + COMMERCE_PURCHASE_STATUS.FAILED) which
races sibling registration workflows; change the flow in the function handling
the checkout to first ensure no sibling registration purchases remain in a
non-terminal state (e.g., PENDING) before deciding: either (A) wait/retry until
all related commercePurchaseTable rows for stripeCheckoutSessionId are in
terminal statuses (FAILED or COMPLETED) and then reconcile to refund if all are
FAILED, or (B) implement a session-wide reconciliation job invoked after
registrations terminalize that queries the same commercePurchaseTable rows and
calls refundAddon when appropriate; update usages of
sessionRegistrationPurchases, refundAddon, and the decision branch so it only
finalizes merch when sibling purchases are not PENDING.
In `@apps/wodsmith-start/test/server-fns/competition-addon-fns.test.ts`:
- Around line 128-151: The tests after the one at line 111 are missing the
required per-test reference comment; for each described it(...) block (e.g. the
"creates a product with variants when entitled" test that calls createAddon and
asserts mockDb.insert and mockHasFeature), add a single-line comment immediately
adjacent to the it(...) declaration containing the exact tag format `@lat`:
[[section-id]] (one tag per test). Repeat this for the remaining tests in this
file (the tests referenced around the other it(...) blocks) so each test has
exactly one `@lat`: [[section-id]] comment placed next to the test declaration.
In `@apps/wodsmith-start/test/workflows/stripe-checkout-workflow.test.ts`:
- Around line 675-694: Each ADDON purchase test in the "ADDON purchases" suite
is missing its required `@lat` reference; add a single line comment containing the
correct `@lat`: [[section-id]] inside each of the four test blocks (e.g., inside
the it('completes the purchase without creating a registration', ...), and the
three sibling it(...) tests) so each test has exactly one `@lat` reference tied to
its spec; place the comment at the top of the test body (just after the it(...)
opening) for the tests exercising StripeCheckoutWorkflow and leave all test
logic unchanged.
In `@lat.md/commerce.md`:
- Line 59: Shorten the leading paragraph for "Checkout Workflow Branch" to under
250 characters (excluding wiki links) by condensing the core idempotency claim
about completeAddonPurchase: mention that
[[apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts#completeAddonPurchase]]
atomically claims variant stock and flips PENDING→COMPLETED using the
conditional status update as the idempotency gate, then move the details about
refund behavior (failed stock claim -> FAILED, partial reverse_transfer refund,
capacity auto-refund grouping, PAYMENT_COMPLETED event, and per-purchase
parallelism/acceptance of rare race) into one or two follow-up sentences.
- Line 47: The leading paragraph in the "Pricing and Coupon Scope" section is
too long; shorten it to ≤250 characters (excluding wiki links) and split it into
two sentences so fee calculation and routing logic are separate. Concretely:
condense the fee behavior sentence that references getAddonUnitBreakdown and
multiplyFeeBreakdown to a concise statement about per-unit all-in pricing
multiplied by quantity, then create a second sentence that states routing
behavior (Stripe path vs all-free shortcut) and that coupons only apply to the
registration subtotal. Ensure both sentences are under the char limit when wiki
links are removed.
- Line 35: The leading paragraph in lat.md/commerce.md's "Registration Add-ons"
section is too long; trim it to ≤250 characters (excluding wiki link content) by
keeping only the high-level summary about add-ons, then move the technical
details—references to competitionProductsTable, competitionProductVariantsTable,
commerce_purchases, commerce_products, and organizer/athlete/fulfillment
behavior implemented in src/server-fns/competition-addon-fns.ts (and the note
about divisionId excluding add-on revenue)—into a new follow-up sentence or
paragraph so the intro meets the length constraint.
- Line 17: The "Registration Checkout" leading paragraph in commerce.md is over
the 250-character limit; reduce it to ≤250 characters (excluding wiki link
markup) by extracting the essential summary into the leading sentence and moving
remaining details into one or more follow-up sentences or a new paragraph;
preserve references to calculateApplicationFeeCents and the behavior about
clamping/logging and coupons so the content remains accurate while the lead
stays concise.
- Line 53: The leading "Availability" paragraph is too long; shorten it to ≤250
characters by keeping only the deadline, its format and semantics (mention
YYYY-MM-DD, end-of-day IANA timezone, and that it shares semantics with
registrationClosesAt and is checked at checkout via isAddonPurchasable). Move
the stock-claim details (stockQty / soldQty soft check at submit and
authoritative claim in the workflow) into a following sentence or separate
paragraph.
- Line 41: Shorten the "Entitlement Gate" leading paragraph to at most 250
characters by removing enforcement details (which reference server-side behavior
like createCompetitionAddonFn, getPublicCompetitionAddonsFn, and
initiateRegistrationPaymentFn) and move those enforcement specifics into the
next sentence or a following sentence so the first paragraph remains a concise
summary and the implementation/behavior details appear immediately after.
In `@lat.md/organizer-dashboard.md`:
- Line 240: The "Merch" paragraph is too long; shorten it to ≤250 characters
(excluding wiki links) by splitting the content: keep the first sentence about
MerchPage and entitlement, then move the product CRUD details (price, size
variants, stock, order-by deadline, max per athlete, status) and the two
fulfillment tables (counts-by-variant, per-athlete pickup) into a separate short
sentence or bullet-like fragment and list the function names
(listCompetitionAddonsFn, createCompetitionAddonFn, updateCompetitionAddonFn,
archiveCompetitionAddonFn, getAddonSalesReportFn) compactly.
---
Outside diff comments:
In `@apps/wodsmith-start/src/server-fns/competition-divisions-fns.ts`:
- Around line 655-673: The grouped pending-purchases query is still including
rows with null divisionId and thus reduces public competition capacity
incorrectly; update the where clause used in the db.select(...) that builds
pendingCount (the block referencing commercePurchaseTable,
COMMERCE_PURCHASE_STATUS.PENDING and PENDING_PURCHASE_MAX_AGE_MINUTES) to also
include isNotNull(commercePurchaseTable.divisionId). Apply the same change to
the equivalent grouped-pending query later in the file (the second block around
the other selection) so both paths align with getCompetitionSpotsAvailableFn's
exclusion of null divisionId.
In `@apps/wodsmith-start/src/server-fns/registration-fns.ts`:
- Around line 913-969: The code currently creates and immediately finalizes
zero-dollar registrations inside the itemFees loop by calling
registerForCompetition, updating paymentStatus to COMMERCE_PAYMENT_STATUS.FREE,
calling storeRegistrationAnswers, notifyRegistrationConfirmed and
settleInviteForFreeRegistration; this must be changed so that free divisions in
a mixed checkout are not marked complete until the paid checkout/session
creation succeeds (or there is an explicit rollback on failure). Modify the flow
around registerForCompetition so you create tentative/pending registrations
(e.g., leave paymentStatus as PENDING or do not call
notifyRegistrationConfirmed/storeRegistrationAnswers/settleInviteForFreeRegistration)
and only perform the update to COMMERCE_PAYMENT_STATUS.FREE, call
storeRegistrationAnswers, notifyRegistrationConfirmed and
settleInviteForFreeRegistration after the paid Stripe session creation completes
successfully (or implement a compensating rollback path that deletes/marks as
cancelled any registration created if session creation or payment fails),
focusing changes on the code paths that call registerForCompetition, the
db.update(...) that sets paymentStatus, storeRegistrationAnswers,
notifyRegistrationConfirmed, and settleInviteForFreeRegistration so that
finalization is atomic with successful paid checkout.
In `@apps/wodsmith-start/test/server/commerce/addons.test.ts`:
- Around line 1-72: Add a single `@lat:` comment to each test case pointing to
its LAT spec: for the tests inside describe("buildAddonFeeConfig") add one
`@lat:` next to the it("drops only the fixed platform fee") test; for
describe("getAddonUnitBreakdown") add `@lat:` comments to both it("charges the
percentage platform fee without the $2 fixed fee") and it("follows the
competition's stripe pass-through setting"); and for
describe("multiplyFeeBreakdown") add `@lat:` to both it("scales every cents
field with zero rounding drift") and it("is identity for quantity 1"). Place
exactly one `@lat:` comment adjacent to each respective `it` (inline or
immediately above) with the correct LAT identifier for that spec.
In `@apps/wodsmith-start/test/server/commerce/application-fee.test.ts`:
- Around line 1-69: Add a `@lat`: comment to each test case in the
calculateApplicationFeeCents suite linking to the relevant specification
section; locate the describe("calculateApplicationFeeCents", ...) and add a
single-line comment (e.g. // `@lat`: <spec-id>) above each it(...) block so all
five it tests reference the correct LAT spec IDs.
In `@apps/wodsmith-start/test/utils/addon-availability.test.ts`:
- Around line 1-109: The tests in addon-availability.test.ts are missing
required `@lat`: spec references next to each test case; update every it(...)
block within the describe blocks ("isAddonPurchasable", "variant stock helpers",
"getMaxSelectableQuantity") to include an inline or directly preceding comment
of the form // `@lat`: <SPEC_ID> (use the correct spec IDs for each behavior being
tested) so each of the 8 it(...) cases references its specification; ensure
comments are added for tests that check isAddonPurchasable, getVariantRemaining,
isVariantSoldOut, canFulfillQuantity, and getMaxSelectableQuantity.
---
Nitpick comments:
In `@apps/wodsmith-start/src/routes/compete/organizer/`$competitionId/merch.tsx:
- Around line 236-299: The handleSave function is doing manual validation on
draft-controlled inputs; migrate this form to use React Hook Form with Zod by
replacing the controlled draft state and handleSave validation logic with a RHF
form that uses a Zod schema (reusing or mirroring the server-side schema),
validate fields (name, priceDollars -> priceCents, maxPerAthlete,
variants.stock, availableUntil, status) via Zod, surface field errors through
RHF error state instead of toast checks, and then call createAddon or
updateAddon with the same shared payload construction (priceCents,
maxPerAthlete, variants mapping) inside the RHF onSubmit handler while retaining
setIsSaving, refresh, and toast notifications.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f12569c2-54e1-42de-aa85-c9d1e63d8d23
📒 Files selected for processing (34)
apps/docs/docs/how-to/organizers/sell-merch.mdapps/docs/docs/tutorials/athletes/first-competition.mdapps/wodsmith-start/scripts/seed/seeders/02-billing.tsapps/wodsmith-start/src/components/competition-sidebar.tsxapps/wodsmith-start/src/components/registration/addons-section.tsxapps/wodsmith-start/src/components/registration/registration-form.tsxapps/wodsmith-start/src/components/registration/registration-sections.tsxapps/wodsmith-start/src/components/registration/use-registration-form.tsapps/wodsmith-start/src/config/features.tsapps/wodsmith-start/src/db/schema.tsapps/wodsmith-start/src/db/schemas/commerce.tsapps/wodsmith-start/src/db/schemas/common.tsapps/wodsmith-start/src/db/schemas/competition-products.tsapps/wodsmith-start/src/routeTree.gen.tsapps/wodsmith-start/src/routes/compete/$slug/register.tsxapps/wodsmith-start/src/routes/compete/organizer/$competitionId.tsxapps/wodsmith-start/src/routes/compete/organizer/$competitionId/merch.tsxapps/wodsmith-start/src/server-fns/competition-addon-fns.tsapps/wodsmith-start/src/server-fns/competition-divisions-fns.tsapps/wodsmith-start/src/server-fns/registration-fns.tsapps/wodsmith-start/src/server/commerce/addons.tsapps/wodsmith-start/src/server/commerce/fee-calculator.tsapps/wodsmith-start/src/server/commerce/utils.tsapps/wodsmith-start/src/utils/addon-availability.tsapps/wodsmith-start/src/workflows/stripe-checkout-workflow.tsapps/wodsmith-start/test/server-fns/competition-addon-fns.test.tsapps/wodsmith-start/test/server/commerce/addons.test.tsapps/wodsmith-start/test/server/commerce/application-fee.test.tsapps/wodsmith-start/test/utils/addon-availability.test.tsapps/wodsmith-start/test/workflows/stripe-checkout-workflow.test.tsdocs/plans/registration-addons-plan.mdlat.md/commerce.mdlat.md/organizer-dashboard.mdlat.md/registration.md
| { | ||
| id: "feat_registration_addons", | ||
| key: "registration_addons", | ||
| name: "Registration Add-ons", | ||
| description: | ||
| "Sell merch and add-ons (e.g., event tees) during competition registration", | ||
| category: "team", | ||
| is_active: 1, | ||
| created_at: ts, | ||
| updated_at: ts, | ||
| update_counter: 0, | ||
| }, |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add an @lat reference for the new registration-addons feature seed.
This new feature record introduces a new concept anchor but doesn’t include a nearby // @lat: [[section-id]] trace comment required for TS source updates.
As per coding guidelines, **/*.{ts,tsx,js,jsx,rs,go,c,h,py} must use // @lat: [[section-id]] code-reference comments.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/wodsmith-start/scripts/seed/seeders/02-billing.ts` around lines 200 -
211, The new feature record with id "feat_registration_addons" / key
"registration_addons" is missing the required source-trace comment; add a nearby
single-line comment like // `@lat`: [[registration_addons]] immediately above (or
adjacent to) the object literal so the TS source updater can anchor this seed
entry; ensure the comment uses the exact // `@lat`: [[section-id]] format and
place it next to the record for "Registration Add-ons" (id:
"feat_registration_addons").
Source: Coding guidelines
| await db | ||
| .update(competitionInvitesTable) | ||
| .set({ | ||
| status: COMPETITION_INVITE_STATUS.ACCEPTED_PAID, | ||
| paidAt: now, | ||
| claimedRegistrationId, | ||
| claimToken: null, | ||
| updatedAt: now, | ||
| }) | ||
| .where( | ||
| and( | ||
| eq(competitionInvitesTable.id, inviteIdForPurchase), | ||
| eq( | ||
| competitionInvitesTable.status, | ||
| COMPETITION_INVITE_STATUS.PENDING, | ||
| ), | ||
| ), | ||
| ) | ||
| logInfo({ | ||
| message: "[Registration] Free-checkout invite flipped to accepted_paid", | ||
| attributes: { | ||
| inviteId: inviteIdForPurchase, | ||
| registrationId: claimedRegistrationId, | ||
| }, | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Fail the free invite flow when the guarded invite flip matches zero rows, and send confirmations after that succeeds.
The status = PENDING guard on Lines 769-775 prevents stomping a revoked/declined invite, but the code never checks whether the update actually matched. Both free branches send notifyRegistrationConfirmed() before settleInviteForFreeRegistration(), so a lost invite race can still email a "confirmed" registration that is immediately removed or no longer authorized. Treat a zero-row invite update as a failed claim, then move the confirmation send after the settlement path.
Also applies to: 819-825, 847-853, 936-941, 955-969
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/wodsmith-start/src/server-fns/registration-fns.ts` around lines 759 -
783, The update against competitionInvitesTable that sets status to
COMPETITION_INVITE_STATUS.ACCEPTED_PAID must check the number of rows matched
and treat zero matches as a failed claim: after calling
db.update(...).where(...), inspect the result (e.g., returned rowCount or
affectedRows) and throw or return an error if it is 0 so the free-invite flow
fails instead of continuing; only call
notifyRegistrationConfirmed(claimedRegistrationId, ...) after
settleInviteForFreeRegistration(...) has succeeded and the invite update was
confirmed; apply the same change to the other identical invite-claim blocks (the
ones that update status from COMPETITION_INVITE_STATUS.PENDING and then call
notifyRegistrationConfirmed/settleInviteForFreeRegistration) so all free-invite
branches validate the update matched before sending confirmations.
| // 1. Group behavior — all registrations in this session failed? | ||
| const sessionRegistrationPurchases = | ||
| await db.query.commercePurchaseTable.findMany({ | ||
| where: and( | ||
| eq(commercePurchaseTable.stripeCheckoutSessionId, session.id), | ||
| isNotNull(commercePurchaseTable.divisionId), | ||
| ), | ||
| columns: { id: true, status: true }, | ||
| }) | ||
| if ( | ||
| sessionRegistrationPurchases.length > 0 && | ||
| sessionRegistrationPurchases.every( | ||
| (p) => p.status === COMMERCE_PURCHASE_STATUS.FAILED, | ||
| ) | ||
| ) { | ||
| await refundAddon( | ||
| "All registrations in this checkout failed - add-on refunded with them", | ||
| ) | ||
| return | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
The add-on refund decision still races sibling registration workflows.
This refunds merch only when every registration purchase in the session is already FAILED at this instant. If the add-on workflow wins the race while sibling registrations are still PENDING, the function falls through to Lines 268-313 and completes the merch sale, and the later registration failures never revisit that add-on. That leaves merch sold for a checkout where all registrations ultimately failed. This needs a session-wide terminal reconciliation step, or the add-on branch has to wait until sibling registration purchases are no longer PENDING before it can finalize.
Also applies to: 251-349
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts` around lines
230 - 249, The current logic in stripe-checkout-workflow.ts refunds the add-on
if all purchases for the session are FAILED at the instant of checking
(sessionRegistrationPurchases + COMMERCE_PURCHASE_STATUS.FAILED) which races
sibling registration workflows; change the flow in the function handling the
checkout to first ensure no sibling registration purchases remain in a
non-terminal state (e.g., PENDING) before deciding: either (A) wait/retry until
all related commercePurchaseTable rows for stripeCheckoutSessionId are in
terminal statuses (FAILED or COMPLETED) and then reconcile to refund if all are
FAILED, or (B) implement a session-wide reconciliation job invoked after
registrations terminalize that queries the same commercePurchaseTable rows and
calls refundAddon when appropriate; update usages of
sessionRegistrationPurchases, refundAddon, and the decision branch so it only
finalizes merch when sibling purchases are not PENDING.
| it("creates a product with variants when entitled", async () => { | ||
| mockDb.queueMockSingleValues([competition]) | ||
|
|
||
| const result = await createAddon({ | ||
| data: { | ||
| competitionId: "comp-1", | ||
| teamId: "team-1", | ||
| name: "Event Tee", | ||
| priceCents: 2500, | ||
| variants: [ | ||
| { label: "M", stockQty: 20 }, | ||
| { label: "L", stockQty: null }, | ||
| ], | ||
| }, | ||
| }) | ||
|
|
||
| expect(result.productId).toMatch(/^cmpprod_/) | ||
| // Product insert + variants insert | ||
| expect(mockDb.insert).toHaveBeenCalledTimes(2) | ||
| expect(mockHasFeature).toHaveBeenCalledWith( | ||
| "team-1", | ||
| "registration_addons", | ||
| ) | ||
| }) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add @lat: reference comments to each test.
Per coding guidelines, each test should have exactly one @lat: [[section-id]] comment placed next to the test (not at the top of the file). Line 111 correctly demonstrates this pattern, but the remaining tests (lines 128, 153, 170, 182, 205, 219, 232) are missing their reference comments.
Also applies to: 153-166, 170-178, 182-201, 205-217, 219-230, 232-283
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/wodsmith-start/test/server-fns/competition-addon-fns.test.ts` around
lines 128 - 151, The tests after the one at line 111 are missing the required
per-test reference comment; for each described it(...) block (e.g. the "creates
a product with variants when entitled" test that calls createAddon and asserts
mockDb.insert and mockHasFeature), add a single-line comment immediately
adjacent to the it(...) declaration containing the exact tag format `@lat`:
[[section-id]] (one tag per test). Repeat this for the remaining tests in this
file (the tests referenced around the other it(...) blocks) so each test has
exactly one `@lat`: [[section-id]] comment placed next to the test declaration.
Source: Coding guidelines
| it('completes the purchase without creating a registration', async () => { | ||
| setupAddonMocks() | ||
|
|
||
| const workflow = new StripeCheckoutWorkflow({} as any, {} as any) | ||
| const step = createFakeStep() | ||
| const event = createWorkflowEvent({ | ||
| ...baseParams, | ||
| session: { | ||
| ...baseSession, | ||
| metadata: {...baseSession.metadata, purchaseId: addonPurchase.id}, | ||
| }, | ||
| }) | ||
|
|
||
| await workflow.run(event as any, step as any) | ||
|
|
||
| expect(mockRegisterForCompetition).not.toHaveBeenCalled() | ||
| expect(mockNotifyRegistrationConfirmed).not.toHaveBeenCalled() | ||
| expect(mockStripeRefundsCreate).not.toHaveBeenCalled() | ||
| expect(mockDb.update).toHaveBeenCalled() | ||
| }) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add @lat: reference comments to each ADDON test.
The four new tests in the "ADDON purchases" suite (lines 675, 696, 728, 753) each need exactly one @lat: [[section-id]] comment to tie the test to its specification. As per coding guidelines, each test should reference its spec, not just the top of the file.
Also applies to: 696-726, 728-751, 753-771
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/wodsmith-start/test/workflows/stripe-checkout-workflow.test.ts` around
lines 675 - 694, Each ADDON purchase test in the "ADDON purchases" suite is
missing its required `@lat` reference; add a single line comment containing the
correct `@lat`: [[section-id]] inside each of the four test blocks (e.g., inside
the it('completes the purchase without creating a registration', ...), and the
three sibling it(...) tests) so each test has exactly one `@lat` reference tied to
its spec; place the comment at the top of the test body (just after the it(...)
opening) for the tests exercising StripeCheckoutWorkflow and leave all test
logic unchanged.
Source: Coding guidelines
|
|
||
| Selling add-ons is gated behind the `registration_addons` team feature, granted per organizing team by platform admins at `/admin/entitlements` — full admin control over which accounts can sell merch. | ||
|
|
||
| Server functions are the authority: CRUD mutations throw without the feature ([[apps/wodsmith-start/src/server-fns/competition-addon-fns.ts#createCompetitionAddonFn]]), the public catalog ([[apps/wodsmith-start/src/server-fns/competition-addon-fns.ts#getPublicCompetitionAddonsFn]]) returns an empty list when the feature is missing or the organizer has no verified Stripe account, and [[apps/wodsmith-start/src/server-fns/registration-fns.ts#initiateRegistrationPaymentFn]] rejects `addOns` input for unentitled teams. The organizer Merch page renders a locked state instead of the editor. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Reduce leading paragraph to ≤250 characters.
The "Entitlement Gate" subsection's leading paragraph exceeds the 250-character limit. Consider moving some of the enforcement details to a follow-up sentence.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lat.md/commerce.md` at line 41, Shorten the "Entitlement Gate" leading
paragraph to at most 250 characters by removing enforcement details (which
reference server-side behavior like createCompetitionAddonFn,
getPublicCompetitionAddonsFn, and initiateRegistrationPaymentFn) and move those
enforcement specifics into the next sentence or a following sentence so the
first paragraph remains a concise summary and the implementation/behavior
details appear immediately after.
Source: Coding guidelines
|
|
||
| Merch pays the percentage platform fee but not the $2 fixed fee, and follows the competition's fee pass-through configuration. | ||
|
|
||
| Pricing is per-unit all-in ([[apps/wodsmith-start/src/server/commerce/addons.ts#getAddonUnitBreakdown]]) multiplied by quantity ([[apps/wodsmith-start/src/server/commerce/addons.ts#multiplyFeeBreakdown]]), so the form summary, Stripe line item, and purchase row are cent-identical. Coupons never discount merch: the discount base stays the registration subtotal only. A free division plus a paid add-on routes through the Stripe path (the all-free shortcut requires zero add-ons). |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Reduce leading paragraph to ≤250 characters.
The "Pricing and Coupon Scope" subsection's leading paragraph exceeds 250 characters when wiki links are excluded. Split the fee behavior and routing logic into separate sentences.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lat.md/commerce.md` at line 47, The leading paragraph in the "Pricing and
Coupon Scope" section is too long; shorten it to ≤250 characters (excluding wiki
links) and split it into two sentences so fee calculation and routing logic are
separate. Concretely: condense the fee behavior sentence that references
getAddonUnitBreakdown and multiplyFeeBreakdown to a concise statement about
per-unit all-in pricing multiplied by quantity, then create a second sentence
that states routing behavior (Stripe path vs all-free shortcut) and that coupons
only apply to the registration subtotal. Ensure both sentences are under the
char limit when wiki links are removed.
Source: Coding guidelines
|
|
||
| Two optional controls per product: an `availableUntil` order-by deadline and per-variant stock; deadline-only is the recommended default. | ||
|
|
||
| The deadline is a `YYYY-MM-DD` string evaluated end-of-day in the competition's IANA timezone (same semantics as `registrationClosesAt`), checked at checkout creation by [[apps/wodsmith-start/src/utils/addon-availability.ts#isAddonPurchasable]] with no webhook re-check — Stripe's 30-minute session expiry bounds the race. Stock (`stockQty`/`soldQty` on variants) gets a soft check at submit and an authoritative claim in the workflow. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Reduce leading paragraph to ≤250 characters.
The "Availability" subsection's leading paragraph exceeds the 250-character limit. Move the stock-claim workflow details to a separate sentence.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lat.md/commerce.md` at line 53, The leading "Availability" paragraph is too
long; shorten it to ≤250 characters by keeping only the deadline, its format and
semantics (mention YYYY-MM-DD, end-of-day IANA timezone, and that it shares
semantics with registrationClosesAt and is checked at checkout via
isAddonPurchasable). Move the stock-claim details (stockQty / soldQty soft check
at submit and authoritative claim in the workflow) into a following sentence or
separate paragraph.
Source: Coding guidelines
|
|
||
| ADDON purchases complete without creating registrations: the checkout workflow branches on the purchase's product type before the registration idempotency checks. | ||
|
|
||
| [[apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts#completeAddonPurchase]] claims variant stock and flips the purchase PENDING→COMPLETED inside one transaction: the conditional status update is the idempotency gate, so a workflow step retry (or concurrent run) can never claim stock twice — either both writes committed or neither did. A failed stock claim (zero rows affected = oversold during payment) marks the purchase FAILED and partial-refunds just that line with `reverse_transfer`; the add-on is also refunded when every registration purchase in the same session already FAILED (capacity auto-refund grouping). Completion records a `PAYMENT_COMPLETED` financial event. Per-purchase workflows run in parallel, so a registration that fails *after* the add-on completes keeps the add-on sold — rare, logged, accepted for v1. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Reduce leading paragraph to ≤250 characters.
The "Checkout Workflow Branch" subsection's leading paragraph significantly exceeds 250 characters (excluding wiki links). Split the idempotency mechanism and refund behavior into follow-up sentences.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lat.md/commerce.md` at line 59, Shorten the leading paragraph for "Checkout
Workflow Branch" to under 250 characters (excluding wiki links) by condensing
the core idempotency claim about completeAddonPurchase: mention that
[[apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts#completeAddonPurchase]]
atomically claims variant stock and flips PENDING→COMPLETED using the
conditional status update as the idempotency gate, then move the details about
refund behavior (failed stock claim -> FAILED, partial reverse_transfer refund,
capacity auto-refund grouping, PAYMENT_COMPLETED event, and per-purchase
parallelism/acceptance of rare race) into one or two follow-up sentences.
Source: Coding guidelines
|
|
||
| Registration add-ons (merch) management for a competition, gated behind the `registration_addons` entitlement. | ||
|
|
||
| `MerchPage` at `/compete/organizer/$competitionId/merch` renders a locked state when the organizing team lacks the entitlement. When entitled: product CRUD (price, size variants with optional stock, order-by deadline, max per athlete, ACTIVE/HIDDEN/ARCHIVED status) plus two fulfillment tables — counts-by-variant for the print shop and the per-athlete pickup list. Uses `listCompetitionAddonsFn`, `createCompetitionAddonFn`, `updateCompetitionAddonFn`, `archiveCompetitionAddonFn`, and `getAddonSalesReportFn`. See [[commerce#Registration Add-ons]] for the checkout and payment flow. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Reduce paragraph to ≤250 characters.
The second paragraph in the "Merch" section exceeds the 250-character limit (excluding wiki link content) specified in coding guidelines. Consider splitting the CRUD function list and fulfillment table details into separate sentences or a bulleted list.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lat.md/organizer-dashboard.md` at line 240, The "Merch" paragraph is too
long; shorten it to ≤250 characters (excluding wiki links) by splitting the
content: keep the first sentence about MerchPage and entitlement, then move the
product CRUD details (price, size variants, stock, order-by deadline, max per
athlete, status) and the two fulfillment tables (counts-by-variant, per-athlete
pickup) into a separate short sentence or bullet-like fragment and list the
function names (listCompetitionAddonsFn, createCompetitionAddonFn,
updateCompetitionAddonFn, archiveCompetitionAddonFn, getAddonSalesReportFn)
compactly.
Source: Coding guidelines
From cubic's review: - Wrap updateCompetitionAddonFn product-field update and variant reconciliation in one transaction so a variant validation error can't leave half-applied product changes (P1). - Reject decimal input for max-per-athlete and variant stock in the merch dialog instead of silently truncating via parseInt. - Index commerce_purchases.variantId; store quantity as unsigned int. From CodeRabbit's review: - getPublicCompetitionDivisionsFn: exclude null-division (ADDON) pending purchases from the grouped count whose sum feeds the public competition-wide capacity — a pending merch purchase could otherwise falsely flip the register page to competition-full. Closes the last unfiltered pending-count site (all others verified division-scoped or intentionally addon-inclusive). Declined with rationale on the PR: entitlement gate on archive (archive only reduces exposure; gating would trap revoked teams with ACTIVE products), per-test @lat comments (would duplicate spec refs and break lat check), and lat.md paragraph-length flags (the 250-char rule applies to leading paragraphs only; lat check passes). https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi
|
Addressed the automated review feedback in dfffec9: Fixed (identified by cubic):
Fixed (identified by CodeRabbit):
Declined, with reasons:
Generated by Claude Code |
Registration Add-ons (Merch), Gated by Entitlement
Implements Option A — in-flow registration add-ons from the merch add-ons research memo: organizers sell merch (e.g., event tees with sizes) inside the registration flow. Selections become extra line items in the same Stripe Checkout Session; pickup is at the venue. Implementation plan:
docs/plans/registration-addons-plan.md.Admin control (the entitlement gate)
Selling add-ons requires the new
registration_addonsteam feature. Because/admin/entitlementslists features straight from the DB, the feature appears in the existing admin UI automatically — platform admins grant/revoke merch selling per organizing team with zero new admin surface. Enforcement is server-side everywhere:initiateRegistrationPaymentFnrejectsaddOnsinput for unentitled teamsWhat's included
competition_products+competition_product_variants(sizes, optional per-variant stock);commerce_purchasesgainsvariantId+quantityreverse_transfer) on oversell, group refund when every registration in the session failed capacity re-checkavailableUntilorder-by deadline (end-of-day in competition timezone, likeregistrationClosesAt) and/or per-variant stock; deadline-only is the recommended defaultisNotNull(divisionId)), invite settling for free invited divisions routed through Stripe by add-ons, fee summary for free divisions, and a Stripeapplication_fee_amountclamp fixing a latent crash when large coupons meet organizer-absorbed feesapps/docs/.../sell-merch.md), athlete tutorial mention, lat.md knowledge sectionsVerification
2,850 tests passing (30 new), type-check + Biome clean,
lat checkgreen, docs site builds.Rollout checklist
pnpm db:pushto the PlanetScale branch, or generate the migration at merge per the local-db workflow — the repo has no MySQL migration journal yet)feat_registration_addonsrow intofeaturesin production (mirrorsscripts/seed/seeders/02-billing.ts)/admin/entitlementsOpen question (non-blocking)
Platform fees are computed from undiscounted totals (coupons are organizer-funded). The new clamp prevents Stripe rejecting sessions when a large coupon meets organizer-absorbed fees, but whether fees should scale down with discounts in general is a fee-policy decision left unchanged.
https://claude.ai/code/session_016sJpPZW1vc5nuMxeCtw9vi
Generated by Claude Code
Summary by cubic
Adds registration add-ons (merch) inside the registration flow, gated by the
registration_addonsteam entitlement. Athletes can add merch to the same Stripe Checkout; organizers get a simple merch catalog and fulfillment reports.New Features
registration_addonscontrols access; enforced in CRUD, public catalog, and checkout.competition_productsandcompetition_product_variants;commerce_purchasesaddsvariantIdand unsignedquantity(with an index onvariantId).updateCompetitionAddonFnnow updates product + variants in one transaction; merch dialog rejects decimal stock/max-per-athlete; docs and tests added.Migration
feat_registration_addonsintofeatures(mirrorsapps/wodsmith-start/scripts/seed/seeders/02-billing.ts).registration_addonsto the organizer team in/admin/entitlements.Written for commit dfffec9. Summary will update on new commits.
Summary by CodeRabbit
New Features
Documentation