# Lopifi APIs: Xero, QuickBooks Online, NetSuite

> Three general ledgers, three different opinions about what an invoice is. This document compares them object by object, so a component built on one does not quietly break on another.

Source: https://lopifi.com/docs/domain/12-ledger-apis
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Lopifi APIs: Xero, QuickBooks Online, NetSuite

Three general ledgers, three different opinions about what an invoice *is*. This document compares them object by object, so a component built on one does not quietly break on another.

Everything in this system eventually reads from somebody else's ledger. The components render a normalised model; the integration layer produces it. The gap between those two is where most accounting products fail — not in the UI, but in the assumption that "invoice" means the same thing in all three systems. It does not.

> **The rule this document exists to enforce:** normalise the *shape*, never the *semantics*. A field that exists in only one system must survive normalisation as an explicit, nullable, source-tagged field — never as a silently defaulted value.

Ordered by how much a difference costs you: model differences first, mechanics last.

---

## 1. The three APIs at a glance

|  | **Xero** | **QuickBooks Online** | **NetSuite** |
|---|---|---|---|
| Style | REST-ish, one resource per noun | REST + SQL-like query language | Three overlapping APIs (REST record, SuiteQL, SOAP — removed in 2028.2) + RESTlets |
| Base | `api.xero.com/api.xro/2.0/` | `quickbooks.api.intuit.com/v3/company/{realmId}/` | `{acct}.suitetalk.api.netsuite.com/services/rest/` |
| Payload | JSON or XML; arrays wrapped (`{"Invoices":[…]}`) | JSON; single object or `QueryResponse` | JSON; sublists nested or as sub-resources |
| Auth | OAuth 2.0 + `Xero-Tenant-Id` header | OAuth 2.0 + `realmId` in path | OAuth 2.0 (M2M cert or auth-code); OAuth 1.0 TBA for existing integrations only — no new ones from 2027.1 |
| Versioning | Path (`2.0`), rarely changes | `minorversion=` query param, changes often and matters | Dated WSDL / REST record schema per release (e.g. `2024.1`) |
| Reads a list | Returns **full records** | Returns **full records** | REST returns **ids only**; SuiteQL returns rows |
| Server-side logic on write | None | Minimal (tax engine) | **Extensive** — SuiteScript, SuiteFlow, custom validation |
| Multi-entity | One org per tenant | One company per realm | **Subsidiaries inside one account** (OneWorld) |
| Custom dimensions | 2 tracking categories, fixed | Class + Department, fixed | **Unlimited custom segments** |
| Webhooks | Invoices, contacts, credit notes (+ app subscriptions), id-only payload | Many entities, id-only payload | **None native** — script it yourself |

The single most consequential row is the last-but-two. Xero and QBO give you a fixed, small dimensional model you can hardcode into a UI. NetSuite gives you a user-defined one, which means any NetSuite-aware component must render dimensions **from metadata, not from a type literal**.

### The mental model of each

**Xero is a document store.** Nouns are flat and few. An invoice is an invoice; `Type` distinguishes `ACCREC` (sales) from `ACCPAY` (purchase). The GL is a *derived, read-only* artefact you fetch from a separate `Journals` endpoint — which, since 2 March 2026, only apps on the Advanced tier or above that have passed Xero's security assessment and had their use case approved may call. You cannot post arbitrary debits and credits except through `ManualJournals`.

**QBO is a transaction engine with a query language.** Nouns are many and specific — `Invoice`, `Bill`, `Purchase`, `SalesReceipt`, `CreditMemo`, `VendorCredit`, `Deposit`, `Transfer` are all separate types for what Xero models as two invoice types and a bank transaction. Lines are polymorphic via `DetailType`. Writes use optimistic locking (`SyncToken`) and are **destructive by default** unless you set `sparse: true`.

**NetSuite is an ERP with a database exposed.** Everything posting is a row in one `transaction` table with a `type` discriminator, plus sublists. Records carry customisations, approval workflows, and scripts that fire on write. The API is closer to "your write attempt enters a business process" than "your write updates a row".

---

## 2. Identity, tenancy, and the entity boundary

| Concept | Xero | QBO | NetSuite |
|---|---|---|---|
| Tenant unit | Organisation (`tenantId`, GUID) | Company (`realmId`, numeric string) | Account (`accountId`), containing many subsidiaries |
| Record id | GUID (`InvoiceID`) | Numeric string (`Id: "145"`), **unique per entity type only** | Numeric string (`id: "8412"`), unique per record type |
| Human number | `InvoiceNumber`, `ContactNumber` | `DocNumber` | `tranid` |
| Concurrency control | Last write wins | **`SyncToken`** — must round-trip, mismatch = 400 | Optimistic via record retrieval; scripts may reject |
| Consolidation | Not in product — one org, one currency base | Not in product | **Native**: subsidiary hierarchy, elimination, consolidated FX rates |

Two hard consequences.

**QBO ids are not globally unique.** `Invoice 145` and `Bill 145` both exist. Any cache, URL, or component key must be `{realmId}:{entityType}:{id}`. This bites the first time you build a global search.

**Multi-entity is a modelling difference, not a feature difference.** A Xero or QBO group is *n* disconnected tenants that you must consolidate yourself: your system owns the entity tree, the intercompany elimination, and the FX translation. A NetSuite OneWorld account already has all three, and every record carries a `subsidiary` field that must be respected on read *and* on write. See [multi-entity and FX](/docs/domain/09-multi-entity-and-fx) for what the UI has to do about it.

Practical rule: model the entity tree in your own schema regardless of source, and treat NetSuite's subsidiary as a mapping into it rather than as the tree itself. Otherwise a mixed-source group (two Xero orgs plus a NetSuite subsidiary — common in mid-market) has no coherent parent.

---

## 3. Chart of accounts

The one object all three broadly agree on, and still the one that most often breaks the P&L.

