Skip to content

Pricing models in depth

The mental model

MerchOS’s PricingModel enum has four values, but only three of them are pricing-model families in the architectural sense:

Enum valueFamilyWhat it means
MONTHLY_SUBSCRIPTIONMONTHLY_SUBSCRIPTIONThe original product. Tiered monthly platform fee + company-paid orders.
SUBSCRIPTION_WAIVED(same family)A flag, not a separate family — used for YCM pilots / deep discounts / internal demos. Charges no platform fee but otherwise behaves identically to MONTHLY_SUBSCRIPTION.
ONE_TIME_SETUPONE_TIME_SETUPUngated public storefronts. One-time $49 setup, no monthly. Anonymous shoppers self-pay at checkout.
MEMBER_PAYSMEMBER_PAYSPhase 7a addition. Gated identity (employee logins) but each invited member self-pays at Stripe Elements. No company-side per-order billing.

The pricing model is set at intake from the chosen vertical’s defaultPricingModel. It rarely changes — when it does, it requires SUPER_ADMIN intervention via the post-activation re-classification flow (ADR-0108, Accepted; build deferred).

Three families, four enum values. The fourth (SUBSCRIPTION_WAIVED) is a state flag inside MONTHLY_SUBSCRIPTION, not a parallel pricing model. Reading the codebase as “three families, with one of them having a waiver mode” is structurally accurate.

MONTHLY_SUBSCRIPTION — the original product

Verticals: businesses-corporate, trades-contractors, creators-collectives.

Who pays whom:

  • YCM ← Company: monthly platform fee on a tier ladder (STARTER / GROWTH / PRO / ENTERPRISE). Set at intake from headcount; can be swapped post-launch via ADR-0099.
  • YCM ← Company: per-order margin. Order is invoiced to the company at end of month.
  • Employee ← Company: zero. The employee never sees prices or invoices.

Order payment flow is per-Company on Company.orderPaymentFlow:

  • INVOICE — orders accrue to a DRAFT Invoice row; finalized monthly and emailed.
  • PREPAID — employees draw from a topped-up balance; YCM owes nothing until exhausted.
  • CARD_ON_FILE — immediate Stripe charge against the company’s saved card at submission.

Defaults per vertical (Vertical.defaultOrderPaymentFlow):

  • businesses-corporate + creators-collectives → INVOICE
  • trades-contractors → INVOICE

The spend-limit invariant (CLAUDE.md §6 rule 2) lives here: Employee.customSpendLimitCents (or the Company default), enforced under a FOR UPDATE lock on SpendRecord at order submission. Hard limit on company-paid totals.

Split-tender — the per-Company option inside MONTHLY_SUBSCRIPTION

ADR-0104, shipped 2026-05-19 as Phase 0141. Per-Company toggle on Company.allowEmployeeTopUp (default-on for trades-contractors, off for businesses-corporate and creators-collectives).

When the employee’s cart exceeds their remaining spend, they can pay the overage on their own card via Stripe Elements at checkout. The company hard-cap stays hard — split-tender adds a parallel rail, it doesn’t soften the cap.

Three-state decision matrix (the pure helper is computeSplitTenderBreakdown in orders.service.ts):

Cart vs capallowEmployeeTopUpsplitModeblocked
≤ capeitherSTANDARDnull
> capTRUESPLIT_TENDERnull
> capFALSESTANDARD"over_limit_no_top_up"

Order schema additions (Phase 0141):

  • Order.splitMode: SplitModeSTANDARD (default) or SPLIT_TENDER.
  • Order.employeePaidCents: Int — what the employee paid out of pocket. 0 for STANDARD.
  • Order.employeePaidStripePaymentIntentId: String? @unique — distinct from Order.stripePaymentIntentId (which is for anonymous orders).
  • Order.employeeRefundedCents: Int — sum of refunds against the employee portion. Used for LIFO refund routing.

Atomicity is the most subtle aspect. ADR-0104 §Atomic order submission:

