Bank Statement Publishing (Xero Bank Feeds) #
Tofu publishes verified bank statements to Xero through the Bank Feeds API. A statement must be
VERIFIED before it can be published, and the bank feed is create-only: once an account group is
posted it is locked (edits can’t be pushed back, and it can’t be published again).
There are two flows, selected by bank_statement.version:
- v1 (single-account) —
POST /api/v1/bank-statements/{id}/publish. Publishes the single latest data row and guards “already published” at the statement level. - v2 (multi-account) —
POST /api/v2/bank-statements/{id}/publish. Publishes all account groups of the statement in one batch and reports a per-group result.
This page documents the v2 flow.
v2: one statement → one call → N per-group results #
A v2 statement fans out to N account groups (the bank_statement_to_data mapping), each with its own
external bank account, balances and transactions. The top-level Publish button on the v2 review
panel sends every group to Xero in a single Bank Feeds POST and returns a results array with one
entry per group:
{
"results": [
{ "bank_statement_to_data_id": "…", "status": "SYNCED", "external_id": "…", "already_published": false },
{ "bank_statement_to_data_id": "…", "status": "ERROR", "error_type": "VALIDATION",
"error_messages": ["statement does not reconcile…"], "already_published": false },
{ "bank_statement_to_data_id": "…", "status": "SYNCED", "already_published": true }
]
}
The endpoint returns 200 for any outcome where the whole-statement preconditions passed — including partial success and all-failed — so the frontend always gets the full per-account breakdown. 4xx is reserved for whole-statement precondition failures: not verified, no/inactive/ read-only accounting integration, statement not found, or not a v2 statement.
Partial success #
Each group succeeds or fails independently. The frontend shows a single mixed toast (“3 published, 2 failed”) and marks each failed account; a partial outcome is never collapsed into a single success or single error.
Re-publish guard #
A group is treated as already published — and skipped on subsequent calls — when its latest mapped
data row is a PUBLISH row (or carries an external_id). Because the success path writes a
PUBLISH row even when Xero reports a duplicate statement (where no external_id comes back),
keying on action_type == PUBLISH also catches the duplicate case. Failed groups never get a
PUBLISH row, and an edit after publishing produces a newer non-PUBLISH row, so both remain
eligible for retry.
Consequence: clicking Publish again only re-attempts the still-failed (or newly edited) groups.
Idempotency holds end-to-end via the persisted per-line transaction_id dedup keys plus the
Idempotency-Key header — a retry reuses existing keys so Xero deduplicates.
Validation (centralized calculator) #
Before any side effects, each group is validated independently with
tofu-calculator-core (validate_with_calculator, shared by v1 and v2). A group that fails local
validation is marked ERROR (VALIDATION) and excluded from the Xero POST; its sibling groups
still publish. Checks: opening/closing balances present, start ≤ end date, every top-level
transaction has a date within range and an amount, the group is non-empty, and
opening_balance + Σ(amounts) reconciles to closing_balance. Rounding is pinned to the hinoki
extraction config (Nearest, 2 dp) so a statement that passed extraction validation also passes
publish validation.
Split transactions (double-entry) #
Only top-level rows (parent_transaction_id IS NULL) become Xero statement lines. Split children
are accounting allocations of a parent movement, not separate bank movements, so emitting them would
double-count and break reconciliation. Both the validator and the Xero statement-line builder skip
children.
Sync activity & observability #
Every attempt writes a bank_statement_data_sync_activity row (SYNCING → SYNCED/ERROR) carrying
error_type and error_messages. The v2 read response surfaces the latest activity per group
(sync_status, sync_error_type, sync_error_messages, synced_at) so a failed account stays
marked across reloads. A whole-batch failure (e.g. rate limit) moves every in-flight SYNCING
candidate to ERROR (with the error type retained) so none are stranded. The
service.publish_bank_statement_v2 span logs batch size and success/fail counts.