| Field | Xero `Account` | QBO `Account` | NetSuite `account` |
|---|---|---|---|
| Id | `AccountID` (GUID) | `Id` | `id` |
| Number | `Code` — string, **unique-enforced, required except on bank accounts, editable** | `AcctNum` — optional; absent unless account numbers enabled | `acctnumber` |
| Name | `Name` | `Name`, plus `FullyQualifiedName` (`"Parent:Child"`) | `acctname`, plus `displayname` |
| Type | `Type` (~30 values: `BANK`, `CURRENT`, `FIXED`, `REVENUE`, `DIRECTCOSTS`, `EXPENSE`, `EQUITY`…) | `AccountType` (~20) + `AccountSubType` (~80) | `accttype` (`Bank`, `AcctRec`, `AcctPay`, `Income`, `COGS`, `Expense`, `OthCurrAsset`, `FixedAsset`…) |
| Class | `Class`: `ASSET`/`LIABILITY`/`EQUITY`/`REVENUE`/`EXPENSE` | `Classification`: same five | Derived from `accttype` |
| Hierarchy | **None** — flat list | `SubAccount` + `ParentRef`, up to 5 levels | `parent` — arbitrary depth |
| Reserved accounts | `SystemAccount` (`DEBTORS`, `CREDITORS`, `BANKCURRENCYGAIN`, `RETAINEDEARNINGS`…) | Implied by `AccountType` (`Accounts Receivable`, `Accounts Payable`) | `AcctRec` / `AcctPay` types; `specialaccounttype` |
| Bank detail | `BankAccountNumber`, `CurrencyCode` | `AcctNum`, `CurrencyRef` | `currency`, bank fields on account |
| Balance | Not on the object | `CurrentBalance`, `CurrentBalanceWithSubAccounts` | Not on the record (query the GL) |
| Multi-entity | — | — | `subsidiary` list — an account can be restricted per subsidiary |
| Disable | `Status: ACTIVE/ARCHIVED` | `Active: true/false` | `isinactive` |

### What actually differs

**Hierarchy.** Xero has none. QBO has five levels and encodes the path in `FullyQualifiedName` with `:` as a separator — which means a colon in an account name is a parsing hazard. NetSuite is arbitrary depth. A COA tree component must therefore handle a flat list gracefully; don't build one that assumes a parent link exists.

**Account numbers are not reliable identifiers.** They are optional in QBO (`AcctNum` is only populated when the preference is on) and on Xero bank accounts — Xero enforces `Code` as unique and requires it on every other account — and editable in all three. Unique is not stable: a renumbered chart breaks every mapping keyed on the code. Key on the source id; display the number.

**Type granularity.** All three separate receivables from other current assets at the type level — QBO `AccountType` has `Accounts Receivable` vs `Other Current Asset`, NetSuite has `AcctRec` vs `OthCurrAsset`. QBO's `AccountSubType` (~80 values) gives the finest granularity of the three, below that. Xero's `Type` is coarser but includes `SystemAccount` for the accounts you must never let a user post to. Mapping all three onto one taxonomy loses information in exactly one direction: **map to the coarsest common type, and keep the source type verbatim alongside it.** Statement grouping should read your own type, tooltips should show theirs.

**Normal balance is not a field anywhere.** Derive it from class — asset and expense are debit-normal, liability, equity and revenue are credit-normal — then **invert it for contra accounts**: accumulated depreciation, allowance for doubtful accounts, treasury stock, sales returns and discounts. Take the contra flag from the source subtype (QBO `AccumulatedDepreciation`, `DiscountsRefundsGiven`…) or from your own mapping; never from class alone. See [the accounting model](/docs/domain/01-accounting-model) §2.

---

## 4. Parties: contacts, customers, vendors

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Model | **One `Contact`** for both roles | **Separate** `Customer` and `Vendor` | Separate `customer` and `vendor` records |
| Role flag | `IsCustomer` / `IsSupplier` — **read-only, derived from transaction history** | Implicit in the entity type | Implicit; `entity` is the shared abstraction |
| Uniqueness | `Name` **must be unique** across the org | `DisplayName` **must be unique across** `Customer`, `Vendor`, `Employee` (and Other Name) — a customer and a vendor cannot both be `"Acme Ltd"` | `entityid` unique |
| Hierarchy | `ContactGroups` (flat grouping, not parenting) | `Job` / sub-customer via `ParentRef`, `BillWithParent` | `parent` on customer; true hierarchy |
| Addresses | `Addresses[]` typed `STREET` / `POBOX` | `BillAddr`, `ShipAddr`, `OtherAddr` as distinct fields | `addressbook` sublist, many addresses, one default each |
| Contacts-of-contact | `ContactPersons[]` | Single name/email fields | `contact` records linked to entity |
| Defaults | `DefaultCurrency`, `SalesDefaultAccountCode`, `PurchasesDefaultAccountCode`, `PaymentTerms` | `CurrencyRef`, `SalesTermRef`, `PaymentMethodRef` | `currency`, `terms`, `receivablesaccount` |
| Tax id | `TaxNumber` | `PrimaryTaxIdentifier` / `TaxIdentifier` | `vatregnumber`, `taxidnum` |
| 1099 | — | `Vendor1099` boolean | `is1099eligible` |
| Balance | — | `Balance`, `BalanceWithJobs` on `Customer` | Query |

### The unification trap

Xero's single-contact model is genuinely different, not cosmetically different. One `Contact` record can be simultaneously a customer and a supplier — and often is, legitimately (a client who is also a subcontractor). QBO forces you to create two records, with **no link between them**, so the same real-world party appears twice with different ids and independent balances.

If your canonical model has a single `Party` with role flags — the Xero shape — then QBO import must decide whether to merge `Customer "Acme Ltd"` and `Vendor "Acme Ltd (V)"` into one party. QBO will not let both be called `"Acme Ltd"` — `DisplayName` is unique across customers, vendors and employees — so the same party usually arrives with a suffix on one side. **Do not auto-merge on name**, exact or fuzzy. Stripping `(V)` finds candidates, not matches, and wrong-merging corrupts both aging reports. Merge only on an explicit user action or a strong identifier (tax id), and keep the two source ids on the merged party.

If your canonical model has separate customers and suppliers — the QBO shape — then a Xero contact that is both must be projected into two, and any UI that navigates "from party to all transactions" has to reunite them.

Pick one, write it down, and make the roundtrip explicit. This is a decision, not a detail.

---

## 5. Sales invoices (AR)

| | Xero `Invoice` (`Type: ACCREC`) | QBO `Invoice` | NetSuite `invoice` |
|---|---|---|---|
| Discriminator | `Type` field on a shared object | Its own entity | `type: invoice` on `transaction` |
| Number | `InvoiceNumber` (unique, may be auto) | `DocNumber` (uniqueness is a preference) | `tranid` |
| Party | `Contact` | `CustomerRef` | `entity` |
| Dates | `Date`, `DueDate` | `TxnDate`, `DueDate` | `trandate`, `duedate` |
| Terms | `Contact.PaymentTerms` (no per-invoice term object) | `SalesTermRef` | `terms` |
| Status | `DRAFT`, `SUBMITTED`, `AUTHORISED`, `PAID`, `VOIDED`, `DELETED` | **No status field** — derive from `Balance`, `TotalAmt`, void state | `status` (`Open`, `Paid In Full`, `Pending Approval`…), `approvalstatus` |
| Open amount | `AmountDue` | `Balance` | `amountremaining` |
| Total | `Total`, `SubTotal`, `TotalTax` | `TotalAmt`, `TxnTaxDetail.TotalTax` | `total`, `taxtotal` |
| Tax mode | `LineAmountTypes`: `Exclusive`/`Inclusive`/`NoTax` | `GlobalTaxCalculation`: `TaxExcluded`/`TaxInclusive`/`NotApplicable` (non-US) | Per-line tax code; nexus-driven |
| Line target | **Account code, or item** | **`ItemRef` effectively required** | Item sublist (item required) or expense sublist |
| Line types | Uniform | `DetailType`: `SalesItemLineDetail`, `GroupLineDetail`, `DescriptionOnly`, `DiscountLine`, `SubTotalLine` | Sublist columns |
| Dimensions | `Tracking[]` per line (≤2) | `ClassRef` (line or header), `DepartmentRef` (header) | department / class / location / custom segments, header **and** line |
| FX | `CurrencyCode`, `CurrencyRate` | `CurrencyRef`, `ExchangeRate` | `currency`, `exchangerate` |
| Links | `Payments[]`, `CreditNotes[]`, `Prepayments[]`, `Overpayments[]` inline | `LinkedTxn[]` (payments, credit memos) | `links` / transaction relationships |
| Attachments | `HasAttachments`, sub-resource | `Attachable` entity, queried separately | `file` records via sublist |

