Skip to content

Bank feeds

SAE Books ingests bank transactions via the CDR (Consumer Data Right) open-banking framework, brokered through an aggregator (currently SISS Data Services / ACSISS). The integration is generic over the aggregator — adding a second provider is a contract implementation, not a rewrite.

Provisional

Bank-feed onboarding is gated behind manual NDA + consumer-consent flows administered by the aggregator. The integration code lives in saebooks.services.bank_feeds but the production wiring is not yet enabled in the community edition. Self-hosters who want to run feeds today need to plumb in their own aggregator credential pair and accept the NDA terms directly.

Why CDR

Australia's CDR regime is the closed door behind which "screen scraping" used to live. Going through a CDR-accredited aggregator gives:

  • A consent UI the consumer trusts (it lives at the bank)
  • Refresh tokens with explicit lifetimes the consumer can revoke
  • Standardised JSON shapes across all major banks
  • A defensible audit trail when the ATO or the consumer asks "where did this data come from?"

The downside is that aggregator integration is paperwork-heavy — there's an NDA, a fitness-and-propriety check, and quarterly attestations. SAE Books abstracts that out so an end customer sees "connect bank" and not "fill in this 38-page form".

Flow

sequenceDiagram actor User participant App as SAE Books participant Aggr as Aggregator participant Bank User->>App: Connect bank App->>Aggr: Begin consent Aggr->>Bank: OAuth/CDR consent Bank-->>User: Consent UI User->>Bank: Approve Bank-->>Aggr: Tokens Aggr-->>App: Webhook + tokens loop Daily App->>Aggr: Pull transactions Aggr->>Bank: CDR /accounts/{id}/transactions Bank-->>Aggr: Transactions Aggr-->>App: Normalised JSON App->>App: Match → suggest journal end

The pull cadence is configurable per tenant — the default is daily, but high-volume tenants run hourly. Webhooks from the aggregator trigger an out-of-band pull when fresh data is available, so users rarely wait 24 hours to see a transaction.

Data model

Incoming bank lines land in bank_statement_lines with one of four states:

  • UNMATCHED — no journal yet; awaiting rule or human review
  • PARTIAL — partially allocated (split across multiple targets)
  • MATCHED — fully reconciled
  • IGNORED — explicitly excluded (test transactions, duplicates)

Allocations are recorded as rows in bsl_matches, a junction table that links a bank statement line to one or more targets (each target_type is either PAYMENT or JOURNAL_ENTRY). A single deposit can be split across multiple payments — each split gets its own bsl_matches row. The sign convention is that amounts on deposit lines are positive and withdrawal lines negative.

Each bank_statement_line also tracks which bank_feed_account_id it came from (the aggregator-side account link) and an external_id used to deduplicate on resync.

Matching pipeline

Incoming bank lines run through a rules pipeline, in order:

  1. Bank rules — per-company rules matched against the line description using one of four match types: CONTAINS, STARTS_WITH, EXACT, or REGEX. Rules are ordered by priority (descending) then name. The first matching rule wins. If the rule has auto_create=True a posted journal entry is created automatically; otherwise the rule is suggested for human confirmation.
  2. Manual classification — lines with no matching rule fall through to the operator UI for manual account selection.

Rules map a bank line to a GL account and optional tax code, and can also associate a contact (customer/supplier) and a custom description template.

Reconciliation

Reconciliation runs as an offline sweep (saebooks.services.bank_feeds.reconcile). For each active feed account the sweep computes:

  • Feed total — sum of all bank_statement_lines.amount for the feed
  • GL total — sum of journal_lines (debit − credit) on the linked ledger account for POSTED and REVERSED entries
  • Variance — feed_total − gl_total; anything over $0.01 is flagged
  • Unmatched count — lines still in UNMATCHED status
  • Staleness — last statement line older than 7 days

Each feed account gets a severity: ok, warn (unmatched lines), or error (stale feed or GL variance). The operator dashboard surfaces this at /admin/bank-feeds/health.

The sweep is read-only — it writes nothing. The CLI entry point is python -m saebooks.cli reconcile-feeds.

Failure modes

  • Consent expired — consumer has revoked the bank's consent or the 12-month CDR cap has lapsed. The user sees a "reconnect" CTA; no transactions are lost, just paused.
  • Aggregator outage — the daily pull fails, the next pull catches up automatically. Webhooks may be delayed.
  • Bank schema change — the aggregator usually absorbs these; if it doesn't, the integration logs a structured error referencing the offending field and the row is left in UNMATCHED status.

All three failure modes are visible in the operator dashboard at /admin/bank-feeds/health.