Skip to content

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.

AreaBeforeAfter
SDD creditor IBAN/BICSepaDirectDebitBuiltinOptions (host config)Per-tenant SepaDirectDebitConfiguration, stamped from a BankAccount
SCT beneficiary IBAN/BICSepaTransferOptions (host config)Per-tenant SepaTransferConfiguration, stamped from a BankAccount (new EF Core + Endpoints packages)
Mandate debtor accountIBAN only on the mandateDebtorPartyId + immutable signed IBAN snapshot; debtor account provisioned into BankAccounts
Account numbersscattered per modulecentralized 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 a BankAccount on the company Party; CreditorIban/CreditorBic are [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).

A Mandate carries the contractually signed account, captured once and never re-read:

FieldRole
DebtorPartyIdThe debtor is a Party — the contractual debtor link (required)
DebtorIban / DebtorBicThe immutable signed snapshot[Encrypted], the source of truth for collection
DebtorBankAccountIdIndicative, non-binding back-reference to the BankAccounts entry — no foreign key, traceability only
CreditorIdSCI 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.

MethodRouteSuccessPermission
GET/sepa-transfer/configuration200 config (masked IBAN) / 404 if unconfiguredSepaTransfer.Configuration.Manage
PUT/sepa-transfer/configuration200 upserted configSepaTransfer.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.

// Program.cs — SEPA Transfer per-tenant beneficiary config
builder.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 from
builder.AddGranitBankAccountsEntityFrameworkCore(o => o.UseNpgsql(connectionString));
app.MapGranitBankAccounts();
  1. Install the referential. Add Granit.BankAccounts (+ .EntityFrameworkCore, .Endpoints) and wire it — the SEPA modules now depend on IBankAccountProvisioner. See Bank Accounts → Host wiring.

  2. Move the creditor coordinates out of host options. The creditor IBAN / BIC / Name were removed from SepaDirectDebitBuiltinOptions (only DefaultScheme remains, under Payments:SepaDirectDebit:Internal). Set them per tenant via PUT /sepa-direct-debit/configuration (with CompanyPartyId + CreditorIban). The SCI stays where it was.

  3. Move the beneficiary coordinates out of host options. The beneficiary IBAN / BIC / Name were removed from SepaTransferOptions (only ReferencePrefix and ExpirationDays remain, under Payments:SepaTransfer). Set them per tenant via PUT /sepa-transfer/configuration, and add the new persistence package.

  4. 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.