### The three differences that actually cost you

**1. QBO has no invoice status.** There is no `Status` field. "Open / Paid / Overdue / Voided" is something you compute:

```ts
// QBO status is derived, never read.
function qboInvoiceStatus(inv: QboInvoice, today: Date): InvoiceStatus {
  if (inv.PrivateNote?.startsWith("Voided")) return "voided"; // voided invoices keep TotalAmt 0
  if (inv.TotalAmt === 0 && inv.Balance === 0) return "voided_or_zero";
  if (inv.Balance === 0) return "paid";
  if (inv.Balance < inv.TotalAmt) return "part_paid";
  if (inv.DueDate && inv.DueDate < today) return "overdue";
  return "open";
}
```
A void in QBO zeroes the amounts and keeps the record — so a naive "sum of `TotalAmt`" revenue figure silently loses voided invoices, correctly, but a naive "count of invoices" over-counts. Xero, by contrast, gives you `VOIDED` explicitly and preserves the original amounts on the record.

**2. QBO wants items; Xero wants accounts.** A Xero invoice line can post straight to `AccountCode: "200"`. A QBO invoice line posts to an `Item`, and the item carries `IncomeAccountRef`. The GL result is the same; the write path is not. To create an invoice in QBO from a source that has only an account, you must resolve or create a service item pointing at that account — and that item then appears in the customer's product list forever. Plan for a dedicated "integration" item set rather than creating one item per account on the fly.

**3. Dimension placement.** Xero puts tracking on the line, always. QBO puts `DepartmentRef` on the *header* and `ClassRef` on the line **or** header depending on a company preference. NetSuite allows both, with line overriding header. A dimensional P&L component must therefore accept per-line dimensions and know that some sources will hand it a header value applied to every line. Fan the header value down to the lines at normalisation time and record that you did — otherwise an edit UI will happily write per-line values back to a system that cannot store them.

### Same fact, three payloads

Selling one consulting day at 1,000, 20% VAT exclusive, tracked to department "R&D":

```jsonc
// Xero — account-based line, tracking on the line
{ "Type": "ACCREC", "Contact": { "ContactID": "…" },
  "Date": "2026-09-07", "DueDate": "2026-10-07",
  "LineAmountTypes": "Exclusive",
  "LineItems": [{
    "Description": "Consulting", "Quantity": 1, "UnitAmount": 1000,
    "AccountCode": "200", "TaxType": "OUTPUT2",
    "Tracking": [{ "Name": "Department", "Option": "R&D" }]
  }] }

// QBO (non-US company) — item-based line, DetailType discriminator,
// department on the header; TaxCodeRef is the company's numeric 20% code id
{ "CustomerRef": { "value": "58" },
  "TxnDate": "2026-09-07", "DueDate": "2026-10-07",
  "GlobalTaxCalculation": "TaxExcluded",
  "DepartmentRef": { "value": "3" },
  "Line": [{
    "DetailType": "SalesItemLineDetail", "Amount": 1000,
    "SalesItemLineDetail": {
      "ItemRef": { "value": "12" }, "Qty": 1, "UnitPrice": 1000,
      "TaxCodeRef": { "value": "8" }
    }
  }] }

// NetSuite — item sublist, segments per line, subsidiary on the header
{ "entity": { "id": "58" }, "trandate": "2026-09-07", "duedate": "2026-10-07",
  "subsidiary": { "id": "2" },
  "item": { "items": [{
    "item": { "id": "12" }, "quantity": 1, "rate": 1000,
    "taxcode": { "id": "5" }, "department": { "id": "7" },
    "cseg_product": { "id": "3" }
  }] } }
```

Three payloads, one journal: Dr AR 1,200 / Cr Revenue 1,000 / Cr VAT 200. The normalised model should be the journal-adjacent one; the payload differences belong entirely in the adapter.

---

## 6. Bills (AP)

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Object | `Invoice` with `Type: ACCPAY` | `Bill` (accrual) — **and** `Purchase` (cash/card, no AP) | `vendorbill` |
| Line to account | `AccountCode` on line | `AccountBasedExpenseLineDetail` | **`expense` sublist** |
| Line to item | `ItemCode` on line | `ItemBasedExpenseLineDetail` | **`item` sublist** |
| AP control account | Implicit `CREDITORS` system account | `APAccountRef` — **selectable** | `account` (AcctPay) |
| Vendor ref | `Contact` | `VendorRef` | `entity` |
| Billable to customer | `LinkedTransactions` — links an `ACCPAY`/`SPEND` line to a customer or `ACCREC` invoice (the billable-expenses API) | `BillableStatus`, `CustomerRef` on line | `isbillable`, `customer` on line |
| PO match | **Not exposed** — only `PurchaseOrder.Status: BILLED` | `LinkedTxn` to `PurchaseOrder` | Native 2- and 3-way match, receipt records |
| Approval | Status `SUBMITTED` (single soft gate) | None in the API | **`approvalstatus`, SuiteFlow multi-step** |
| Open amount | `AmountDue` | `Balance` | `amountremaining` |

### The differences

**Xero models a bill as an invoice.** Same endpoint, same object, `Type` flips the sign of the GL impact and swaps the control account. This is elegant and it is a trap for a component that renders "invoice" — an AR aging table fed from `/Invoices` without a `Type` filter will show your payables as receivables. **Always filter on `Type`, never assume the endpoint.**

**QBO splits accrual from cash.** A vendor bill you will pay later is a `Bill`. A card swipe or immediate payment is a `Purchase` (with `PaymentType: Cash | Check | CreditCard`) which posts **no AP** at all. Any "total spend with this vendor" figure that reads only `Bill` under-reports, often dramatically, because card spend lives in `Purchase`. Xero puts the equivalent in `BankTransactions` (`Type: SPEND`); NetSuite has `vendorbill` versus `check` / `expensereport` / card charge.

