StarkSphere Labs
← Engineering blog
Sep 20, 2026integrationsodoozohoerpnextarchitecture

One interface, three ERPs that agree on nothing

We build agents that post real accounting documents into whichever ERP a client already runs. The product promise is one chat interface over Odoo, Zoho Books or ERPNext, and the client should not be able to tell which one is underneath.

That promise is mostly a lie you have to work very hard to make true, because the three systems do not agree on the things the abstraction most needs them to agree on. Adding the third one was the real test of the boundary, and it is where the design either holds or you discover you have three products.

The abstraction that worked

One port, one selection site:

// internal/integration
type Port interface {
    Read(ctx context.Context, entity Entity, q Query) ([]Row, error)
    Write(ctx context.Context, action Action, doc Document) (Result, error)
    Fingerprint(ctx context.Context) (Capabilities, error)
}

Adding ERPNext was a new package, one case in adapterFor, and credentials in the wiring file. No consumer changed, and there was no migration, because provider is free TEXT rather than an enum somebody has to widen.

Two parts of that are load-bearing and neither is the interface itself.

Reads normalise onto shared row keys. Nine entities — chart of accounts, customers, vendors, products, invoices, bills, payments, company profile, cash balance — land on the same keys the sync and import layers already consume. The adapter absorbs the naming, not the caller.

Fingerprint lets an adapter admit what it cannot do. ERPNext's send_invoice is unwired, so its capabilities report CanSendInvoice: false. The agent then withholds the offer rather than offering it and refusing at confirm time. This is the single most useful thing in the design: a capability query means the abstraction does not have to pretend uniformity it does not have, and the user never gets offered an action that will fail.

Where they genuinely disagree

A draft is not a post

ERPNext transactions are submittable. A document inserts at docstatus 0 — a draft, which has not touched the ledger — and must be submitted to reach 1. Odoo and Zoho have no equivalent intermediate state in the same shape.

So the adapter inserts, then submits, and reports Verified: true only for a submitted document. Reporting a draft as posted would tell a client their books had moved when they had not, which in a finance product is the worst class of lie you can tell: confidently, in the direction of reassurance.

When the submit fails, the adapter leaves the stamped draft in place. The instinct is to clean up, and it is wrong — that draft carries the idempotency stamp, and it is the only thing that makes a retry safe rather than a double-post.

Your idempotency key has nowhere to live

Every retryable write needs a stamp in the target system so a retry can recognise its own earlier attempt. The obvious ERPNext fields are po_no and bill_no.

Both are already taken. They hold the counterparty's number — the customer's purchase order, the supplier's bill reference — which the read side maps back to ref. Writing our key there would overwrite a customer's PO number and then report our own key back to them as theirs. That is the precise mistake an earlier helper exists to unpick on the Odoo side, so we knew the shape of it before we could make it again.

The stamp went into remarks instead, written explicitly on insert so ERPNext's "No Remarks" default never lands over it. Journal entries have no remarks, so they use user_remark.

That has a known cost, and it is worth stating rather than hiding: a client who hand-edits that remark breaks retry-dedup for that one document. The alternative was requiring a custom field in the client's own ERP before they could connect at all, which is a much worse product. Sometimes the correct engineering decision is the one with the documented failure mode, not the one with no failure mode and an onboarding step nobody completes.

Un-posting does not exist, so do not offer it

The product has a reset_document action. On ERPNext there is no return-to-draft for a submitted document. The adapter cancels it — docstatus 2 — and says so in a Notice, rather than dressing a cancellation up as a reset. delete_document cancels then deletes, because ERPNext refuses to delete a submitted document at all.

This is the hardest discipline in adapter work. The interface has a verb; the system underneath has something adjacent but different. You can approximate and stay silent, or do the nearest correct thing and tell the truth about it. Approximating is how a client discovers, during an audit, that a document they believe was reset is sitting cancelled in their ledger.

Zoho disagrees about errors

Two separate fixes on the Zoho side were both about error semantics rather than functionality.

A duplicate bill number came back as a generic failure and surfaced as an outage. It is not an outage — it is a validation result belonging to a specific field, and reporting it against the bill number lets the user fix it in one action instead of filing a support ticket.

A foreign-currency document against a company whose Zoho currency setup cannot take it is now held rather than attempted. And documents are keyed in our ledger by Books id, not by number, because numbers are user-editable and ids are not — a distinction that only shows up when a client renumbers something and your reconciliation quietly stops matching.

Refusing is a feature

The ERPNext adapter fails closed in three places, and all three are refusals to post something the target system would have accepted in a misleading shape:

  • A document mixing two vatable rates, which a single document-level tax row cannot express.
  • A VAT document against a company whose chart has no tax account — posting it would understate the client's VAT liability while looking like a clean post.
  • A settlement against a closed document, or for more than is outstanding. That one is real money leaving twice.

Each of these could have been a best-effort post plus a warning. In accounting, a wrong number that looks right costs more than a refusal, because refusals get resolved in minutes and wrong numbers get found at year end.

What adding the third one exposed

The most valuable thing the ERPNext work produced was not the adapter. It was finding that the provider list had drifted into three copies.

There was a single exported Providers list, documented as "one list, exported, because it was about to be two". By the time we looked, an API file kept a private copy and another had two inline != "odoo" && != "zoho" checks. Every one of them would have rejected a tenant with a genuinely connected ERPNext site, with a 400 and no useful message.

A comment saying "one list, because it was about to be two" is a warning that already came true once. It will come true again unless the invariant is enforced by something other than the comment — in this case a KnownProvider function that the checks now have to call.

Related: the connect form used to require a database coordinate from every provider. An ERPNext site is its base URL; there is no database beside it. The fix was a provider-declared DatabaseOptional, registered next to the adapter rather than branched on a provider name, so the generic integration layer keeps no ERP-specific knowledge. Branching on provider names in shared code is how you end up with four provider lists.

The takeaway

A good integration boundary is not one that makes three systems look identical. It is one where each adapter can declare what it cannot do, refuse what it must not approximate, and keep its system's oddities from leaking into shared code.

The test of the abstraction is the third implementation, not the second — the second tends to get accommodated by special cases nobody notices. If you are building the second one now, the useful question is which of your current branches are on a provider name rather than a provider capability. Those are the ones that will be wrong when the third arrives.