Skip to content

Adding an ERP Adapter

This is the TypeScript side: the webapp’s write path (IERPService, createOrder, updateOrder, the revision workbench). For the Dagster/DLT side, syncing catalog, customers and cross-references into the warehouse, see Adding a New ERP Type in the Dagster app. That guide is unrelated to this one. An ERP can have one, the other, or both.

An engineer adding support for the next ERP the sales team signs. Epicor Kinetic (apps/webapp/src/services/erp/implementations/kinetic-service.ts) is the reference adapter, so copy its shape rather than an older one. The legacy adapters (WhereFour, Eclipse E4, Prophet21) predate the shared kit this guide describes and still carry their own hand-rolled versions of everything below. Migrating them onto the kit is tracked separately in TODOS.md and is not what this guide is for.

The contract test is green:

Terminal window
cd apps/webapp && npx vitest run src/services/erp/adapter-contract.test.ts

adapter-contract.test.ts enumerates every production row in ERP_REGISTRY and checks, for each one, that the row’s write flag is declared in Flagsmith, createOrder/updateOrder refuse with the flag off and issue no HTTP request, every write goes through the audit ledger, credential errors are rethrown rather than swallowed, the type resolves in getERPService, and the adapter’s source files lint clean under the no-bare-fetch ESLint rule. A new adapter has no exemption from this test. Only the legacy adapters that predate the kit do (CONTRACT_EXEMPTIONS, fixed to the types that existed when the test was written; a type can never be added to that list later), and each of those exemptions carries an owner and an expiry date. Each failure names the specific thing that’s missing, so read the assertion message before guessing.

The single ERP-type list is packages/contracts/src/erp.ts. Every other list in the codebase (the Drizzle enum, the connection-creation zod schema, the orders-filter schema, the PostHog union) derives from it, so this one row is the only place you register the type:

{ type: 'your_erp', label: 'Your ERP', kind: 'production', writeFlag: 'your_erp_writes_v1' },
  • type: stored in erp_connections.type. Matches what you’ll use everywhere else (the adapter’s erpType, the factory key, the flag’s connection scoping).
  • label: the human name shown in the UI and in error messages ("<label> createOrder is disabled...").
  • kind: 'production' (has a real adapter), 'mock' (test/demo double), or 'planned' (registered, not built; sap/sap_b1 today). Only 'production' rows are enumerated by the contract test and need a factory entry.
  • writeFlag: the Flagsmith flag name gating this adapter’s writes. null only for the handful of legacy adapters with no gate at all (prophet21, netsuite, odoo, dynamics). Every new adapter needs a real flag here; the contract test fails on null for a non-exempt production row.

packages/db imports only the derived ERP_TYPES tuple for the Drizzle column (text('type', { enum: ERP_TYPES })). It does not depend on the webapp or know what an adapter is. That split is why the registry lives in @repo/contracts (zod-only, no webapp deps) rather than beside the Drizzle schema.

Add the flag to infrastructure/flagsmith/configs/flags.json, default off. Follow the shape of an existing ERP-write flag, e.g. erp_kinetic_writes_v1 in that same file:

{
"name": "your_erp_writes_v1",
"description": "ERP write gate for <Your ERP> order submission/update. When OFF (default) <YourErpService>.createOrder and updateOrder refuse before issuing any request and return a non-success ErpOrderResult. Evaluated per connection (getFlagForConnection, identity connection_<id>). Turn ON for a connection only after certification passes for that connection's target instance.",
"type": "boolean",
"default_enabled": false,
"tags": ["erp-write"]
}

Build <your-erp>-service.ts on the shared kit at apps/webapp/src/services/erp/write-kit.ts instead of reinventing each piece. Read the kit’s own header comment first, it has the one-paragraph version of everything below, then follow kinetic-service.ts (and its transport, kinetic-rest-client.ts) as the worked example.

