Xero — ontology map
What Xero gives Kula Intelligence, what it cannot give, and why the derived (API) and gl_import (CSV) feeds must never be summed together.
Source id: com.xero (one id for both feeds — see below) · Role: general ledger / accounting — a satellite source, not a system of record for the studio floor · Access: Xero Custom Connection (OAuth2 client-credentials, studio's own Xero subscription pays for it), AU/NZ/UK/US only, plus an optional GL CSV upload for studios without one
Xero knows the money side of the business as Xero itself understands it — accounts, invoices, bills, payments, bank transactions — and, for studios that upload a GL export, the actual posted ledger including payroll and depreciation. It knows nothing about classes, attendance, instructors, or members as people; a Xero-only studio has a GL and no operations, and its contacts are counterparties (customers, suppliers, contractor companies), not people.member rows.
The single most important thing to understand about Xero here is the two-feed model.
The two feeds: derived vs gl_import
Feed
Where it comes from
origin
What it is
derived
The live Custom Connection, synthesised from source documents (invoices, bills, payments, bank transactions, manual journals)
derived
P&L lines only — a live, incremental, API-shaped view of revenue and expense
gl_import
An operator-uploaded Xero Journal Report / Account Transactions / Trial Balance CSV
gl_import
The full posted GL for the uploaded period — including payroll and depreciation journals the API can never see
Both feeds write to the same source = 'com.xero' — there is no com.xero.gl split. The discriminator is the origin column on accounting.entry (and, for accounting.journal, whether source_external_id carries the glimport: prefix — see Counting traps). Never read the base tables directly; accounting.entry_guarded and accounting.journal_guarded resolve the two feeds to exactly one per (period_year, period_month) — gl_import wins for any period it covers, derived fills the rest. Summing both for a period double-counts.
A studio can run either feed alone, or both (CSV to backfill history and capture payroll/depreciation; API for near-real-time coverage of what it can see).
Coverage at a glance
Organisation
(read at connect time only — org name, currency, country)
one per tenant
full pull, not stored canonically
Contacts
people.company (+ accounting.contact_link once resolved)
one per contact
incremental (UpdatedDateUTC watermark)
Accounts
accounting.account
one per chart-of-accounts code
incremental
TrackingCategories
accounting.tracking_category
one per category+options
full pull (unpaged, no watermark)
Items
raw only (ingest.raw_record, poll.items) — not mapped canonically
—
incremental
TaxRates
raw only — not mapped canonically
—
full pull (unpaged, no UpdatedDateUTC)
Invoices
accounting.journal (header) + accounting.entry (lines)
one journal + N entry lines
date-windowed on Date, journal_type='invoice'
CreditNotes
accounting.journal + accounting.entry
one journal + N lines
date-windowed, folds into invoice-shaped entries
Payments
accounting.journal header only
one journal, no entry lines
date-windowed, journal_type='payment'
Overpayments
accounting.journal header only
one journal, no entry lines
date-windowed, journal_type='overpayment'
Prepayments
accounting.journal header only
one journal, no entry lines
date-windowed, journal_type='prepayment'
BankTransactions
accounting.journal + accounting.entry
one journal + N lines
date-windowed, journal_type='bank_transaction'
BankTransfers
accounting.journal header only
one journal, no entry lines
date-windowed, journal_type='bank_transfer'
ManualJournals
accounting.journal + accounting.entry
one journal + N lines
date-windowed, journal_type='manual'
GL CSV import
accounting.entry + accounting.journal, origin='gl_import'
whatever the export contains, including payroll/depreciation
as of the last upload — operator-driven, not scheduled
If a row isn't in this table, we don't have it — see What we cannot get.
Every line carries tracking (JSONB) when the source document has Xero Tracking Categories applied (e.g. [{"name":"Location","option":"Downtown"}]) — join to accounting.tracking_category for the category's full option set.
What we cannot get
Tier/vendor-gated:
Posted GL (
/Journals) is Xero Advanced-tier only (roughly $895–1,445/month plus a security assessment), and post-April-2026 Custom Connection scopes exclude journals entirely. We cannot read Xero's own journal feed — instead we synthesise P&L entries from source documents (origin='derived').Payroll journals are invisible to accounting API scopes — Xero Payroll posts its own system journals that the Accounting API never exposes. Manual-journal payroll (an operator posting payroll by hand) IS captured; the Xero Payroll API itself is deliberately not wired. GL CSV upload covers the rest.
Depreciation and other fixed-asset system journals — same class of gap as payroll.
/Journalsor a GL CSV import are the only paths.Unreconciled bank statement lines — there is no public API for raw bank feed data (Bank Feeds is partner-restricted).
BankTransactionsare coded/reconciled entries only, so cash here is "as coded in Xero," never "as sitting at the bank."Custom Connections are AU/NZ/UK/US only, and the studio pays for it directly (roughly $10/month on their own Xero subscription). Studios outside those regions are GL-CSV-only.
5,000 API calls/day and 60/minute per tenant — a large org's first backfill can legitimately take several days; see Counting traps.
No webhooks — this is a poll-based ingestor. Coverage is as fresh as the last sweep or on-demand sync, never real-time.
Product doesn't do it:
Zero
bookings.*coverage. No members, classes, attendance, or appointments. Xero doesn't know what a class is.No member-level revenue. Xero contacts are settlement-level — a Stripe payout landing as one lump deposit is one Xero contact, not the members behind it. Member money lives on the Stripe/MBO/Wix axes;
accounting.contact_linkis an opt-in join (propose → confirm), and analytics only ever readstatus='confirmed'links.
Architectural consequences (deliberate design, not gaps to fix):
The derived feed is P&L only. AR/AP and bank control accounts are intentionally not synthesised, so there is no balance-sheet math on the API path — Trial Balance needs the GL CSV import.
Payments, credit notes, overpayments, prepayments and bank transfers land as journal header only — cash-timing signal, no P&L lines. This prevents double-counting the invoice/bill lines that already carry the P&L side.
Only
AUTHORISED/PAIDinvoices,AUTHORISEDbank transactions, andPOSTEDmanual journals produce derived entries.DRAFT/SUBMITTEDdocuments are invisible; a voided document flips its journal'sstatusrather than disappearing (join onstatus, don't assume absence means never-existed).Items and TaxRates land raw only (
ingest.raw_record) — join keys (accounting.invoice_line.item_code,accounting.entry.tax_code) already exist on the canonical lines; the reference tables themselves aren't projected.
Identity and join keys
Contact
ContactID (GUID)
accounting.contact_link.contact_external_id, resolved via entity_lookup(type=company) or a confirmed link's target_id
Account
Code
accounting.account.source_external_id, accounting.entry.account_code
Tracking Category
TrackingCategoryID
accounting.tracking_category.source_external_id; each entry's tracking JSONB carries {name, option} pairs, not ids
Item
Code
accounting.invoice_line.item_code (raw item detail lives in ingest.raw_record, not projected)
Tax Rate
TaxType
accounting.entry.tax_code (no GUID — TaxType itself is the natural key)
Invoice/Bill/Bank Transaction/Manual Journal
vendor GUID or natural-key hash
accounting.journal.source_external_id
GL CSV row
deterministic hash of the export row
accounting.journal.source_external_id, prefixed glimport:
A Xero contact is settlement-level, not a person. accounting. contact_link resolves one to a people.staff / people.company / people.identity_link-canonical member / people.location row — target_type says which. Resolution is opt-in and two-step: propose_contact_match (AI, status='proposed') then confirm_contact_match (operator, status='confirmed'). Only confirmed links feed accounting.journal_resolved — an unmatched or still-proposed contact leaves resolved_target_id NULL, which is the normal state for most contacts, not a bug.
Cost splitting works the same way: accounting.allocation splits a journal or a recurring account-code rule across location / class_category / staff, proposed via propose_allocation and made authoritative via confirm_allocation. accounting.entry_allocated and accounting.teacher_cost_month include only confirmed splits — an entry with no confirmed allocation is simply absent from those views, not zeroed out.
Counting traps
derived vs gl_import — the one that matters most. Query accounting.entry_guarded / accounting.journal_guarded, never the base tables. A raw SUM(accounting.entry.debit) over a period that has both an API-derived line and a GL-imported line for the same month double-counts every time.
Payments/credit notes/overpayments/prepayments/bank transfers have NO entry lines — only a journal header. SUM(accounting.entry.debit) FROM ... JOIN accounting.journal WHERE journal_type IN ('payment', 'overpayment', ...) returns nothing (correctly) because these entries don't exist; that is not a missing-data bug.
Multi-currency is real here and not converted. CurrencyRate is read from Xero but not applied — every amount is in the document's own currency. A multi-currency studio's rows will have mixed currency values across accounting.entry/journal; group by currency, never blind-SUM (exactly like accounting.entry_allocated and accounting. teacher_cost_month already enforce by design).
DRAFT and SUBMITTED documents are invisible, and voids don't delete. A draft invoice never produces a derived entry — don't read its absence as "no sales this period," check get_system_status and the coverage window first. A voided invoice's journal flips to status='voided'; the row is still there, so filter on status, don't assume every open-ended SELECT is already excluding it.
Edited invoices can leave a stale line. Xero line items are GUID-keyed but not tombstoned on our side when a line is removed after initial sync — a subsequent full re-pull (datafix) or the journal's total_amount versus the summed entry lines is the drift check.
Day-cap-partial first backfills are normal, not stuck. A large org's initial pull can legitimately span several days under Xero's 5,000 calls/tenant/day ceiling — get_system_status and the coverage card show progress; a "day 2 of 5" backfill is working as designed, not broken.
Axis separation. A Xero sales invoice and a Stripe charge for the same revenue event are different axes recording different things — never add accounting.entry into commerce.payment or commerce.sale totals. commerce.payment.settlement_batch_id is a reserved hook for a future payout-reconciliation join; it is not populated yet.
Questions this source can and can't answer
Can answer well
P&L by account/category/period, from whichever feed (or blend) covers that period — via
accounting.entry_guardedCash timing on payments, credit notes, bank transactions, overpayments, prepayments and transfers (journal-header level)
Cost of a specific instructor/location/class-category, once contacts and allocations are confirmed —
accounting.teacher_cost_month,accounting.entry_allocatedWho a settlement-level contact resolves to on the people side, once confirmed —
accounting.journal_resolvedWhether this studio's Xero connection (or CSV import) is current —
get_system_status
Cannot answer from Xero alone
Anything operational — classes, attendance, instructors, members as people (not in Xero)
Balance sheet position from the API feed (derived is P&L only — needs a GL CSV Trial Balance import)
Payroll or depreciation detail (invisible to API scopes — GL CSV only)
Per-member revenue (Xero contacts are settlement-level — join through
accounting.contact_link, and expect an unmatched remainder)Anything before the earliest backfilled window, or before a GL CSV import's covered period
When both a live Xero connection and a GL CSV import exist, the CSV wins per period it covers and the API fills the rest — accounting.entry_guarded already resolves this; don't attempt to pick a feed yourself.
Recipes — what works well
Is the data current → get_system_status
Xero is polled (backfill + incremental sweep), not webhook-driven, and a first backfill on a large org can span several days by design. Check here before assuming staleness is a bug.
P&L by account, correctly de-duplicated
Reading entry_guarded (not accounting.entry) is what makes this safe against the derived/gl_import overlap.
Resolving a contact to a person → entity_lookup(type=company), then propose_contact_match / confirm_contact_match
Propose with auto=true first (deterministic email/exact-name match against people.company/people.staff) — it reports no_match/ambiguous rather than guessing, so a human only needs to look at the genuinely unclear ones.
What did this instructor actually cost us → accounting.teacher_cost_month
Zero rows means no confirmed staff-dimension allocation exists yet for this person — propose one with propose_allocation, not a sign that the instructor cost nothing.
Cash timing on payments/bank activity (no P&L double-count)
Update-to-now vs a full re-pull
The connector page's "Update to now" runs an incremental If-Modified-Since sync from the watermark — safe to run anytime, cheap, and it is not the same operation as a windowed re-pull or a per-entity datafix (those force-reload a date range or a whole entity and are heavier). Coverage-gap re-pulls in the operator console use the windowed path automatically; the sync button never does.
What doesn't work on Xero alone
list_at_risk_members, get_class_utilisation, get_teacher_performance and every other bookings-shaped tool return empty on a Xero-only org — there is no booking data for them to read, and empty is the correct answer, not a fault to chase.
Where this lives in the code
Entity catalogue (specs, Dated/ModifiedSince, the where=Date window builder)
services/ingestors/xero/internal/xeroclient/specs.go
Stage-1 pull (watermark vs windowed, day-cap, per-page checkpoints)
services/ingestors/xero/internal/orchestrator/stage1.go
Transforms (journal-header-only entities, tracking passthrough)
services/ingestors/xero/internal/transform/
Two-feed resolution views
services/intelligence/migrations/postgres/org/950_guarded_views/003_entry_guarded_feed_resolution.sql, 004_journal_guarded_feed_resolution.sql
Contact/allocation tables + analytics views
services/intelligence/migrations/postgres/org/400_accounting/006_contact_link.sql, 007_allocation.sql, 950_guarded_views/006_accounting_analytics.sql
Matching/allocation MCP tools
services/intelligence/internal/tools/accounting_links.go
GL CSV import + staleness reminder
services/intelligence/internal/admin/httpapi/ingestor_xero_gl.go, nightly_xero_gl_reminder.go
Operator-facing connect guide
Last updated
Was this helpful?