Suppliers + fulfillment
The two suppliers
| Supplier | Catalog scope | Webhook auth | CA fulfillment |
|---|---|---|---|
| Gelato | Apparel + non-apparel print-on-demand (mugs, posters, etc.) — ~177K variants | HMAC body signature | Toronto hub (gln-toronto); Quote API computes per-variant |
| Printful | EU/US apparel + non-apparel | URL-key shared secret (no body signing — Postmark-Inbound pattern) | Subset of catalog ships from CA-friendly fulfillment centers |
Both suppliers share one architectural surface — SupplierVariantRaw is the local mirror, mapping tables hold the per-supplier axis codes, FulfillmentTask is the per-supplier order task. Differences are isolated to the provider modules under apps/api/src/modules/suppliers/providers/.
A single multi-supplier MerchOS order can produce N FulfillmentTask rows — one per supplier the cart routed to. Each task is submitted independently to its supplier’s API.
The local mirror
SupplierVariantRaw rows are upserted by nightly per-supplier sync crons:
RawSyncService(Gelato) — crawls Gelato’s catalog API; ~177K variants today.PrintfulRawSyncService(Printful) — crawls Printful’s product API.
Both write the same row shape: productUid is the supplier-supplied stable key (PK), attributes is a JSONB blob with everything else (color, size, print method, prices, CA-availability — all the supplier-specific schema lives here). attributesHash is a SHA-256 of the canonical-JSON attributes; on resync, only rows whose hash changed get re-written. lastSeenAt is updated on every sweep; rows older than the current sweep’s start time are soft-deleted (deactivatedAt = now).
Sync is append-driven. You never hand-edit a SupplierVariantRaw row.
The RawStyle aggregation
A RawStyle represents a product in the supplier-agnostic sense — “Gildan 64000 unisex T-shirt.” Multiple suppliers can fulfill the same RawStyle; the linkage lives in RawStyleSupplierLink[rawStyleId, supplierCode, supplierExternalKey, variantProductUids[]].
Natural key:
- Apparel →
(brand, styleCode). Gelato + Printful both list Gildan 64000; the natural key collapses them into oneRawStyleautomatically. - Non-apparel →
productUid. Printful’s “White Glossy Mug”id=19and Gelato’smug_white_glossy_oz_11are NOT automatically collapsed — they create separateRawStylerows. Cross-supplier non-apparel merge is operator-driven (deferred — see POST_DEPLOY_BACKLOG.md).
CA-fulfillability — the filter Joy actually uses
Most YCM customers are Canadian; their employees / shoppers need orders that fulfill from a CA hub (or ship to Canada efficiently). The Raw Catalog UI’s “Fulfilled in Canada” filter is one of the most-used surfaces.
Powered by the caFulfillableVariantCount column on the raw_catalog_styles matview. Computed from two JSONB paths:
- Printful side —
attributes #>> '{PrintfulCaAvailability,selling_regions,0,availability}' IN ('in stock', 'out of stock'). Populated byPrintfulRawSyncServiceduring sync (Printful’s product API surfaces this inline). - Gelato side —
attributes #>> '{GelatoCaAvailability,selling_regions,0,availability}' IN ('in stock', 'out of stock'). Populated by a separate worker (scripts/backfill-gelato-ca-availability.cjsoperator-run +GelatoCaAvailabilityRefreshCronweekly Sunday 02:00 UTC) because Gelato’s catalog API doesn’t surface CA-fulfillability inline — it’s resolved via the Quote API per(style, color, size). ~21h to fully refresh 26K Gelato style groups at the rate-limit-safe 5 RPS.
Phase 0155 (2026-05-19) shipped this surface. Accuracy issue from earlier data (64% accurate per a spot-check) is being repaired by a full --force re-quote of the Gelato catalog.
Webhooks — the path of an order back from the supplier
Every supplier event MerchOS cares about routes through BillingController.webhook (Stripe) or FulfillmentService.handleWebhook (suppliers). Both verify auth, then fan out to per-purpose handlers that discriminate by metadata kind / supplier code.
Gelato webhooks
Body-signed via HMAC-SHA256 against a shared secret in GELATO_WEBHOOK_SECRET. Events MerchOS handles:
| Event | Handler | What it does |
|---|---|---|
order_status_updated | FulfillmentService.handleGelatoStatusUpdate | Updates FulfillmentTask.status + tracking fields |
shipment_dispatched | (same) | Captures carrier + tracking URL |
shipment_delivered | (same) | Sets FulfillmentTask.deliveredAt |
ADR-0050 (Proposed) tracks completion work — shipped/delivered customer emails + idempotency hardening + internal-admin event surface.
Printful webhooks
ADR-0085. No body signing. Webhook auth is a URL-key shared secret: register the webhook URL with ?key=<shared-secret> query param; the handler verifies the query param constant-time on receipt.
Same pattern as Postmark Inbound, surprisingly. If you’re new and this looks weird — read ADR-0085 §Notes for why this is actually the spec’d Printful auth pattern, not a YCM shortcut.
Events MerchOS handles:
order_updated— status transitionspackage_shipped— setsFulfillmentTask.shippedAt+ trackingpackage_returned— flips status; operator follow-up
Webhook registration is API-only — no Printful dashboard config field. MerchOS calls POST /webhooks with auth Bearer <PRINTFUL_API_KEY> + body {url, types} at startup.
The webhook event router
Stripe webhooks fan out to four handlers in BillingController.webhook:
BillingService.handleSubscriptionWebhook— invoice events + subscription state.BillingService.handleSetupFeeActivationWebhook— activation Stripe PI for setup-fee.OrdersService.handleMemberPaidOrderWebhook— Phase 7a / MEMBER_PAYS.OrdersService.handleSplitTenderOrderWebhook— Phase 0141 / split-tender.AnonymousOrdersService.handlePaymentWebhook— anonymous shopper checkout.
Each handler discriminates via metadata.merchos_kind (or absence-of-it for anonymous) so they don’t collide. Adding a new handler means picking a new merchos_kind and adding it to the fan-out.
Multi-supplier routing — one cart, N supplier tasks
ADR-0084 (Phase 0129) made multi-supplier first-class. Pre-Phase 0129 there was a single Order.fulfillmentProvider column; one order = one supplier. Post-Phase 0129, FulfillmentTask is the per-supplier-per-Order row:
Order (top-level customer-facing row) ├── FulfillmentTask supplier=gelato status=SHIPPED trackingUrl=... └── FulfillmentTask supplier=printful status=IN_PRODUCTIONOrder.status aggregates from its tasks via MIN-status-rank — the slowest task wins. When all tasks reach SHIPPED, Order is SHIPPED. When one is SHIPPED and another is still IN_PRODUCTION, Order stays IN_PRODUCTION. Storefront customers see the package count + per-package tracking links in the success screen and the order-confirmation email (ADR-0084 Slice 5 §Decision 5: supplier-agnostic UI copy — customer sees “your order will arrive in 2 packages” not “Gelato + Printful”).
How a cart routes to suppliers
FulfillmentService.routeCart() decides per-OrderItem which supplier fulfills it. The decision walks:
- Read the Sku’s
primarySupplierCode(Sku.supplierId→Supplier.code). - For each OrderItem: look up the
SkuColorChoice/SkuSizeChoice/SkuPrintOptionaxes the customer picked → resolve via the supplier mapping tables to a concrete supplier code triple. - Look up the matching
SupplierVariantRawrow by(supplierCode, attributes → tuple). The row’sproductUidis what the supplier expects in their order submission API. - If the primary supplier doesn’t carry the picked tuple, fall back to a configured alternate supplier (today: per-Sku; future: per-tuple).
Cross-supplier carts produce N FulfillmentTask rows — one per supplier the routing visited.
Variant cell state — the precomputed unfulfillable set
ADR-0115, Phase 0153. The order resolver short-circuits via SkuVariantCellState:
SkuVariantCellState[skuId, color, size, print, supplierId, kind] kind = supplier_unavailable // supplier sync said this tuple isn't fulfillable kind = manual_excluded // operator chose to exclude (e.g. seasonal)Default-allow on empty: a Sku with no cell-state rows is assumed fully fulfillable. The supplier-sync hook + a 30-min cron + post-choice-edit hooks (Phase 0155 / 2026-05-19) keep this current. Storefront swatch + size buttons show as disabled (40% opacity, line-through) when the current cart pick would resolve unfulfillable.
Order shipping + tax
Both suppliers compute shipping via their respective Quote APIs at order-submit time. Gelato has 24h-TTL caching via FulfillmentQuoteService to amortize quote calls — same (productUid, country, quantity) tuple hits cache.
Tax — for anonymous-shopper orders (ONE_TIME_SETUP) — runs through Stripe Tax on the PaymentIntent. The MerchOS-side Order.taxCents is set at draft time from tax.calculations.create; on payment_intent.succeeded we record a tax.transaction so Stripe Tax’s filing reports are populated. Calculations expire after ~48h; abandoned-cart sweeper retires PENDING_PAYMENT orders well before that.
Tax for gated MONTHLY_SUBSCRIPTION orders is more nuanced — billing flow handles it on the company invoice. See Pricing models for the per-family breakdown.
What’s next
- Storefronts + commerce (Chapter 8) — the visible-half walk: gated vs ungated, anonymous checkout, GivesBack.
- Billing + Stripe (Chapter 10) — how all of this lands on the Company’s books.
Canonical sources
prisma/schema.prisma—Supplier,SupplierVariantRaw,RawStyle,RawStyleSupplierLink,FulfillmentTask,FulfillmentQuotemodels.apps/api/src/modules/suppliers/—RawSyncService(Gelato) +PrintfulRawSyncService+ provider modules +FulfillmentQuoteService+GelatoCaAvailabilityService.apps/api/src/modules/fulfillment/—FulfillmentService.routeCart+submitForOrder+ webhook dispatch.- ADR-0068 — Gelato Raw Catalog mirror architecture.
- ADR-0084 — Printful onboarding + cross-supplier curation.
- ADR-0085 — Printful URL-key webhook auth (no body signing).
- ADR-0050 — Gelato webhook completion (Proposed).
- ADR-0080 — multi-supplier canonical identity rules.
- ADR-0115 — SkuVariantCellState additive override.
- ADR-0116 — mapping tables.
docs/services/suppliers.md— service-level deep read.docs/services/fulfillment.md— service-level deep read onrouteCart+ webhook dispatch.FIRST_ORDER_RUNBOOK.md— operator runbook including the 168-line Printful supplement.
Triggers for update
Update this chapter if you:
- Add or retire a supplier.
- Change the
RawStylenatural-key rule (apparel:brand, styleCode; non-apparel:productUid). - Change the mapping-table shape (ADR-0116).
- Add or change a webhook event type for either supplier.
- Change the multi-supplier routing decision (
FulfillmentService.routeCart). - Change the
Order.statusaggregation rule acrossFulfillmentTaskrows. - Change
CA-fulfillabilitydata path (today: Printful inline at sync; Gelato via Quote API worker). - Move tax calculation between Stripe Tax and an in-house path.