Charge employee’s Stripe PaymentIntent first. Only on confirmed-success, run the existing company-spend decrement + Order/OrderItem creation inside one DB transaction.

The flow:

  1. OrdersService.finalizeSplitTenderOrder (inside the submit tx) locks SpendRecord FOR UPDATE, re-validates allowEmployeeTopUp + overage > 0, creates Order(status=PENDING_PAYMENT, splitMode=SPLIT_TENDER, employeePaidCents). SpendRecord is NOT decremented.
  2. Post-tx: BillingService.createSplitTenderCheckoutIntent creates a Stripe PI for employeePaidCents only. Customer is User.stripeCustomerId (shared with MEMBER_PAYS — one Stripe Customer per User across both flows). clientSecret returned to storefront.
  3. Storefront opens AnonymousCheckoutModal (reused from MEMBER_PAYS) with amount = employeePaysCents.
  4. Stripe webhook payment_intent.succeededhandleSplitTenderOrderWebhook does the company-side commit in one tx: re-lock SpendRecord, decrement by companyPaysCents, promote Order to SUBMITTED. Post-tx: invoice accrual, fundraising-markup, fulfillment dispatch, AuditEvent, employee receipt.
  5. Stripe webhook payment_intent.payment_failed → cancel Order. No rollback needed; SpendRecord was never touched.

Compensating refund for the rare case where Stripe charged but the DB tx fails: the webhook catches the error, auto-fires a Stripe refund for employeePaidCents, cancels Order, stamps employeeRefundedCents. If the comp refund itself fails: loud ERROR log demanding manual operator action.

Refund LIFO (refundSplitTenderOrder): employee portion refunds first (last money in), via Stripe refundOrder against employeePaidStripePaymentIntentId. Once exhausted, the company rail restores SpendRecord.spentCents. Partial refunds within the employee portion never touch the company side.

Privacy invariant (CLAUDE.md §6 in reverse): the company NEVER sees what the employee paid out of pocket. The employee’s receipt (splitTenderEmployeeReceiptTemplate) shows only their share. The company’s per-order invoice shows only the company-paid portion.

Coverage: 32 integration tests across four suites (spend-settings.int.spec.ts, split-tender-math.spec.ts, split-tender-webhook.int.spec.ts, split-tender-refund.int.spec.ts) plus 9 unit tests on the pure split-math helper.

ONE_TIME_SETUP — public + GivesBack-eligible

Verticals: churches, teams, community-cause, events, travel-tourism.

Who pays whom:

  • YCM ← Company: one-time $49 setup fee at intake. No monthly.
  • YCM ← Shopper (direct): per-order margin. Anonymous shopper pays via Stripe Checkout at order time.
  • Host org (Church / school / event) ← YCM: monthly markup remittance when Vertical.allowsGivesBack = true.

The fundraising markup is the load-bearing fact (and the trap):

Fundraising markup is a custodial liability, not YCM revenue. Accruals on FundraisingMarkupEntry are money YCM holds on behalf of the client until monthly remittance. Balance-sheet liability (“unremitted markup payable”), not revenue. No T4A from YCM.

(CLAUDE.md §6 rule 8.) If you accidentally book markup as revenue, the books lie and the next audit catches fire.

Order payment flow: there is no Company.orderPaymentFlow setting for ONE_TIME_SETUP. Anonymous shoppers go through AnonymousOrdersService — a separate code path from gated OrdersService — which creates a Stripe PaymentIntent directly + writes the Order on payment_intent.succeeded.

Currency: still set at intake from Company.country (CAD or USD); the shopper’s card is charged in the company’s currency regardless of where the shopper lives.

Activation: the $49 setup fee is the activation trigger. Until the fee clears via webhook, Company.activatedAt is null and the storefront stays in preview mode (browse only, no checkout). See ADR-0091.

MEMBER_PAYS — gated identity, self-pay

Vertical: departments (the ninth, added 2026-05-11).

