Skip to main content

Factory Onboarding & Fulfil Tokens

Full new-factory checklist (code + ops + migrations), Fulfil token selection, PO category.

Deep-dive doc split out of .claude/rules/architecture.md (which is now an index). Append new findings about this area here, not to the index.

Fulfil Client (src/clients/fulfilClient.ts)

The Fulfil client supports multi-factory authentication. Env tokens (src/utils/envConfig.ts, ~lines 56–62, all str({ default: '' })): FULFIL_TOKEN (default / Aolong dress), FULFIL_TOKEN_VN1, FULFIL_TOKEN_MILLY, FULFIL_TOKEN_MILLY_SCARF, FULFIL_TOKEN_JEWELRY. Only fulfilShipmentAcknowledge uses a per-factory Authorization token (set explicitly in the request headers); every other Fulfil call (fulfilGetShippingLabel, fulfilGetShipmentState, fulfilGetInventoryMovesByShipmentNumber, fulfilShipmentPack, fulfilGetShipmentsTracking) sends { 'X-API-KEY': '' } and relies on the request interceptor to inject the global FULFIL_X_API_KEY / FULFIL_TOKEN. The interceptor only fills Authorization when its value is the empty string, so the acknowledge call's explicit token is left untouched.

Two Fulfil API surfaces, two auth mechanisms. The split is by API, not by accident:

