# Sales invoices

> Three payloads, one journal. Xero posts a line to an account; QuickBooks posts it to an item that points at an account; NetSuite posts it to an item sublist with a segment set. And QuickBooks has no status field at all.

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

---

**What does an accounts-receivable invoice look like in each API?** Filter Xero invoices by Type on every read, derive QBO status rather than reading it, and fan header-level dimensions down to the lines at normalisation.

## Divergences between Xero, QuickBooks Online and NetSuite

### QuickBooks has no invoice status

There is no Status field on a QBO Invoice: open, paid, part-paid and overdue are computed from Balance, TotalAmt and DueDate. A voided invoice keeps its record and zeroes its amounts, so counts over-report while sums do not.

**Handling:** Derive it in one function at normalisation, store it, and mark it derived. Never present it as the source's own.

### Xero serves receivables and payables from one endpoint

GET /Invoices returns ACCREC and ACCPAY documents together. An aging table fed from that endpoint without a Type filter shows payables as receivables, with plausible-looking numbers and the wrong sign on the balance sheet.

**Handling:** Every Xero invoice read carries a Type filter. Make it a required parameter in the adapter's function signature so it cannot be forgotten.

### QuickBooks wants an item where Xero wants an account

You cannot post a QBO invoice line directly to an income account — the line references an Item, and the item carries the account. Creating invoices from a source that only knows accounts means resolving or creating a service item per account, and that item then lives in the customer's product list permanently.

**Handling:** Maintain a small, explicitly named set of integration items rather than creating one per account on the fly. Show the user which item a line will use before the first write.

### Dimensions sit at different levels

Xero always puts tracking on the line. QBO puts Department on the header and Class on either, depending on a company preference. NetSuite allows both and lets the line override the header.

**Handling:** Fan header values down to every line at normalisation and record that you did, so an edit UI does not write per-line values back to a system that cannot store them.

## Canonical shape

```ts
type LedgerDocument = {
  id: string;
  type: "sales_invoice" | "purchase_bill" | "sales_credit" | "purchase_credit"
      | "cash_sale" | "cash_purchase" | "journal";
  number?: string;
  party?: Party;
  issueDate: string;          // calendar date, no time, no zone
  dueDate?: string;
  currency: string;
  exchangeRate?: string;
  total: Money;
  totalBase?: Money;          // from the source — never recomputed as amount × rate
  outstanding: Money;
  /** Ours. Derived where the source has none. */
  status: "draft" | "awaiting_approval" | "open" | "part_paid" | "paid" | "voided" | "voided_or_zero" | "deleted";
  statusIsDerived: boolean;
  sourceStatus?: string;      // theirs, for the tooltip
  lines: DocumentLine[];
  allocations: Allocation[];
  periodId?: string;
  source: SourceRef;
};

/** QBO status is computed, never read. One function, one place. */
function qboInvoiceStatus(inv: QboInvoice, today: string): DocumentStatus {
  if (inv.PrivateNote?.startsWith("Voided")) return "voided"; // voided invoices keep TotalAmt 0
  if (inv.TotalAmt === 0 && inv.Balance === 0) return "voided_or_zero"; // a legitimate $0 invoice looks the same
  if (inv.Balance === 0) return "paid";
  if (Number(inv.Balance) < Number(inv.TotalAmt)) return "part_paid";
  if (inv.DueDate && inv.DueDate < today) return "open";   // overdue is a view, not a status
  return "open";
}
```
