spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
1<!--2Project : AIR — Accounting Intermediate Representation3Author : Simon-Pierre Boucher4Contact : contact@spboucher.ai5File : erp-apis.md6-->78# ERP Accounting APIs — Research Report910**Purpose:** Ground the design of AIR's `Backend` interface (`capabilities() / compile() / post() / reverse()`) in the reality of the ERP APIs we intend to target.11**Research method:** 9 targeted web searches/fetches, official developer documentation prioritized.12**Consultation date: 2026-08-05.**1314---1516## 1. QuickBooks Online (QBO) — AIR's first real backend1718### 1.1 API model1920- REST + JSON, base URL `https://quickbooks.api.intuit.com/v3/company/{realmId}/...` (sandbox: `https://sandbox-quickbooks.api.intuit.com/...`). The `realmId` is the company ID, obtained during the OAuth flow.21- **Minor versions:** since **August 1, 2025**, minor versions 1–74 are deprecated; **minor version 75 is the base/default** and any `minorversion` parameter below 75 is ignored. Recommendation: pin requests explicitly (`?minorversion=75`) and validate schema compatibility against mv75.22- **Rate limits:** ~500 requests/minute per company (realm ID), max ~10 concurrent requests, and ~120 requests/minute on the Batch endpoint. Throttling returns HTTP 429 → backend must implement backoff + retry.2324### 1.2 Authentication2526- OAuth 2.0 Authorization Code flow via Intuit's identity platform. Access tokens expire after **1 hour**; refresh tokens must be used (and rotated/persisted). Scope: `com.intuit.quickbooks.accounting`.27- Free **sandbox companies** are available in the developer dashboard for full end-to-end testing (Phase 3 target).2829### 1.3 JournalEntry entity3031Reference: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/journalentry3233- A `JournalEntry` is a header + array of `Line` objects.34- Each line: `Description`, `Amount` (always **positive**), `DetailType: "JournalEntryLineDetail"`, and a `JournalEntryLineDetail` object containing:35 - `PostingType`: **`"Debit"` or `"Credit"`** — the debit/credit convention is an *explicit enum per line*, with an unsigned amount.36 - `AccountRef` (`value` = account ID, `name` optional) — reference to the Account.37 - Optional `Entity` (Customer/Vendor/Employee reference — required for lines on AR/AP accounts), class/department refs, `TaxCodeRef`/`TaxApplicableOn` for tax lines.38- Header: `TxnDate`, `DocNumber`, `PrivateNote`, `CurrencyRef` + `ExchangeRate` (multicurrency companies).39- Operations: create (POST), read, full update, sparse update, **delete** (`?operation=delete` with Id + SyncToken). QBO uses **optimistic concurrency via `SyncToken`** — every update/delete must supply the current SyncToken or fails.40- **No native "reverse" operation on JournalEntry**: reversal = either delete the entry, or post a mirror-image journal entry (swap Debit/Credit) — AIR should generate an explicit reversal entry to preserve the audit trail rather than deleting.4142### 1.4 Invoice and Payment entities4344- `Invoice`: header (CustomerRef, TxnDate, DueDate, CurrencyRef, DocNumber) + `Line[]` with `DetailType: "SalesItemLineDetail"` (`ItemRef`, `Qty`, `UnitPrice`, `TaxCodeRef`). Tax computed via `TxnTaxDetail` (and Automated Sales Tax in US companies).45- `Payment`: separate entity linked to invoices via **`LinkedTxn`** (`TxnId`, `TxnType: "Invoice"`). `LinkedTxn` on the Invoice side is read-only; linkage is written from the Payment side.46- This matters for AIR: a `Sale` economic event may compile to an Invoice + Payment pair rather than a raw JournalEntry — the backend's `capabilities()` should advertise which target document types it can emit.4748### 1.5 Idempotency4950- QBO does **not** support modern `Idempotency-Key` headers. Two mechanisms:51 1. **`requestid` query parameter** (a UUID unique per company): if the service receives a duplicate `requestid`, it returns the original response instead of re-executing. Intuit strongly recommends sending it on every mutating request — this is our primary idempotency mechanism.52 2. Fallback: enable `CustomTxnNumbers` and rely on `DocNumber` uniqueness (duplicate → error 6140), usable as a duplicate detector.5354Sources (consulted 2026-08-05):55- https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/journalentry56- https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-linked-transactions57- https://blogs.intuit.com/2015/04/06/15346/ (Request ID / idempotency)58- https://medium.com/intuitdev/changes-to-our-accounting-api-that-may-impact-your-application-c330bd1a06f5 (minor version 75)59- https://docs.codat.io/updates/250219-qbo-minor-versions-update/60- https://satvasolutions.com/blog/quickbooks-online-api-guide (rate limits)61- https://truto.one/blog/how-to-integrate-with-the-quickbooks-online-api-2026-guide6263---6465## 2. Xero Accounting API6667### 2.1 API model & auth6869- REST + JSON, base `https://api.xero.com/api.xro/2.0/...`. OAuth 2.0 only (OAuth 1.0a fully retired).70- **Tenant model:** one OAuth connection can grant access to multiple Xero organisations; **every request must carry the `Xero-Tenant-Id` header**. The backend must persist the (token, tenantId) pair per organisation.71- **Rate limits:** 60 calls/min per tenant, 5,000 calls/day per tenant, 10,000 calls/min app-wide. Daily per-tenant cap is the binding constraint for batch posting — favor batch endpoints (up to multiple elements per PUT/POST call).7273### 2.2 ManualJournals7475Reference: https://developer.xero.com/documentation/api/accounting/manualjournals7677- Fields: `Narration` (required), `Date`, `Status`, `JournalLines[]`.78- Each `JournalLine`: `LineAmount`, `AccountCode`, `Description`, `TaxType`, `Tracking` (analytic categories).79- **Debit/credit convention: signed amount** — **positive = debit, negative = credit**. No PostingType enum. At least one debit and one credit line; the journal must balance.80- `Status`: `DRAFT` or `POSTED` on creation; a posted manual journal is removed by setting status to **`VOIDED`** (drafts → `DELETED`). Voiding is the sanctioned "reversal" path, but AIR may still prefer an explicit contra-journal for traceability.81- Invoices: `Invoices` endpoint (Type ACCREC/ACCPAY, LineItems with LineAmountTypes Exclusive/Inclusive/NoTax), separate `Payments` endpoint.8283### 2.3 Idempotency8485- **Native support:** `Idempotency-Key` HTTP header (UUID) on POST/PUT — retries do not create duplicates. This is the cleanest idempotency model of all surveyed APIs.8687Sources (consulted 2026-08-05):88- https://developer.xero.com/documentation/api/accounting/manualjournals89- https://developer.xero.com/documentation/guides/oauth2/limits/90- https://developer.xero.com/faq/limits91- https://xeroapi.github.io/xero-python/v1/accounting/index.html (idempotency_key parameter on ManualJournals operations)92- https://cdn.cdata.com/help/DXF/jdbc/pg_accountingtable-manualjournals.htm (sign convention, balance requirement)9394---9596## 3. Odoo9798### 3.1 API model & auth99100- External API via **XML-RPC** (`/xmlrpc/2/common`, `/xmlrpc/2/object`) and **JSON-RPC** (`/jsonrpc`); a REST-ish surface exists since Odoo 17. All protocols hit the same ORM (`execute_kw(db, uid, password, model, method, args)`).101- **Deprecation:** `/xmlrpc`, `/xmlrpc/2` and `/jsonrpc` are scheduled for removal in **Odoo 22 (fall 2028)** / Online 21.1 (winter 2027), replaced by the **External JSON-2 API**. AIR's Odoo backend should abstract transport so the RPC layer can be swapped.102- Auth: database + login + API key (used as password in RPC). External API access requires a **Custom pricing plan** (not available on One App Free / Standard) — a real deployment constraint.103104### 3.2 Journal entries: `account.move`105106- One model for everything: journal entries, customer invoices, vendor bills, credit notes — discriminated by `move_type` (`entry`, `out_invoice`, `in_invoice`, `out_refund`, ...).107- Lines in **`line_ids`** (`account.move.line`), each with **separate `debit` and `credit` decimal fields** (one of the two is 0.0), `account_id`, `partner_id`, `name`. Invoice-style lines go through `invoice_line_ids`.108- Lifecycle via `state`: `draft` → `posted` → `cancel`. Post with **`action_post`** (`execute_kw(..., 'account.move', 'action_post', [[move_id]])`); back to draft with `button_draft`.109- Balance is validated at post time (debits = credits); to build temporarily unbalanced drafts pass context `{'check_move_validity': False}`.110- **Reversal:** first-class — `_reverse_moves` / reversal wizard creates a contra move with `reversed_entry_id` linking back to the original (good fit for AIR's provenance graph).111112### 3.3 Idempotency113114- **None built in.** RPC create calls are not idempotent; the backend must implement its own dedupe (e.g., store the AIR event ID in `ref`/a custom field and search before create).115116Sources (consulted 2026-08-05):117- https://www.odoo.com/documentation/19.0/developer/reference/external_rpc_api.html118- https://www.odoo.com/documentation/19.0/developer/reference/external_api.html (External JSON-2)119- https://github.com/odoo/odoo/blob/14.0/addons/account/models/account_move.py120- https://www.odoo.com/forum/help-1/creating-journal-entries-via-external-api-xml-rpc-130818121- https://www.getknit.dev/blog/odoo-api-integration-guide-in-depth122123---124125## 4. SAP S/4HANA (brief survey — defer details, complexity high)126127- **Modern path:** SOAP/OData APIs under the **Finance – Posting Integration (SAP_COM_0002)** communication scenario: *Journal Entry – Post* (synchronous and asynchronous) and *Journal Entry – Change*, plus OData read APIs (e.g., Operational Journal Entry Item – Read). Journal entries are complex documents: header (company code, document type, posting date) + typed item categories (G/L items, debtor items, creditor items, product tax items, withholding tax items). Debit/credit is expressed via signed amounts / debit-credit codes depending on the API.128- **Reversal:** a dedicated *Journal Entry – Reverse* service; requires `ReversalReason` and `ReversalReferenceDocument`, with **no line items** — reversal is a header-level operation referencing the original document. Sync and async variants exist.129- **Legacy path:** `BAPI_ACC_DOCUMENT_POST` (post) and `BAPI_ACC_DOCUMENT_CHECK` (validate-only — interesting analogue of AIR's `Validate()`), plus IDoc-based batch interfaces. On-premise landscapes still rely heavily on these.130- Complexity drivers: communication arrangements setup, document splitting, parallel ledgers, tax determination inside SAP, strict field controls per document type. **Decision: defer SAP backend; requires a dedicated deep research pass (OData vs SOAP vs BAPI, S/4HANA Cloud vs on-prem).**131132Sources (consulted 2026-08-05):133- https://community.sap.com/t5/technology-blog-posts-by-sap/apis-for-journal-entries-the-collection-updated-july-2025/ba-p/13565258134- https://community.sap.com/t5/enterprise-resource-planning-blog-posts-by-sap/guidelines-for-api-journal-entry-post/ba-p/13421397135- https://help.sap.com/docs/SAP_S4HANA_CLOUD/b978f98fc5884ff2aeb10c8fdeb8a43b/57b40036b71f4825adad70a0a5b91573.html (Journal Entry – Reverse)136- https://community.sap.com/t5/enterprise-resource-planning-blog-posts-by-sap/user-guide-for-journal-entry-post-api-on-s-4-hana-cloud/ba-p/13424193137138---139140## 5. NetSuite SuiteTalk & Sage Intacct (brief capability survey)141142### 5.1 NetSuite (SuiteTalk REST)143144- SuiteTalk REST Web Services: JSON, standard verbs (GET/POST/PATCH/DELETE) on records including **`journalEntry`** (`/services/rest/record/v1/journalEntry`). Legacy SOAP SuiteTalk still exists.145- Journal entry = header (subsidiary, currency, trandate) + `line` sublist; each line has account reference and **separate `debit` / `credit` fields** (Odoo-like convention).146- Auth: **Token-Based Authentication (OAuth 1.0-signed, via an Integration Record with consumer key/secret + token)** or **OAuth 2.0** (auth code and client-credentials machine-to-machine flows). Setup ceremony is significant (integration record, roles, permissions).147- Reversal: native support via reversal date fields on the journal entry record (creates a linked reversing entry). No native idempotency-key header; use `externalId` on records for dedupe/upsert.148149Sources (consulted 2026-08-05):150- https://www.moderntreasury.com/journal/how-to-authenticate-to-netsuites-suitetalk-rest-web-services-api151- https://yepcode.io/recipes/rest-api-to-oracle-netsuite-journal-entries/152- https://www.brokenrubik.com/blog/netsuite-rest-api-guide153154### 5.2 Sage Intacct155156- **Two API surfaces:** legacy **XML API** (envelope: `<request><control><operation><content>`, two-level authentication: sender ID/password + company/user credentials) and a newer **REST API with OAuth 2.0** — all new objects/features ship on REST only.157- Journal entries: XML API `GLBATCH` object (journal entry is an owned element of a GL transaction/batch — header journal + `GLENTRY` lines with `TR_TYPE`/amount or debit-credit indicators); REST API exposes `general-ledger/journal-entries` create endpoint on the developer portal.158- Dimensions (department, location, class, project) are pervasive on lines — the AIR line model must carry analytic dimensions generically.159160Sources (consulted 2026-08-05):161- https://developer.intacct.com/api/general-ledger/journal-entries/162- https://developer.sage.com/intacct/apis/intacct/1/intacct-openapi/groups/general-ledger/groups/journal-entries/tags/general_ledger_journal-entries/paths/create-general-ledger-journal-entry163- https://www.getknit.dev/blog/sage-intacct-api-integration-guide-in-depth164165---166167## 6. Comparison matrix168169| Aspect | QBO | Xero | Odoo | SAP S/4HANA | NetSuite | Intacct |170|---|---|---|---|---|---|---|171| Protocol | REST/JSON | REST/JSON | XML-RPC/JSON-RPC (→ JSON-2) | OData/SOAP (+BAPI/IDoc) | REST/JSON (+SOAP) | XML + REST |172| Auth | OAuth 2.0 (1h tokens) | OAuth 2.0 + Xero-Tenant-Id | API key via RPC login | Comm. arrangement / OAuth | TBA (OAuth1) or OAuth 2.0 | 2-level XML creds / OAuth 2.0 |173| D/C convention | `PostingType` enum + unsigned amount | **signed** LineAmount (+debit / −credit) | separate `debit`/`credit` fields | item categories + D/C codes | separate `debit`/`credit` fields | GLENTRY type/amount |174| Idempotency | `requestid` query param | `Idempotency-Key` header | none (manual dedupe) | async msg IDs / none simple | `externalId` upsert | none simple |175| Reversal | delete or contra entry (manual) | VOID status | `_reverse_moves` (linked contra) | dedicated Reverse API (reason + ref) | native reversal date | contra entry |176| Rate limits | 500/min/realm | 60/min + 5k/day/tenant | server-dependent | gateway-dependent | account-tier concurrency | account-tier |177| Sandbox | free sandbox company | demo company | local/docker instance | trial/CAL complex | dev account (paid) | dev account |178179---180181## 7. Implications for AIR backend interface182183The common `Backend` interface (`capabilities() / compile() / post() / reverse()`) must accommodate:1841851. **Debit/credit representation is backend-specific.** AIR's `CompiledJournal` should use one canonical internal convention (explicit `side: Debit|Credit` + unsigned `Decimal` amount is the safest, lossless superset); each backend's `compile()` maps it to signed amounts (Xero), PostingType enums (QBO), or paired debit/credit fields (Odoo, NetSuite).1862. **`capabilities()` must be rich**, declaring at minimum: supported document types (raw journal entry vs invoice/payment documents), multicurrency support, tax-line handling (does the target recompute tax — QBO AST, SAP — or accept our tax lines — Xero, Odoo), analytic dimensions (Xero Tracking, Intacct dimensions, Odoo analytic), draft-then-post vs direct post, max lines/batch sizes, and **native idempotency mechanism**.1873. **Idempotency is an adapter concern with a common contract.** The interface should require: `post(payload, idempotency_key)` where the key is derived from the AIR event ID + policy version. Adapters map it to `Idempotency-Key` (Xero), `requestid` (QBO), `externalId` (NetSuite), or a search-before-create fallback (Odoo, Intacct). Every backend MUST persist the (AIR event → target document ID) mapping in the `PostingReceipt`.1884. **`reverse()` must support three strategies**, selected via capabilities: (a) native reversal API (SAP, NetSuite, Odoo `_reverse_moves`), (b) status change/void (Xero VOIDED), (c) synthesized contra entry (QBO, Intacct, generic CSV). Regardless of mechanism, AIR always records a reversal event in the provenance graph; deletion of posted entries is forbidden even where the API allows it (QBO delete).1895. **Concurrency/version tokens**: receipts must carry target-side version tokens (QBO `SyncToken`) so later reverse/update operations don't fail on optimistic locking.1906. **Rate limiting and retries belong in a shared posting runtime** (token bucket per realm/tenant, exponential backoff on 429/503, batch endpoints where available: QBO Batch ≤ ~30 ops, Xero batched PUT/POST) — not reimplemented per backend.1917. **Multi-tenancy is first-class:** a backend connection = (credentials, tenant/realm/db identifier, environment sandbox|prod). QBO realmId, Xero tenantId, Odoo db, SAP company code must be part of the connection config, never of the compiled journal.1928. **API versioning must be pinned per backend** (QBO `minorversion=75`, Odoo transport migration to JSON-2 by 2028, Intacct XML→REST) and surfaced in `capabilities()` so golden tests can be version-scoped.1939. **Validate-before-post** is natively supported by some targets (SAP `BAPI_ACC_DOCUMENT_CHECK`, Odoo draft state, Xero DRAFT status) — the interface should expose an optional `dry_run`/draft mode used by AIR's Approval pass before final posting.19410. **Ordering of backend development confirmed:** generic CSV → QBO (best docs, free sandbox, simple entity model) → Xero (cleanest idempotency) → Odoo (open source, self-hostable test instance) → SAP last (deep dedicated research required).195