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.
Who this is for
Section titled “Who this is for”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.
What “done” means
Section titled “What “done” means”The contract test is green:
cd apps/webapp && npx vitest run src/services/erp/adapter-contract.test.tsadapter-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.
Step 1: add a registry row
Section titled “Step 1: add a registry row”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 inerp_connections.type. Matches what you’ll use everywhere else (the adapter’serpType, 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_b1today). Only'production'rows are enumerated by the contract test and need a factory entry.writeFlag: the Flagsmith flag name gating this adapter’s writes.nullonly 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 onnullfor 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.
Step 2: declare the write flag
Section titled “Step 2: declare the write flag”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"]}Step 3: write the adapter on the kit
Section titled “Step 3: write the adapter on the kit”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.
Step 4: optional capabilities
Section titled “Step 4: optional capabilities”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: callslookupOrdersByPofor PO matches, thengetOrderDetailson each to build the revision workbench’s candidate list.mapChangeSet: turns the workbench’s generic line diff into your adapter’s ownErpOrderItemInput[]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 setreviewedExistingto the quantity, price, due date and SKU the CSR reviewed.apply: callsservice.updateOrder(connection, targetId, { items }, options), passingenableWrite,idempotencyKeyandexpectedUpdatedAt: guard.expectedVersionTokenfrom the guard.enableWriteis required becauseupdateOrdermust 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-fetchcheck 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>CredentialsErrorsubclass.credentials: fed to the mockedCredentialService.getCredentials().makeConnection(): a minimalERPConnectionfor your type.minimalOrderInput(): a minimal, valid order (at least one line with anerpItemId).minimalUpdate(): args for a no-opupdateOrdercall (the flag-off path only; this never has to reach the network).buildHappyPathFetch(): a fetch stub covering every requestcreateOrderissues forminimalOrderInput()with the write flag on, ending in success.
Then run:
cd apps/webapp && npx vitest run src/services/erp/adapter-contract.test.tsRead 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):
cd apps/webapp && npx vitest run src/services/erp/implementations/your-erp-service.test.tsStep 6: go-live
Section titled “Step 6: go-live”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:
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 gatedcreateOrder, for the customer and item inextraConfig.certification_customer_idandcertification_item_sku. With the write flag off it fails as gated off; pass--order/--lineto 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_trailrow per write step, matched by org, ERP type, the step’s time window andentity_id, which holds the target order id. Pass the ERP order id asentityIdon 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.
Document the access it needs
Section titled “Document the access it needs”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.
Things that bit us
Section titled “Things that bit us”- 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_REGISTRYinpackages/contracts/src/erp.tsis now the one place. Every other list derives fromERP_TYPESorPRODUCTION_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
writeGatecall referencing a flag name that was never added toinfrastructure/flagsmith/configs/flags.jsonlooks 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(inpackages/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.