Bank Statement Publishing (Xero Bank Feeds)

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 (SYNCINGSYNCED/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.