Who pays whom:

  • YCM ← Company: one-time $49 setup fee at intake. No monthly.
  • YCM ← Employee (direct): per-order margin. Each invited team member pays for their own order at Stripe Elements checkout.
  • Company ← anything: zero post-setup. No invoices, no spend caps, no PREPAID pool — none of those concepts apply.

This family was added for municipal and educational departments whose budgets can’t fund subscriptions but whose employees can still buy. The gated-identity invariant (employees log in with magic links + see only their own orders) still holds — they just bring their own wallet.

Order schema:

  • Order.stripePaymentIntentId is set (not the employeePaidStripePaymentIntentId field used by split-tender — entirely separate columns).
  • User.stripeCustomerId is the dedup key — one Stripe Customer per User across MEMBER_PAYS and split-tender (ADR-0094).

Tax + shipping: Stripe Tax computes tax per the customer’s address. Shipping is included in the Stripe PI amount.

Flow at submit: OrdersService.submitOrder switches on Company.pricingModel; MEMBER_PAYS routes to finalizeMemberPaidOrder. The same AnonymousCheckoutModal Stripe Elements component used by the anonymous-shopper flow handles the actual payment.

Family comparison at a glance

PropertyMONTHLY_SUBSCRIPTIONONE_TIME_SETUPMEMBER_PAYS
Gated identity✗ (anonymous)
Monthly platform fee✓ (tier ladder)
Setup feeper discretion$49$49
Per-order revenuemargin onlymargin onlymargin only
Spend caps on employees✓ (hard)n/a
Company invoiceINVOICE flown/an/a
Employee pays at checkoutonly via split-tender opt-inn/a (anonymous)always
Fundraising markup eligible✓ (most verticals)
Currency immutability
Where billing livesBillingService + PaymentRouter + invoice cronAnonymousOrdersService + Stripe CheckoutBillingService.createMemberCheckoutIntent

The pricing-tier ladder (MONTHLY_SUBSCRIPTION only)

Four tiers, all CAD/USD:

TierCADUSDRecommended headcount
STARTER$49/mo$39/moup to 5
GROWTH$99/mo$79/mo6-25
PRO$199/mo$159/mo26-99
ENTERPRISE$399/mo$319/mo100+

Set at intake by BillingService.recommendTier(headcount). Swap any time via BillingService.swapTier (Phase 0123 / ADR-0073) — Stripe-first ordering: subscriptions.update runs first, only on success does the local Company tier update commit.

SUBSCRIPTION_WAIVED companies skip the subscription entirely. YCM staff toggle this on /companies/[id]/billing via the internal admin. See ADR-0106 § “YCM-internal billing relief actions” for the deferred ergonomics work (auto-expiring waivers, setup-fee credit-back).

What’s next


Canonical sources

  • prisma/schema.prismaPricingModel, SubscriptionTier, OrderPaymentFlow, SplitMode enums + Company + Order + SpendRecord + Vertical models.
  • ADR-0094 — MEMBER_PAYS family.
  • ADR-0104 — split-tender within MONTHLY_SUBSCRIPTION.
  • ADR-0099 — current tier ladder.
  • ADR-0073 — earlier tier-swap implementation.
  • ADR-0106 — the per-pricing-model /billing page architecture.
  • ADR-0108 — post-activation re-classification (Accepted, build deferred).
  • apps/api/src/modules/orders/orders.service.ts — pricing-model switch in submitOrder.
  • apps/api/src/modules/billing/billing.service.tscreateMemberCheckoutIntent + createSplitTenderCheckoutIntent + tier-swap.
  • docs/services/orders.md §17 — split-tender service-level deep read.

Triggers for update

Update this chapter if you:

  • Add or retire a pricing-model family or a PricingModel enum value.
  • Change the tier ladder (add a tier, retire one, change prices).
  • Add or remove an OrderPaymentFlow enum value.
  • Change the split-tender decision matrix or atomicity model.
  • Change the fundraising-markup custodial-liability accounting.
  • Move billing logic between BillingService / PaymentRouter / OrdersService boundaries.
  • Add or change a vertical’s default pricing model or order-payment flow.