This is the single most common source of wrong vendor-spend numbers. See [procure to pay](/docs/domain/06-procure-to-pay) — the spend dashboard must aggregate across bill *and* non-bill spend objects, per source.

**NetSuite's two sublists are not interchangeable.** A vendor bill carries an `expense` sublist (account, amount, memo, segments) and an `item` sublist (item, quantity, rate, receipt matching). Item lines participate in three-way match and inventory; expense lines do not. Normalising both into one flat `lines[]` loses the distinction, which matters the moment someone asks why a bill won't match its receipt. Keep a `lineKind: "account" | "item"` discriminator in the canonical model — QBO needs it too, for its two `DetailType`s.

**Approval is real in NetSuite only.** Xero's `SUBMITTED` status is a flag, not a workflow. QBO's API has no approval concept. NetSuite has multi-step approval routing that your write may enter and not clear. A payables approval component that assumes it can approve via the API works on NetSuite and is decorative elsewhere — where the approval state must live in *your* system, not the ledger's.

---

## 7. Payments and allocation

| | Xero | QBO | NetSuite |
|---|---|---|---|
| AR payment | `Payment` (also `BatchPayment` for grouping) | `Payment` | `customerpayment` |
| AP payment | Same `Payment` object | **`BillPayment`** — separate entity | `vendorpayment` |
| Allocation model | **One payment record per invoice** — but `PUT /Payments` takes an array and `BatchPayments` settles many invoices in one call | One payment, **`Line[]` with `LinkedTxn` per invoice** | One payment, `apply` sublist per invoice |
| Partial payment | Amount < `AmountDue`; multiple payment records | `Line.Amount` per linked txn | `apply.amount` per line |
| Unapplied cash | `Overpayment` / `Prepayment` objects | `UnappliedAmt` on `Payment` | Unapplied on payment; `customerdeposit` |
| Bank account | `Account` (must have `EnablePaymentsToAccount`) | `DepositToAccountRef` | `account` |
| Payment method | `Reference` free text | `PaymentMethodRef` | `paymentmethod` |
| FX on settlement | `CurrencyRate` on payment; Xero computes realised gain | `ExchangeRate`; QBO computes | `exchangerate`; NetSuite computes, per book |

**The allocation shapes are genuinely incompatible.** Xero: N payment records, each pointing at one invoice. QBO and NetSuite: one payment record with N allocation lines. A canonical `Payment { lines: Allocation[] }` handles all three — Xero simply produces one-line payments, which you may group by `BatchPayment` id when present. Going the other way (canonical to Xero) means splitting one user action into N payment records. They can travel in one call — an array to `PUT /Payments`, or one `BatchPayments` — but with `summarizeErrors=false` each item succeeds or fails on its own: **check every allocation in the response, make the write replayable**, and never show the user "payment recorded" until all N succeed.

**Unapplied cash has three names.** Xero splits it into `Overpayment` (customer paid too much on an invoice) and `Prepayment` (customer paid before an invoice existed) — separate objects with their own endpoints, which many integrations forget entirely, then wonder why AR doesn't tie. QBO keeps it as `UnappliedAmt` on the payment; on the accrual basis that credit sits in Accounts Receivable, and the "Unapplied Cash Payment Income/Expense" lines appear only on cash-basis reports. NetSuite leaves it unapplied on the payment record and additionally has `customerdeposit`.

An AR aging component that only sums invoice balances will disagree with the ledger's own aging report by exactly the unapplied cash. Fetch it, show it as its own row, and tie. See [order to cash](/docs/domain/05-order-to-cash).

---

## 8. Credit notes, refunds, and the negative document

| Purpose | Xero | QBO | NetSuite |
|---|---|---|---|
| Reduce a customer invoice | `CreditNote` (`Type: ACCRECCREDIT`) | `CreditMemo` | `creditmemo` |
| Reduce a vendor bill | `CreditNote` (`Type: ACCPAYCREDIT`) | `VendorCredit` | `vendorcredit` |
| Cash back to customer | Allocate credit to `Payment`, or `Refund` on the credit note | `RefundReceipt` | `customerrefund` |
| Cash back from vendor | `Payment` against `ACCPAYCREDIT` | `Deposit` linked to `VendorCredit` | `vendorpayment` / deposit |
| Cash sale (no AR) | `BankTransaction` `Type: RECEIVE` | `SalesReceipt` | `cashsale` |

Two rules that hold across all three:

**Never render a credit note as a negative invoice.** It is a distinct document with its own number sequence, and finance teams reconcile them separately. A ledger table that merges them into one signed column destroys the audit path. See [the accounting model](/docs/domain/01-accounting-model) §1.

**Amounts are stored positive.** All three store credit notes with positive amounts and encode the direction in the document type. If your normalisation flips signs, do it in exactly one place and test the round trip — a double-negation bug here reads as a plausible number and survives review.

---

## 9. Journals and the general ledger

This is where the three diverge most and where reconciliation lives.

| | Xero | QBO | NetSuite |
|---|---|---|---|
| User-entered JE | `ManualJournals` | `JournalEntry` | `journalentry` |
| Full GL feed | **`Journals`** — read-only, every posting from every source. Since 2 Mar 2026: Advanced tier or above, security assessment and use-case approval | **No equivalent** — no GL export endpoint | `transaction` + `transactionline` via SuiteQL |
| Debit/credit shape | Journals: `NetAmount` signed (+ = debit). ManualJournals: `LineAmount` signed | `JournalEntryLineDetail.PostingType: "Debit" \| "Credit"` + positive `Amount` | `debit` and `credit` columns on the line |
| Pagination | `offset={JournalNumber}`, 100 per page, sequential | n/a | `limit`/`offset` on SuiteQL |
| Entity on a line | `Contact` not available on Journals lines | `Entity` ref (customer/vendor/employee) per line | `entity` per line |
| Dimensions | `TrackingCategories[]` per line | `ClassRef`, `DepartmentRef` per line | full segment set per line |
| Multi-book | — | — | `accountingbook` — the same txn posts differently per book |
| Reversal | Create the opposite manual journal | `JournalEntry` with `Adjustment` flag; reversal is manual | `reversaldate` on the record — native auto-reversal |

**Xero's `Journals` endpoint is the crown jewel and is easy to miss.** It emits the actual GL — every posting from invoices, payments, bills, bank transactions, manual journals — as immutable, sequentially-numbered journal records. Paginate by `offset` on `JournalNumber` and you get an append-only, gapless, perfectly replayable ledger feed. If you are building anything that reconciles or re-derives statements, this is the endpoint you build on, not `/Invoices` — **if you can get it.** Since Xero's app tiers took effect on 2 March 2026, `Journals` is a premium endpoint: the app must be on the Advanced tier or above, pass Xero's security assessment (initial and annual) and have its use case approved; a Starter-tier app gets a 401. `ManualJournals` stays open on every tier, but it only carries user-entered journals, not the postings behind invoices, bills and payments. Without `Journals` access, Xero falls back to the same options as QBO below: the Reports API, or reconstruction from the document endpoints. Check the [Xero developer pricing page](https://developer.xero.com/pricing) before designing on it.

