Billing + Stripe
The five money rails
Five distinct Stripe surfaces, all in BillingService:
| Rail | Stripe construct | Triggered by | Handler |
|---|---|---|---|
| Subscription (MONTHLY_SUBSCRIPTION) | Subscription + Invoice + PaymentMethod | Activation Stripe PI clears at intake; monthly billing cron | handleSubscriptionWebhook |
| Setup-fee activation (ONE_TIME_SETUP + MEMBER_PAYS) | PaymentIntent | Intake step 5 confirm | handleSetupFeeActivationWebhook |
| Anonymous-shopper checkout (ONE_TIME_SETUP order) | PaymentIntent | Shopper hits Pay at storefront | AnonymousOrdersService.handlePaymentWebhook |
| MEMBER_PAYS (Departments) | PaymentIntent (employee card) | Employee places order | OrdersService.handleMemberPaidOrderWebhook |
Split-tender (MONTHLY_SUBSCRIPTION + allowEmployeeTopUp = TRUE) | PaymentIntent (employee card; overage only) | Employee cart exceeds spend cap | OrdersService.handleSplitTenderOrderWebhook |
Each rail has its own webhook handler. All five fan out from one BillingController.webhook endpoint after signature verification:
await Promise.all([ this.billing.handleSubscriptionWebhook(event), this.billing.handleSetupFeeActivationWebhook(event), this.orders.handleMemberPaidOrderWebhook(event), this.orders.handleSplitTenderOrderWebhook(event), // Phase 0141 this.anonymousOrders.handlePaymentWebhook(event),]);Each handler is a no-op for events it doesn’t care about. Discrimination is via event.metadata.merchos_kind — SETUP_FEE_ACTIVATION / MEMBER_PAYS_ORDER / SPLIT_TENDER_ORDER / absent (anonymous). Adding a new rail means picking a new merchos_kind and adding it to the fan-out.
Tier ladder + swap
Four MONTHLY_SUBSCRIPTION tiers (ADR-0099 — Tier modernization and swap):
| Tier | CAD | USD | Stripe price-id key |
|---|---|---|---|
STARTER | $49/mo | $39/mo | tier:STARTER:CAD / tier:STARTER:USD |
GROWTH | $99/mo | $79/mo | tier:GROWTH:CAD / tier:GROWTH:USD |
PRO | $199/mo | $159/mo | tier:PRO:CAD / tier:PRO:USD |
ENTERPRISE | $399/mo | $319/mo | tier:ENTERPRISE:CAD / tier:ENTERPRISE:USD |
Recommended at intake by BillingService.recommendTier(headcount):
headcount ≤ 5 → STARTER6 ≤ headcount ≤ 25 → GROWTH26 ≤ headcount ≤ 99 → PROheadcount ≥ 100 → ENTERPRISEswapTier() — Stripe-first ordering
The load-bearing detail. BillingService.swapTier({companyId, fromTier, toTier}):
- Defensive guard: caller declares
fromTier. IfCompany.subscriptionTier !== fromTier, throw (stale-claim — prevents accidental double-swaps from race conditions). - Resolve
toTierprice-id from theCompany.billingCurrency-aware map. - Stripe call FIRST:
subscriptions.update(stripeSubscriptionId, { items: [...], proration_behavior: "create_prorations" }). Preservesmetadata.tieralongside other metadata. - Only on Stripe success → local commit:
prisma.company.update({ subscriptionTier: toTier }).
The ordering matters. If the local update commits first and Stripe fails, MerchOS thinks the company is on PRO while Stripe still bills them for STARTER. Stripe-first avoids this entire class of bug; if step 4 fails post-step-3, the operator sees a Stripe-vs-MerchOS drift in /companies/[id]/billing + a logged ERROR.
ADR-0073 — Tier modernization is the earlier implementation; ADR-0099 modernized the price ladder. The swap method itself hasn’t changed.
SUBSCRIPTION_WAIVED — the YCM-internal pilot lever
ADR-0106 — Per-pricing-model /billing page. YCM staff can flip a Company’s pricingModel from MONTHLY_SUBSCRIPTION to SUBSCRIPTION_WAIVED — same behavior, no monthly charge.
Today: a manual enum flip via the internal admin’s /companies/[id]/billing page. Fragile — YCM staff have to remember to flip it back when the comp period ends.
Proposed refactor (in POST_DEPLOY_BACKLOG.md “YCM-internal billing relief actions”): replace the enum value with a Company.subscriptionWaivedUntil: DateTime? field that auto-expires; cron sweep flips back to MONTHLY_SUBSCRIPTION on expiry. Plus a “credit-back setup fee” via a new StoreCreditEntry kind. Promotes when YCM accumulates >3 manual waiver flips OR the first comp customer onboards.
Setup-fee activation
ADR-0091 §Decision 2. The setup fee is the activation trigger across all three families.
| Family | Setup fee | What it activates |
|---|---|---|
| MONTHLY_SUBSCRIPTION | Per-discretion | First month’s subscription + Company.activatedAt |
| ONE_TIME_SETUP | $49 CAD / $39 USD | Company.activatedAt (no monthly) |
| MEMBER_PAYS | $49 CAD / $39 USD | Company.activatedAt (no monthly) |
BillingService.createSetupFeeActivationIntent(companyId) creates the PaymentIntent with metadata.merchos_kind = "SETUP_FEE_ACTIVATION". On payment_intent.succeeded:
Company.activatedAt = now- Free auto-issued
SETUP_FEE_CREDITwas DISABLED in Phase 0116 / ADR-0067 — the per-order auto-credit-issuance proved too generous in practice; manual grants only now.
Company.activatedAt is one of two gates the order-submit path checks (Onboarding §Activation gates). Order rejected until cleared.
Anonymous-shopper checkout (ONE_TIME_SETUP)
AnonymousOrdersService.createDraftOrder() creates the Order in PENDING_PAYMENT + a Stripe PaymentIntent with metadata.merchos_kind absent (the discriminator-by-omission pattern — Stripe Webhook routes events with no kind to AnonymousOrdersService.handlePaymentWebhook).
Stripe Tax computes tax per the customer address. Order.taxCents is set at draft time from tax.calculations.create; on payment_intent.succeeded the handler records a tax.transaction for the tax-filing reports.
Tax-calculation lifetime: ~48h. AbandonedCartSweeperService runs nightly to clean up PENDING_PAYMENT Orders older than the threshold so stale tax-calculation IDs don’t pile up.
MEMBER_PAYS checkout
Phase 7a / ADR-0094. Triggered by OrdersService.submitOrder when Company.pricingModel === "MEMBER_PAYS":
finalizeMemberPaidOrder(inside the submit tx) creates Order in PENDING_PAYMENT.- Post-tx:
BillingService.createMemberCheckoutIntentcreates the PaymentIntent. User.stripeCustomerIdis dedup’d per User — first MEMBER_PAYS order for a user creates the Stripe Customer; subsequent orders re-use.- Storefront opens
AnonymousCheckoutModalwith the order’stotalCents. - Webhook
payment_intent.succeeded→handleMemberPaidOrderWebhookpromotes the Order to PENDING + dispatches fulfillment.
No SpendRecord write, no company invoice, no PREPAID pool — none of those concepts apply.
Split-tender (MONTHLY_SUBSCRIPTION + allowEmployeeTopUp = TRUE)
Phase 0141 / ADR-0104. Detailed in Pricing models; the billing-side specifics:
- Stripe PI amount =
employeePaidCents(overage only), NOT the full order total. - Customer =
User.stripeCustomerId— shared with MEMBER_PAYS. One Stripe Customer per User across both flows. Confirmed 2026-05-19. - Metadata kind =
SPLIT_TENDER_ORDER. - Atomicity model: the company-side
SpendRecorddecrement happens in the webhook commit tx, not at submit. If Stripe fails: nothing rolls back because nothing committed yet. - Refund LIFO: employee portion first via Stripe; company rail restores
SpendRecord. - Compensating refund: if the webhook commit tx fails after Stripe charged, the handler auto-fires a refund + cancels the Order + stamps
employeeRefundedCents. Loud ERROR log if the refund itself fails (operator action required).
Refund flows
POST /orders/:id/refund is SUPER_ADMIN-only. Branches on the order shape:
| Order type | Refund path |
|---|---|
| Anonymous shopper (ONE_TIME_SETUP) | BillingService.refundOrder(stripePaymentIntentId) → Stripe Refund → charge.refunded webhook updates Order.refundedCents |
| Gated company-paid STANDARD | No Stripe path — operator credits the next invoice manually. Returns “use company credit flow” error. (Pending: a PaymentRouter.reverse() API.) |
| Gated MEMBER_PAYS | Stripe Refund on the employee’s PI |
| Gated SPLIT_TENDER | LIFO: employee Stripe portion first, then company SpendRecord restore. Handles full + partial. |
The charge.refunded webhook updates Order.refundedCents. Promote to REFUNDED status when fully refunded.
RefundRequest rows (from tickets module) are separate — they’re customer-initiated requests pending operator approval. Approving creates the actual refund call.
Invoice cron + monthly finalize
Companies on INVOICE flow accrue orders to a DRAFT Invoice via BillingService.appendOrderToInvoice (called from PaymentRouter.finalize post-order-commit). At month-end, a cron:
- Locks the DRAFT invoice row (FOR UPDATE).
- Finalizes via
stripe.invoices.finalizeInvoice(invoiceId). - Stripe charges the company’s stored payment method (or sends the invoice email if no card on file).
- On
invoice.paidwebhook: marksInvoice.status = "PAID"+ recordspaidAt. - On
invoice.payment_failed: marksInvoice.status = "PAYMENT_FAILED"+ emails Jess for follow-up.
InvoiceFinalizerService owns the cron. Overdue invoices are swept by OverdueInvoiceSweeperService with a separate notification flow.
Stripe Tax + the CRA registration
Stripe Tax is wired across all five rails. One pending operator action blocks real-money tax collection from Canadian shoppers at go-live: YCM’s CRA GST/HST registration is in progress; until the registration number is filed in Stripe Tax’s settings, Stripe applies a 0% rate to Canadian transactions.
This is captured in the LAUNCH_CHECKLIST.md 🔴 section. Pre-launch: paid-by-card US shoppers are fine; Canadian shoppers will pay no tax (under-collected) until the registration lands and we backdate-correct.
Memory anchor: memory/tax_registration_pending.md — explicit operator-action item.
Fundraising-markup remittance
The custodial-liability flow described in Storefronts §GivesBack.
| State | What happens |
|---|---|
ACCRUED | FundraisingMarkupService.accrueForOrder(orderId) on payment_intent.succeeded for orders against GivesBack-enabled Companies. Idempotent. |
ASSIGNED_TO_BATCH | YCM staff cuts a monthly batch in /internal/fundraising-markup. |
PAID | Cheque or bank transfer issued; batch + entries flip status. Audit-logged. |
YCM never pays the host org via Stripe — these are cheques / bank transfers because the host orgs are typically nonprofits with non-Stripe banking. The internal-admin batch UI exports a CSV the bookkeeper imports into accounting software.
Store credit
ADR-0067 — Setup fee disclosure + manual store credit grant. StoreCreditEntry rows are operator-issued credits a Company can apply against future orders. Kinds:
SETUP_FEE_CREDIT_BACK— refund-by-credit (pending in POST_DEPLOY_BACKLOG.md — see “YCM-internal billing relief actions”).MANUAL_GRANT— discretionary credit for goodwill.
StoreCreditService.apply(orderId) decrements outstanding credit when an order pays. SUPER_ADMIN only writes new entries.
What’s next
- Operations + reliability (Chapter 11) — Fly deploy cadence, migrations, observability.
- Conventions (Chapter 12) — CLAUDE.md rules + the ADR pattern.
Canonical sources
apps/api/src/modules/billing/billing.service.ts—swapTier,createMemberCheckoutIntent,createSplitTenderCheckoutIntent,chargeLogoDesign,refundOrder, all webhook handlers.apps/api/src/modules/billing/billing.controller.ts— webhook fan-out + the five handlers.apps/api/src/modules/orders/payment-router.service.ts— INVOICE / PREPAID / CARD_ON_FILE routing.apps/api/src/modules/orders/invoice-finalizer.service.ts— monthly invoice cron.apps/api/src/modules/orders/fundraising-markup.service.ts— accrual + batch payout.apps/api/src/modules/billing/store-credit.service.ts—StoreCreditEntryissuance + application.- ADR-0067 — Setup fee disclosure + manual store credit.
- ADR-0073 — Tier swap implementation.
- ADR-0099 — Current tier ladder.
- ADR-0094 — MEMBER_PAYS.
- ADR-0104 — Split-tender.
- ADR-0106 — Per-pricing-model
/billingpage. - ADR-0114 — Stripe Tax + onboarding invariants.
docs/services/billing.md— service-level deep read.
Triggers for update
Update this chapter if you:
- Add or retire a money rail (today: 5).
- Change the tier ladder prices, currencies, or names.
- Change the
swapTierordering (Stripe-first → local-commit-first would be a regression — flag it). - Add or change a
metadata.merchos_kindvalue in the webhook fan-out. - Change the SUBSCRIPTION_WAIVED implementation (enum value →
subscriptionWaivedUntil, etc.). - Move tax-calculation between Stripe Tax and an in-house path.
- Change the
FundraisingMarkupEntrylifecycle states or the remittance method (today: cheque/bank transfer, not Stripe). - Add a new
StoreCreditEntrykind. - Land the CRA GST/HST registration (drop the “pending” framing).