Kit helper When to use it The decision behind it
writeGate(connection, flag, label, op) First line of createOrder and updateOrder. Returns null when allowed, a disabled ErpOrderResult otherwise. The gate lives inside the adapter, not the caller, so a new call path can’t forget it (AGENTS.md rule 4). The contract test asserts no HTTP request happens when it returns non-null.
refuseEmptyLines(items, label) Right after the gate, before any network call. An empty order, or a line missing erpItemId, can never be sent meaningfully. Fail before spending a request on it.
dedupeBeforeCreate({ lookup, isSame, ... }) Before the create write, when the ERP has no field to carry our own order reference and you need an idempotency check on retry. Reuses a candidate only when exactly one match passes isSame. When the lookup itself throws, it warns and proceeds to create rather than blocking the submission. A stuck order during a transient ERP blip is worse than an occasional duplicate, which the existing erp_duplicate_of dedupe sweep already catches. A credential error from the lookup is the one exception: it’s rethrown, since every later call on that connection would fail the same way.
verifyOrderLines(erpOrderNumber, sent, erpItems, opts?) After every write, against a read-back of the order. An HTTP 200 only means the ERP accepted the request, not that every line landed. A sent line with erpLineId is matched to that same ERP line first, and fails if that line now points at a different SKU. The rest is a multiset match by SKU/id. Quantity is always compared; price (comparePrice) and due date (compareDueDate) only when you ask, and only for fields the sent line carries, so an absent field means “unchanged”. A price- or date-only difference still reports as quantity_mismatch. It doesn’t handle deletions, so check that removed lines are gone yourself, as verifyKineticUpdate does.
partialWriteResult(label, erpOrderNumber, cause, extra?) When verifyOrderLines reports a mismatch after a create, or a write fails partway through. Returns failureCode: 'WRITE_OUTCOME_UNKNOWN' and a message that says “finish or void it in <label>; do not resubmit.” Resubmitting a partial write risks creating a second, duplicate order on top of the broken one.
classifyErpMessage(message, ctx?) Wrap every raw ERP error string (and HTTP status, when you have one) before returning it. One classifier instead of an ad hoc regex per adapter. Maps profit/margin, credit limit, customer, shipping and item text to typed ErpErrorTypes. A 401/403 maps to AUTHENTICATION with a suggestedAction that lists likely causes (ERP user’s security, API key scope, expired creds) rather than asserting one it can’t actually tell apart.
createReadClient(opts?) / createWriteClient(connection, opts?) / createAuthClient({ allowedPaths, ...opts }) Every HTTP call your adapter makes, with no exceptions. createWriteClient wraps every call in auditErpFetch, so a write ledger row (integration_audit_trail) is unconditional; no adapter built on it can forget to record a write (see AGENTS.md’s “Every ERP write is also recorded”). createReadClient refuses anything but GET/HEAD, so a read path can’t quietly become a write. createAuthClient is deliberately not audited (login/token exchange carries credentials, not order data) but refuses to POST anywhere outside an explicit allowedPaths allowlist, so a bug can’t reuse it as a write client by accident. Direct calls to global fetch in a production adapter file are banned by ESLint (no-restricted-globals/no-restricted-properties), enforced structurally rather than by a scanner someone could route around. There is no marker or exemption comment that lets a mutating call skip the ledger.

Classify every failed write before you return it. Once a write request has gone out, the only clean failure is a definite refusal, meaning an HTTP status under 500 that isn’t in TRANSIENT_STATUSES from @repo/integrations/services. A timeout or abort, a network error, a 5xx, a 408/425/429, or a 2xx you can’t read because it isn’t JSON or has no order number may still have committed in the ERP. Return failureCode: 'WRITE_OUTCOME_UNKNOWN' for those, with a message that says to check the ERP before resubmitting. In updateOrder, while nothing has landed yet, a stale-version refusal is STALE_TARGET and any other definite refusal is PRE_WRITE_FAILED, which the caller can safely retry. Never retry a write inside the adapter, since Temporal owns retries. KineticService.createOrder and applyKineticOrderUpdate show the pattern.

All three transports accept opts?: KitTransportOptions ({ fetch?, timeoutMs? }). Thread it through your constructor and every client call. It’s how tests and the certification runner substitute their own fetch instead of stubbing global.fetch, and it’s what lets the certification runner inject a mid-request abort for its fault-recovery step. See KineticRestClient’s constructor for the pattern.

Register the finished adapter in apps/webapp/src/services/erp/erp-service-factory.ts’s ERP_SERVICE_FACTORIES map. It’s typed satisfies Record<ProductionErpType | ..., () => IERPService>, so leaving a production type unregistered is a compile error, not a runtime “Unsupported ERP type” surprise. The worker’s factory (apps/ts-temporal-worker/src/services/erp-service-factory.ts) just re-exports this one. There’s nothing to touch there.

IERPService (packages/integrations/src/services/ierp-service.ts) has a handful of optional methods. Implement the ones your ERP can actually support; a caller that finds a method missing falls back to “unchecked”, never to a false negative.