**QBO has nothing equivalent, and this is the hardest structural gap in the three.** There is no endpoint that returns GL postings. Your options, in order of practicality:

1. **Reports API** — `GeneralLedger`, `TrialBalance`, `ProfitAndLoss` return report *rows*, not records: nested, presentation-shaped, with header/summary rows to walk. Correct totals, awkward parsing, no stable row ids.
2. **Reconstruct** from every transaction entity plus knowledge of QBO's posting rules. Full control, and you now own the correctness of QBO's tax and FX posting logic — which is a large, ongoing liability.
3. **`/cdc`** (change data capture) to keep entity mirrors fresh, and derive statements from the mirror.

Most teams end up with (1) for tie-out and (3) for detail. Whichever you choose, **display which one produced the number** — a P&L reconstructed from entities and a P&L from the Reports API will occasionally differ, and the user needs to know which they are looking at.

**NetSuite gives you SQL.** `transactionline` joined to `transaction` and `accountingtransaction` (or `transactionaccountingline` for the debit/credit amounts by book) is the GL. It is the most powerful of the three and the most demanding: you must know which line is the posting line, filter `posting = 'T'`, and pick the right accounting book.

```sql
-- NetSuite: GL lines for a period, primary book
SELECT t.trandate, t.tranid, t.type, a.acctnumber, a.acctname,
       tal.debit, tal.credit, tl.department, tl.class, tl.location, tl.memo
FROM   transaction t
JOIN   transactionline tl            ON tl.transaction = t.id
JOIN   transactionaccountingline tal ON tal.transaction = t.id
                                    AND tal.transactionline = tl.id
JOIN   account a                     ON a.id = tal.account
WHERE  tal.posting = 'T'
  AND  tal.accountingbook = 1
  AND  t.trandate BETWEEN TO_DATE('2026-08-01','YYYY-MM-DD')
                      AND TO_DATE('2026-08-31','YYYY-MM-DD')
```

Note the shape difference that survives into your model: Xero hands you one signed `NetAmount`, QBO a `PostingType` enum, NetSuite two columns. **Store two columns.** Converting signed to two columns is lossless; the reverse is not, once rounding and zero-amount lines are involved.

---

## 10. Items, products, and services

| | Xero `Item` | QBO `Item` | NetSuite item records |
|---|---|---|---|
| Types | One type; `IsTrackedAsInventory` flag | `Type`: `Inventory`, `NonInventory`, `Service`, `Group` (a bundle in the UI), `Category` | **Many record types**: `inventoryitem`, `noninventoryitem`, `serviceitem`, `assemblyitem`, `kititem`, `itemgroup`, `giftcertificateitem`… |
| Code | `Code` (unique) | `Name` / `Sku` | `itemid` |
| Sales side | `SalesDetails.{UnitPrice, AccountCode, TaxType}` | `IncomeAccountRef`, `UnitPrice` | `incomeaccount`, pricing sublists |
| Purchase side | `PurchaseDetails.{UnitPrice, COGSAccountCode}` | `ExpenseAccountRef`, `PurchaseCost` | `expenseaccount` / `cogsaccount` |
| Inventory | `InventoryAssetAccountCode`, `QuantityOnHand` | `AssetAccountRef`, `QtyOnHand`, `InvStartDate` | Full: locations, bins, lots, serials, costing methods |
| Hierarchy | None | `ParentRef` via `Category` items | `parent`, `matrix` items |

Xero treats items as an optional convenience — most Xero invoices have no item at all. QBO treats them as the required mechanism for sales lines. NetSuite treats them as a whole subsystem with its own costing engine.

For a components library the implication is narrow but real: **an invoice line editor cannot require an item, and cannot forbid one.** Item is nullable, and whether it is *required* is a property of the connected source, delivered as capability metadata — not a prop hardcoded per instance.

---

## 11. Tax

