ADR-069: Bank accounts as the single source of truth
Date: 2026-06-14 Authors: Jean-Francois Meyers Scope:
Granit.BankAccounts.*,Granit.Payments.SepaDirectDebit.*,Granit.Payments.SepaTransfer.*(granit-business, EPIC #24)
Context
Section titled “Context”Bank account numbers were scattered across the payment modules and stored inconsistently:
- SEPA Direct Debit kept the creditor IBAN/BIC in
SepaDirectDebitBuiltinOptions(host config), and the debtor IBAN only on theMandate. - SEPA Transfer kept the beneficiary IBAN/BIC in
SepaTransferOptions(host config). - Nothing answered “which accounts does this customer hold?”, account numbers were IBAN-only (breaking outside SEPA), and masking was applied in some places but not others — a clear IBAN could reach a grid, an export, or a log depending on the path.
Two requirements pull in opposite directions. Deduplication and a single answer want one shared store. Legal durability wants a mandate’s signed IBAN to be immune to a later edit of “the same” account — a bank rejects a collection if the coordinates on file no longer match what the debtor signed.
Decision
Section titled “Decision”Introduce Granit.BankAccounts — a centralized bank-account referential modelled on
Odoo res.partner.bank — as the single source of truth for account numbers, and
make every consumer depend on it through abstractions only:
-
One store, owned by a Party. A
BankAccountaggregate (IBAN + domestic schemes: US ACH, Canada EFT, AU/NZ BSB, India IFSC, Other) hangs off aPartyId. The account identifier is[Encrypted]at rest; routing code and BIC are public identifiers in clear. -
Masked across boundaries. Cross-module reads go through
IBankAccountResolver, returning aBankAccountSnapshotwhose identifier is always masked. The clear IBAN never crosses a module boundary, and is deliberately excluded from the query grid, search, and export (ADR-020). -
Resolve-or-create, deduplicated.
IBankAccountProvisioner.ResolveOrCreateAsyncdeduplicates by(PartyId, normalized identifier), so repeated setups never spawn duplicates. Payment modules reference this contract, never the persistence. -
Binding artefacts stamp their own snapshot. A
MandatecarriesDebtorPartyIdplus an immutable, encrypted signed IBAN/BIC snapshot and a non-bindingDebtorBankAccountId(no foreign key). The per-tenantSepaDirectDebitConfigurationandSepaTransferConfigurationlikewise stamp an encrypted creditor / beneficiary snapshot from aBankAccount. pain.008 generation and transfer instructions read the stamped snapshot — never the referential at use time. The creditor/beneficiary coordinates were removed fromSepaDirectDebitBuiltinOptions/SepaTransferOptions.
Evaluated Alternatives
Section titled “Evaluated Alternatives”- Foreign key from mandate/config to
BankAccount. Rejected: archiving or editing the source account would shift or invalidate a historically-signed mandate’s coordinates, corrupting a legally-binding artefact (and breaking referential integrity on soft-archive). - Keep account numbers per module (status quo). Rejected: duplication, inconsistent masking, no cross-module view, no dedup.
- Centralize and always read live from the referential. Rejected: the masked projection cannot serve a real pain.008/transfer instruction, and a live read makes the signed value mutable — the exact failure mode SEPA forbids.
Justification
Section titled “Justification”The split — single source of truth for the number, stamped snapshot for the binding
artefact — satisfies both forces. Centralization gives dedup, a per-party view, and a
uniform masking/encryption boundary; the immutable snapshot gives legal durability.
The soft, FK-less DebtorBankAccountId keeps traceability back to the referential
without coupling the artefact’s correctness to the referential’s mutability.
Scheme-awareness removes the IBAN-only assumption for non-SEPA deployments.
Consequences
Section titled “Consequences”- Adopters install
Granit.BankAccounts(+ EF Core + Endpoints); the SEPA modules now depend onIBankAccountProvisioner. Two new packages ship for SEPA Transfer (.EntityFrameworkCore,.Endpoints). - Breaking for adopters who set creditor/beneficiary coordinates in
appsettings: those options were removed and must move to the per-tenant configuration endpoints (PUT /sepa-direct-debit/configuration,PUT /sepa-transfer/configuration). - Mandate setup now requires a
DebtorPartyId; existing signed mandates keep their stamped IBAN unchanged. - The clear IBAN is constrained to legitimate in-process flows only; grids, exports, and API responses are masked-or-absent by construction.
Status
Section titled “Status”Accepted. Shipped in granit-business EPIC #24. See Bank Accounts and SEPA configuration.