Method What it lights up
lookupOrdersByPo + classifyPoLookupError The PO-history banner on the extracted-order page (shows “already in ERP” matches for this PO before submit) and the revision workbench’s candidate search for this ERP’s sales_order target. Throws on failure; classifyPoLookupError maps that failure to a reason string the client keys off, or return null to fall back to the caller’s generic “unreachable” message.
checkItemExistence The pre-submit existence check: an advisory flag on a bound line whose erpItemId doesn’t exist in the live ERP catalog. Never throws. A degraded lookup returns no issues (fail open); submit-time resolution is still the real, fail-closed gate.

If you implement lookupOrdersByPo, also add a revision target so the workbench can edit an existing order in this ERP. Model it on apps/webapp/src/lib/revision/targets/kinetic-sales-order.ts:

  • findCandidates: calls lookupOrdersByPo for PO matches, then getOrderDetails on each to build the revision workbench’s candidate list.
  • mapChangeSet: turns the workbench’s generic line diff into your adapter’s own ErpOrderItemInput[] shape. Only put a price or due date on an item when the review actually changed it. An absent field means “leave it alone”, and verification skips it. On every existing line you change or remove, also set reviewedExisting to the quantity, price, due date and SKU the CSR reviewed.
  • apply: calls service.updateOrder(connection, targetId, { items }, options), passing enableWrite, idempotencyKey and expectedUpdatedAt: guard.expectedVersionToken from the guard. enableWrite is required because updateOrder must be a no-op for every other caller by default. The version token is the lost-update guard against an edit made in the ERP between review and submit.

The header version token doesn’t always move when someone edits a single line in the ERP. So your updateOrder should also compare each item’s reviewedExisting against a fresh pre-read of the order and refuse the whole update with STALE_TARGET if any targeted line changed or disappeared. The per-line stale check in applyKineticOrderUpdate is the model.

Register the target in apps/webapp/src/lib/revision/targets/index.ts (see the epicor_kinetic entries there) and give it its own write flag via revisionApplyFlagFor. Reuse your registry row’s writeFlag rather than inventing a second constant that can drift from it.

Step 5: add the contract fixture and run the test

Section titled “Step 5: add the contract fixture and run the test”

adapter-contract.test.ts enumerates non-exempt production types from NON_EXEMPT_FIXTURES, defined in apps/webapp/src/services/erp/adapter-contract.fixtures.ts. That’s the file you edit. Add an AdapterContractFixture for your type (the Kinetic entry in that file is the reference) and push it into NON_EXEMPT_FIXTURES:

  • type: your registry type.
  • sourceFiles: the repo-root-relative paths the ESLint no-bare-fetch check must cover: your service file, its transport, its revision adapter, everything that issues a request.
  • AdapterClass: your service class. Must accept { fetch? } in its constructor (the transport seam from Step 3).
  • CredentialsErrorClass: your adapter’s own <X>CredentialsError subclass.
  • credentials: fed to the mocked CredentialService.getCredentials().
  • makeConnection(): a minimal ERPConnection for your type.
  • minimalOrderInput(): a minimal, valid order (at least one line with an erpItemId).
  • minimalUpdate(): args for a no-op updateOrder call (the flag-off path only; this never has to reach the network).
  • buildHappyPathFetch(): a fetch stub covering every request createOrder issues for minimalOrderInput() with the write flag on, ending in success.

Then run:

Terminal window
cd apps/webapp && npx vitest run src/services/erp/adapter-contract.test.ts

Read every failure message; each one names the missing piece (flag not declared, write not refused with the flag off, no ledger row, credential error swallowed, type not resolved in the factory, ESLint error in one of your sourceFiles) rather than just failing red. Also run your adapter’s own unit tests co-located next to it (kinetic-service.test.ts and kinetic-rest-client.test.ts are the reference pair; mock @repo/integrations/auth and @/lib/feature-flags/connection-flag the way acumatica-service.test.ts does):

Terminal window
cd apps/webapp && npx vitest run src/services/erp/implementations/your-erp-service.test.ts

A passing contract test proves the adapter is wired correctly. It does not prove the write shapes are accepted by a real instance of your ERP. That needs a live run against a test company:

Terminal window
cd apps/webapp && pnpm exec tsx scripts/certify-erp-revision.ts \
--connection <id> [--order <num> --line <n>] [--apply] [--evidence <path>] [--confirm <company>]