The messiest area, and the one where "just normalise it" fails hardest.

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Object | `TaxRate` (with `TaxComponents[]` for compound rates) | `TaxCode` → `TaxRate` (many-to-many via `TaxRateDetail`) | `taxcode`, `taxgroup`, `taxtype`, nexus |
| Line reference | `TaxType` (the rate's code, e.g. `OUTPUT2`) | `TaxCodeRef` | `taxcode` |
| Inclusive/exclusive | `LineAmountTypes` on the document | `GlobalTaxCalculation` on the document (non-US only) | Per-nexus configuration |
| Can you create rates? | Yes | **US automated sales tax: no** — rates are Intuit-managed. Non-US: yes | Yes |
| Computed tax | `TotalTax`, per-line `TaxAmount` | `TxnTaxDetail.{TotalTax, TaxLine[]}` | `taxtotal`, per-line |
| Overriding computed tax | Per-line `TaxAmount` override allowed | `TxnTaxDetail` override, honoured inconsistently under AST | Line-level override |
| Jurisdictions | Flat rate list | AST resolves by address | **Nexus model** — jurisdiction by subsidiary and address |

Three things to internalise:

**QBO US is a different product from QBO non-US.** Under Automated Sales Tax you cannot create tax rates via the API, and the tax on a transaction is computed by Intuit from the addresses. Sending your own tax amounts may be silently overridden. Any tax UI must therefore be read-mostly for US QBO and editable elsewhere — again, capability metadata, not a hardcoded branch.

**Compound and multi-component tax survives only as a list.** Xero's `TaxComponents[]` and QBO's `TaxLine[]` both express "this 13% is 5% federal plus 8% provincial". A canonical `taxAmount: Money` scalar throws that away, and it is exactly what a tax return needs. Model tax as `{ code, name, components: [{name, rate, amount}], total }`.

**Never re-derive tax from rate × base.** Rounding rules differ per system and per jurisdiction (per-line versus per-document, half-up versus banker's). Store the tax amount the source computed, and if you display a rate, display it as *the source's* rate. See [numerics](/docs/foundations/06-numerics).

---

## 12. Dimensions: tracking, class, department, segment

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Mechanism | `TrackingCategory` with `TrackingOption[]` | `Class`, `Department` entities | `department`, `class`, `location`, `subsidiary`, **`custom segments`** |
| How many | **Maximum 2 active** | 2 (Class + Department), gated by preference | 4 built-in + unlimited custom |
| Naming | User-named categories ("Region", "Team") | Fixed names; UI relabels Department as "Location" | User-named custom segments |
| Hierarchy | Flat options | `SubClass` / `ParentRef` | Hierarchical |
| Where applied | Invoice/bill/journal **lines** | Class on line or header (preference); Department on **header** | Header and line, line wins |
| Constraint | — | — | Segments can be **required, filtered by subsidiary, and validated by script** |
| API surface | `/TrackingCategories` | `/query?query=select * from Class` | `customsegment` metadata + `cseg_*` fields on records |

This table is the reason a dimensional filter bar cannot be a static component. Xero gives you at most two, named by the user. NetSuite gives you an arbitrary set whose *names and field keys are discovered at runtime* (`cseg_product`, `cseg_channel`, …). Any component that filters or groups by dimension must take:

```ts
type Dimension = {
  key: string;            // "tracking.0" | "class" | "department" | "cseg_product"
  label: string;          // the user's name for it, from the source
  options: DimensionOption[];  // may be hierarchical
  appliesTo: "line" | "header" | "both";
  required: boolean;
};
```

…as data, and render *n* of them. Hardcoding "Class" and "Department" columns produces a component that is wrong on two of the three systems.

Note also that **Xero's two-category limit is a hard product limit**, not a plan limit. A client who wants department *and* project *and* location on Xero cannot have it, and the honest UI answer is to say so rather than to offer a control that cannot be saved.

---

## 13. Periods, locking, and the close

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Period object | None — an org has a `PeriodLockDate` and `EndOfYearLockDate` | None — `Preferences.AccountingInfoPrefs.BookCloseDate` | **`accountingperiod` record** — first class, per year/quarter/month |
| Granularity | Two org-wide dates: `PeriodLockDate` blocks everyone but advisers, `EndOfYearLockDate` blocks everyone | One close date, optionally password-protected | Per period, **per subsidiary**, and separately for AP/AR/GL |
| Enforced on API writes? | Depends on the connected user's role — before `EndOfYearLockDate` always fails; before `PeriodLockDate` fails unless that user is an adviser | Partially — close date is advisory for some paths | Yes, strictly, per book and per subsidiary |
| Reopen | Move the date | Move the date | Explicit period reopen, audited |
| Adjusting entries | Ordinary manual journals | `JournalEntry.Adjustment: true` | Period-specific adjustment flag, `isadjust` |

NetSuite is the only one with a real period model. Xero and QBO have *dates* — two in Xero, whose effect on an API write depends on the connected user's role — which means "period" in your system is your own construct that you must reconcile against a single cut-off. See [periods and close](/docs/domain/02-periods-and-close) — the close checklist component should treat period state as owned by your system, with the ledger's lock date as one input signal rather than the source of truth.

Practical consequence for write paths: **always check the lock date before offering an edit.** A disabled control with "Period locked 31 Aug" is correct UI; a 400 from Xero surfaced as a red toast after the user typed for a minute is not.

---

## 14. Attachments, memos, and the audit trail

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Attachment model | `Attachments` sub-resource on most endpoints | **`Attachable`** entity with `AttachableRef[]` pointing back at txns | `file` record in the file cabinet, linked via sublist |
| Upload | `PUT/POST /{Endpoint}/{Guid}/Attachments/{FileName}`, raw body | Multipart `/upload` | `file` record with base64 `contents`, then attach |
| Discovery | `HasAttachments` flag on the record | Query `Attachable` by `AttachableRef` | Sublist on the record |
| Internal notes | `HistoryAndNotes` endpoint | `PrivateNote` field | `memo`, `usernotes`, system notes |
| Change history | `HistoryAndNotes` (read + append note) | **None via API** | **`systemnote`** — full field-level audit, queryable |
| Who/when | `UpdatedDateUTC` | `MetaData.{CreateTime, LastUpdatedTime}` — **no user** | `lastmodifiedby`, `owner`, systemnote per field |

**Only NetSuite gives you a real audit trail through the API.** Xero gives you a history feed per record; QBO gives you timestamps and no actor at all. Any audit or evidence component (see [controls and audit](/docs/domain/08-controls-and-audit)) must therefore build its own trail over *your* actions and be explicit that ledger-side changes made by a human in the ledger's own UI may be invisible to you — on QBO, they are.

Attachment retrieval is the most under-estimated cost in a ledger integration: QBO requires a second query per transaction unless you batch, and files are the largest thing you will sync.

---

## 15. Reports

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Endpoint | `/Reports/{ProfitAndLoss,BalanceSheet,TrialBalance,AgedReceivablesByContact,BankSummary,…}` | `/reports/{ProfitAndLoss,BalanceSheet,GeneralLedger,TrialBalance,AgedReceivables,…}` | Saved searches, SuiteAnalytics, SuiteQL |
| Shape | `Rows[]` with `RowType: Header \| Section \| Row \| SummaryRow` | `Rows` / `Columns` nested, `ColData[]` | Rows you define |
| Stable ids | Some rows carry `AccountID` in cells | `ColData.id` sometimes present | You control the query |
| Comparatives | `periods`, `timeframe` params | `date_macro`, `summarize_column_by` | Query it |
| Drill-down | Not linked | Not linked | Native |

Report endpoints are presentation output, not data. They are the right tool for **tie-out** — proving your reconstructed P&L equals the ledger's own — and the wrong tool for populating an interactive statement component, because the rows have no reliable identity to drill from.

The pattern that works: build statements from GL data (or entity mirrors), then run the report endpoint on a schedule and assert equality. Surface a variance if they disagree. That check is worth more to a controller than any chart on the page. See [statement semantics](/docs/domain/03-statement-semantics).

---

## 16. Mechanics: reading, writing, limits

### Reading and pagination

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Page mechanism | `page` + `pageSize` (newer endpoints); `Journals` uses `offset` | `STARTPOSITION` + `MAXRESULTS` in the query string | `limit` + `offset`, `hasMore` / `links` |
| Page size | 100 default, up to 1000 on endpoints that support `pageSize` | Up to 1000 rows | Up to 1000 |
| Filtering | `where=` expression, `order=`; string ops are limited | SQL-like `WHERE`, `ORDER BY`, no `JOIN`, no aggregate functions | SuiteQL: near-full SQL incl. joins and aggregates |
| List returns | Full records | Full records | **REST: ids + links only.** Use SuiteQL for rows |
| Delta sync | `If-Modified-Since` header (UTC) | `WHERE MetaData.LastUpdatedTime > '…'`, or `/cdc` | `lastmodifieddate` in SuiteQL |
| Deletes visible? | Status becomes `DELETED`/`VOIDED` — still returned | `/cdc` reports deletes; queries do not | Hard deletes are **invisible** — reconcile by id set, or use `deletedrecord` |

The NetSuite REST list behaviour deserves emphasis: `GET /record/v1/invoice` returns a page of `{id, links}` objects, not invoices. Fetching 5,000 invoices that way is 5,001 calls against a concurrency limit in the single digits. **Use SuiteQL for bulk reads.** This is not an optimisation; it is the difference between a sync that finishes and one that does not.

Deletion visibility is the quiet data-integrity issue. Xero never truly deletes — it flips status, so a delta sync sees it. QBO surfaces deletes only through `/cdc` (with a limited lookback window), so a poll-only integration accumulates ghost records. NetSuite hard-deletes, and a deleted record simply stops appearing. Any full-refresh strategy must reconcile the id set, not just apply changes.

### Writing

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Update semantics | POST the object; supplied fields update, arrays replace wholesale | **Full update replaces the entity** unless `"sparse": true` | PATCH updates supplied fields |
| Concurrency | Last write wins | `SyncToken` must match, else 400 | Record-level; scripts may reject |
| Idempotency | `Idempotency-Key` header (supported on write endpoints) | **`requestid`** query parameter — unique per company, up to 50 characters (36 in a batch); a retry with the same id returns the original response instead of creating a duplicate | None — dedupe yourself |
| Batch | Arrays of up to 50–60 objects per POST on most endpoints | `/batch`, up to 30 operations | Not on REST record; SuiteScript for bulk |
| Partial failure | `SummarizeErrors=false` returns per-object validation results | Per-operation results in the batch response | Per-call |
| Delete | Status change (`VOIDED`, `DELETED`) | `POST ?operation=delete` or `?operation=void` | `DELETE` (real) where permitted |

**QBO's full-replace update is the most dangerous single behaviour in these three APIs.** POST an `Invoice` with only the fields you wanted to change and you will delete every line on it. Read the current record, mutate, write back the whole thing with the current `SyncToken` — or set `sparse: true` and accept its narrower semantics. Never construct a QBO update payload from scratch.

### Rate limits and concurrency

*Approximate, and the first thing vendors change — verify against current documentation before relying on a number.*

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Per tenant/company | ~60 calls/minute, ~5,000/day (1,000/day on the Starter tier since 2 Mar 2026) | ~500 requests/minute per realm; batch: 40/min per realm and app | Governed by **concurrency**, not rate — base 5 (Standard), 15 (Premium), 20 (Enterprise/Ultimate) concurrent per account, plus 10 per SuiteCloud Plus licence |
| Per app | ~10,000 calls/minute across tenants | Limits are per realm and app — no separate app-wide cap published | — |
| Concurrency | ~5 concurrent per tenant | ~10 concurrent per realm and app | Shared across SOAP, REST and RESTlets; RESTlets additionally consume SuiteScript usage units |
| On breach | 429 + `Retry-After` | 429 (`ThrottleExceeded`) — wait 60 seconds | 429 / `SSS_REQUEST_LIMIT_EXCEEDED` |

The shapes differ enough to change your architecture. Xero's daily cap means a full history backfill of a large org is a **multi-day** job that must be resumable. NetSuite's concurrency governance means parallelism is capped hard and queueing must be centralised per account — two workers each "politely" using three connections will trip a five-connection limit. QBO is the most forgiving of the three.

Build one token-bucket limiter per `(source, tenant)` and make every call pass through it, including retries. Retry 429 and 5xx with exponential backoff and jitter; never retry a 400.

### Webhooks

| | Xero | QBO | NetSuite |
|---|---|---|---|
| Available | Invoices, Contacts, Credit notes (added March 2026), app Subscriptions | Most entities | **None** |
| Payload | Ids + event type, **no data** | Ids + operation, **no data** | — |
| Signature | HMAC-SHA256, `x-xero-signature` | HMAC-SHA256, `intuit-signature` | — |
| Validation | Must return 2xx within seconds or the endpoint is disabled ("intent to receive") | Retries with backoff | — |

Both are notifications, not data feeds: you receive "invoice X changed" and must fetch. Both require the signature check before you trust anything — and Xero will disable an endpoint that fails its validation handshake, so the verification path has to work before any business logic does.

NetSuite has no webhooks. The substitutes are a User Event SuiteScript that calls out on save, or scheduled polling on `lastmodifieddate`. Most integrations poll, which makes freshness a per-source property your UI should be honest about: "Synced 4 minutes ago" is a real component, not decoration.

### Dates, numbers, currency

- **Dates.** Xero JSON returns legacy `.NET` epoch strings (`"/Date(1749254400000+0000)/"`) on some endpoints alongside ISO-8601 on others — parse both. QBO uses ISO-8601 dates for transactions and full timestamps in `MetaData`. NetSuite dates are rendered **in the account's or user's timezone and preferred format**, which is the classic source of off-by-one-day errors at period boundaries. Normalise every date to a plain calendar date (no time, no zone) for transaction dates, and to UTC instants for audit timestamps. See [date handling in numerics](/docs/foundations/06-numerics).
- **Money.** All three send JSON numbers. Parse to a decimal type immediately — never let a float touch a monetary value — and carry the currency code with it.
- **FX.** Each transaction carries its own rate and each system computes realised/unrealised gains its own way. Do not recompute the base-currency amount from `amount × rate`; store the source's base amount when it provides one. NetSuite additionally has per-book and consolidated rates, so "the" base amount is not a single number.

---

## 17. The canonical model

The model these components render. Every adapter produces this; nothing above this line appears in a component prop.

```ts
type SourceSystem = "xero" | "qbo" | "netsuite";

/** Every normalised record keeps its provenance. Non-negotiable. */
type SourceRef = {
  system: SourceSystem;
  tenantId: string;          // Xero tenantId | QBO realmId | NetSuite accountId
  entityType: string;        // the source's own noun, verbatim
  id: string;
  /** The untouched source payload, for the "why is this number this number" question. */
  raw?: unknown;
  fetchedAt: string;         // ISO instant
};

type Money = { amount: string; currency: string };  // decimal string, never number

type Party = {
  id: string;
  name: string;
  roles: ("customer" | "supplier")[];   // Xero: both on one record. QBO: two records merged, or not.
  sources: SourceRef[];                  // plural — a merged party has two
  taxNumber?: string;
  defaultCurrency?: string;
};

type LineDimension = { key: string; optionId: string; label: string };

type DocumentLine = {
  id: string;
  kind: "account" | "item";        // NetSuite expense vs item sublist; QBO's two DetailTypes
  description?: string;
  quantity?: string;
  unitAmount?: Money;
  accountId?: string;              // resolved even when the source only had an item
  itemId?: string;
  net: Money;
  tax: TaxBreakdown;               // never a bare scalar — see §11
  dimensions: LineDimension[];     // fanned down from the header where the source put it there
  source: SourceRef;
};

type LedgerDocument = {
  id: string;
  /** The discriminator all three encode differently. Ours is explicit. */
  type: "sales_invoice" | "purchase_bill" | "sales_credit" | "purchase_credit"
      | "cash_sale" | "cash_purchase" | "journal";
  number?: string;
  party?: Party;
  issueDate: string;               // calendar date
  dueDate?: string;
  currency: string;
  exchangeRate?: string;
  total: Money;
  totalBase?: Money;               // from the source, not recomputed
  outstanding: Money;
  /** Ours, derived where the source has none (QBO). Never read from the source directly. */
  status: "draft" | "awaiting_approval" | "open" | "part_paid" | "paid" | "voided" | "deleted";
  sourceStatus?: string;           // the source's own word, for the tooltip
  lines: DocumentLine[];
  allocations: Allocation[];
  periodId?: string;
  source: SourceRef;
};

/** What the connected source can actually do. Components read this, not a hardcoded branch. */
type SourceCapabilities = {
  dimensions: Dimension[];                 // 0, 1, 2, or many — see §12
  lineTarget: "account" | "item" | "both";
  canCreateTaxRates: boolean;              // false for QBO US under AST
  hasApprovalWorkflow: boolean;            // NetSuite only
  hasGlFeed: boolean;                      // Xero Journals (Advanced tier + approval), NetSuite SuiteQL; false for QBO
  hasAuditTrail: boolean;                  // NetSuite only
  periodLock: { date?: string; perSubsidiary: boolean };
  writeable: boolean;
};
```

Two properties of this model are the whole point.

**`SourceRef` on every record, including `raw`.** The question "why does this number look like that" is asked constantly in accounting software, and the only satisfying answer is the source payload. Components should be able to surface it in a detail drawer.

**`SourceCapabilities` as data.** Every difference in this document that changes what a user can *do* resolves to a capability flag. A component that branches on `source.system === "xero"` will be wrong the moment a fourth ledger appears — and there is always a fourth ledger.

---

## 18. What this changes in the UI

The differences that reach the surface, and what the component must do about them.

| Difference | Consequence for the component |
|---|---|
| QBO has no invoice status | Status is derived. Show the derivation on hover; never claim a status the source didn't assert. |
| Xero AR and AP share an endpoint | Every list is filtered by `type`. An unfiltered ledger table is a bug, not a view. |
| QBO splits `Bill` from `Purchase` | Vendor spend aggregates several document types. Label the total precisely: "billed" ≠ "spent". |
| Dimensions are 0–2 in Xero, *n* in NetSuite | Dimension controls render from metadata. No hardcoded Class/Department columns. |
| No GL feed in QBO | Statement components declare their provenance: reconstructed or reported. Show the tie-out variance. |
| Unapplied cash is a separate object in Xero | AR aging shows an explicit unapplied row and ties to the ledger's own aging. |
| Approval exists only in NetSuite | Approval state lives in our system; the ledger's approval is mirrored where it exists, and the control is hidden where it does not. |
| Audit trail exists only in NetSuite | Never claim "complete history". Say what we can see and when we last looked. |
| Period lock is a date, not a period, in two of three | Edit affordances are disabled before the lock date, with the date stated in the disabled state. |
| Xero daily call cap; NetSuite concurrency cap | Sync status is a first-class UI object: last synced, in progress, throttled, resumable. Never a spinner with no end. |
| Items required on QBO sales lines | The line editor's item field is required-per-capability, never required-per-component. |
| QBO full-replace writes | Edit flows read-then-write the whole record; optimistic UI must handle a `SyncToken` conflict as a real, user-visible outcome. |

---

## 19. Naming crosswalk

One row per concept, for the moment you are reading someone else's field name and need to know what it is.

| Concept | Xero | QBO | NetSuite |
|---|---|---|---|
| Sales invoice | `Invoice` (`ACCREC`) | `Invoice` | `invoice` |
| Vendor bill | `Invoice` (`ACCPAY`) | `Bill` | `vendorbill` |
| Cash sale | `BankTransaction` (`RECEIVE`) | `SalesReceipt` | `cashsale` |
| Card/cash purchase | `BankTransaction` (`SPEND`) | `Purchase` | `check` / `expensereport` |
| Customer credit | `CreditNote` (`ACCRECCREDIT`) | `CreditMemo` | `creditmemo` |
| Vendor credit | `CreditNote` (`ACCPAYCREDIT`) | `VendorCredit` | `vendorcredit` |
| Customer receipt | `Payment` | `Payment` | `customerpayment` |
| Supplier payment | `Payment` | `BillPayment` | `vendorpayment` |
| Unapplied receipt | `Overpayment` / `Prepayment` | `Payment.UnappliedAmt` | unapplied / `customerdeposit` |
| Manual journal | `ManualJournal` | `JournalEntry` | `journalentry` |
| GL posting | `Journal` (read-only) | — | `transactionaccountingline` |
| Account | `Account` | `Account` | `account` |
| Customer | `Contact` (`IsCustomer`) | `Customer` | `customer` |
| Supplier | `Contact` (`IsSupplier`) | `Vendor` | `vendor` |
| Product/service | `Item` | `Item` | `inventoryitem` et al. |
| Tax rate | `TaxRate` | `TaxCode` / `TaxRate` | `taxcode` / `taxgroup` |
| Dimension | `TrackingCategory` | `Class` / `Department` | `department` / `class` / `location` / `cseg_*` |
| Entity/company | `Organisation` | `CompanyInfo` | `subsidiary` |
| Period lock | `PeriodLockDate` | `BookCloseDate` | `accountingperiod` |
| Attachment | `Attachment` | `Attachable` | `file` |
| Bank feed | `BankTransaction` / `BankStatement` | `Purchase`/`Deposit` + banking feed | `transaction` + bank import |
| Purchase order | `PurchaseOrder` | `PurchaseOrder` | `purchaseorder` |
| Quote/estimate | `Quote` | `Estimate` | `estimate` |
| Recurring | `RepeatingInvoice` | `RecurringTransaction` | recurring/memorised txn |

---

## 20. Using this document

**Volatile facts are marked as approximate.** Rate limits, page sizes, minor versions, and QBO's automated-sales-tax behaviour change without much notice. Treat every number in §16 as "check this", and treat the *shapes* — which are stable — as the durable content.

**When a difference is discovered that is not in here, it belongs in here.** The failure mode this document prevents is a per-integration workaround that nobody else learns about. Every row above was, at some point, somebody's surprise.

**Adapters own the difference; components never see it.** If a component prop needs to know which ledger it is talking to, either the canonical model in §17 is missing a field or `SourceCapabilities` is missing a flag. Fix it there.

Related: [the accounting model](/docs/domain/01-accounting-model) · [periods and close](/docs/domain/02-periods-and-close) · [order to cash](/docs/domain/05-order-to-cash) · [procure to pay](/docs/domain/06-procure-to-pay) · [multi-entity and FX](/docs/domain/09-multi-entity-and-fx) · [controls and audit](/docs/domain/08-controls-and-audit) · [lexicon](/docs/domain/11-lexicon)
