Skip to main content

Supplier integrations

Three suppliers, three completely different channels, one uniform interface inside the system. Every supplier is a suppliers row with a type, and the type selects a transport handler inside the supplier-dispatch job processor. Adding a fourth supplier is a new type value, a new handler and a config schema — nothing above the handler changes.

SuppliertypeChannelOutbound artefactDispatch referenceInbound
SyscosyscoSFTP file dropX12 850 EDI fileThe EDI file nameX12 855 and 997, polled every 15 minutes by the sysco-poll CronJob
Costco Business CentercostcoSMTP emailStructured HTML order tableThe SMTP messageIdNone in the proof of concept
Central Kitchencentral_kitchenSMTP email with attachmentPuppeteer-rendered PDF purchase orderThe SMTP messageIdNone in the proof of concept

Sysco is the non-negotiable integration and the only one with a return path. The two email channels log that the message was accepted by the relay and stop there; there is no inbox monitoring in scope.

Detail pages: Sysco EDI, Costco email, Central Kitchen PDF.

How a dispatch is selected

Two separate decisions, made in two different places, and it is worth keeping them apart.

Which supplier gets a line is decided by the API at order placement, in resolveOrderLines and commitOrder (apps/api/src/domain/orders.ts). Each catalog item has at most one mapping row, so the lookup is a single findMany over the requested catalog items with the mapping and its primary supplier joined in. There is no supplier logic in the ordering path beyond that read.

Which transport runs is decided by the worker, in processSupplierDispatch (apps/worker/src/handlers/supplier-dispatch.ts), with a switch on the job payload's supplierType that calls dispatchToSysco, dispatchToCostco or dispatchToCentralKitchen. The switch reads the type from the payload, not from the freshly loaded row; the suppliers row is still loaded, and it is where the handler gets the name, the active flag and the config it validates.

At order placement

resolveOrderLines(input):
one findMany over every requested catalog item, mapping + primary supplier joined
unknown catalog item id → 404, offending ids echoed back
inactive catalog item → 409, offending items echoed back
no mapping, mapping inactive, or primary
supplier inactive → 422, offending items echoed back
otherwise → line.supplierId / supplierType / supplierSku / supplierProductCode
are copied off the mapping

commitOrder(tx): # one transaction
orders row, order_line_items rows (status pending)
groupBySupplier(lines) # the fan-out
one supplier_dispatches row per group (status pending)
supplier_dispatch_line_items join rows
ORDER_PLACED + one SUPPLIER_DISPATCH_CREATED per group

after the commit:
for each group → supplierDispatchJobSchema.parse(...) → enqueueSupplierDispatch(payload)
order.status = 'dispatching', or 'failed' if no job reached the queue at all

Four properties of that sequence are load-bearing:

  1. All or nothing. A single unmapped line rejects the whole order rather than silently dropping the line. A chef who thinks they ordered eight items must not receive seven. The three rejection reasons have distinct status codes so the UI can say which one happened.
  2. Grouping is by supplier, not by line. One Sysco job carries every Sysco line on the order, so Sysco receives one purchase order rather than eight.
  3. The mapping is read at placement time and copied onto the dispatch payload. Re-mapping an item tomorrow does not retroactively re-route an order placed today, and no handler re-reads the catalog.
  4. Enqueuing happens after the commit, and a failed enqueue keeps the order. That dispatch is marked failed with a SUPPLIER_DISPATCH_FAILED event and an administrator re-dispatches it; the chef is not told an order vanished. Only if every enqueue fails does the order itself go to failed.

At back order

Back-order rerouting is a separate queue and a separate handler (apps/worker/src/handlers/backorder-reroute.ts), triggered from the inbound 855 path.

855 line disposition ∈ { backordered (ACK01 IB, BP), rejected (ACK01 IR) }
→ order_line_items.status = 'backordered', LINE_ITEM_BACKORDERED, enqueue backorder-reroute