SurfaceEndpointsAuth today
3PL API/services/3pl/v1/customer-shipments/acknowledge.jsonper-factory Authorization: Bearer <FULFIL_TOKEN_*>, set explicitly in headers
Admin/model REST API/api/v2/model/stock.shipment.out/*, /api/v2/model/inventory_movementsingle global X-API-KEY (env.FULFIL_X_API_KEY), no Authorization header at all

The request interceptor (fulfilClient.ts:26-34) is an auto-fill-on-empty-string trick, not a default-header mechanism: it substitutes env.FULFIL_X_API_KEY only when the header value is exactly '', and env.FULFIL_TOKEN for Authorization likewise. config.headers.get(...) returns undefined (not '') when a header was never set, so the Authorization fallback fires only for a call that explicitly passes Authorization: '' — in practice only fulfilShipmentAcknowledge when _makeToken returns '' for an unmapped factory. Deleting that fallback is therefore not behaviour-neutral for the acknowledge path, even though nothing else reads it.

⚠️ One /done call bypasses the client module entirely. ShipmentService.updateStatus (src/services/shipment/shipment.service.ts:653) calls fulfilClient.put('/api/v2/model/stock.shipment.out/<n>/done') inline with headers: { 'X-API-KEY': '' }, instead of going through a named exported function. Grepping src/clients/fulfilClient.ts alone therefore undercounts the Fulfil call sites — always grep for fulfilClient. too. Its spec (shipment.service.spec.ts) asserts on a mocked fulfilClient.put, so promoting it to a named export requires switching that mock from fulfilClient.put to the new function.

Why the split is per-warehouse on one side and workspace-wide on the other. Per Fulfil's docs, the two surfaces have genuinely different credential models, so this is not an accident of history:

  • 3PL API tokens are per-warehouse JWTs, obtained in the Fulfil UI at Inventory & Stock → Locations → select warehouse → View access token, and rotatable there individually. That is exactly what every FULFIL_TOKEN_* value is, and why _makeToken has to pick one per factory.
  • Admin/model API OAuth tokens are workspace-wide, gated by per-model scopes with no warehouse dimension anywhere in the authorization flow. One token therefore covers every factory.

Fulfil's OAuth has two access modes, selected by access_type on the authorize URL: user_session (online, default — tied to a user's web session, expires, dies on that user's logout) and offline_access (belongs to the app installation, permanent until the app is uninstalled, and returns the same token on every re-authorization). Only the latter is usable for server-to-server calls. Two gotchas when handling one:

  1. The offline-mode token response contains both access_token (user-…, expires_in: 3600) and offline_access_token (bot-…). Grabbing the wrong field yields a credential that works in testing and starts 401ing about an hour after deploy. Correct values start with bot-.
  2. Scopes are fixed at install time and are per model (stock.shipment.out, inventory_movement, …). A missing scope returns 403, not 401 — so any 401-specific alerting will not catch a scope misconfiguration.

Because an offline token never expires, the realistic revocation event is someone uninstalling the app in the Fulfil admin, which breaks every factory at once rather than degrading gradually.

⚠️ A per-call retryConfig replaces the instance one wholesale, including shouldRetry. The instance config sets shouldRetry: (r) => ![400, 401, 403, 404].includes(r.status), so 4xx never retries — but mergeConfig swaps the whole retryConfig object, it does not deep-merge. Any call that passes its own retryConfig (fulfilGetShippingLabel, fulfilShipmentAcknowledge, and the inline /done) therefore loses that guard and falls back to shouldRetry = () => true, i.e. a 401/400 on those three is retried retryCount times before finally rejecting. Calls with no per-call retryConfig (fulfilGetShipmentState, fulfilGetInventoryMovesByShipmentNumber, fulfilShipmentPack, fulfilGetShipmentsTracking) inherit the instance guard and fail fast. Repeat shouldRetry in a per-call retryConfig if you want 4xx fail-fast preserved.

Token selection — _makeToken(factoryCode, category?) (fulfilClient.ts:48). A switch on the factoryCode string (matched against env.FACTORY_CODE_AOLONG / _VN1 / _MILLY / _JEWELRY), not a factory-name enum. Aolong and Milly each branch further on category (PO_CATEGORIES.SCARFFULFIL_TOKEN_AOLONG_SCARF / FULFIL_TOKEN_MILLY_SCARF); VN1 and Jewelry are 1:1 with their own warehouse and ignore category. A missing category means dress, since PurchaseOrder.category defaults to 'dress'. (An earlier version of this doc described a 'aolong' | 'vn1' | 'milly' name-based ternary and a module-private FACTORY_MAP: Record<number, …> — both are stale; neither exists in the code today. INFRA-633 removed a fifth case keyed on env.FACTORY_CODE_AOLONG_SCARF — see below.)

⚠️ Unmapped factoryCode fails silently, not loudly. The default return '' produces an empty Authorization header, which the request interceptor (fulfilClient.ts:26-34) then substitutes with env.FULFIL_TOKEN — i.e. Aolong's dress token is sent to whatever warehouse the unmapped factory belongs to, accompanied only by a logger.warn. There is no throw and no Sentry capture on this path. fulfilClient.spec.ts (one test per token branch, asserting the Authorization header on a vi.spyOn(fulfilClient, 'put')) is the only real guard — always add a branch test and a negative test when onboarding a factory.

Factory resolution — resolveFulfilFactory(factoryId) (src/services/shipment/shipment.service.ts:794, a top-level export function, not a ShipmentService.* static) is a plain DB lookup: FactoryModel.findUnique({ where: { factoryId }, select: { factoryCode: true } }) returning the DB factoryCode string (e.g. '247'). Null factoryId or a factory with no factoryCodemakeBadRequestError + captureFulfilFactoryResolutionFailed (Sentry). Because it just reads the DB, a newly-added factory needs no change here — only a _makeToken case. Call chain: makeShipmentsAcknowledge(poRecord, …)_makeFulfilShipmentAcknowledge(poRecord, poRecName, shipments, factoryId) → (per chunk) fulfilShipmentAcknowledge(fulfilReqBody, fulfilFactoryCode, poRecord.category). The poRecord is in scope throughout, so any PO-derived dimension is available at the client call site without an extra load.

PO Category (category)

PurchaseOrder.category (prisma/schema.prisma:73, String @default("dress") @db.VarChar(20), added by INFRA-524) propagates to ShipmentWorkOrder. Allowed values come from src/constants/poCategory.tsPO_CATEGORIES = { DRESS: 'dress', SCARF: 'scarf', JEWELRY: 'jewelry' } (jewelry added by INFRA-569), DEFAULT_PO_CATEGORY = 'dress', and the PoCategory union type. There is no DB-level enum or CHECK constraint on the column — it's a plain VarChar(20), and the only real enforcement is z.nativeEnum(PO_CATEGORIES) at src/schemas/purchaseorder/index.ts:48. The trailing // comment on the schema line is documentation only and has drifted from PO_CATEGORIES before. The Zod create schema validates via z.nativeEnum(PO_CATEGORIES).default(DEFAULT_PO_CATEGORY). The generated Prisma PurchaseOrder type (re-exported through src/models/purchaseorder/types.d.ts) types category as a plain string, not the PoCategory union — compare against PO_CATEGORIES.SCARF rather than relying on type narrowing.

Token selection by (factory, category) — INFRA-527. Milly runs two Fulfil warehouses behind one MES factory: dress↔warehouse 210 (FULFIL_TOKEN_MILLY) and scarf↔warehouse 250 (FULFIL_TOKEN_MILLY_SCARF). n8n owns the warehouse→factory mapping and sends category on the PO; MES never learns Fulfil warehouse IDs. So fulfilShipmentAcknowledge must pick the token by (factory, category), not factory alone — category is threaded from poRecord.category through _makeFulfilShipmentAcknowledge into the client. resolveFulfilFactory stays factory-only; category is a separate argument. Load-bearing assumption: category↔warehouse is strictly 1:1 at Milly; if a category could route across warehouses with different tokens, (factory, category) is insufficient and warehouse identity must be carried explicitly.

"Same factory, second warehouse" — prefer the category split. Two shapes exist for this. The category split (Milly Scarf, INFRA-527; Aolong Scarf as of INFRA-633) reuses the existing Factory row and picks the token by (factoryCode, category). The own Factory row shape keys a brand-new Factory on the new warehouse id so the token keys off factoryCode alone — same as Jewelry/Enigma. Pick by whether the two warehouses should be separately switchable/filterable in AdminJS (own row) or behave as one operational unit (category split).

⚠️ Aolong Scarf tried the own-row shape and it was reverted. INFRA-613 gave it its own Factory row plus a FACTORY_CODE_AOLONG_SCARF env var whose correct value differs per environment (staging warehouse 358, production 300). Staging was configured with a value that did not match the Factory row's factoryCode, _makeToken fell through to return '', and the interceptor substituted Aolong's dress token — so scarf acknowledges hit warehouse 247 and Fulfil answered 207 ["Shipment not found"]. INFRA-633 folded it back into (Aolong, category='scarf').

The lesson generalizes: an own-row onboarding introduces a per-environment FACTORY_CODE_* value that must stay in lockstep with a hand-created AdminJS row, and nothing verifies that pairing at boot or at call time. A category split has no such coupling — category arrives on the PO and the existing factory code is already correct. Treat the own-row shape as the exception, justified only when ops genuinely need the two warehouses separated in the AdminJS switcher.

Onboarding a New Factory — Full Surface Checklist

Adding a Factory is not a single-file change, and roughly half the work is data/ops rather than code. Learned across Milly, Enigma/Jewelry (INFRA-569) and Aolong Scarf (INFRA-613):

Code

  1. src/utils/envConfig.tsFULFIL_TOKEN_<NAME>: str({ default: '' }) (~line 56) and FACTORY_CODE_<NAME>: str() (~line 154). Factory codes are declared required with no default, so the app refuses to boot if unset — this is deliberate (a missing factory code should be a hard failure, not a silent mis-route).
  2. .env.example — mirror both. Load-bearing for CI, not just documentation: src/setup-test-env.ts loads .env.example when TEST_ENV === 'ci', so a new required str() var missing from .env.example fails every CI spec that imports the real envConfig. Local .env needs it too or npm run dev won't boot.
  3. src/clients/fulfilClient.ts — a _makeToken case (see the silent-fallback gotcha above).
  4. src/clients/fulfilClient.spec.ts — the spec's vi.hoisted mockEnv object is a hand-maintained mirror of the real env; add both the new FACTORY_CODE_* and FULFIL_TOKEN_* there or the new branch matches undefined.
  5. Any spec with its own vi.mock('utils/envConfig', …) that exercises factory-code branching — currently src/services/workorder/workorder.service.spec.ts (line ~73) and src/routers/api/workorder/v1/workorder.router.spec.ts (line ~23). These mocks are partial by design; a new key is invisible until added.
  6. prisma/seed/seed-factories.ts — local/dev parity only (never runs in deploy; the workflows only run db:seed:serials). ⚠️ It has a notIn deactivation sweep (lines 37-40) that sets isActive = false on every factory absent from its FACTORIES.data list, and Enigma is currently missing from that list — so running npm run db:seed:factories today silently deactivates the Jewelry factory. Add any new factory and backfill Enigma (factoryCode: env.FACTORY_CODE_JEWELRY, factoryNameEn: 'Enigma', country: 'US') when touching this file. ⚠️ Note the seed's update clause rewrites factoryNameEn/country/isActive on every run, so a value here that disagrees with the real row silently overwrites it in whichever DB the seed is pointed at — keep this list and FACTORIES.data in lockstep. (A local dev DB may show a stale country such as 'Unknown' for Enigma; 'US' is the correct value.)

Data / ops (no deploy) 7. The Factory row itself is always created by hand in AdminJS (Factory → New, adminRoleAuth) per environment — no migration has ever inserted one. factoryNameEn must be byte-identical across environments, because factoryCode differs per env and every config migration matches on LOWER(TRIM("factoryNameEn")). Getting this wrong is exactly the Enigma-vs-'Jewelry' mismatch INFRA-577 had to fix by editing an already-applied migration in place. 8. isActive must be true, or userFactoryAccess.service.ts:38 filters the factory out of the AdminJS switcher even for users holding a valid access row. 9. UserFactoryAccess rows — one per user who needs the factory, created via AdminJS. ⚠️ UserFactoryAccess.accessLevel is the user's effective role for that factory, not User.role: auth.provider.ts:43 sets role: assignedFactories[0].accessLevel at login and session.controller.ts:37 re-sets newAdminUser.role = newSelectFactory.accessLevel on every factory switch. A wrong accessLevel silently grants or removes permissions on switch. Changes take effect at next login or next switch — no redeploy. The default landing factory is the lowest UserFactoryAccess.id (makeAssignedFactoriesByUserId orders { id: 'asc' }, auth.provider.ts:38 takes [0]), so a newly-granted factory never becomes anyone's landing page.

Config migration 10. A factory created after the earlier all-factory config migrations starts with zero FactoryConfiguration rows and silently falls back to every hardcoded default (see § Factory Configuration). Seed the full key set for it in a new migration. 11. ⚠️ Ordering hazard: since the seed matches on factoryNameEn, if the Factory row doesn't exist when prisma migrate deploy runs, the INSERT ... SELECT affects 0 rows, the migration is still recorded as applied, and it never retries. Create the Factory row in each environment before the deploy, or add the config rows by hand in AdminJS afterward. Deploy order is therefore: set env vars → create Factory row → deploy → grant UserFactoryAccess.

Acknowledge token spec pattern (src/clients/fulfilClient.spec.ts): each test vi.spyOn(fulfilClient, 'put') and asserts the third arg expect.objectContaining({ headers: { Authorization: env.FULFIL_TOKEN_<X> }, retryConfig: {…} }). One test per (factory[, category]) branch.