How code that talks to somebody else's API is put together here, and the rules that exist because breaking them cost this project real, shipped defects.
For the user-facing side of each service — which keys, where they go, what
breaks — see ../integrations/.
The QuickBooks adapter shipped six code paths that had never once worked in
production, while its unit suite was green the entire time. Every update was
sent as a PUT (v3 has no PUT). Every invoice was sent without a CustomerRef
(required). Every invoice from the dashboard was sent with an empty Line
(required). The duplicate-name retry matched fault 6140, a code QuickBooks
never returns. A colon in a customer's name was refused forever. A voided
invoice was read back as paid in full.
They share one cause, and it is not carelessness: the tests supplied the upstream's answers. A fixture that invents the response it asserts on can only prove the code agrees with itself. Everything below is a consequence.
server/services/<service>/
api-base.ts auth, token refresh, the HTTP call, error shaping
<entity>-payload.ts PURE: our record → their document
<entity>-sync.ts the push: lookup, call, map, retry, record
inbound-*.ts reading THEIR state and deciding what we believe
Payload shape lives in its own pure module. Which fields the API requires
and how cents become dollars is a question about shape; answering it should not
mean reading a retry loop. It also has to be reachable from a contract spec
without constructing a service — that reachability is the point, not a
side-effect. qbo/invoice-payload.ts and qbo/customer-payload.ts are the
pattern.
Inbound is a separate module from outbound. qbo/inbound-reconcile.ts reads
QuickBooks' state and decides how much of it we are allowed to believe; that is a
different question from "render our invoice as theirs", and keeping them apart
is what makes the answer readable on its own.
Declare every key in server/lib/secrets-catalog.ts. The names are
byte-identical to the Worker env binding names — that identity is what lets
integration-secrets.ts merge a tenant's stored value into c.env and have
every downstream reader work unchanged.
Precedence is per-key and stated in one place:
- Default — env wins, the tenant's DB value is the self-host fallback. A platform-provided key can then be added without hijacking a company that brought its own.
TENANT_OWNED_KEYS— DB wins. Only Stripe today. A platform env key overriding a tenant's own would route their customers' money into the wrong account, which is not a precedence question so much as a safety one.
Two rules with teeth:
- Never write to
c.envin place. The runtime reuses oneenvobject for every request in an isolate, so an in-place write leaves this tenant's secret there for the next one. Copy, then merge into the copy. - No fallback constants. A missing key disables the feature and says so. It
does not fall back to a default endpoint, a shared key, or a guess.
QBO_ENVis the sharpest case: sandbox and production keys authenticate only against their own kind, so a guessed host is wrong half the time and fails looking exactly like a bad credential.
A status code alone is not a record. When one code covers several causes, it
hides all of them. Error('QBO 400') was written to qbo_sync_errors.error_msg
for years, and 400 means at once: a missing required field, an invalid
reference, an over-long string, a stale token, an unsupported verb. Two fatal
defects lived behind that string while QuickBooks named them in the response.
- Keep every entry the upstream reports, with its field and code. One response can carry two problems; reporting the first costs a round trip to discover the second.
- A retry loop must keep the last upstream error and repeat it on
exhaustion. The old loop threw
after 3 stale-token retries— an assertion of its own, and wrong for every 400 that was not a stale token, which sent the reader to inspect a token that was fine. A wrong diagnosis is worse than none. - When the failure is OUR missing data, say so before the round trip.
"Cannot send to QuickBooks: invoice has no contact" names something the
operator can act on; a validation fault about
CustomerRefdoes not.
qbo/error-detail.ts (describeQboError) is the shape to copy.
Inbound state is a claim, not a fact, and the adapter decides how much of it we accept.
- Our ledger is authoritative for money we collected. When the two disagree, RECORD the disagreement with both figures; do not write an adjusting entry. An adjustment is money movement nobody performed, and afterwards it is indistinguishable from money that really moved.
- Two identical numbers can mean two different things. A voided QuickBooks
invoice reports
TotalAmt0 andBalance0 — byte-identical to a fully settled one. Reading balance alone marked a voided $555 invoice paid in full against a ledger holding nothing, which unlocked the report and counted revenue nobody sent. Before trusting a field, ask what else produces that value. - Do not mirror a destructive state change from a poll. The void is recorded, not applied: voiding here resets the payment gate and can retract a published report, and that is a decision rather than a reading.
Four suites can touch one, and they answer different questions. Full rules:
testing.md.
| Question | Where |
|---|---|
| Does our code do what we intended? | tests/unit/<domain>/ |
| Is what we intended what the API accepts? | tests/contract/<party>/*.contract.spec.ts |
| What does the API actually answer? | tests/contract/<party>/*.live.spec.ts |
The rule that would have caught all six defects: never fabricate the
upstream's response. If a spec needs a fault body, a status, or a field, take
it off the wire and say in a comment when it was captured. A fabricated 6140
passed ten specs while the ladder it gated had never run.
Practical consequences:
- Fixtures must not supply fields production never sets. The QuickBooks specs
inserted invoice rows carrying
contactIdwhile no write path in the product ever set that column — so the tests ran against data production cannot produce, and the push they exercised had never once succeeded. - Pair every negative claim with a positive control. "We send nothing undeclared" passes vacuously against a parser returning an empty set.
- Prove a fix red first. Remove it, watch the new spec fail, put it back.
- Enumerate the credentials, add them to
secrets-catalog.ts, and decide whether they are tenant-owned. Write down why. - Write the payload module first, pure, and a contract spec against whatever
the vendor publishes — a schema, a spec file, anything you did not write.
Vendor it with its provenance (see
tests/contract/qbo/vendor/SOURCES.md). - Ask the API the questions the vendor's documents cannot answer, and pin the answers in a live contract spec.
- Then write the sync path, its failure recording, and its unit specs.
- Add the page under
docs/integrations/, including what breaks when the credentials are absent.