SEPA Configuration — Per-Tenant Creditor & Beneficiary Accounts
SEPA needs three account numbers in three places: the creditor IBAN that signs a
pain.008 direct-debit batch, the debtor IBAN a mandate authorises, and the
beneficiary IBAN a customer transfers to. Historically those lived as host options
and ad-hoc fields. They now converge onto a single referential —
Granit.BankAccounts — while the legally-binding
copies are stamped as immutable, encrypted snapshots that never re-read the
referential.
What changed
Section titled “What changed”| Area | Before | After |
|---|---|---|
| SDD creditor IBAN/BIC | SepaDirectDebitBuiltinOptions (host config) | Per-tenant SepaDirectDebitConfiguration, stamped from a BankAccount |
| SCT beneficiary IBAN/BIC | SepaTransferOptions (host config) | Per-tenant SepaTransferConfiguration, stamped from a BankAccount (new EF Core + Endpoints packages) |
| Mandate debtor account | IBAN only on the mandate | DebtorPartyId + immutable signed IBAN snapshot; debtor account provisioned into BankAccounts |
| Account numbers | scattered per module | centralized in BankAccounts |
Stripe is unchanged — it is tokenised and stores no local IBAN.
SEPA Direct Debit — creditor configuration
Section titled “SEPA Direct Debit — creditor configuration”SepaDirectDebitConfiguration is the per-tenant singleton holding the Creditor
Identifier (SCI), the scheme/provider defaults, and now the creditor collection
account. The SCI and the creditor account are governed independently:
- SCI is contractually bound to every mandate reference (RUM). It is locked once the tenant has any mandate — changing it requires a SEPA mandate migration (amendment indicator + original SCI in pain.008), not an in-place edit.
- Creditor account (
CompanyPartyId,CreditorIban,CreditorBic) may change when the merchant changes bank. It is stamped from aBankAccounton the company Party;CreditorIban/CreditorBicare[Encrypted]at rest and decrypted in-process for pain.008 generation.
// PUT /sepa-direct-debit/configuration (permission: SepaDirectDebit.Configuration.Manage)// When a CreditorIban is supplied with a CompanyPartyId, the endpoint provisions the// account into BankAccounts and stamps the snapshot onto the configuration:BankAccountSnapshot creditor = await provisioner.ResolveOrCreateAsync( new BankAccountProvisionRequest( tenantId, companyPartyId, BankAccountScheme.Iban, creditorIban, creditorName, ibanCountryCode, creditorBic), ct);
configuration.SetCreditorAccount(companyPartyId, creditor.Id, creditorIban, creditorBic);pain.008 reads CreditorIban / CreditorBic from the configuration — not from
host options. HasCreditorAccount gates file generation: no stamped account, no batch.
The response masks the IBAN (CreditorIbanMasked).
Mandate snapshot model
Section titled “Mandate snapshot model”A Mandate carries the contractually signed account, captured once and never
re-read:
| Field | Role |
|---|---|
DebtorPartyId | The debtor is a Party — the contractual debtor link (required) |
DebtorIban / DebtorBic | The immutable signed snapshot — [Encrypted], the source of truth for collection |
DebtorBankAccountId | Indicative, non-binding back-reference to the BankAccounts entry — no foreign key, traceability only |
CreditorId | SCI active at creation, stamped for historical traceability |
At mandate setup the debtor account is provisioned into the referential (dedup by
(PartyId, IBAN)), but the mandate still stamps and keeps its own encrypted IBAN:
sequenceDiagram
participant App
participant MS as MandateSetupService
participant BA as IBankAccountProvisioner
participant M as Mandate (aggregate)
App->>MS: SetupAsync(DebtorPartyId, DebtorIban, DebtorBic)
MS->>BA: ResolveOrCreateAsync(Party, IBAN) %% dedup
BA-->>MS: BankAccountSnapshot (masked, .Id)
MS->>M: Create(... DebtorIban [signed], debtorBankAccountId = snapshot.Id)
Note over M: signed IBAN/BIC copied & encrypted;<br/>never re-read from the referential
SEPA Transfer — beneficiary configuration
Section titled “SEPA Transfer — beneficiary configuration”The convergence adds two new packages —
Granit.Payments.SepaTransfer.EntityFrameworkCore and
Granit.Payments.SepaTransfer.Endpoints — mirroring the creditor pattern for the
beneficiary (collection) account.
SepaTransferConfiguration is a per-tenant singleton: BeneficiaryName, IsActive,
and the stamped CompanyPartyId / BeneficiaryIban / BeneficiaryBic (the last two
[Encrypted]). The checkout-session factory reads this snapshot to print transfer
instructions — never the referential’s masked projection.
| Method | Route | Success | Permission |
|---|---|---|---|
GET | /sepa-transfer/configuration | 200 config (masked IBAN) / 404 if unconfigured | SepaTransfer.Configuration.Manage |
PUT | /sepa-transfer/configuration | 200 upserted config | SepaTransfer.Configuration.Manage |
On PUT, when a BeneficiaryIban + CompanyPartyId is supplied the account is
provisioned into BankAccounts on the company Party and SetBeneficiaryAccount(...)
stamps the snapshot. Optimistic concurrency (ConcurrencyStamp,
ADR-061) guards the
singleton against a lost update.
Host wiring
Section titled “Host wiring”// Program.cs — SEPA Transfer per-tenant beneficiary configbuilder.AddGranitPaymentsSepaTransferEntityFrameworkCore(o => o.UseNpgsql(connectionString));app.MapGranitSepaTransfer(); // GET/PUT /sepa-transfer/configuration
// SEPA Direct Debit (creditor config lives on the existing configuration)builder.AddGranitPaymentsSepaDirectDebitEntityFrameworkCore(o => o.UseNpgsql(connectionString));app.MapGranitSepaDirectDebit(); // mandates + /sepa-direct-debit/configuration
// Required by both — the centralized referential the snapshots are provisioned frombuilder.AddGranitBankAccountsEntityFrameworkCore(o => o.UseNpgsql(connectionString));app.MapGranitBankAccounts();Migration notes for adopters
Section titled “Migration notes for adopters”-
Install the referential. Add
Granit.BankAccounts(+.EntityFrameworkCore,.Endpoints) and wire it — the SEPA modules now depend onIBankAccountProvisioner. See Bank Accounts → Host wiring. -
Move the creditor coordinates out of host options. The creditor
IBAN/BIC/Namewere removed fromSepaDirectDebitBuiltinOptions(onlyDefaultSchemeremains, underPayments:SepaDirectDebit:Internal). Set them per tenant viaPUT /sepa-direct-debit/configuration(withCompanyPartyId+CreditorIban). The SCI stays where it was. -
Move the beneficiary coordinates out of host options. The beneficiary
IBAN/BIC/Namewere removed fromSepaTransferOptions(onlyReferencePrefixandExpirationDaysremain, underPayments:SepaTransfer). Set them per tenant viaPUT /sepa-transfer/configuration, and add the new persistence package. -
Supply a debtor Party at mandate setup. Mandate creation now requires
DebtorPartyId. Existing signed mandates keep their stamped IBAN; only new setups provision a referential entry.
See also
Section titled “See also”- ADR-069 — Bank accounts as the single source of truth
- Payments overview — the payment ecosystem and FSM
- Optimistic concurrency (ADR-061) — the per-tenant singleton guard