processBackorderReroute:
a reroute dispatch already exists for this line → discard, outcome 'discarded'
a BACKORDER_NO_ALTERNATE event already exists for
(lineItemId, originalSupplierId) → discard
candidate = job.alternateSupplierId ?? mapping.alt_supplier_id
candidate missing, inactive, or equal to the original → line 'backorder_no_alternate',
BACKORDER_NO_ALTERNATE, admin alert,
job COMPLETES successfully
otherwise → new supplier_dispatches row (isReroute = true,
rerouteOfDispatchId = original), line 'rerouted',
BACKORDER_REROUTED, enqueue supplier-dispatch
with dedupSuffix `reroute:{lineItemId}`

IC, IQ, IP, SP and DR classify as changed: recorded on the line and in the audit trail, and deliberately not rerouted, because rerouting a line the supplier is still shipping double-orders it.

Two details that are easy to get wrong. "No alternate supplier" is a business outcome, not a job failure — the handler completes successfully, so the failed set stays a list of real problems. And the reroute is a new dispatch, never a mutation of the original: the original row remains the record of what Sysco was sent and how they answered, with isReroute and rerouteOfDispatchId linking the two.

The fallback is one hop deep. If the alternate supplier also back-orders the line there is no second alternate to try. Because the POC allows exactly one mapping per catalog item, the alternate is sent the same supplier_sku as the primary.

What is common and what is per type

ConcernOwner
Idempotency check, loading, routing, state transitionshandlers/supplier-dispatch.ts (shared)
Retry classification, metrics, audit events, admin alerthandlers/supplier-dispatch.ts (shared)
Reading and validating suppliers.configsupplier-config.ts (shared, per-type schema)
Building the artefact and moving the byteshandlers/sysco.ts, costco.ts, central-kitchen.ts

The three transport handlers know nothing about the database. Each receives the same common object — orderId, orderNumber, supplierId, restaurantName, restaurantLocation, placedByEmail, deliveryNotes, lineItems, now — plus its own resolved config, and returns a reference (and, for Central Kitchen, a pdfUrl and pdfKey). deliveryNotes falls back to orders.notes when the payload omits it, so the chef's note travels to every supplier on the order.

Before any transport runs, the shared handler intersects the payload's line items with the rows actually linked to the dispatch and fails permanently if the intersection is empty, and fails permanently if the supplier row is inactive.

Where supplier configuration lives

ValueStored inRead by
Channel typesuppliers.type (Prisma enum supplier_type)The dispatch switch
emailAddress, ccAddresses, facilityNamesuppliers.config (JSONB)Costco and Central Kitchen handlers
SFTP directoriessuppliers.config, falling back to SYSCO_SFTP_*_DIRSysco handler and inbound poll
EDI interchange identifiers (ISA05ISA08, version, product ID qualifier)suppliers.config, falling back to EDI_*X12 850 serialiser
Names of the SFTP credential secretssuppliers.config (credentialsSecretName, sftpHostSecretRef)Resolved from the environment by sftp/client.ts
SMTP host, port, credentials, from-addressEnvironment (Vault-injected in the cluster)Both email handlers

resolveSupplierConfig layers the process configuration underneath the stored object and then parses the result with the per-type schema from packages/types/src/supplier.ts, which also supplies that schema's defaults. A freshly created supplier with config: {} is therefore usable in development for Sysco, but not for the email types: there is deliberately no environment fallback for emailAddress, because quietly substituting ADMIN_ALERT_EMAIL would send a restaurant's purchase order to the operations mailbox and report success. suppliers.config never holds a credential — only non-secret settings and the names of secrets.

Nothing partner-specific is compiled in. That is decision D3 in IMPLEMENTATION.md: because Sysco's Trading Partner Implementation Guide was not available during the build, every value the guide will specify is driven from configuration so the guide can be applied without a code change.

Failure and retry, uniformly

