Storefronts + commerce
The two storefronts
apps/storefront/ is one Next.js app, but it runs two structurally different checkout flows. Routing happens at the company-lookup layer:
Request hits /store/[slug] │ ▼GET /companies/by-slug/:slug → { gated: boolean, ... } │ ├── gated = true ─→ Employee-invite + login flow └── gated = false ─→ Anonymous shopper flow (AnonymousCart)The split lives at apps/storefront/src/app/store/[slug]/cart/page.tsx. The page does a useState for company lookup, then renders either AnonymousCart (ungated) or falls through to the employee-auth flow (gated).
One app, two flows. If you’re tempted to split them into two apps later, don’t — the BrandKit theming, product browsing, and cart-state logic are all shared. The divergence is only at checkout.
Anonymous shopper flow (ungated)
Verticals: churches, teams, community-cause, events, travel-tourism (i.e. every ONE_TIME_SETUP family member).
The shopper:
- Lands on
store.yourcustommerch.ca/<slug>from a marketing link or QR code. - Browses + adds to cart (no login).
- Hits checkout → enters shipping address + email → Stripe Checkout takes over.
- Returns to
/track/:trackingTokenURL emailed to them post-purchase.
No Shopper model. Anonymous orders live on Order.customerEmail + Order.customerName + Order.customerPhone directly. The AnonymousOrder ↔ shopper-account migration is post-launch scope (see POST_DEPLOY_BACKLOG.md “Anonymous shopper accounts (storefront)”).
Service path: AnonymousOrdersService (separate from gated OrdersService). Creates a Stripe PaymentIntent at checkout-init, holds the Order in PENDING_PAYMENT until payment_intent.succeeded fires.
Tracking: every anonymous order gets a trackingToken (random URL-safe slug). The customer’s only authenticator. Order-status emails embed the deep link /track/:trackingToken. No login.
Currency: the storefront’s currency is Company.billingCurrency — set at intake. A US shopper buying from a CA storefront pays in CAD.
Gated employee flow
Verticals: businesses-corporate, trades-contractors, creators-collectives (MONTHLY_SUBSCRIPTION) + departments (MEMBER_PAYS).
The employee:
- Receives a magic-link invite emailed by their Company Admin (or via CSV bulk-import).
- Sets a password OR uses magic-link-only auth.
- Browses + adds to cart (logged in; sees the same BrandKit-themed catalog).
- Enters shipping address (employee personal address; per-employee or shared company address).
- Submits the order.
Checkout divergence by pricing model:
| Family | Who pays | At checkout |
|---|---|---|
| MONTHLY_SUBSCRIPTION (default) | Company | Order submits server-side; no Stripe Elements step. Company sees the line on next invoice (INVOICE flow) or company card is charged (CARD_ON_FILE) or prepaid pool deducts (PREPAID). |
| MONTHLY_SUBSCRIPTION + split-tender (overage) | Company up to cap + employee for the rest | AnonymousCheckoutModal opens for the employee’s overage. On Stripe success → webhook commits company side + dispatches fulfillment. |
| MEMBER_PAYS | Employee | AnonymousCheckoutModal opens for the full order total. Same modal as anonymous + split-tender. |
The AnonymousCheckoutModal is the reused Stripe Elements component. Three paths land in it (anonymous shopper, MEMBER_PAYS, split-tender overage); the only difference is the amount and the metadata kind the backend stamps on the PaymentIntent.
Employee login
/store/<slug>/login — magic-link or password. Token lives in localStorage (emp_token, emp_employee_id) for cart-page access. JWT 1h TTL; refresh-token path exists but is conservative — most carts complete within the window so refresh is rare in practice.
The cart Continue button is disabled until the employee is logged in. The login form lives inline on the cart page — clicking Continue while logged out opens a sign-in panel, not a new route, so the cart state survives.
Shipping address
Each employee has a default Address row keyed to their Employee.id. The cart-page address form either:
- Uses the stored default if it exists (no form shown).
- Creates a new Address on submit if the employee hasn’t set one yet.
Google Places API is wired into <AddressAutocomplete> for street-level autocomplete (Phase 0024). Falls back to manual entry if the API key isn’t configured.
Address ownership is server-validated: an employee can only ship to addresses they own (or addresses on their Company without an employeeId set). Cross-employee or cross-company access returns 403.
GivesBack — the markup-as-fundraising layer
ADR-0065 — Vertical capabilities + GivesBack hygiene.
Vertical.allowsGivesBack: Boolean flags which verticals can layer a fundraising markup on top of YCM’s retail price. Today: churches, teams, community-cause, events. Removed from travel-tourism in Phase 0113 — boutique tour operators didn’t fit the framing.
When enabled, the Company Admin sets a default per-product markup (Company.defaultMarkupCents) and/or per-Product overrides (Product.fundraisingMarkupCents). At checkout, the shopper sees the marked-up price; YCM charges the full price, then accrues the markup portion as a custodial liability for monthly remittance to the host org.
The custodial-liability invariant
(CLAUDE.md §6 rule 8.)
Fundraising markup is a custodial liability, not YCM revenue. Accruals on
FundraisingMarkupEntryare money YCM holds on behalf of the client until monthly remittance. Balance-sheet liability (“unremitted markup payable”), not revenue. No T4A from YCM.
The trap: if YCM accidentally books fundraising markup as revenue, the books lie and the next audit catches fire. Reflecting that in the financial system requires:
- Markup income is recorded in a separate liability account on YCM’s GL, not in revenue.
- Monthly remittance pays the host org from that liability account.
- The host org records the receipt as their own donor / fundraising income — they’re the ones issuing T4As to donors if applicable.
FundraisingMarkupEntry lifecycle
ACCRUED ─→ ASSIGNED_TO_BATCH ─→ PAIDACCRUED— recognized at Order’spayment_intent.succeeded. Idempotent:FundraisingMarkupService.accrueForOrder(orderId)is no-op on replays.ASSIGNED_TO_BATCH— YCM staff cuts a monthly payout batch grouping all unpaid entries for a Company.PAID— cheque issued (or bank transfer). Audit-logged.
The full ledger view lives at /internal/fundraising-markup (SUPER_ADMIN only). Includes outstanding balance per Company + CSV export.
Order tracking
Anonymous orders: /track/<trackingToken> (no auth). Shows status + tracking carrier + tracking URL when shipped.
Gated orders: the employee’s /store/<slug>/my-orders (auth required, scoped to the employee).
Multi-package orders (Phase 0129 / ADR-0084): one MerchOS Order can have N FulfillmentTask rows — one per supplier. Tracking pages show per-package status + per-package tracking. Customer-facing copy is supplier-agnostic (“your order will arrive in 2 packages” — never “Gelato + Printful”).
Order.status aggregates from the slowest task — when one is SHIPPED and another is still IN_PRODUCTION, the order is IN_PRODUCTION.
The 5 non-negotiables on the storefront
The CLAUDE.md §6 rules, re-stated through the storefront lens:
- Print files gate orders — server-side at submit; storefront UI mirrors but never trusts.
- Spend limits are hard limits on company-paid totals —
FOR UPDATElock onSpendRecordat submit. Split-tender’s employee-paid rail is parallel; doesn’t soften the cap. - Currency never changes mid-contract — storefront pulls
Company.billingCurrencyat company-lookup time; no per-session override. - Cross-tenant isolation is absolute — every storefront API call carries the employee JWT (gated) or anonymous (ungated); never trust the slug alone.
- Employees never see payment information — no credit-card fields, no invoice totals, no billing details on the storefront. Commerce is between YCM and the company only — except for MEMBER_PAYS + split-tender’s employee portion, where the employee sees only what they themselves pay.
Theming — the BrandKit applied
Every gated + ungated storefront is themed live from Company.brandKit. CSS custom properties cover:
--color-primary,--color-accent,--color-text-on-accent,--color-text-on-primary--font-heading,--font-body--surface-raised,--surface-subtle,--border-subtle,--input-border--text-muted
The storefront app never hardcodes colors. If you find bg-orange-500 or text-red-700 in storefront JSX, that’s a bug. Use the custom-property classes via style={{ ... }} or the <CompanyThemeProvider> wrapper.
Storefront theming has no vibrancy gate (unlike the company-admin portal — see ADR-0061). Shoppers see the company’s actual brand colors verbatim, even when they’re low-saturation. The vibrancy gate only kicks in on the admin portal because Joy + Jess + Company Admins need to do real work in a portal that doesn’t look beige-on-beige.
What’s next
- Internal admin — Joy + Jess’s daily workflow inside the system.
- Billing + Stripe — the per-flow billing implementation.
Canonical sources
prisma/schema.prisma—Vertical.allowsGivesBack,Company.brandKit,Order,OrderItem,Address,Employee,FundraisingMarkupEntry,FundraisingMarkupStatus.apps/storefront/— the storefront Next.js app.apps/api/src/modules/orders/orders.service.ts— gated submit + split-tender + MEMBER_PAYS.apps/api/src/modules/orders/anonymous-orders.service.ts— anonymous shopper flow.apps/api/src/modules/orders/fundraising-markup.service.ts— accrual + batch payout.- ADR-0065 — GivesBack capability flag + hygiene.
- ADR-0084 — multi-supplier order shape + customer-facing copy ([Slice 5] supplier-agnostic).
- ADR-0094 — MEMBER_PAYS checkout flow.
- ADR-0104 — split-tender employee top-up.
- ADR-0052 — cross-portal password reset.
docs/services/orders.md— service-level deep read.
Triggers for update
Update this chapter if you:
- Add a third storefront flow.
- Add or change a
Vertical.allowsGivesBackvalue. - Change the
FundraisingMarkupEntrylifecycle states. - Change the storefront’s per-portal theming model (BrandKit fields, vibrancy gate scope).
- Move tracking from
trackingTokento a logged-in shopper model. - Add a
Shoppermodel for anonymous accounts. - Change the split-tender or MEMBER_PAYS checkout UI surface.