Skip to content

Billing + Stripe

The five money rails

Five distinct Stripe surfaces, all in BillingService:

RailStripe constructTriggered byHandler
Subscription (MONTHLY_SUBSCRIPTION)Subscription + Invoice + PaymentMethodActivation Stripe PI clears at intake; monthly billing cronhandleSubscriptionWebhook
Setup-fee activation (ONE_TIME_SETUP + MEMBER_PAYS)PaymentIntentIntake step 5 confirmhandleSetupFeeActivationWebhook
Anonymous-shopper checkout (ONE_TIME_SETUP order)PaymentIntentShopper hits Pay at storefrontAnonymousOrdersService.handlePaymentWebhook
MEMBER_PAYS (Departments)PaymentIntent (employee card)Employee places orderOrdersService.handleMemberPaidOrderWebhook
Split-tender (MONTHLY_SUBSCRIPTION + allowEmployeeTopUp = TRUE)PaymentIntent (employee card; overage only)Employee cart exceeds spend capOrdersService.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_kindSETUP_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):

TierCADUSDStripe price-id key
STARTER$49/mo$39/motier:STARTER:CAD / tier:STARTER:USD
GROWTH$99/mo$79/motier:GROWTH:CAD / tier:GROWTH:USD
PRO$199/mo$159/motier:PRO:CAD / tier:PRO:USD
ENTERPRISE$399/mo$319/motier:ENTERPRISE:CAD / tier:ENTERPRISE:USD

Recommended at intake by BillingService.recommendTier(headcount):

headcount ≤ 5 → STARTER
6 ≤ headcount ≤ 25 → GROWTH
26 ≤ headcount ≤ 99 → PRO
headcount ≥ 100 → ENTERPRISE

swapTier() — Stripe-first ordering

The load-bearing detail. BillingService.swapTier({companyId, fromTier, toTier}):

  1. Defensive guard: caller declares fromTier. If Company.subscriptionTier !== fromTier, throw (stale-claim — prevents accidental double-swaps from race conditions).
  2. Resolve toTier price-id from the Company.billingCurrency-aware map.
  3. Stripe call FIRST: subscriptions.update(stripeSubscriptionId, { items: [...], proration_behavior: "create_prorations" }). Preserves metadata.tier alongside other metadata.
  4. 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.

FamilySetup feeWhat it activates
MONTHLY_SUBSCRIPTIONPer-discretionFirst month’s subscription + Company.activatedAt
ONE_TIME_SETUP$49 CAD / $39 USDCompany.activatedAt (no monthly)
MEMBER_PAYS$49 CAD / $39 USDCompany.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_CREDIT was 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":

  1. finalizeMemberPaidOrder (inside the submit tx) creates Order in PENDING_PAYMENT.
  2. Post-tx: BillingService.createMemberCheckoutIntent creates the PaymentIntent.
  3. User.stripeCustomerId is dedup’d per User — first MEMBER_PAYS order for a user creates the Stripe Customer; subsequent orders re-use.
  4. Storefront opens AnonymousCheckoutModal with the order’s totalCents.
  5. Webhook payment_intent.succeededhandleMemberPaidOrderWebhook promotes 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 SpendRecord decrement 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 typeRefund path
Anonymous shopper (ONE_TIME_SETUP)BillingService.refundOrder(stripePaymentIntentId) → Stripe Refund → charge.refunded webhook updates Order.refundedCents
Gated company-paid STANDARDNo Stripe path — operator credits the next invoice manually. Returns “use company credit flow” error. (Pending: a PaymentRouter.reverse() API.)
Gated MEMBER_PAYSStripe Refund on the employee’s PI
Gated SPLIT_TENDERLIFO: 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:

  1. Locks the DRAFT invoice row (FOR UPDATE).
  2. Finalizes via stripe.invoices.finalizeInvoice(invoiceId).
  3. Stripe charges the company’s stored payment method (or sends the invoice email if no card on file).
  4. On invoice.paid webhook: marks Invoice.status = "PAID" + records paidAt.
  5. On invoice.payment_failed: marks Invoice.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.

StateWhat happens
ACCRUEDFundraisingMarkupService.accrueForOrder(orderId) on payment_intent.succeeded for orders against GivesBack-enabled Companies. Idempotent.
ASSIGNED_TO_BATCHYCM staff cuts a monthly batch in /internal/fundraising-markup.
PAIDCheque 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


Canonical sources

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 swapTier ordering (Stripe-first → local-commit-first would be a regression — flag it).
  • Add or change a metadata.merchos_kind value 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 FundraisingMarkupEntry lifecycle states or the remittance method (today: cheque/bank transfer, not Stripe).
  • Add a new StoreCreditEntry kind.
  • Land the CRA GST/HST registration (drop the “pending” framing).