AspectBehaviour
AttemptsDISPATCH_ATTEMPTS, default 3
BackoffBullMQ exponential with base DISPATCH_BACKOFF_MS, default 1000 ms — so roughly 1 s then 2 s
Enqueue dedupBullMQ Simple-Mode deduplication: { id }, keyed dispatch:{orderId}:{supplierType}; reroutes add a reroute:{lineItemId} suffix to avoid a collision
Execution dedupThe handler reads supplier_dispatches.status first and returns { skipped: true, reason: 'already_submitted' } when it is submitted or confirmed
Terminal failureDispatch failed, its lines dispatch_failed, order recomputed, SUPPLIER_DISPATCH_FAILED appended, alert to ADMIN_ALERT_EMAIL, Sentry event
Job retentionremoveOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 86400 }
Partial orderSome suppliers succeed and some fail → recomputeOrderStatus rolls the order up to partial; the chef sees exactly which lines failed
Metricssupplier_dispatch_duration_seconds{supplier_type,outcome} and supplier_dispatch_failures_total{supplier_type,terminal}

The execution-level check matters because deduplication only prevents duplicate enqueues. A worker that crashes mid-execution has its job re-queued by the stall detector, and that path bypasses dedup entirely — so the status read is what makes re-execution of an irreversible SFTP drop or email harmless.

Permanent versus retryable

isPermanentFailure classifies, and a permanent failure calls job.discard() so BullMQ does not retry something that cannot succeed. Permanent: PermanentDispatchError (invalid job payload, missing dispatch row, no line items in common with the payload, inactive supplier), SupplierConfigError, PermanentMailError (5xx or every recipient rejected), SftpConfigError, a ZodError, ChromiumUnavailableError (a deployment fault — every retry in this pod fails identically), and any error carrying permanent === true. Everything else is retryable, deliberately: over-classifying as permanent loses an order that a second attempt would have delivered.

Channel-specific notes:

  • Concurrency is 1 for all three types. There is a single supplier-dispatch Worker at concurrency: 1 with lockDuration: 120_000, because Sysco's SSH server enforces a session limit and the producer writes every supplier type to one queue. EMAIL_WORKER_CONCURRENCY (default 4) applies only to backorder-reroute. The trade-off is stated in the header comment of worker.ts: at ~25 dispatches a day, serialising email and PDF work alongside Sysco is fine, and it is correct.
  • Costco and Central Kitchen — SMTP transient errors (ECONNRESET, ETIMEDOUT, a 4xx reply) retry; a 5xx, or a relay that accepts no recipient at all, fails immediately.
  • Central Kitchen only — a Chromium crash mid-render is retryable; a missing Chromium binary is not.

Automated test coverage

apps/worker/tests/ contains edi/, email/, pdf/, sftp/ and handlers/. The handler suites drive the real processSupplierDispatch / processBackorderReroute against a seeded database and real infrastructure — an in-process SFTP server, MinIO, Mailpit, Redis and real Chromium. Nothing in the transport chain is stubbed.

Suite in apps/worker/tests/handlers/What it proves
sysco.test.tsA valid 850 lands on the SFTP server, ISA13 increases per interchange, the session is closed
costco.test.tsThe email as a receiving MTA parsed it, and a config: {} supplier fails permanently
central-kitchen.test.tsA real render and upload; the stored object and the decoded attachment are byte-identical
idempotency.test.tsRunning the same job twice drops one file and sends one email; submitted and confirmed short-circuit, a failed attempt does not
failure.test.tsA refused SFTP port stays retryable until the final attempt then transitions terminally; a 550 recipient fails on attempt 1; no line items and a deactivated supplier fail immediately; a failed admin alert does not fail the job
backorder-reroute.test.tsThe reroute dispatch is created and linked, the job is read back out of Redis with the right deduplication id, and every "no alternate" branch parks the line and alerts once

The API side adds apps/api/tests/orders.backorder.test.ts, orders.place.test.ts and orders.query.test.ts (which covers the presigned PDF link endpoint). See testing.

Adding a fourth supplier

  1. Add the value to supplierTypeSchema in packages/types/src/enums.ts and to the SupplierType Prisma enum via a migration.
  2. Add a config schema in packages/types/src/supplier.ts and register it in supplierConfigSchemaFor.
  3. Add a resolver branch in apps/worker/src/supplier-config.ts and a case in the dispatch switch.
  4. Add the connection secrets to Vault and reference them by name from suppliers.config.
  5. Create the supplier and its mappings through the admin API.

Nothing in the API's ordering path, the frontend, or the data model needs to change.

Where to go next