The runner is apps/webapp/scripts/certify-erp-revision.ts, backed by the testable core at apps/webapp/src/lib/erp/certification/run-certification.ts. The script’s header lists the env vars it needs. The core is ERP-agnostic. Everything ERP-specific sits behind a CertificationDriver, and only epicor_kinetic has one today, in kinetic-driver.ts. The script exits with no certification driver for <type> for anything else, so write a driver for your ERP and select it by connection type in the script.

It refuses to run at all unless the target connection’s extraConfig.certification_target is exactly true, and prints host, instance and company plus a typed confirmation before any --apply step. Changing a connection’s API URL or company clears certification_target, so a re-pointed connection has to be approved again. Without --apply only the read-only steps run. With --apply:

  • Step 0, the nested-create probe, runs only without --order. It creates a throwaway order through your adapter’s gated createOrder, for the customer and item in extraConfig.certification_customer_id and certification_item_sku. With the write flag off it fails as gated off; pass --order/--line to certify against an existing test order instead. Every other certification write goes straight through the transport and skips the flag on purpose, since certification has to pass before the flag goes on.
  • Steps 1 to 6 come from the base harness, except that step 2 runs in memory only. It checks that the concurrency guard rejects a drifted snapshot and sends no write.
  • 2b makes a real write that bumps the line’s version token, then sends a write carrying the old token. It passes only if the ERP refuses that write as stale and the line reads back unchanged.
  • 5b sends a write whose transport aborts right after the request goes out, then reads back. It passes only if the abort really fired and the line shows the write applied once or not at all, with no duplicate line. A run where the fault never triggered fails.
  • Every write step needs a ledger row. The runner looks for at least one integration_audit_trail row per write step, matched by org, ERP type, the step’s time window and entity_id, which holds the target order id. Pass the ERP order id as entityId on every write, or the step fails with “ledger missing”.
  • Cleanup runs whenever an order id is known. It voids any order the runner created, including a probe order whose create came back ambiguous and was then found by PO. On an operator-supplied order it restores the line’s original quantity and description, since 2b overwrites Kinetic’s LineDesc. A failed cleanup fails the run.

See the workbench skill epicor-kinetic-write for the full write checklist (network path, write-capable API scope, credentials in both 1Password and Infisical, the certification_target flag) and what each certification step actually proves. Read that skill before running this against your ERP, since the prerequisites and the shape of “test company” are ERP-specific. Only turn the write flag on for a connection after its certification run passes.

Add a section for the new type to ERP access requirements in the same PR: credentials and where they live, the network path, every service or endpoint the adapter and its Dagster source call, and the scope or role the customer has to grant in the vendor’s own terms. Update it whenever the adapter starts calling a new endpoint, since a customer’s scoped API key fails on anything not listed.

  • Hand-copied type lists. Before the registry existed, adding a type meant finding and updating roughly a dozen scattered lists by hand (Drizzle enum, zod schemas, the factories, the PostHog union…). One got missed: Acumatica was absent from the worker’s factory for a while. Fix: ERP_REGISTRY in packages/contracts/src/erp.ts is now the one place. Every other list derives from ERP_TYPES or PRODUCTION_ERP_TYPES.
  • Worker factory drift. The Temporal worker kept its own hand-copied factory switch that silently fell behind the webapp’s (see above). Fix: the worker’s factory file is now just a re-export of the webapp’s, so there’s only one factory to keep correct.
  • Flags never declared. A writeGate call referencing a flag name that was never added to infrastructure/flagsmith/configs/flags.json looks like a working gate in code review but fails open at runtime (see the callout in Step 2). Fix: the contract test checks that the flag is declared in that file for every non-exempt production adapter.
  • Certification harness steps 2 and 5 were simulations. The base ERPRevisionCertificationHarness (in packages/integrations) checks its concurrency guard against a snapshot it drifts in memory, and its fault-recovery step re-reads without ever actually interrupting a request. A “6/6 passed” overstated what either step proved against a real ERP. Worse, the harness’s step 2 also sends a real write with a decoy token. Kinetic puts each line’s current SysRevID in the payload and ignores that token, so on a live connection the “stale” write would land for real and step 2 would fail for a write it never tested. Fix: the runner runs step 2 in memory only, with no write, and adds two live steps on top of the harness’s six. 2b makes a real version bump, then sends a stale-token update that must be refused. 5b injects a transport abort, then reconciles from a read-back. Both are required before a connection’s write flag goes on.