Skip to content

Add explicit Bank Frick personal IBAN routing#4384

Open
TaprootFreak wants to merge 7 commits into
developfrom
feat/buy-frick-personal-iban
Open

Add explicit Bank Frick personal IBAN routing#4384
TaprootFreak wants to merge 7 commits into
developfrom
feat/buy-frick-personal-iban

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add the explicit personalIbanProvider: "Frick" buy payment-info request option
  • restrict Bank Frick personal IBAN issuance to verified EUR bank buys and fail strictly instead of falling back
  • guarantee that requests without the selector never route to Bank Frick
  • persist the selected bank and virtual IBAN on the transaction request so invoices and history keep the exact payment details
  • reopen a stuck issuance only from the reconciliation job, never from the request path
  • return short error tokens to customers and keep diagnostic detail internal, free of account numbers

Database

  • add transaction_request.bankId and transaction_request.virtualIbanId without foreign keys
  • add virtual_iban_issuance_intent for cross-instance, crash-recoverable Frick vIBAN issuance
  • add virtual_iban_issuance_event as immutable audit history, referencing the virtual IBAN record rather than storing its IBAN
  • remove Bank Frick from generic virtual-IBAN provider selection while retaining explicit opt-in routing

Why the request path never retries

The bank's vIBAN API has no idempotency key (unlike its payment API). Whether a create call already
produced an account can therefore only be answered from a listing — and an empty listing is not
proof
while another process is still working on the same intent. Building a retry on that
assumption produced a duplicate-account race in every variant we tried.

The request path is now strictly fail-closed: no reset, no retry after an empty recovery search.
Reopening a stuck intent is the sole responsibility of the hourly reconciliation job, which
additionally requires the intent to be older than the worst-case bank round trip (30 min, derived
from the HTTP timeout across create + activate + a possible re-authorisation) before it treats an
empty listing as proof.

The one retained exception is a provider-proven "nothing was sent" — a stronger guarantee than an
empty search. That proof now rests on a signature-verified response: the verifier previously ran
for successful responses only, so an unsigned error from anywhere on the path could have been read
as proof.

Residual risk, accepted deliberately: with no idempotency key, a sufficiently unlucky
interleaving can still leave a second real account at the bank. docs/bank-frick-operations.md
documents which interleaving, which alert surfaces it, and what to do about it.

Behaviour changes outside the selector path

Both are intentional and worth reviewing explicitly:

  1. Bank and personal IBAN are pinned on every new transaction request, not only on selector
    requests — that is what makes invoices and history reproduce the exact payment details of the
    quote. If that account is deactivated before a later document request, the request now fails
    closed
    instead of silently substituting a currently active one. A silent substitution is the
    worse outcome: the customer may already have noted the original IBAN.
  2. The user-level personal-IBAN lookup no longer returns buy-scoped records. The two pools are
    deliberately disjoint. No buy-scoped record exists in production (0 of 3053), and both feature
    flags that would create one are off.

Fee determination for customers without the selector is unchanged from the merge base — the bank
override is passed only when the selector was explicitly set.

Validation

  • npm run format, npm run lint, npm run type-check, npm run build
  • default test run across all touched areas: 41 suites, 1053 tests
  • dedicated Frick gate npm run test:frick:cov: 10 suites, 496 tests, 100% statements, branches, functions and lines — now including the issuance and provider logic, which was previously excluded
  • migration verified against a real Postgres emulation, not string comparison
  • every fix in the hardening rounds was verified adversarially: revert the fix, confirm the test turns red, restore
  • reviewed across seven rounds by cross-vendor agents (GPT-5.6) against the full diff each time, not just the latest change

An authenticated sandbox round-trip remains a deployment verification step because it creates
external Bank Frick state. Endpoint DNS/TLS/routing was checked separately from the target hosts.

Rollout

The environment variable was renamed to FRICK_VBAN_API_URL, which only this version reads — the
pre-rename application treats the old name as "Frick is the generic EUR provider" and would issue
accounts to every KYC50+ customer unasked. DFXServer/server#965 sets the new name and pins the old
one empty, so the ordering is enforced technically rather than by convention.

Related: DFXswiss/services#1190 (consumer), DFXswiss/packages#191 (SDK types).

@github-actions

Copy link
Copy Markdown

❌ TypeScript: 5 errors

@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Completed 4 full independent review passes. All findings were addressed; both final full-diff reviews report 0 findings and the latest CI is fully green.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 24, 2026 22:28
Review of the initial implementation found several ways the issuance path
could create a second real account at the bank, strand a customer, or leak
personal data. The bank's vIBAN API offers no idempotency key, so "did the
bank already create one?" can only be answered from a listing - and an empty
listing is not proof while another process is still working on the same
intent.

The request path is therefore strictly fail-closed: it never resets an intent
and never retries after an empty recovery search. Reopening a stuck intent is
now the sole responsibility of the reconciliation job, which additionally
requires the intent to be older than the worst-case bank round trip before it
treats an empty listing as proof. The one exception is a provider-proven
"nothing was sent", which is a stronger guarantee than an empty search; that
proof now rests on a signature-verified response, since the verifier
previously ran for successful responses only.

Deactivating a personal IBAN reopens its intent atomically, and account merges
resolve deactivation, ownership and both sides' intents inside one
transaction - a customer request could otherwise slip into the gap and issue a
second real account without any bank-side ambiguity at all.

The issuance event log no longer stores the customer IBAN in clear text; it
references the record instead, and the migration refuses to drop the old
columns while any row cannot be resolved. Customer-facing errors are short
tokens, with the diagnostic detail kept in logs and the intent record, free of
account numbers and raw provider text.

The reconciliation job isolates failures per bank, alerts on chronically
incomplete listings, and no longer reports a clean run when it could not
evaluate what it found.

Note: bank and personal IBAN are pinned on every new transaction request, so
invoices and history reproduce the exact payment details of the quote. A later
document request fails closed if that account was deactivated in the meantime,
rather than silently substituting a different one.
The migration read its count via a numeric array index. The repository's
guard against MSSQL bracket quoting matches any word characters inside
brackets, so a plain JavaScript index tripped it. Destructuring keeps the
logic identical without the bracket pattern; the guard itself stays as is,
since it protects against syntax that would break on Postgres.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant