diff --git a/.env.example b/.env.example index f62fa5f470..17e0d1d19d 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,14 @@ RAIFFEISEN_USER_ID= RAIFFEISEN_PASSPHRASE= RAIFFEISEN_IV= +FRICK_BASE_URL= +FRICK_API_KEY= +FRICK_PRIVATE_KEY= +FRICK_SERVER_PUBLIC_KEY= +FRICK_CUSTOMER= +FRICK_PAYOUT_ENABLED= +FRICK_APPROVE_WITHOUT_TAN= + YAPEAL_BASE_URL= YAPEAL_PARTNERSHIP_UID= YAPEAL_ADMIN_UID= @@ -309,4 +317,4 @@ REALUNIT_BANK_NAME= REQUEST_KNOWN_IPS= -CRON_JOB_DELAY= \ No newline at end of file +CRON_JOB_DELAY= diff --git a/.github/workflows/api-pr.yaml b/.github/workflows/api-pr.yaml index 1952d72143..0f720ad483 100644 --- a/.github/workflows/api-pr.yaml +++ b/.github/workflows/api-pr.yaml @@ -64,5 +64,8 @@ jobs: env: MIGRATION_TEST_PG: postgres://postgres:postgres@localhost:5432/postgres + - name: Enforce Bank Frick coverage + run: npm run test:frick:cov + - name: Security audit run: npm audit --audit-level=critical diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md new file mode 100644 index 0000000000..abb3eeda57 --- /dev/null +++ b/docs/bank-frick-operations.md @@ -0,0 +1,256 @@ +# Bank Frick — Operations Runbook + +Operational notes for the Bank Frick statement import (`BankTxFrickService`), payout rail +(`FiatOutputFrickService`), registry placeholders and cryptographic activation. Bank Frick must +remain disabled until every activation check below has passed. + +## 1. Bank Frick watermark backfill + +### Background + +The poller tracks progress per receiving Bank Frick account via the setting key +`lastBankFrickDate:` (`bankId` = the `Bank` row id, not the IBAN). It advances only after a +**non-empty** response was fully persisted, to +`min(now, latest processed booking date) − FRICK_WATERMARK_OVERLAP_DAYS` (currently two days). +Empty, structurally invalid and partially persisted responses do not advance it. The setting +update uses a transaction-scoped PostgreSQL advisory lock and a monotonic comparison, so a stale +worker cannot move the value backwards. Duplicate re-fetches inside the overlap window are +expected and are absorbed by the existing `accountServiceRef` uniqueness check. + +Bank Frick does not expose an ingestion cursor. If an entry becomes visible with a booking date +**older than the overlap window already covered**, the watermark will not pick it up on its own — +this requires a manual rewind. Conversely, an idle account whose first fetch is empty retains its +seeded value; this is deliberate and is why initial seeding is mandatory. + +### When to intervene + +- Support/Finance reports a booking that is missing from `bank_tx` and its booking date is + older than `now − FRICK_WATERMARK_OVERLAP_DAYS` relative to when it was first pollable. +- A Bank Frick account was inactive/misconfigured for a period and needs its history + re-imported once fixed. +- Before activating polling for a **newly added** Bank Frick account (initial seed, see below). + +### Procedure + +1. Confirm the missing entry actually exists on the Bank Frick side (camt.053 export or their + portal) and note its booking date and `accountServiceRef`. +2. Identify the affected `bankId` (the `Bank.id` for the Bank Frick IBAN in question). +3. Rewind the setting key to a timestamp before the missing entry's booking date, with margin: + ```sql + UPDATE setting + SET value = '' + WHERE key = 'lastBankFrickDate:'; + ``` + If the key does not exist yet, insert it instead of updating. +4. Let the next scheduled poll run (or trigger it manually in a lower environment first if + unsure). The service re-fetches everything from the new watermark forward. +5. **Watch for duplicate-conflict volume** on the affected account for the next few poll + cycles — every entry between the rewound watermark and the original one will be re-fetched + and is expected to hit the `create()` dedup (`ConflictException`, logged as handled, not as + an error). A spike here right after a rewind is normal; it should die down once the + watermark catches back up to where it was. +6. If genuinely new `bank_tx` rows are created, verify them against the source statement and + hand off to Finance/Support as usual. +7. Do not rewind further back than necessary — a very old watermark on a busy account causes a + large one-off re-fetch and a correspondingly large burst of dedup conflicts. + +### Initial seed for newly activated accounts + +When a new Bank Frick receiving account is added, its `lastBankFrickDate:` key does not +exist yet and defaults to the epoch (`1970-01-01`) on first poll. Seed the setting key explicitly +**before** flipping `Bank.receive` to `true`, to the earliest date that should be imported (or +`now`, if only new activity should be picked up). Do not enable an account with an epoch cursor. + +## 2. `Iso20022Service`'s deterministic reference does not affect existing Raiffeisen/Yapeal imports + +`Iso20022Service.parseCamt053Json`'s reference-less-entry fallback now derives a deterministic, +content-scoped hash instead of a random string. This does **not** create a duplicate-`bank_tx` +risk for Raiffeisen or Yapeal, because neither bank's actual import-and-create path runs through +this fallback: + +- Raiffeisen's `bank_tx` creation uses the entirely separate `SepaParser`, with its own + independent reference scheme (`CUSTOM///`) - it never calls + `Iso20022Service` at all. +- Yapeal's only call into `Iso20022Service.parseCamt053Json` + (`BankTxService.enrichYapealTransactions`) uses the result solely to **enrich already-existing** + `bank_tx` rows (matched by their already-stored `accountServiceRef`) with address/bank-transaction- + code fields - it never creates a `bank_tx` row. + +No monitoring or manual cleanup pass is required for either bank as a result of this change. + +## 3. Registry and default-off activation + +The migration never creates, updates or deletes a `bank` row. The only prior migration that ever +inserted one (Yapeal EUR) was reverted (`f897b98a2 chore: remove migration (already inserted +manually)`) because the row is a manual production step, not something a schema migration should +own. The two new Bank Frick account rows are created the same way, manually, as part of this +runbook. The local seed (`migration/seed/bank.csv`) keeps clearly synthetic, checksum-valid +IBANs/ids for a fresh local database only - it is never applied to production (`migration/seed/ +seed.js` hard-blocks any non-local host/environment). + +### 3.1 Manually insert the two new Bank Frick accounts + +The real Bank Frick CT account IBANs for the new account: + +- **EUR: `LI75088110105923K000E`** +- **CHF: `LI32088110105923K000C`** + +Run manually against production. BIC is `BFRILI22` for both rows (it identifies Bank Frick itself, +not the individual account, so it is the same as the existing legacy rows' BIC): + +```sql +-- Defensive: this INSERT relies on bank_id_seq via the identity column. Explicit-id inserts +-- elsewhere in this codebase (see migration/seed/seed.js) are a known source of a lagging +-- sequence; bump it first so this insert can never collide with a not-yet-advanced sequence. +SELECT setval('bank_id_seq', GREATEST((SELECT COALESCE(MAX(id), 1) FROM "bank"), (SELECT last_value FROM bank_id_seq))); + +INSERT INTO "bank" + ("updated", "created", "name", "iban", "bic", "currency", "receive", "send", "sctInst", "amlEnabled", "sendPriority") +VALUES + (NOW(), NOW(), 'Bank Frick', 'LI75088110105923K000E', 'BFRILI22', 'EUR', FALSE, FALSE, FALSE, TRUE, 2000), + (NOW(), NOW(), 'Bank Frick', 'LI32088110105923K000C', 'BFRILI22', 'CHF', FALSE, FALSE, FALSE, TRUE, 2000); +``` + +`receive`, `send` and `sctInst` all start `FALSE` and `sendPriority` starts at `2000` (worse than +every pre-existing row's backfilled `1000`), so inserting these rows changes nothing about current +routing by itself - Ops must deliberately flip flags/lower the priority per the steps below. + +### 3.2 Retire the legacy Bank Frick rows + +Bank Frick was integrated once before and removed; three legacy `Bank Frick` rows (the old +account's IBANs, `receive=false`/`send=false`) were never cleaned up. Once the new rows above +exist, `(name, currency)` would match two rows each for EUR/CHF unless the legacy rows are +retired. **Decision: rename, not delete** (keeps history/audit trail intact). Run manually against +production, immediately after 3.1: + +```sql +UPDATE "bank" +SET "name" = 'Bank Frick (legacy)' +WHERE "name" = 'Bank Frick' + AND "receive" = FALSE AND "send" = FALSE + AND "iban" NOT IN ('LI75088110105923K000E', 'LI32088110105923K000C'); +``` + +This is scoped so it can never match the two new rows inserted in 3.1 (excluded by IBAN) or any +row that is actually live - `receive`/`send` both `FALSE` only matches the dormant legacy rows +today. To roll back (rename the legacy rows back), reverse the `SET`: + +```sql +UPDATE "bank" SET "name" = 'Bank Frick' WHERE "name" = 'Bank Frick (legacy)'; +``` + +As defense in depth independent of this cleanup step, `BankService.getBankInternal`/ +`loadIbanCache` now deterministically prefer the highest `Bank.id` per `(name, currency)` - the +newest row always wins a name/currency collision even before (or if ever again after) this cleanup +runs. + +### 3.3 Activate + +1. Set `receive`, `send` and `sctInst` from the confirmed account-role matrix. Never infer these + flags from currency. **A Frick row used for `send=true` must also have `receive=true`** - a + send-only Frick row can never see its own booked debit come back on a statement, so it can never + reach `isComplete` and its reserved liquidity silently never releases. This combination is + checked and logged loudly on every `BankTxFrickService` poll cycle, but must not be relied upon + as the primary safeguard - set the flags correctly up front. + Before setting `send=true`, link the row to the correctly configured custody/liquidity asset and + verify that its balance is refreshed; payout readiness deliberately requires that balance. + Instant outputs additionally require `sctInst=true`; a row without that confirmed capability is + excluded from instant routing. +2. Seed `lastBankFrickDate:` before setting `receive=true`. +3. Leave `FiatOutputFrickTransmission` and `FiatOutputFrickStatusCheck` in the `disabledProcess` + setting until the sandbox checklist below is complete. The migration adds both without + removing or duplicating existing process switches. +4. Enable the status process before (or in the same controlled change as) transmission. If new + payout creation is later stopped, keep status polling enabled until all existing Frick orders + are terminal or reconciled. +5. Set `Bank.sendPriority` deliberately before enabling a Frick `send` flag. Lower value is tried + first; every pre-existing row defaults to `1000` and the new Frick rows are inserted at `2000` + (3.1), so enabling Frick's `send` flag changes nothing by default - Frick coexists with, but + loses ties against, the incumbent (Olkypay/Yapeal) until Ops explicitly lowers its priority + below `1000` to cut traffic over. Two eligible banks sharing the exact same priority for a + currency is treated as a genuine misconfiguration and leaves the output unassigned until Ops + resolves the tie. + +The API also refuses to assign or ready a new Frick payout while creation is unavailable. If +another eligible sender bank exists it is selected instead; otherwise the output remains +unassigned, not stranded inside a disabled Frick rail. + +## 4. Required cryptographic configuration + +All values remain blank in `.env.example`. Deployment must provide: + +- `FRICK_BASE_URL`, `FRICK_API_KEY`, `FRICK_CUSTOMER` +- `FRICK_PRIVATE_KEY` — client signing key, PEM with `
` line separators +- `FRICK_SERVER_PUBLIC_KEY` — Bank Frick response-verification key, PEM with `
` separators; + obtain it from Bank Frick through the authenticated onboarding channel +- `FRICK_PAYOUT_ENABLED=true` only after inbound verification +- `FRICK_APPROVE_WITHOUT_TAN=true` only after Bank Frick confirms backend exemption + +`BankFrickService.isAvailable()` requires both keys. Every request signs the exact serialized +body. Every response remains raw text until its detached `Signature` and `algorithm` headers have +been verified (`rsa-sha512`, `rsa-sha384` or `rsa-sha256`); only then is JSON parsed. A missing key, +header, unsupported algorithm or signature mismatch fails closed. + +## 5. Payout and reconciliation decisions + +- EUR uses `SEPA` or `SEPA_INSTANT`; instant is never sent for non-EUR. +- EUR creation additionally requires a SEPA-country creditor IBAN and the existing automated-bank + country allowlist (`Country.yapealEnable`). Unsupported routes fail before a bank order is created. +- CHF uses Bank Frick `FOREIGN` because that is the selected JSON contract. A missing creditor BIC + is resolved through SepaTools and accepted only when exactly one unique candidate exists. The + default charge is `SHA`; an explicit `BEN`/`OUR`/`SHA` value is preserved. +- Every bank reference begins with `DFX-FO-` and is capped at 140 characters. User + remittance text follows the stable identifier, so the statement echo remains unique. +- Approval uses a safely representable Bank Frick `orderId` where available. It falls back to the + OpenAPI `customIds` selector when JSON cannot represent the int64 safely. +- With `FRICK_APPROVE_WITHOUT_TAN=false`, a created order deliberately remains `PREPARED` until an + operator approves it in the Bank Frick portal; the independent status poll continues tracking it. + Enable automatic approval only after Bank Frick has confirmed the backend TAN exemption. +- Reconciliation accepts exactly one debit transaction with the same source account, amount, + currency, readiness window and reference/end-to-end ID. Zero matches wait; multiple matches fail + closed and never mark the output complete. + +## 6. Mandatory sandbox checklist + +Do not remove the default process switches until all items are evidenced with Bank Frick test or +sandbox credentials: + +1. Verify authorize, accounts and camt.053 responses with the environment-specific server key. +2. Import an official-shape camt.053 containing offset dates, `Pty` wrappers and entry-level bank + transaction codes; reconcile the resulting row to the source statement. +3. Confirm the real CHF routing contract (`FOREIGN`, creditor BIC and `SHA`) with one harmless test + order. If Bank Frick requires a domestic type instead, change the mapping before production. +4. Confirm `signTransactionWithoutTan` accepts `orderIds` and the configured token exemption. Keep + automatic approval disabled if this has not been confirmed. +5. Confirm the booked statement echoes the full `DFX-FO- ...` reference and that the strict + reconciliation query completes exactly one fiat output. +6. Exercise 401 re-authorization, invalid response signature, ambiguous BIC, ambiguous bank match, + empty statement and import-persistence failure; each must leave money/cursors unchanged. +7. Enable `FiatOutputFrickStatusCheck`, observe clean polling, then enable + `FiatOutputFrickTransmission` in a separate controlled step. +8. Verify with a real camt.053 sample that Bank Frick books a charged debit **gross** + (`Ntry/Amt` inclusive of `Chrgs`), matching what the #8 net-of-charge reconciliation fix + (`bank-tx-outgoing-match.service.ts`) assumes. If Bank Frick ever books net instead while still + sending a `Chrgs` element, the matcher would subtract a phantom charge from an already-net amount + and the payout would never reconcile - this can only be confirmed against a real booked statement, + not from the API documentation alone. The matcher and `BankTxService.fillBankTx`'s accounting + (which now also treats a DEBIT row's `amount` as gross and does not add `chargeAmount` back on top) + both encode the same gross-booking assumption - if this verification ever concludes Bank Frick books + net instead, both must be re-aligned together, not just the matcher. +9. Verify that a payment order just created via `PUT /transactions` is immediately visible through + `GET /transactions?customId=...` (read-after-write). If Bank Frick can transiently return no + result right after creation, the #6 self-heal in `checkFrickOrderStatus` could misread that as + "order was never created", clear the reservation, and trigger a re-`PUT` for the same customId. + Confirm this with a real sandbox order before enabling automatic status polling. + +## 7. Coverage gate on the shared `iso20022.service.ts` + +`package.json`'s `coverageThreshold` holds `src/integration/bank/services/iso20022.service.ts` to +100% even though this file is shared with Yapeal/Raiffeisen parsing, not Frick-only. This is a +deliberate trade-off, not an oversight: the money-critical fixes in this PR (malformed-entry +rejection, the missing-bank-reference guard, and bank-charge parsing) live in exactly this file, +and 100% branch coverage is the only mechanical guarantee that a future change cannot silently +regress them. The cost - a future, unrelated Yapeal/Raiffeisen-only change could fail CI on an +uncovered branch it didn't intend to touch - is accepted deliberately in exchange for that +protection. If this ever becomes a real blocker, the long-term fix is to split the Frick-specific +strict-mode parsing into its own file with its own gate, not to lower this threshold. diff --git a/eslint.config.js b/eslint.config.js index 4b686a1ae0..48dca806d2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -59,6 +59,13 @@ module.exports = tseslint.config( }, }, { - ignores: ['eslint.config.js', 'migration/**/*.js', 'scripts/*.js', 'dist/**', 'node_modules/**'], + ignores: [ + 'eslint.config.js', + 'jest.frick.config.js', + 'migration/**/*.js', + 'scripts/*.js', + 'dist/**', + 'node_modules/**', + ], }, ); diff --git a/infrastructure/bicep/api/dfx-api.bicep b/infrastructure/bicep/api/dfx-api.bicep index 784ce53c86..82ce57525c 100644 --- a/infrastructure/bicep/api/dfx-api.bicep +++ b/infrastructure/bicep/api/dfx-api.bicep @@ -235,6 +235,17 @@ param olkyUser string @secure() param olkyPassword string +param frickBaseUrl string +@secure() +param frickApiKey string +@secure() +param frickPrivateKey string +param frickServerPublicKey string +@secure() +param frickCustomer string +param frickPayoutEnabled string +param frickApproveWithoutTan string + param letterUrl string param letterUser string @secure() @@ -1209,6 +1220,34 @@ resource apiAppService 'Microsoft.Web/sites@2018-11-01' = { name: 'OLKY_PASSWORD' value: olkyPassword } + { + name: 'FRICK_BASE_URL' + value: frickBaseUrl + } + { + name: 'FRICK_API_KEY' + value: frickApiKey + } + { + name: 'FRICK_PRIVATE_KEY' + value: frickPrivateKey + } + { + name: 'FRICK_SERVER_PUBLIC_KEY' + value: frickServerPublicKey + } + { + name: 'FRICK_CUSTOMER' + value: frickCustomer + } + { + name: 'FRICK_PAYOUT_ENABLED' + value: frickPayoutEnabled + } + { + name: 'FRICK_APPROVE_WITHOUT_TAN' + value: frickApproveWithoutTan + } { name: 'COIN_GECKO_API_KEY' value: coinGeckoApiKey diff --git a/infrastructure/bicep/api/parameters/dev.json b/infrastructure/bicep/api/parameters/dev.json index 1c6500fbb7..8aa5f13bee 100644 --- a/infrastructure/bicep/api/parameters/dev.json +++ b/infrastructure/bicep/api/parameters/dev.json @@ -434,6 +434,27 @@ "olkyPassword": { "value": "xxx" }, + "frickBaseUrl": { + "value": "https://olbtest.bankfrick.li/webapi/v2" + }, + "frickApiKey": { + "value": "xxx" + }, + "frickPrivateKey": { + "value": "xxx" + }, + "frickServerPublicKey": { + "value": "xxx" + }, + "frickCustomer": { + "value": "xxx" + }, + "frickPayoutEnabled": { + "value": "false" + }, + "frickApproveWithoutTan": { + "value": "false" + }, "letterUrl": { "value": "https://sandbox.letterxpress.de/v1" }, diff --git a/infrastructure/bicep/api/parameters/loc.json b/infrastructure/bicep/api/parameters/loc.json index c1bfb5507b..b3661efba7 100644 --- a/infrastructure/bicep/api/parameters/loc.json +++ b/infrastructure/bicep/api/parameters/loc.json @@ -434,6 +434,27 @@ "olkyPassword": { "value": "xxx" }, + "frickBaseUrl": { + "value": "https://olbtest.bankfrick.li/webapi/v2" + }, + "frickApiKey": { + "value": "xxx" + }, + "frickPrivateKey": { + "value": "xxx" + }, + "frickServerPublicKey": { + "value": "xxx" + }, + "frickCustomer": { + "value": "xxx" + }, + "frickPayoutEnabled": { + "value": "false" + }, + "frickApproveWithoutTan": { + "value": "false" + }, "letterUrl": { "value": "https://sandbox.letterxpress.de/v1" }, diff --git a/infrastructure/bicep/api/parameters/prd.json b/infrastructure/bicep/api/parameters/prd.json index 78d729df54..c4625e2848 100644 --- a/infrastructure/bicep/api/parameters/prd.json +++ b/infrastructure/bicep/api/parameters/prd.json @@ -434,6 +434,27 @@ "olkyPassword": { "value": "xxx" }, + "frickBaseUrl": { + "value": "https://olb.bankfrick.li/webapi/v2" + }, + "frickApiKey": { + "value": "xxx" + }, + "frickPrivateKey": { + "value": "xxx" + }, + "frickServerPublicKey": { + "value": "xxx" + }, + "frickCustomer": { + "value": "xxx" + }, + "frickPayoutEnabled": { + "value": "false" + }, + "frickApproveWithoutTan": { + "value": "false" + }, "letterUrl": { "value": "https://api.letterxpress.de/v1" }, diff --git a/jest.frick.config.js b/jest.frick.config.js new file mode 100644 index 0000000000..3fb238757a --- /dev/null +++ b/jest.frick.config.js @@ -0,0 +1,37 @@ +// Bank Frick coverage gate. Kept out of package.json's shared Jest config so the strict per-file 100% +// threshold on the shared iso20022.service.ts cannot red an unrelated `test:cov` run - only the +// dedicated test:frick:cov step (with its own --collectCoverageFrom scope) enforces it. +const base = require('./package.json').jest; + +module.exports = { + ...base, + coverageThreshold: { + 'src/integration/bank/dto/frick.dto.ts': { branches: 100, functions: 100, lines: 100, statements: 100 }, + 'src/integration/bank/services/frick.service.ts': { branches: 100, functions: 100, lines: 100, statements: 100 }, + 'src/integration/bank/services/iso20022.service.ts': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + 'src/config/frick.config.ts': { branches: 100, functions: 100, lines: 100, statements: 100 }, + 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + 'src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}; diff --git a/migration/1783944000000-AddBankFrickPayoutTracking.js b/migration/1783944000000-AddBankFrickPayoutTracking.js new file mode 100644 index 0000000000..d8fe33f373 --- /dev/null +++ b/migration/1783944000000-AddBankFrickPayoutTracking.js @@ -0,0 +1,88 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Schema-only migration: `fiat_output` payout-tracking columns, the `bank.sendPriority` sender + * tie-breaker column (backfilled to a neutral default on every existing row), and the default-off + * process switches. It deliberately never inserts, updates or deletes `bank` rows - the only prior + * migration that ever did that (`1768943778000-AddYapealEurManualBank.js`) was reverted because the + * row was already inserted manually (`f897b98a2`). The two new Bank Frick account rows, and any + * cleanup of the legacy Bank Frick rows, are manual production steps documented in + * `docs/bank-frick-operations.md` §3, exactly like the existing Yapeal EUR account. A fresh local + * registry is populated from migration/seed/bank.csv instead. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddBankFrickPayoutTracking1783944000000 { + name = 'AddBankFrickPayoutTracking1783944000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + // Bank Frick's orderId is a plain integer (max 16 digits); varchar(64) is deliberately generous + // rather than the unrelated 256-character width used for the other string-identifier columns below. + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderId" character varying(64)`); + // Stores DFX's own generated customId (e.g. "DFX-FO-42"), not a Bank Frick transaction id - named + // accordingly rather than "frickTxId". + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickCustomId" character varying(256)`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderStatus" character varying(256)`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickError" character varying(256)`); + // Holds exactly the reference string sent to Bank Frick (customId-prefixed, bank-bound) so + // reconciliation can match on it without ever overwriting the customer-facing remittanceInfo. + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickReference" character varying(256)`); + // Sender priority is a deliberate, operator-controlled tie-breaker: lower value tried first. Every + // pre-existing bank row is backfilled to the neutral default (1000) so this column changes nothing + // about today's routing. The new Frick rows are seeded worse than that default (2000) so simply + // flipping Frick's `send` flag on cannot silently steal traffic from a working incumbent (Olkypay, + // Yapeal) — Ops must deliberately lower Frick's priority below the incumbent's to cut over. + await queryRunner.query(`ALTER TABLE "bank" ADD "sendPriority" integer NOT NULL DEFAULT 1000`); + await queryRunner.query(` + INSERT INTO "setting" ("key", "value", "updated", "created") + VALUES ( + 'disabledProcess', + '["FiatOutputFrickTransmission","FiatOutputFrickStatusCheck"]', + NOW(), + NOW() + ) + ON CONFLICT ("key") DO UPDATE SET "value" = ( + COALESCE(NULLIF("setting"."value", ''), '[]')::jsonb + || CASE + WHEN COALESCE(NULLIF("setting"."value", ''), '[]')::jsonb @> '["FiatOutputFrickTransmission"]'::jsonb + THEN '[]'::jsonb ELSE '["FiatOutputFrickTransmission"]'::jsonb + END + || CASE + WHEN COALESCE(NULLIF("setting"."value", ''), '[]')::jsonb @> '["FiatOutputFrickStatusCheck"]'::jsonb + THEN '[]'::jsonb ELSE '["FiatOutputFrickStatusCheck"]'::jsonb + END + )::text, "updated" = NOW() + `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(` + UPDATE "setting" + SET "value" = COALESCE(( + SELECT jsonb_agg(process) + FROM jsonb_array_elements_text(COALESCE(NULLIF("setting"."value", ''), '[]')::jsonb) AS processes(process) + WHERE process NOT IN ('FiatOutputFrickTransmission', 'FiatOutputFrickStatusCheck') + ), '[]'::jsonb)::text, + "updated" = NOW() + WHERE "key" = 'disabledProcess' + `); + await queryRunner.query(`ALTER TABLE "bank" DROP COLUMN "sendPriority"`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickReference"`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickError"`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickOrderStatus"`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickCustomId"`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickOrderId"`); + } +}; diff --git a/migration/seed/bank.csv b/migration/seed/bank.csv index 64aa9f4d28..3ec7b6cad7 100644 --- a/migration/seed/bank.csv +++ b/migration/seed/bank.csv @@ -1,7 +1,9 @@ -id,updated,created,name,iban,bic,currency,receive,send,sctInst,amlEnabled,assetId -16,2025-12-05 14:27:39,2025-12-05 14:27:39,Yapeal,CH6783019DFXEUR000002,YAPECHZ2,EUR,TRUE,TRUE,FALSE,TRUE,405 -15,2025-12-05 14:27:39,2025-12-05 14:27:39,Yapeal,CH5283019DFXCHF000001,YAPECHZ2,CHF,TRUE,TRUE,FALSE,TRUE,404 -14,2025-09-10 23:30:56,2025-09-10 23:30:56,Maerki Baumann,LU116060002000005040,OLKILUL1,EUR,FALSE,FALSE,FALSE,TRUE, -13,2025-09-10 23:30:07,2025-09-10 23:30:07,Maerki Baumann,CH4880808002186504370,RAIFCH22,CHF,FALSE,FALSE,FALSE,TRUE, -12,2025-09-10 23:30:07,2025-09-10 23:30:07,Maerki Baumann,CH7780808002608614092,RAIFCH22,EUR,FALSE,FALSE,FALSE,TRUE, -8,2025-09-10 23:30:06,2025-09-10 23:30:06,Maerki Baumann,CH3808573109968202333,MAEBCHZZ,USD,FALSE,FALSE,FALSE,TRUE, \ No newline at end of file +id,updated,created,name,iban,bic,currency,receive,send,sctInst,amlEnabled,assetId +9001,2026-07-13 00:00:00,2026-07-13 00:00:00,Bank Frick,LI5600000FRICKEUR0001,BFRILI22,EUR,FALSE,FALSE,FALSE,TRUE, +9002,2026-07-13 00:00:00,2026-07-13 00:00:00,Bank Frick,LI4200000FRICKCHF0001,BFRILI22,CHF,FALSE,FALSE,FALSE,TRUE, +16,2025-12-05 14:27:39,2025-12-05 14:27:39,Yapeal,CH6783019DFXEUR000002,YAPECHZ2,EUR,TRUE,TRUE,FALSE,TRUE,405 +15,2025-12-05 14:27:39,2025-12-05 14:27:39,Yapeal,CH5283019DFXCHF000001,YAPECHZ2,CHF,TRUE,TRUE,FALSE,TRUE,404 +14,2025-09-10 23:30:56,2025-09-10 23:30:56,Maerki Baumann,LU116060002000005040,OLKILUL1,EUR,FALSE,FALSE,FALSE,TRUE, +13,2025-09-10 23:30:07,2025-09-10 23:30:07,Maerki Baumann,CH4880808002186504370,RAIFCH22,CHF,FALSE,FALSE,FALSE,TRUE, +12,2025-09-10 23:30:07,2025-09-10 23:30:07,Maerki Baumann,CH7780808002608614092,RAIFCH22,EUR,FALSE,FALSE,FALSE,TRUE, +8,2025-09-10 23:30:06,2025-09-10 23:30:06,Maerki Baumann,CH3808573109968202333,MAEBCHZZ,USD,FALSE,FALSE,FALSE,TRUE, diff --git a/package.json b/package.json index bbe145ee8b..ef700dd2d0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "jest --silent", "test:watch": "jest --watch", "test:cov": "jest --coverage", + "test:frick:cov": "jest --config jest.frick.config.js integration/bank/services/__tests__/frick.service.spec.ts integration/bank/services/__tests__/iso20022.service.spec.ts config/__tests__/frick.config.spec.ts config/__tests__/bank-frick-config.spec.ts subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts --coverage --runInBand --collectCoverageFrom=integration/bank/dto/frick.dto.ts --collectCoverageFrom=integration/bank/services/frick.service.ts --collectCoverageFrom=integration/bank/services/iso20022.service.ts --collectCoverageFrom=config/frick.config.ts --collectCoverageFrom=subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts --collectCoverageFrom=subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts --collectCoverageFrom=subdomains/supporting/fiat-output/fiat-output-frick.service.ts", "type-check": "tsc --noEmit", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "check": "npm run lint && npm run test", diff --git a/src/config/__tests__/bank-frick-config.spec.ts b/src/config/__tests__/bank-frick-config.spec.ts new file mode 100644 index 0000000000..ffe40bc538 --- /dev/null +++ b/src/config/__tests__/bank-frick-config.spec.ts @@ -0,0 +1,20 @@ +import { GetConfig } from '../config'; + +describe('Bank Frick config', () => { + it('restores PEM line breaks in the private key', () => { + const previousPrivateKey = process.env.FRICK_PRIVATE_KEY; + const previousServerPublicKey = process.env.FRICK_SERVER_PUBLIC_KEY; + process.env.FRICK_PRIVATE_KEY = 'synthetic-line-one
synthetic-line-two'; + process.env.FRICK_SERVER_PUBLIC_KEY = 'synthetic-public-one
synthetic-public-two'; + + try { + expect(GetConfig().bank.frick.privateKey).toBe('synthetic-line-one\nsynthetic-line-two'); + expect(GetConfig().bank.frick.serverPublicKey).toBe('synthetic-public-one\nsynthetic-public-two'); + } finally { + if (previousPrivateKey === undefined) delete process.env.FRICK_PRIVATE_KEY; + else process.env.FRICK_PRIVATE_KEY = previousPrivateKey; + if (previousServerPublicKey === undefined) delete process.env.FRICK_SERVER_PUBLIC_KEY; + else process.env.FRICK_SERVER_PUBLIC_KEY = previousServerPublicKey; + } + }); +}); diff --git a/src/config/__tests__/frick.config.spec.ts b/src/config/__tests__/frick.config.spec.ts new file mode 100644 index 0000000000..c01c808aa7 --- /dev/null +++ b/src/config/__tests__/frick.config.spec.ts @@ -0,0 +1,60 @@ +import { buildFrickConfig } from '../frick.config'; + +function env(overrides: Partial = {}): NodeJS.ProcessEnv { + return { ...overrides } as NodeJS.ProcessEnv; +} + +describe('buildFrickConfig', () => { + it('passes through baseUrl, apiKey and customer', () => { + const config = buildFrickConfig( + env({ + FRICK_BASE_URL: 'https://synthetic.frick', + FRICK_API_KEY: 'synthetic-key', + FRICK_CUSTOMER: 'synthetic-customer', + }), + ); + + expect(config.baseUrl).toBe('https://synthetic.frick'); + expect(config.apiKey).toBe('synthetic-key'); + expect(config.customer).toBe('synthetic-customer'); + }); + + it('restores PEM line breaks when private and server public keys are set', () => { + const config = buildFrickConfig( + env({ + FRICK_PRIVATE_KEY: 'synthetic-private-one
synthetic-private-two', + FRICK_SERVER_PUBLIC_KEY: 'synthetic-public-one
synthetic-public-two', + }), + ); + + expect(config.privateKey).toBe('synthetic-private-one\nsynthetic-private-two'); + expect(config.serverPublicKey).toBe('synthetic-public-one\nsynthetic-public-two'); + }); + + it('leaves both signing keys undefined when they are not set', () => { + const config = buildFrickConfig(env()); + + expect(config.privateKey).toBeUndefined(); + expect(config.serverPublicKey).toBeUndefined(); + }); + + it.each([ + ['true', true], + ['false', false], + [undefined, false], + ])('resolves payoutEnabled from %s to %s', (value, expected) => { + const config = buildFrickConfig(env({ FRICK_PAYOUT_ENABLED: value })); + + expect(config.payoutEnabled).toBe(expected); + }); + + it.each([ + ['true', true], + ['false', false], + [undefined, false], + ])('resolves approveWithoutTan from %s to %s', (value, expected) => { + const config = buildFrickConfig(env({ FRICK_APPROVE_WITHOUT_TAN: value })); + + expect(config.approveWithoutTan).toBe(expected); + }); +}); diff --git a/src/config/config.ts b/src/config/config.ts index 54ea8ebc99..add33fa6c7 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -26,6 +26,7 @@ import { MailOptions } from 'src/subdomains/supporting/notification/services/mai import { RealUnitLegalAgreement } from 'src/subdomains/supporting/realunit/enums/realunit-legal-agreement.enum'; import { LoggerOptions } from 'typeorm'; import { EVM_CHAINS } from './chains.config'; +import { buildFrickConfig } from './frick.config'; export type NetworkName = 'mainnet' | 'testnet' | 'regtest'; @@ -1202,6 +1203,7 @@ export class Configuration { clientSecret: process.env.OLKY_CLIENT_SECRET, }, }, + frick: buildFrickConfig(process.env), raiffeisen: { credentials: { url: process.env.RAIFFEISEN_EBICS_URL, diff --git a/src/config/frick.config.ts b/src/config/frick.config.ts new file mode 100644 index 0000000000..c8cd1fbb0a --- /dev/null +++ b/src/config/frick.config.ts @@ -0,0 +1,21 @@ +export interface FrickConfig { + baseUrl: string; + apiKey: string; + privateKey: string; + serverPublicKey: string; + customer: string; + payoutEnabled: boolean; + approveWithoutTan: boolean; +} + +export function buildFrickConfig(env: NodeJS.ProcessEnv): FrickConfig { + return { + baseUrl: env.FRICK_BASE_URL, + apiKey: env.FRICK_API_KEY, + privateKey: env.FRICK_PRIVATE_KEY?.split('
').join('\n'), + serverPublicKey: env.FRICK_SERVER_PUBLIC_KEY?.split('
').join('\n'), + customer: env.FRICK_CUSTOMER, + payoutEnabled: env.FRICK_PAYOUT_ENABLED === 'true', + approveWithoutTan: env.FRICK_APPROVE_WITHOUT_TAN === 'true', + }; +} diff --git a/src/integration/bank/bank.module.ts b/src/integration/bank/bank.module.ts index d0fa42f56a..a664ca0b1e 100644 --- a/src/integration/bank/bank.module.ts +++ b/src/integration/bank/bank.module.ts @@ -5,6 +5,7 @@ import { YapealWebhookController } from './controllers/yapeal-webhook.controller import { OlkyRecipient } from './entities/olky-recipient.entity'; import { OlkyRecipientRepository } from './repositories/olky-recipient.repository'; import { IbanService } from './services/iban.service'; +import { BankFrickService } from './services/frick.service'; import { OlkypayService } from './services/olkypay.service'; import { RaiffeisenService } from './services/raiffeisen.service'; import { YapealWebhookService } from './services/yapeal-webhook.service'; @@ -14,6 +15,7 @@ import { YapealService } from './services/yapeal.service'; imports: [TypeOrmModule.forFeature([OlkyRecipient]), SharedModule], controllers: [YapealWebhookController], providers: [ + BankFrickService, IbanService, OlkypayService, OlkyRecipientRepository, @@ -21,6 +23,6 @@ import { YapealService } from './services/yapeal.service'; YapealService, YapealWebhookService, ], - exports: [IbanService, OlkypayService, RaiffeisenService, YapealService, YapealWebhookService], + exports: [BankFrickService, IbanService, OlkypayService, RaiffeisenService, YapealService, YapealWebhookService], }) export class BankIntegrationModule {} diff --git a/src/integration/bank/dto/frick.dto.ts b/src/integration/bank/dto/frick.dto.ts new file mode 100644 index 0000000000..588933c393 --- /dev/null +++ b/src/integration/bank/dto/frick.dto.ts @@ -0,0 +1,153 @@ +// Distinguishable from a generic Error so callers can safely treat "no such order" as reliable +// evidence a payout was never created (the lookup always searches from BankFrickService's earliest +// fromDate, never a narrow recent window) instead of an ambiguous transport/validation failure. +export class FrickPaymentOrderNotFoundError extends Error { + constructor(customId: string) { + super(`Bank Frick payment order ${customId} not found`); + this.name = 'FrickPaymentOrderNotFoundError'; + } +} + +// A failed response-signature verification is a detected tampered-response attack, not a generic +// transport failure - distinguishable so it is never reported to operators as the indistinguishable +// "request failed" string that defeats detection of the exact attack the signature check exists for. +export class FrickSignatureVerificationError extends Error { + constructor(reason: string) { + super(reason); + this.name = 'FrickSignatureVerificationError'; + } +} + +export interface FrickAuthorizeRequest { + key: string; +} + +export interface FrickAuthorizeResponse { + token: string; +} + +export interface FrickAccount { + account: string; + type: string; + iban?: string; + customer: string; + currency: string; + balance: number; + available?: number; +} + +export interface FrickAccountsResponse { + date: string; + moreResults: boolean; + resultSetSize: number; + accounts: FrickAccount[]; +} + +export interface FrickBalance { + iban: string; + currency: string; + balance: number; + availableBalance?: number; +} + +export enum FrickPaymentType { + SEPA = 'SEPA', + SEPA_INSTANT = 'SEPA_INSTANT', + FOREIGN = 'FOREIGN', +} + +export enum FrickPaymentCharge { + BENEFICIARY = 'BEN', + OUR = 'OUR', + SHARED = 'SHA', +} + +export enum FrickPaymentState { + PREPARED = 'PREPARED', + IN_PROGRESS = 'IN_PROGRESS', + EXECUTED = 'EXECUTED', + BOOKED = 'BOOKED', + REJECTED = 'REJECTED', + EXPIRED = 'EXPIRED', + DELETED = 'DELETED', + DELETION_REQUESTED = 'DELETION_REQUESTED', + ERROR = 'ERROR', +} + +// DELETION_REQUESTED is intentionally NOT terminal: the bank order can still be executed or fail +// later, so liquidity must stay reserved and the status must keep being polled until a real terminal +// state (or a matching debit bankTx via the isComplete path) arrives. Exported so both +// FiatOutputFrickService and the payment monitor can agree on exactly which states are terminal. +export const FRICK_TERMINAL_STATES = [ + FrickPaymentState.REJECTED, + FrickPaymentState.EXPIRED, + FrickPaymentState.DELETED, + FrickPaymentState.ERROR, +]; + +export interface FrickPaymentAccount { + accountNumber?: string; + iban?: string; + name?: string; + address?: string; + postalcode?: string; + city?: string; + country?: string; + bic?: string; + creditInstitution?: string; + // Older Bank Frick response examples use this misspelling. Never send it, but accept it when comparing a + // returned order with the idempotent request that created it. + creditInsitution?: string; +} + +export interface FrickPaymentOrderInput { + customId: string; + amount: number; + currency: 'CHF' | 'EUR'; + instant?: boolean; + reference?: string; + charge?: FrickPaymentCharge; + debtorIban: string; + creditor: FrickPaymentAccount; +} + +export interface FrickCreateTransaction { + customId: string; + type: FrickPaymentType; + amount: number; + currency: 'CHF' | 'EUR'; + express?: boolean; + reference?: string; + charge?: FrickPaymentCharge; + debitor: FrickPaymentAccount; + creditor: FrickPaymentAccount; +} + +export interface FrickCreateTransactionsRequest { + transactions: FrickCreateTransaction[]; +} + +export interface FrickPaymentOrder { + orderId?: number; + transactionNr?: string; + customId: string; + type: FrickPaymentType; + state: FrickPaymentState; + amount: number | string; + currency: string; + express?: boolean; + reference?: string; + charge?: FrickPaymentCharge; + debitor: FrickPaymentAccount; + creditor: FrickPaymentAccount; +} + +export interface FrickTransactionsResponse { + moreResults: boolean; + resultSetSize: number; + transactions: FrickPaymentOrder[]; +} + +export type FrickApproveWithoutTanRequest = + | { orderIds: number[]; customIds?: never } + | { customIds: string[]; orderIds?: never }; diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts new file mode 100644 index 0000000000..2896910574 --- /dev/null +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -0,0 +1,938 @@ +import { createSign, generateKeyPairSync, verify } from 'crypto'; +import * as IbanTools from 'ibantools'; +import { Config, ConfigService } from 'src/config/config'; +import { HttpService } from 'src/shared/services/http.service'; +import { BankTxIndicator } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { + FrickPaymentCharge, + FrickPaymentOrder, + FrickPaymentOrderNotFoundError, + FrickPaymentState, + FrickPaymentType, + FrickSignatureVerificationError, + FrickTransactionsResponse, +} from '../../dto/frick.dto'; +import { BankFrickService } from '../frick.service'; + +describe('BankFrickService', () => { + const keys = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const debtorIban = createSyntheticIban('LI', '00000TESTACCOUNT1'); + const creditorIban = createSyntheticIban('DE', '000000000000000001'); + + let http: { request: jest.Mock }; + let service: BankFrickService; + + beforeAll(() => new ConfigService()); + + beforeEach(() => { + Config.bank.frick = { + baseUrl: 'https://bank.invalid/webapi/v2/', + apiKey: 'synthetic-api-key', + privateKey: keys.privateKey, + serverPublicKey: keys.publicKey, + customer: '0000000', + payoutEnabled: false, + approveWithoutTan: false, + }; + http = { request: jest.fn() }; + service = new BankFrickService(http as unknown as HttpService); + }); + + it('signs the exact serialized authorize body and normalizes the base URL', async () => { + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(accountsResponse()); + + await service.getBalances(); + + const authorize = http.request.mock.calls[0][0]; + expect(authorize.url).toBe('https://bank.invalid/webapi/v2/authorize'); + expect(authorize.data).toBe(JSON.stringify({ key: 'synthetic-api-key' })); + expect(authorize.headers.algorithm).toBe('rsa-sha512'); + expect(authorize.headers.Authorization).toBeUndefined(); + expectSignature(authorize.data, authorize.headers.Signature); + expect(http.request.mock.calls[1][0].url).toBe('https://bank.invalid/webapi/v2/accounts/0000000'); + // Combined with the unbounded cron lock on the status poller, a hung connection with no timeout + // would silently kill it permanently. + expect(authorize.timeout).toBe(30_000); + expect(http.request.mock.calls[1][0].timeout).toBe(30_000); + + for (const [index, [request]] of http.request.mock.calls.entries()) { + const rawResponse = JSON.stringify({ syntheticResponse: index }); + const signer = createSign('sha512'); + signer.update(rawResponse); + expect(() => + request.responseVerifier(rawResponse, { + signature: signer.sign(keys.privateKey, 'base64'), + algorithm: 'rsa-sha512', + }), + ).not.toThrow(); + } + }); + + it('signs bodyless GET requests over the empty string and caches the JWT', async () => { + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(accountsResponse()) + .mockResolvedValueOnce(accountsResponse()); + + await service.getBalances(); + await service.getBalances(); + + const accountCalls = http.request.mock.calls + .map(([request]) => request) + .filter((request) => request.url.endsWith('/accounts/0000000')); + expect(accountCalls).toHaveLength(2); + expect(accountCalls[0].data).toBe(''); + expect(accountCalls[0].headers.Authorization).toMatch(/^Bearer /); + expectSignature('', accountCalls[0].headers.Signature); + expect(http.request.mock.calls.filter(([request]) => request.url.endsWith('/authorize'))).toHaveLength(1); + }); + + it('shares one in-flight authorization across concurrent requests', async () => { + let resolveAuthorization: (value: { token: string }) => void; + const authorization = new Promise<{ token: string }>((resolve) => (resolveAuthorization = resolve)); + http.request.mockImplementation((request) => + request.url.endsWith('/authorize') ? authorization : Promise.resolve(accountsResponse()), + ); + + const requests = [service.getBalances(), service.getBalances(), service.getBalances()]; + await Promise.resolve(); + expect(http.request.mock.calls.filter(([request]) => request.url.endsWith('/authorize'))).toHaveLength(1); + resolveAuthorization({ token: jwt() }); + await Promise.all(requests); + }); + + it('refreshes once after a 401 and retries the original request once', async () => { + let authorizationCount = 0; + let accountCount = 0; + http.request.mockImplementation((request) => { + if (request.url.endsWith('/authorize')) return Promise.resolve({ token: jwt(++authorizationCount) }); + if (request.url.endsWith('/accounts/0000000') && ++accountCount === 1) + return Promise.reject({ response: { status: 401 } }); + return Promise.resolve(accountsResponse()); + }); + + await expect(service.getBalances()).resolves.toHaveLength(1); + expect(authorizationCount).toBe(2); + expect(accountCount).toBe(2); + }); + + it('never retries an unauthorized request more than once', async () => { + let authorizationCount = 0; + let accountCount = 0; + http.request.mockImplementation((request) => { + if (request.url.endsWith('/authorize')) return Promise.resolve({ token: jwt(++authorizationCount) }); + accountCount += 1; + return Promise.reject({ response: { status: 401 } }); + }); + + await expect(service.getBalances()).rejects.toThrow('HTTP 401'); + expect(authorizationCount).toBe(2); + expect(accountCount).toBe(2); + }); + + it('does not propagate arbitrary transport messages that could contain credentials', async () => { + http.request.mockRejectedValueOnce(new Error('transport included synthetic-api-key')); + const request = service.getBalances(); + + await expect(request).rejects.toThrow('Bank Frick authorization failed: request failed'); + await expect(request).rejects.not.toThrow('synthetic-api-key'); + }); + + it('reports a tampered response signature distinctly from a generic transport failure, both during authorization and a normal request', async () => { + // A FrickSignatureVerificationError is what HttpService.request() throws internally when + // verifyResponse rejects a response - simulated directly here since http.request is mocked at + // the HttpService boundary. + http.request.mockRejectedValueOnce(new FrickSignatureVerificationError('Invalid Bank Frick response signature')); + await expect(service.getBalances()).rejects.toThrow( + 'Bank Frick authorization response signature verification failed: Invalid Bank Frick response signature', + ); + + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockRejectedValueOnce(new FrickSignatureVerificationError('Invalid Bank Frick response signature headers')); + await expect(service.getBalances()).rejects.toThrow( + 'Bank Frick response signature verification failed (GET accounts/0000000): Invalid Bank Frick response signature headers', + ); + }); + + it('fails loud when connection configuration is incomplete', async () => { + Config.bank.frick.privateKey = undefined; + + expect(service.isAvailable()).toBe(false); + await expect(service.getBalances()).rejects.toThrow('Bank Frick is not configured'); + expect(http.request).not.toHaveBeenCalled(); + }); + + it.each(['baseUrl', 'apiKey', 'privateKey', 'serverPublicKey', 'customer'] as const)( + 'reports the integration unavailable when %s is missing', + (field) => { + Config.bank.frick[field] = undefined; + + expect(service.isAvailable()).toBe(false); + }, + ); + + it.each([ + ['rsa-sha512', 'sha512'], + ['rsa-sha384', 'sha384'], + ['rsa-sha256', 'sha256'], + ] as const)('verifies exact response bytes for %s', (headerAlgorithm, hashAlgorithm) => { + const body = '{ "synthetic": "response bytes" }'; + const signer = createSign(hashAlgorithm); + signer.update(body); + const signature = signer.sign(keys.privateKey, 'base64'); + + expect(() => + service['verifyResponse'](Buffer.from(body), { Signature: signature, Algorithm: headerAlgorithm } as never), + ).not.toThrow(); + }); + + it.each([ + [{}, 'Invalid Bank Frick response signature headers'], + [{ signature: 'not-a-signature', algorithm: 'rsa-sha512' }, 'Invalid Bank Frick response signature'], + [{ signature: 'irrelevant', algorithm: 'rsa-pss-sha512' }, 'Invalid Bank Frick response signature headers'], + ])('rejects missing, invalid or unsupported response signatures', (headers, expectedError) => { + expect(() => service['verifyResponse'](Buffer.from('{"synthetic":true}'), headers as never)).toThrow(expectedError); + }); + + it('fails closed when a payment is attempted without the explicit payout flag', async () => { + await expect(service.createPaymentOrder(paymentInput())).rejects.toThrow('payout is not explicitly enabled'); + await expect(service.approvePaymentWithoutTan(paymentOrder())).rejects.toThrow('payout is not explicitly enabled'); + expect(http.request).not.toHaveBeenCalled(); + }); + + it('fails closed when approval without TAN is not explicitly enabled', async () => { + Config.bank.frick.payoutEnabled = true; + + await expect(service.approvePaymentWithoutTan(paymentOrder())).rejects.toThrow( + 'approval without TAN is not explicitly enabled', + ); + expect(http.request).not.toHaveBeenCalled(); + }); + + it('recovers an existing idempotent order by customId without sending a PUT', async () => { + Config.bank.frick.payoutEnabled = true; + const order = paymentOrder(); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(transactionsResponse([order])); + + await expect(service.createPaymentOrder(paymentInput())).resolves.toEqual(order); + + expect(http.request.mock.calls.some(([request]) => request.method === 'PUT')).toBe(false); + expect(http.request.mock.calls[1][0].url).toContain('transactions?customId=DFX-FO-42'); + expect(http.request.mock.calls[1][0].url).toContain('fromDate=1970-01-01'); + }); + + it('recovers BOOKED orders through the explicit historical lookup', async () => { + Config.bank.frick.payoutEnabled = true; + const order = paymentOrder({ state: FrickPaymentState.BOOKED }); + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(transactionsResponse([])) + .mockResolvedValueOnce(transactionsResponse([order])); + + await expect(service.createPaymentOrder(paymentInput())).resolves.toEqual(order); + + const requests = http.request.mock.calls.map(([request]) => request); + expect(requests[1].url).toContain('transactions?customId=DFX-FO-42'); + expect(requests[1].url).toContain('fromDate=1970-01-01'); + expect(requests[2].url).toContain('status=BOOKED'); + expect(requests[2].url).toContain('fromDate=1970-01-01'); + expect(requests.some((request) => request.method === 'PUT')).toBe(false); + }); + + it('signs and sends the payment JSON exactly once serialized', async () => { + Config.bank.frick.payoutEnabled = true; + const order = paymentOrder(); + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(transactionsResponse([])) + .mockResolvedValueOnce(transactionsResponse([])) + .mockResolvedValueOnce(transactionsResponse([order])); + + await service.createPaymentOrder(paymentInput()); + + const put = http.request.mock.calls.map(([request]) => request).find((request) => request.method === 'PUT'); + expect(JSON.parse(put.data)).toEqual({ + transactions: [ + { + customId: 'DFX-FO-42', + type: 'SEPA', + amount: 10.25, + currency: 'EUR', + express: false, + reference: 'Synthetic payout 42', + debitor: { iban: debtorIban }, + creditor: { name: 'Synthetic Recipient', iban: creditorIban }, + }, + ], + }); + expectSignature(put.data, put.headers.Signature); + }); + + it('creates an instant SEPA transaction and rejects instant CHF', () => { + expect(service['createTransaction']({ ...paymentInput(), instant: true })).toMatchObject({ + type: FrickPaymentType.SEPA_INSTANT, + currency: 'EUR', + }); + expect(service['createTransaction']({ ...paymentInput(), instant: true })).not.toHaveProperty('express'); + + expect(() => + service['createTransaction']({ + ...paymentInput(), + currency: 'CHF', + instant: true, + charge: FrickPaymentCharge.SHARED, + creditor: { ...paymentInput().creditor, bic: 'TESTDEFF' }, + }), + ).toThrow('instant payments are only supported for EUR'); + }); + + it('rejects unsupported currencies before contacting Bank Frick', async () => { + Config.bank.frick.payoutEnabled = true; + + await expect(service.createPaymentOrder({ ...paymentInput(), currency: 'USD' as 'EUR' })).rejects.toThrow( + 'Unsupported Bank Frick currency: USD', + ); + expect(http.request).not.toHaveBeenCalled(); + }); + + it('rejects CHF payments without both BIC and charge before creating an order', async () => { + Config.bank.frick.payoutEnabled = true; + const input = { ...paymentInput(), currency: 'CHF' as const }; + + await expect(service.createPaymentOrder(input)).rejects.toThrow('requires creditor BIC'); + expect(http.request).not.toHaveBeenCalled(); + + const inputWithBic = { ...input, creditor: { ...input.creditor, bic: 'TESTLI22' } }; + await expect(service.createPaymentOrder(inputWithBic)).rejects.toThrow('requires a valid charge'); + }); + + it('rejects a non-SEPA creditor IBAN before creating an EUR order', async () => { + Config.bank.frick.payoutEnabled = true; + const input = { + ...paymentInput(), + creditor: { ...paymentInput().creditor, iban: 'BR1500000000000010932840814P2' }, + }; + + await expect(service.createPaymentOrder(input)).rejects.toThrow('EUR payout requires a SEPA creditor IBAN'); + expect(http.request).not.toHaveBeenCalled(); + }); + + it("enforces Bank Frick's documented 35-character creditor name limit", async () => { + Config.bank.frick.payoutEnabled = true; + const input = { + ...paymentInput(), + creditor: { ...paymentInput().creditor, name: 'A'.repeat(36) }, + }; + + await expect(service.createPaymentOrder(input)).rejects.toThrow('creditor name exceeds 35 characters'); + expect(http.request).not.toHaveBeenCalled(); + }); + + it('normalizes every optional FOREIGN creditor field', () => { + const transaction = service['createTransaction']({ + ...paymentInput(), + currency: 'CHF', + charge: FrickPaymentCharge.OUR, + creditor: { + name: ' Synthetic Recipient ', + iban: creditorIban.toLowerCase(), + bic: ' test de ff ', + address: ' Synthetic Street 42 ', + postalcode: ' 8000 ', + city: ' Zurich ', + country: ' CH ', + creditInstitution: ' Synthetic Bank ', + }, + }); + + expect(transaction).toMatchObject({ + type: FrickPaymentType.FOREIGN, + charge: FrickPaymentCharge.OUR, + creditor: { + name: 'Synthetic Recipient', + iban: creditorIban, + bic: 'TESTDEFF', + address: 'Synthetic Street 42', + postalcode: '8000', + city: 'Zurich', + country: 'CH', + creditInstitution: 'Synthetic Bank', + }, + }); + }); + + it.each([ + [undefined, 'creditor is required'], + [{ name: 'Synthetic Recipient', iban: creditorIban, address: 'A'.repeat(71) }, 'creditor address exceeds'], + [{ name: 'Synthetic Recipient', iban: creditorIban, postalcode: '1'.repeat(12) }, 'creditor postal code exceeds'], + [{ name: 'Synthetic Recipient', iban: creditorIban, city: 'A'.repeat(71) }, 'creditor city exceeds'], + [{ name: 'Synthetic Recipient', iban: creditorIban, country: 'A'.repeat(71) }, 'creditor country exceeds'], + [{ name: 'Synthetic Recipient', iban: creditorIban, bic: 'INVALID' }, 'Invalid creditor BIC'], + [ + { name: 'Synthetic Recipient', iban: creditorIban, creditInstitution: 'A'.repeat(51) }, + 'credit institution exceeds', + ], + ])('rejects malformed creditor details %#', (creditor, expectedError) => { + expect(() => service['validateCreditor'](creditor)).toThrow(expectedError); + }); + + it.each([Number.NaN, 0, 1_000_000_000_000, 1.001])('rejects invalid payment amount %s', (amount) => { + expect(() => service['validateAmount'](amount)).toThrow('Invalid Bank Frick payment amount'); + }); + + it('rejects blank identifiers and invalid IBAN values', () => { + expect(() => service['validateString']('', 'customId', 50, true)).toThrow('Invalid Bank Frick customId'); + expect(() => service['normalizeAndValidateIban'](undefined, 'account IBAN')).toThrow( + 'Invalid Bank Frick account IBAN', + ); + expect(() => service['normalizeAndValidateIban']('DE00INVALID', 'account IBAN')).toThrow( + 'Invalid Bank Frick account IBAN', + ); + }); + + it('rejects an existing customId when any sent payment detail differs', async () => { + Config.bank.frick.payoutEnabled = true; + const input = { + ...paymentInput(), + currency: 'CHF' as const, + charge: FrickPaymentCharge.SHARED, + creditor: { ...paymentInput().creditor, bic: 'TESTDEFF' }, + }; + const conflicting = paymentOrder({ + type: FrickPaymentType.FOREIGN, + currency: 'CHF', + charge: FrickPaymentCharge.SHARED, + creditor: { ...paymentOrder().creditor, bic: 'OTHERDFF' }, + }); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(transactionsResponse([conflicting])); + + await expect(service.createPaymentOrder(input)).rejects.toThrow('customId collision'); + expect(http.request.mock.calls.some(([request]) => request.method === 'PUT')).toBe(false); + }); + + it('accepts a semantically identical FOREIGN order with normalized optional fields', () => { + const input = { + ...paymentInput(), + currency: 'CHF' as const, + charge: FrickPaymentCharge.SHARED, + creditor: { + ...paymentInput().creditor, + bic: 'TESTDEFF', + address: 'Synthetic Street 42', + postalcode: '8000', + city: 'Zurich', + country: 'CH', + creditInstitution: 'Synthetic Bank', + }, + }; + const requested = service['createTransaction'](input); + const existing = paymentOrder({ + type: FrickPaymentType.FOREIGN, + currency: 'CHF', + charge: FrickPaymentCharge.SHARED, + debitor: { iban: ` ${debtorIban.toLowerCase()} ` }, + creditor: { + name: ' Synthetic Recipient ', + iban: ` ${creditorIban.toLowerCase()} `, + bic: ' testdeffxxx ', + address: ' Synthetic Street 42 ', + postalcode: ' 8000 ', + city: ' Zurich ', + country: ' CH ', + creditInsitution: ' Synthetic Bank ', + }, + }); + + expect(() => service['assertSamePayment'](existing, requested)).not.toThrow(); + }); + + it('matches an idempotent order when both references are omitted', () => { + const requested = service['createTransaction']({ ...paymentInput(), reference: undefined }); + const existing = paymentOrder({ reference: undefined }); + + expect(() => service['assertSamePayment'](existing, requested)).not.toThrow(); + }); + + it('falls back to the documented customId selector when the JSON order id is unsafe', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = true; + const order = paymentOrder({ orderId: Number.MAX_SAFE_INTEGER + 1 }); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(transactionsResponse([order])); + + await service.approvePaymentWithoutTan(order); + + const approval = http.request.mock.calls[1][0]; + expect(JSON.parse(approval.data)).toEqual({ customIds: ['DFX-FO-42'] }); + expect(service.getSafeOrderId(order)).toBeUndefined(); + expectSignature(approval.data, approval.headers.Signature); + }); + + it('uses the bank order id selector when the order id is safely representable', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = true; + const order = paymentOrder(); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(transactionsResponse([order])); + + await service.approvePaymentWithoutTan(order); + + const approval = http.request.mock.calls[1][0]; + expect(JSON.parse(approval.data)).toEqual({ orderIds: [4242] }); + expectSignature(approval.data, approval.headers.Signature); + }); + + it('returns a safe positive order id', () => { + expect(service.getSafeOrderId(paymentOrder())).toBe('4242'); + expect(service.getSafeOrderId(paymentOrder({ orderId: 0 }))).toBeUndefined(); + }); + + it('gets an existing payment order and rejects a missing one', async () => { + const order = paymentOrder(); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(transactionsResponse([order])); + + await expect(service.getPaymentOrder('DFX-FO-42')).resolves.toEqual(order); + + http = { request: jest.fn() }; + service = new BankFrickService(http as unknown as HttpService); + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(transactionsResponse([])) + .mockResolvedValueOnce(transactionsResponse([])); + await expect(service.getPaymentOrder('DFX-FO-42')).rejects.toThrow(FrickPaymentOrderNotFoundError); + }); + + it('resolves a real Bank Frick BOOKED response that carries neither customId nor type', async () => { + // Reproduces the Bank Frick spec's actual BOOKED transaction shape: settled payouts never echo + // customId/type, only orderId/state/amount/currency/debitor/creditor. A prior, stricter validator + // unconditionally required both fields and made this the normal success path throw forever. + const bookedPayload = { + orderId: 4242, + state: FrickPaymentState.BOOKED, + amount: '10.25', + currency: 'EUR', + debitor: { iban: debtorIban }, + creditor: { name: 'Synthetic Recipient', iban: creditorIban }, + }; + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(transactionsResponse([bookedPayload as unknown as FrickPaymentOrder])); + + await expect(service.getPaymentOrder('DFX-FO-42')).resolves.toEqual(bookedPayload); + }); + + it('trusts the customId-scoped filter for a BOOKED response missing customId, but still rejects one with a genuinely mismatched customId', async () => { + const bookedWithoutCustomId = { + orderId: 1, + state: FrickPaymentState.BOOKED, + amount: '1.00', + currency: 'EUR', + debitor: { iban: debtorIban }, + creditor: { name: 'Synthetic Recipient', iban: creditorIban }, + }; + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(transactionsResponse([bookedWithoutCustomId as unknown as FrickPaymentOrder])); + + await expect( + service['getFilteredPaymentOrder'](new URLSearchParams({ customId: 'DFX-FO-42' }), 'DFX-FO-42'), + ).resolves.toEqual(bookedWithoutCustomId); + + http.request.mockResolvedValueOnce( + transactionsResponse([{ ...bookedWithoutCustomId, customId: 'DFX-FO-OTHER' } as unknown as FrickPaymentOrder]), + ); + await expect( + service['getFilteredPaymentOrder'](new URLSearchParams({ customId: 'DFX-FO-42' }), 'DFX-FO-42'), + ).rejects.toThrow('Invalid Bank Frick payment lookup response for DFX-FO-42'); + }); + + it('recognises an already-BOOKED order missing customId/type as idempotent instead of a collision', async () => { + Config.bank.frick.payoutEnabled = true; + const bookedWithoutCustomIdOrType = { + orderId: 1, + state: FrickPaymentState.BOOKED, + amount: 10.25, + currency: 'EUR', + express: false, + reference: 'Synthetic payout 42', + debitor: { iban: debtorIban }, + creditor: { name: 'Synthetic Recipient', iban: creditorIban }, + }; + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(transactionsResponse([bookedWithoutCustomIdOrType as unknown as FrickPaymentOrder])); + + await expect(service.createPaymentOrder(paymentInput())).resolves.toEqual(bookedWithoutCustomIdOrType); + expect(http.request.mock.calls.some(([request]) => request.method === 'PUT')).toBe(false); + }); + + it.each([ + [{ moreResults: true, resultSetSize: 1, transactions: [paymentOrder()] }, 'Ambiguous Bank Frick payment lookup'], + [transactionsResponse([paymentOrder({ customId: 'OTHER' })]), 'Invalid Bank Frick payment lookup response'], + [transactionsResponse([paymentOrder(), paymentOrder()]), 'Duplicate Bank Frick payment orders'], + ])('rejects unsafe payment lookup responses %#', async (response, expectedError) => { + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(response); + + await expect(service.getPaymentOrder('DFX-FO-42')).rejects.toThrow(expectedError); + }); + + it('rejects ambiguous or non-matching single-payment responses', () => { + expect(() => + service['getSinglePayment']({ moreResults: true, resultSetSize: 1, transactions: [paymentOrder()] }, 'DFX-FO-42'), + ).toThrow('Ambiguous Bank Frick payment response'); + expect(() => service['getSinglePayment'](transactionsResponse([]), 'DFX-FO-42')).toThrow( + 'Invalid Bank Frick payment response', + ); + }); + + it('validates transaction and account response envelopes and rows', () => { + expect(() => service['validateTransactionsResponse'](undefined, true)).toThrow( + 'Invalid Bank Frick transactions response', + ); + expect(() => + service['validateTransactionsResponse']( + transactionsResponse([{ ...paymentOrder(), state: 'UNKNOWN' as never }]), + true, + ), + ).toThrow('Invalid Bank Frick payment order response'); + expect(() => + service['validateTransactionsResponse'](transactionsResponse([paymentOrder({ orderId: -1 })]), true), + ).toThrow('Invalid Bank Frick orderId response'); + + expect(() => service['validateAccountsResponse'](undefined)).toThrow('Invalid Bank Frick accounts response'); + expect(() => + service['validateAccountsResponse']({ + date: '2026-07-13', + moreResults: false, + resultSetSize: 1, + accounts: [{ account: 42 } as never], + }), + ).toThrow('Invalid Bank Frick account response'); + }); + + it('parses signed and European-formatted response amounts and rejects unsafe values', () => { + expect(service['parseResponseAmount']('-12.34')).toBe(12.34); + expect(service['parseResponseAmount']('-1.234,56')).toBe(1234.56); + expect(() => service['parseResponseAmount'](undefined)).toThrow('Invalid Bank Frick payment amount response'); + expect(() => service['parseResponseAmount']('12.345')).toThrow('Invalid Bank Frick payment amount response'); + expect(() => service['parseResponseAmount']('9'.repeat(400))).toThrow('Invalid Bank Frick payment amount response'); + }); + + it('rejects incomplete account pagination and filters accounts without an IBAN', async () => { + const response = accountsResponse(); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce({ + ...response, + resultSetSize: 2, + accounts: [...response.accounts, { ...response.accounts[0], iban: undefined }], + }); + await expect(service.getBalances()).resolves.toHaveLength(1); + + http = { request: jest.fn() }; + service = new BankFrickService(http as unknown as HttpService); + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce({ ...accountsResponse(), moreResults: true }); + await expect(service.getBalances()).rejects.toThrow('Incomplete Bank Frick accounts response'); + }); + + it('rejects invalid transaction dates and unsafe CAMT responses', async () => { + await expect(service.getFrickTransactions(new Date('invalid'), debtorIban)).rejects.toThrow( + 'Invalid Bank Frick transaction start date', + ); + + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce({ not: 'xml' }); + await expect(service.getFrickTransactions(new Date('2026-07-01'), debtorIban)).rejects.toThrow( + 'Invalid Bank Frick camt.053 response', + ); + + http = { request: jest.fn() }; + service = new BankFrickService(http as unknown as HttpService); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(' '); + await expect(service.getFrickTransactions(new Date('2026-07-01'), debtorIban)).resolves.toEqual({ + transactions: [], + fullyParsed: true, + }); + }); + + it('maps debit CAMT entries as debit transactions', async () => { + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(camt053Fixture().replace('CRDT', 'DBIT')); + + const { transactions, fullyParsed } = await service.getFrickTransactions(new Date('2026-07-01'), debtorIban); + + expect(transactions[0].creditDebitIndicator).toBe(BankTxIndicator.DEBIT); + expect(fullyParsed).toBe(true); + }); + + it('parses a booked debit with Amt=1005.00 and Chrgs=5.00 into a real chargeAmount instead of hard-coding 0', async () => { + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(camt053ChargedDebitFixture()); + + const { transactions } = await service.getFrickTransactions(new Date('2026-07-01'), debtorIban); + + expect(transactions[0]).toMatchObject({ amount: 1005, chargeAmount: 5, chargeCurrency: 'CHF' }); + }); + + it('accepts JWTs without exp and caches them indefinitely', async () => { + http.request + .mockResolvedValueOnce({ token: jwtWithPayload({}) }) + .mockResolvedValueOnce(accountsResponse()) + .mockResolvedValueOnce(accountsResponse()); + + await service.getBalances(); + await service.getBalances(); + + expect(http.request.mock.calls.filter(([request]) => request.url.endsWith('/authorize'))).toHaveLength(1); + }); + + it.each([ + ['missing payload', 'not-a-jwt'], + ['missing signature', `${jwtWithPayload({}).split('.').slice(0, 2).join('.')}.`], + ['invalid expiry', jwtWithPayload({ exp: 0 })], + ['unsafe millisecond expiry', jwtWithPayload({ exp: Number.MAX_SAFE_INTEGER })], + ['malformed payload', `header.${Buffer.from('{').toString('base64url')}.signature`], + ])('rejects a JWT with %s', async (_case, token) => { + http.request.mockResolvedValueOnce({ token }); + + await expect(service.getBalances()).rejects.toThrow('Invalid Bank Frick JWT'); + }); + + it('rejects an invalid authorization response and signing configuration', async () => { + http.request.mockResolvedValueOnce({ token: '' }); + await expect(service.getBalances()).rejects.toThrow('Invalid Bank Frick authorization response'); + + Config.bank.frick.privateKey = 'not-a-private-key'; + expect(() => service['sign']('synthetic body')).toThrow('Invalid Bank Frick signing configuration'); + }); + + it('reports bounded transport codes without exposing upstream messages', () => { + expect(service['getHttpFailureReason']({ code: 'ECONNRESET', message: 'secret response body' })).toBe('ECONNRESET'); + expect(service['getHttpFailureReason']({ response: { status: 99 }, code: 'unsafe-code' })).toBe('request failed'); + }); + + it('rejects an unsafe customer path segment', () => { + Config.bank.frick.customer = '../customer'; + + expect(() => service['validateCustomer']()).toThrow('Invalid Bank Frick customer configuration'); + }); + + it('rejects a customer number exceeding the documented 7-digit limit', () => { + Config.bank.frick.customer = '12345678'; + + expect(() => service['validateCustomer']()).toThrow('Invalid Bank Frick customer configuration'); + }); + + it('maps a signed camt.053 response completely into BankTx fields', async () => { + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(camt053Fixture()); + + const { transactions, fullyParsed } = await service.getFrickTransactions( + new Date('2026-07-01T00:00:00Z'), + debtorIban, + ); + const [transaction] = transactions; + + expect(fullyParsed).toBe(true); + expect(transaction).toMatchObject({ + accountServiceRef: expect.stringMatching(/^FRICK-[a-f0-9]{64}$/), + txId: 'SYNTHETIC-REF-1', + txCount: 1, + amount: 12.34, + instructedAmount: 12.34, + txAmount: 12.34, + chargeAmount: 0, + currency: 'EUR', + instructedCurrency: 'EUR', + txCurrency: 'EUR', + chargeCurrency: 'EUR', + creditDebitIndicator: BankTxIndicator.CREDIT, + iban: creditorIban, + bic: 'TESTDEFF', + name: 'Synthetic Sender', + remittanceInfo: 'Synthetic transfer', + endToEndId: 'SYNTHETIC-E2E-1', + accountIban: debtorIban, + type: null, + }); + expect(transaction.bookingDate).toEqual(new Date('2026-07-02')); + + const camtRequest = http.request.mock.calls[1][0]; + expect(camtRequest.url).toContain('/camt053?'); + expect(camtRequest.headers.Accept).toBe('application/xml'); + expect(camtRequest.data).toBe(''); + expectSignature('', camtRequest.headers.Signature); + }); + + it('reports fullyParsed=false and drops only the malformed entry when a statement mixes a well-formed and a reference-less debit', async () => { + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(camt053MixedFixture()); + + const { transactions, fullyParsed } = await service.getFrickTransactions(new Date('2026-07-01'), debtorIban); + + expect(fullyParsed).toBe(false); + expect(transactions).toHaveLength(1); + expect(transactions[0]).toMatchObject({ txId: 'GOOD-ENTRY-REF', amount: 5 }); + }); + + it('formats statement boundaries in Bank Frick local time', async () => { + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(''); + + await service.getFrickTransactions(new Date('2026-07-01T22:30:00.000Z'), debtorIban); + + const statementUrl = new URL(http.request.mock.calls[1][0].url); + expect(statementUrl.searchParams.get('fromDate')).toBe('2026-07-02'); + }); + + function expectSignature(body: string, signature: string): void { + expect(verify('RSA-SHA512', Buffer.from(body), keys.publicKey, Buffer.from(signature, 'base64'))).toBe(true); + } + + function paymentInput() { + return { + customId: 'DFX-FO-42', + amount: 10.25, + currency: 'EUR' as const, + reference: 'Synthetic payout 42', + debtorIban, + creditor: { name: 'Synthetic Recipient', iban: creditorIban }, + }; + } + + function paymentOrder(overrides: Partial = {}): FrickPaymentOrder { + return { + orderId: 4242, + customId: 'DFX-FO-42', + type: FrickPaymentType.SEPA, + state: FrickPaymentState.PREPARED, + amount: 10.25, + currency: 'EUR', + express: false, + reference: 'Synthetic payout 42', + debitor: { iban: debtorIban }, + creditor: { name: 'Synthetic Recipient', iban: creditorIban }, + ...overrides, + }; + } + + function transactionsResponse(transactions: FrickPaymentOrder[]): FrickTransactionsResponse { + return { moreResults: false, resultSetSize: transactions.length, transactions }; + } + + function accountsResponse() { + return { + date: '2026-07-13', + moreResults: false, + resultSetSize: 1, + accounts: [ + { + account: '0000000/000.000.001', + type: 'CURRENT ACCOUNT', + iban: debtorIban, + customer: '0000000 Synthetic Customer', + currency: 'EUR', + balance: 100, + available: 90, + }, + ], + }; + } + + function jwt(sequence = 1): string { + return jwtWithPayload({ exp: Math.floor(Date.now() / 1000) + 3600, sequence }); + } + + function jwtWithPayload(payload: object): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${header}.${encodedPayload}.synthetic-signature`; + } + + function camt053Fixture(): string { + return ` + + + + ${debtorIban} + + 12.34 + CRDT + BOOK +
2026-07-02
+
2026-07-02
+ SYNTHETIC-REF-1 + + + SYNTHETIC-E2E-1 + + Synthetic Sender + ${creditorIban} + + TESTDEFF + Synthetic transfer + + +
+
+
+
`; + } + + function camt053MixedFixture(): string { + return ` + + + + ${debtorIban} + + 5 + DBIT + BOOK +
2026-07-02
+
2026-07-02
+ GOOD-ENTRY-REF +
+ + 7 + DBIT + BOOK +
2026-07-02
+
2026-07-02
+
+
+
+
`; + } + + function camt053ChargedDebitFixture(): string { + return ` + + + + ${debtorIban} + + 1005 + DBIT + BOOK +
2026-07-02
+
2026-07-02
+ CHARGED-DEBIT-REF + 5 +
+
+
+
`; + } +}); + +function createSyntheticIban(country: string, bban: string): string { + const rearranged = `${bban}${country}00`; + const numeric = rearranged + .split('') + .map((char) => (/[A-Z]/.test(char) ? String(char.charCodeAt(0) - 55) : char)) + .join(''); + const checkDigits = String(98n - (BigInt(numeric) % 97n)).padStart(2, '0'); + const iban = `${country}${checkDigits}${bban}`; + if (!IbanTools.validateIBAN(iban).valid) throw new Error('Synthetic IBAN fixture is invalid'); + return iban; +} diff --git a/src/integration/bank/services/__tests__/iso20022.service.spec.ts b/src/integration/bank/services/__tests__/iso20022.service.spec.ts new file mode 100644 index 0000000000..3b15ab04da --- /dev/null +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -0,0 +1,1176 @@ +import { BankTxIndicator } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { CamtStatus, CamtTransaction, Iso20022Service, Pain001Payment } from '../iso20022.service'; + +describe('Iso20022Service camt.054 parsing', () => { + it('maps a full CDTN entry with root-level NtryDtls, array TxDtls and a virtual IBAN', () => { + const camt054 = { + BkToCstmrDbtCdtNtfctn: { + Ntfctn: { + Id: 'SYNTHETIC-NTFCTN-ID', + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-IBAN' } }, + Ntry: { + BookgDt: { Dt: '2026-07-01' }, + ValDt: { Dt: '2026-07-02' }, + Amt: { Value: 100, Ccy: 'EUR' }, + CdtDbtInd: 'CDTN', + Sts: 'BOOK', + }, + }, + NtryDtls: { + TxDtls: [ + { + Refs: { AcctSvcrRef: 'SYNTHETIC-REF-1', EndToEndId: 'SYNTHETIC-E2E-1' }, + RltdPties: { + Dbtr: { Nm: 'Synthetic Sender', PstlAdr: { AdrLine: ['Street 1', 'City 1'], Ctry: 'CH' } }, + DbtrAcct: { Id: { IBAN: 'SYNTHETIC-COUNTERPARTY-IBAN' } }, + CdtrAcct: { Id: { IBAN: 'SYNTHETIC-VIRTUAL-IBAN' } }, + UltmtDbtr: { Nm: 'Synthetic Ultimate Sender', PstlAdr: { AdrLine: 'Single Line', Ctry: 'LI' } }, + }, + RltdAgts: { DbtrAgt: { FinInstnId: { BIC: 'SYNTHETICBIC1' } } }, + RmtInf: { Ustrd: ['line one', 'line two'] }, + BkTxCd: { Domn: { Cd: 'PMNT', Fmly: { Cd: 'ICDT', SubFmlyCd: 'ESCT' } } }, + }, + ], + }, + }, + }; + + const result = Iso20022Service.parseCamt054Json(camt054); + + expect(result).toEqual( + expect.objectContaining({ + accountServiceRef: 'SYNTHETIC-REF-1', + endToEndId: 'SYNTHETIC-E2E-1', + amount: 100, + currency: 'EUR', + creditDebitIndicator: BankTxIndicator.CREDIT, + accountIban: 'SYNTHETIC-ACCOUNT-IBAN', + virtualIban: 'SYNTHETIC-VIRTUAL-IBAN', + name: 'Synthetic Sender', + addressLine1: 'Street 1', + addressLine2: 'City 1', + country: 'CH', + iban: 'SYNTHETIC-COUNTERPARTY-IBAN', + bic: 'SYNTHETICBIC1', + ultimateName: 'Synthetic Ultimate Sender', + ultimateAddressLine1: 'Single Line', + ultimateAddressLine2: undefined, + ultimateCountry: 'LI', + remittanceInfo: 'line one line two', + status: 'BOOK', + domainCode: 'PMNT', + familyCode: 'ICDT', + subFamilyCode: 'ESCT', + }), + ); + expect(result.bookingDate).toEqual(new Date('2026-07-01')); + expect(result.valueDate).toEqual(new Date('2026-07-02')); + }); + + it('maps a DBTN entry with nested NtryDtls fallback, non-array TxDtls, BICFI fallback, structured remittance info and TxId fallback reference', () => { + const camt054 = { + BkToCstmrDbtCdtNtfctn: { + Ntfctn: { + Id: 'SYNTHETIC-NTFCTN-ID', + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-IBAN' } }, + Ntry: { + Amt: { Value: 50, Ccy: 'CHF' }, + CdtDbtInd: 'DBTN', + Sts: 'BOOK', + NtryDtls: { + TxDtls: { + Refs: { TxId: 'SYNTHETIC-TX-ID' }, + RltdPties: { Cdtr: { Nm: 'Synthetic Recipient' } }, + RltdAgts: { CdtrAgt: { FinInstnId: { BICFI: 'SYNTHETICBICFI' } } }, + RmtInf: { Strd: 'structured remittance info' }, + }, + }, + }, + }, + }, + }; + + const result = Iso20022Service.parseCamt054Json(camt054); + + expect(result).toEqual( + expect.objectContaining({ + accountServiceRef: 'SYNTHETIC-TX-ID', + endToEndId: undefined, + creditDebitIndicator: BankTxIndicator.DEBIT, + virtualIban: undefined, + name: 'Synthetic Recipient', + bic: 'SYNTHETICBICFI', + ultimateName: undefined, + remittanceInfo: 'structured remittance info', + addressLine1: undefined, + addressLine2: undefined, + country: undefined, + }), + ); + // no BookgDt/ValDt in the fixture: both default to "now" + expect(result.bookingDate.getTime()).toBeCloseTo(Date.now(), -2); + expect(result.valueDate).toEqual(result.bookingDate); + }); + + it('does not treat a matching creditor IBAN as a virtual IBAN on a credit entry', () => { + const camt054 = { + BkToCstmrDbtCdtNtfctn: { + Ntfctn: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-IBAN' } }, + Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CDTN', Sts: 'BOOK' }, + NtryDtls: { TxDtls: { RltdPties: { CdtrAcct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-IBAN' } } } } }, + }, + }, + }; + + const result = Iso20022Service.parseCamt054Json(camt054); + + expect(result.virtualIban).toBeUndefined(); + }); + + it('falls back to the notification id when no transaction-level reference and no remittance info are present', () => { + const camt054 = { + BkToCstmrDbtCdtNtfctn: { + Ntfctn: { + Id: 'SYNTHETIC-FALLBACK-NTFCTN-ID', + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-IBAN' } }, + Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CDTN', Sts: 'BOOK' }, + }, + }, + }; + + const result = Iso20022Service.parseCamt054Json(camt054); + + expect(result.accountServiceRef).toBe('SYNTHETIC-FALLBACK-NTFCTN-ID'); + expect(result.remittanceInfo).toBeUndefined(); + expect(result.domainCode).toBeUndefined(); + }); + + it('joins an unstructured remittance info given as a plain string', () => { + const camt054 = { + BkToCstmrDbtCdtNtfctn: { + Ntfctn: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-IBAN' } }, + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CDTN', + Sts: 'BOOK', + NtryDtls: { TxDtls: { RmtInf: { Ustrd: 'single string remittance' } } }, + }, + }, + }, + }; + + const result = Iso20022Service.parseCamt054Json(camt054); + + expect(result.remittanceInfo).toBe('single string remittance'); + }); + + it('falls back to the current date when the booking date does not match the expected format', () => { + const camt054 = { + BkToCstmrDbtCdtNtfctn: { + Ntfctn: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-IBAN' } }, + Ntry: { BookgDt: { Dt: 'not-a-real-date' }, Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CDTN', Sts: 'BOOK' }, + }, + }, + }; + + const result = Iso20022Service.parseCamt054Json(camt054); + + expect(result.bookingDate.getTime()).toBeCloseTo(Date.now(), -2); + }); + + it('throws when the camt.054 notification wrapper is missing', () => { + expect(() => Iso20022Service.parseCamt054Json({})).toThrow('Invalid camt.054 format: missing Ntfctn'); + }); + + it('throws when the camt.054 account IBAN is missing', () => { + const camt054 = { BkToCstmrDbtCdtNtfctn: { Ntfctn: { Ntry: { CdtDbtInd: 'CDTN' } } } }; + + expect(() => Iso20022Service.parseCamt054Json(camt054)).toThrow('Invalid camt.054 format: missing account IBAN'); + }); +}); + +describe('Iso20022Service postal address parsing', () => { + const baseEntry = { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + BookgDt: { Dt: '2026-07-01' }, + }; + + function withDebtorAddress(postalAddress: object) { + return { + BkToCstmrStmt: { + Stmt: { + Ntry: { + ...baseEntry, + NtryDtls: { TxDtls: { RltdPties: { Dbtr: { Nm: 'Synthetic Sender', PstlAdr: postalAddress } } } }, + }, + }, + }, + }; + } + + it('reads a structured street and town address', () => { + const [result] = Iso20022Service.parseCamt053Json( + withDebtorAddress({ StrtNm: 'Synthetic Street', BldgNb: '12', PstCd: '9490', TwnNm: 'Synthetic Town' }), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.addressLine1).toBe('Synthetic Street 12'); + expect(result.addressLine2).toBe('9490 Synthetic Town'); + }); + + it('drops an empty city part from a structured address with only a street name', () => { + const [result] = Iso20022Service.parseCamt053Json( + withDebtorAddress({ StrtNm: 'Synthetic Street' }), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.addressLine1).toBe('Synthetic Street'); + expect(result.addressLine2).toBeUndefined(); + }); + + it('drops empty street/city parts from a structured address with only a town name', () => { + const [result] = Iso20022Service.parseCamt053Json( + withDebtorAddress({ TwnNm: 'Synthetic Town' }), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.addressLine1).toBeUndefined(); + expect(result.addressLine2).toBe('Synthetic Town'); + }); + + it('returns no address lines when the postal address has neither AdrLine nor structured fields', () => { + const [result] = Iso20022Service.parseCamt053Json(withDebtorAddress({ Ctry: 'CH' }), 'SYNTHETIC-ACCOUNT'); + + expect(result.addressLine1).toBeUndefined(); + expect(result.addressLine2).toBeUndefined(); + expect(result.country).toBe('CH'); + }); +}); + +describe('Iso20022Service camt.053 references', () => { + const entry = { + Amt: { Value: 1.25, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + BookgDt: { Dt: '2026-07-01' }, + ValDt: { Dt: '2026-07-01' }, + NtryDtls: { + TxDtls: { + RltdPties: { + Dbtr: { Nm: 'Synthetic Sender' }, + DbtrAcct: { Id: { IBAN: 'SYNTHETIC-SENDER-ACCOUNT' } }, + }, + }, + }, + }; + + it('parses singleton statements and entries', () => { + const result = Iso20022Service.parseCamt053Json(statement(entry), 'SYNTHETIC-ACCOUNT-A'); + + expect(result).toHaveLength(1); + expect(result[0].amount).toBe(1.25); + }); + + it('uses a stable account-scoped hash when bank references are absent', () => { + const first = Iso20022Service.parseCamt053Json(statement(entry), 'SYNTHETIC-ACCOUNT-A')[0]; + const reorderedEntry = { + ValDt: entry.ValDt, + NtryDtls: entry.NtryDtls, + BookgDt: entry.BookgDt, + CdtDbtInd: entry.CdtDbtInd, + Amt: { Ccy: 'EUR', Value: 1.25 }, + }; + const reordered = Iso20022Service.parseCamt053Json(statement(reorderedEntry), 'SYNTHETIC-ACCOUNT-A')[0]; + const otherAccount = Iso20022Service.parseCamt053Json(statement(entry), 'SYNTHETIC-ACCOUNT-B')[0]; + + expect(first.accountServiceRef).toMatch(/^CAMT-[a-f0-9]{64}$/); + expect(reordered.accountServiceRef).toBe(first.accountServiceRef); + expect(otherAccount.accountServiceRef).not.toBe(first.accountServiceRef); + }); + + it('deterministically distinguishes identical entries without bank references', () => { + const camt053 = { BkToCstmrStmt: { Stmt: { Ntry: [entry, entry] } } }; + + const firstParse = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT-A'); + const secondParse = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT-A'); + + expect(firstParse).toHaveLength(2); + expect(firstParse[0].accountServiceRef).not.toBe(firstParse[1].accountServiceRef); + expect(firstParse.map(({ accountServiceRef }) => accountServiceRef)).toEqual( + secondParse.map(({ accountServiceRef }) => accountServiceRef), + ); + }); + + it('drops a malformed entry instead of failing the whole statement, in both strict and non-strict mode', () => { + const malformedEntry = { ...entry, CdtDbtInd: undefined }; + + expect(Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A')).toEqual([]); + expect(Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A', true)).toEqual([]); + }); + + it('reports a dropped entry via onEntryRejected in strict mode, and still returns other well-formed entries in the same statement', () => { + const malformedEntry = { ...entry, CdtDbtInd: undefined }; + // A real Bank Frick entry is BOOKED and carries a bank reference; both are required for this entry + // to survive strict validation and demonstrate that the malformed sibling above does not poison it. + const goodEntry = { ...entry, Sts: 'BOOK', AcctSvcrRef: 'GOOD-ENTRY-REF' }; + const camt053 = { + BkToCstmrStmt: { Stmt: { Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-A' } }, Ntry: [malformedEntry, goodEntry] } }, + }; + const rejections: Error[] = []; + + const result = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT-A', true, (error) => + rejections.push(error), + ); + + expect(result).toHaveLength(1); + expect(rejections).toHaveLength(1); + expect(rejections[0].message).toBe('Missing CdtDbtInd in CAMT entry'); + }); + + it('does not consume an occurrence suffix when a non-strict entry is discarded', () => { + let indicatorReads = 0; + const transientlyMalformedEntry = { ...entry }; + Object.defineProperty(transientlyMalformedEntry, 'CdtDbtInd', { + enumerable: true, + get: () => (++indicatorReads === 2 ? undefined : 'CRDT'), + }); + + const result = Iso20022Service.parseCamt053Json( + { BkToCstmrStmt: { Stmt: { Ntry: [transientlyMalformedEntry, transientlyMalformedEntry] } } }, + 'SYNTHETIC-ACCOUNT-A', + ); + + expect(result).toHaveLength(1); + expect(result[0].accountServiceRef).toMatch(/^CAMT-[a-f0-9]{64}$/); + }); + + it.each([ + [{ ...entry, Amt: undefined }, 'Invalid amount'], + [{ ...entry, Amt: { Value: 1.25, Ccy: 'EU' } }, 'Invalid currency'], + [{ ...entry, CdtDbtInd: 'UNKNOWN' }, 'Invalid CdtDbtInd'], + [{ ...entry, BookgDt: { Dt: '2026-02-31' } }, 'Invalid booking date'], + [{ ...entry, ValDt: { Dt: '2026-02-31' } }, 'Invalid value date'], + ])( + 'rejects the entry (not the whole statement) for an unsafe money-critical CAMT default in strict mode', + (malformedEntry, expectedError) => { + const rejections: Error[] = []; + + const result = Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A', true, (error) => + rejections.push(error), + ); + + expect(result).toEqual([]); + expect(rejections).toHaveLength(1); + expect(rejections[0].message).toContain(expectedError); + }, + ); + + function statement(transactionEntry: object) { + return { + BkToCstmrStmt: { + Stmt: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-A' } }, + Ntry: { Sts: 'BOOK', ...transactionEntry }, + }, + }, + }; + } +}); + +describe('Iso20022Service camt.053 entry field parsing', () => { + it('maps a full CRDT entry with BIC, remittance array, ultimate party and AcctSvcrRef', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: '12.50', Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + BookgDt: { Dt: '2026-07-01' }, + ValDt: { Dt: '2026-07-02' }, + NtryDtls: { + TxDtls: [ + { + Refs: { AcctSvcrRef: 'SYNTHETIC-ACCT-SVCR-REF', EndToEndId: 'SYNTHETIC-E2E' }, + RltdPties: { + Dbtr: { Nm: 'Synthetic Sender', PstlAdr: { AdrLine: ['Street 1'], Ctry: 'CH' } }, + DbtrAcct: { Id: { IBAN: 'SYNTHETIC-DEBTOR-IBAN' } }, + UltmtDbtr: { Nm: 'Synthetic Ultimate', PstlAdr: { AdrLine: ['Ultimate Street'], Ctry: 'AT' } }, + }, + RltdAgts: { DbtrAgt: { FinInstnId: { BIC: 'SYNTHETICBIC2' } } }, + RmtInf: { Ustrd: ['first', 'second'] }, + BkTxCd: { Domn: { Cd: 'PMNT', Fmly: { Cd: 'ICDT', SubFmlyCd: 'ESCT' } } }, + }, + ], + }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT-IBAN'); + + expect(result).toEqual( + expect.objectContaining({ + amount: 12.5, + currency: 'EUR', + creditDebitIndicator: BankTxIndicator.CREDIT, + name: 'Synthetic Sender', + iban: 'SYNTHETIC-DEBTOR-IBAN', + bic: 'SYNTHETICBIC2', + addressLine1: 'Street 1', + country: 'CH', + ultimateName: 'Synthetic Ultimate', + ultimateAddressLine1: 'Ultimate Street', + ultimateCountry: 'AT', + remittanceInfo: 'first second', + accountServiceRef: 'SYNTHETIC-ACCT-SVCR-REF', + endToEndId: 'SYNTHETIC-E2E', + status: CamtStatus.BOOKED, + accountIban: 'SYNTHETIC-ACCOUNT-IBAN', + virtualIban: undefined, + domainCode: 'PMNT', + familyCode: 'ICDT', + subFamilyCode: 'ESCT', + }), + ); + }); + + it('maps a DBIT entry with BICFI fallback, a single Ustrd string, TxId reference and no ultimate party', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 5, Ccy: 'CHF' }, + CdtDbtInd: 'DBIT', + BookgDt: { Dt: '2026-07-01' }, + NtryDtls: { + TxDtls: { + Refs: { TxId: 'SYNTHETIC-TX-ID' }, + RltdPties: { Cdtr: { Nm: 'Synthetic Creditor' } }, + RltdAgts: { CdtrAgt: { FinInstnId: { BICFI: 'SYNTHETICBICFI2' } } }, + RmtInf: { Ustrd: 'single line remittance' }, + }, + }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT-IBAN'); + + expect(result).toEqual( + expect.objectContaining({ + creditDebitIndicator: BankTxIndicator.DEBIT, + name: 'Synthetic Creditor', + bic: 'SYNTHETICBICFI2', + remittanceInfo: 'single line remittance', + accountServiceRef: 'SYNTHETIC-TX-ID', + endToEndId: '', + ultimateName: undefined, + }), + ); + }); + + it('falls back to entry.AcctSvcrRef, then entry.NtryRef, when transaction-level refs are absent', () => { + const withEntryAcctSvcrRef = { + BkToCstmrStmt: { + Stmt: { Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT', AcctSvcrRef: 'SYNTHETIC-ENTRY-REF' } }, + }, + }; + const withEntryNtryRef = { + BkToCstmrStmt: { + Stmt: { Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT', NtryRef: 'SYNTHETIC-NTRY-REF' } }, + }, + }; + + expect(Iso20022Service.parseCamt053Json(withEntryAcctSvcrRef, 'SYNTHETIC-ACCOUNT')[0].accountServiceRef).toBe( + 'SYNTHETIC-ENTRY-REF', + ); + expect(Iso20022Service.parseCamt053Json(withEntryNtryRef, 'SYNTHETIC-ACCOUNT')[0].accountServiceRef).toBe( + 'SYNTHETIC-NTRY-REF', + ); + }); + + it('ignores malformed optional references in non-strict compatibility mode', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + AcctSvcrRef: { malformed: true }, + NtryDtls: { TxDtls: { Refs: { TxId: null, EndToEndId: { malformed: true } } } }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.accountServiceRef).toMatch(/^CAMT-[a-f0-9]{64}$/); + expect(result.endToEndId).toBe(''); + }); + + it('reads remittance info from AddtlNtryInf when no structured or unstructured RmtInf is present', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + AddtlNtryInf: 'additional entry information', + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.remittanceInfo).toBe('additional entry information'); + }); + + it('reads remittance info from a structured Strd field', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + NtryDtls: { TxDtls: { RmtInf: { Strd: 'structured entry remittance' } } }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.remittanceInfo).toBe('structured entry remittance'); + }); + + it('reads one or more creditor references from structured remittance objects', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + NtryDtls: { + TxDtls: { + RmtInf: { + Strd: [ + { CdtrRefInf: { Ref: 'SYNTHETIC-CREDITOR-REFERENCE-1' } }, + { CdtrRefInf: { Ref: 'SYNTHETIC-CREDITOR-REFERENCE-2' } }, + ], + }, + }, + }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.remittanceInfo).toBe('SYNTHETIC-CREDITOR-REFERENCE-1 SYNTHETIC-CREDITOR-REFERENCE-2'); + }); + + it('falls back from malformed remittance data in non-strict compatibility mode', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + AddtlNtryInf: 'safe fallback information', + NtryDtls: { TxDtls: { RmtInf: { Ustrd: { unexpected: true }, Strd: { unsupported: true } } } }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.remittanceInfo).toBe('safe fallback information'); + }); + + it('treats blank remittance fields as absent in non-strict compatibility mode', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + AddtlNtryInf: ' ', + NtryDtls: { TxDtls: { RmtInf: { Ustrd: [' ', ''], Strd: ' ' } } }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.remittanceInfo).toBeUndefined(); + }); + + it('reads the first NtryDtls entry when NtryDtls itself is an array', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + NtryDtls: [{ TxDtls: { RmtInf: { Strd: 'first detail remittance' } } }], + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.remittanceInfo).toBe('first detail remittance'); + }); + + it('accepts an unwrapped amount value and defaults currency to CHF', () => { + const camt053 = { BkToCstmrStmt: { Stmt: { Ntry: { Amt: 42, CdtDbtInd: 'CRDT' } } } }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.amount).toBe(42); + expect(result.currency).toBe('CHF'); + }); + + it('retains the zero-amount fallback in non-strict compatibility mode', () => { + const camt053 = { BkToCstmrStmt: { Stmt: { Ntry: { CdtDbtInd: 'CRDT' } } } }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.amount).toBe(0); + expect(result.currency).toBe('CHF'); + }); + + it('reads an amount from the #text field', () => { + const camt053 = { BkToCstmrStmt: { Stmt: { Ntry: { Amt: { '#text': '7.5', Ccy: 'EUR' }, CdtDbtInd: 'CRDT' } } } }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.amount).toBe(7.5); + }); + + it('returns an empty array when the camt.053 message has no statement', () => { + expect(Iso20022Service.parseCamt053Json({}, 'SYNTHETIC-ACCOUNT')).toEqual([]); + }); + + it('parses statements and entries provided as arrays and skips statements without entries', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: [ + { Ntry: undefined }, + { + Ntry: [ + { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT' }, + { Amt: { Value: 2, Ccy: 'EUR' }, CdtDbtInd: 'DBIT' }, + ], + }, + ], + }, + }; + + const result = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result).toHaveLength(2); + }); + + it('rejects (drops) a strict entry whose booking date is not a well-formed date string', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT' } }, + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + Sts: 'BOOK', + BookgDt: { Dt: 'not-a-date' }, + }, + }, + }, + }; + const rejections: Error[] = []; + + const result = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT', true, (error) => + rejections.push(error), + ); + + expect(result).toEqual([]); + expect(rejections.map((error) => error.message)).toEqual(['Invalid booking date in CAMT entry']); + }); + + it('keeps the calendar date fallback for a malformed DateTime in non-strict compatibility mode', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + BookgDt: { DtTm: '2026-07-01T99:00:00Z' }, + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT'); + + expect(result.bookingDate).toEqual(new Date('2026-07-01')); + }); + + it('skips the value-date check in strict mode when no value date is present', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT' } }, + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + Sts: { Cd: 'BOOK' }, + BookgDt: { Dt: '2026-07-01' }, + AcctSvcrRef: 'SYNTHETIC-REF', + }, + }, + }, + }; + + const [result] = Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT', true); + + expect(result.valueDate).toEqual(result.bookingDate); + }); + + it('computes a stable default fallback reference when the private entry parser is invoked without one', () => { + const entry = { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT' }; + + const result: CamtTransaction = ( + Iso20022Service as unknown as Record CamtTransaction> + )['parseCamt053JsonEntry'](entry, 'SYNTHETIC-ACCOUNT'); + + expect(result.accountServiceRef).toMatch(/^CAMT-[a-f0-9]{64}$/); + }); +}); + +describe('Iso20022Service camt.053 charge parsing', () => { + function debitEntry(overrides: object = {}) { + return { + Amt: { Value: 1005, Ccy: 'CHF' }, + CdtDbtInd: 'DBIT', + BookgDt: { Dt: '2026-07-01' }, + AcctSvcrRef: 'SYNTHETIC-DEBIT-REF', + ...overrides, + }; + } + + function statement(entry: object) { + return { BkToCstmrStmt: { Stmt: { Ntry: entry } } }; + } + + it('parses a debit entry with Amt=1005.00 and Chrgs/TtlChrgsAndTaxAmt=5.00 into chargeAmount=5', () => { + const [result] = Iso20022Service.parseCamt053Json( + statement(debitEntry({ Chrgs: { TtlChrgsAndTaxAmt: { Value: 5, Ccy: 'CHF' } } })), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.amount).toBe(1005); + expect(result.chargeAmount).toBe(5); + expect(result.chargeCurrency).toBe('CHF'); + }); + + it('sums multiple Chrgs/Rcrd entries when no TtlChrgsAndTaxAmt total is present', () => { + const [result] = Iso20022Service.parseCamt053Json( + statement( + debitEntry({ + Chrgs: { Rcrd: [{ Amt: { Value: 3, Ccy: 'CHF' } }, { Amt: { Value: 2, Ccy: 'CHF' } }] }, + }), + ), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.chargeAmount).toBe(5); + expect(result.chargeCurrency).toBe('CHF'); + }); + + it('defaults chargeAmount to 0 when the entry genuinely carries no Chrgs element', () => { + const [result] = Iso20022Service.parseCamt053Json(statement(debitEntry()), 'SYNTHETIC-ACCOUNT'); + + expect(result.chargeAmount).toBe(0); + expect(result.chargeCurrency).toBe('CHF'); + }); + + it('never attributes a charge to a credit entry, even if Chrgs is present', () => { + const [result] = Iso20022Service.parseCamt053Json( + statement({ ...debitEntry({ Chrgs: { TtlChrgsAndTaxAmt: { Value: 5, Ccy: 'CHF' } } }), CdtDbtInd: 'CRDT' }), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.chargeAmount).toBe(0); + }); + + it('reads a total charge given as #text/attribute or a bare primitive value', () => { + const [textShaped] = Iso20022Service.parseCamt053Json( + statement(debitEntry({ Chrgs: { TtlChrgsAndTaxAmt: { '#text': '5', Ccy: 'CHF' } } })), + 'SYNTHETIC-ACCOUNT', + ); + expect(textShaped.chargeAmount).toBe(5); + + const [bareShaped] = Iso20022Service.parseCamt053Json( + statement(debitEntry({ Chrgs: { TtlChrgsAndTaxAmt: 5 } })), + 'SYNTHETIC-ACCOUNT', + ); + expect(bareShaped.chargeAmount).toBe(5); + // no Ccy on a bare-value total - falls back to the entry's own currency + expect(bareShaped.chargeCurrency).toBe('CHF'); + }); + + it('defaults an unparseable total charge to 0 instead of propagating NaN', () => { + const [result] = Iso20022Service.parseCamt053Json( + statement(debitEntry({ Chrgs: { TtlChrgsAndTaxAmt: 'not-a-number' } })), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.chargeAmount).toBe(0); + }); + + it('falls back to the entry currency for a Rcrd charge with no Ccy, and treats a record missing Amt or with an unparseable Amt as 0', () => { + const [result] = Iso20022Service.parseCamt053Json( + statement(debitEntry({ Chrgs: { Rcrd: [{}, { Amt: 'garbage' }, { Amt: { Value: 2, Ccy: 'CHF' } }] } })), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.chargeAmount).toBe(2); + expect(result.chargeCurrency).toBe('CHF'); + }); + + it('accepts a single, non-array Rcrd charge', () => { + const [result] = Iso20022Service.parseCamt053Json( + statement(debitEntry({ Chrgs: { Rcrd: { Amt: { Value: 5, Ccy: 'CHF' } } } })), + 'SYNTHETIC-ACCOUNT', + ); + + expect(result.chargeAmount).toBe(5); + }); + + it('defaults to a 0 charge for a Chrgs element with neither a total nor any records', () => { + const [result] = Iso20022Service.parseCamt053Json(statement(debitEntry({ Chrgs: {} })), 'SYNTHETIC-ACCOUNT'); + + expect(result.chargeAmount).toBe(0); + expect(result.chargeCurrency).toBe('CHF'); + }); +}); + +describe('Iso20022Service camt.053 XML parsing', () => { + it('parses a camt.053 XML document into transactions', () => { + const xml = ` + + + + + 99.90 + CRDT +
2026-07-01
+ 00012345678901234567890 +
+
+
+
`; + + const result = Iso20022Service.parseCamt053Xml(xml, 'SYNTHETIC-ACCOUNT'); + + expect(result).toHaveLength(1); + expect(result[0].amount).toBe(99.9); + expect(result[0].currency).toBe('EUR'); + expect(result[0].accountServiceRef).toBe('00012345678901234567890'); + }); + + it('parses a camt.053 XML document without a Document root element', () => { + const xml = ` + + + + 10 + CRDT + + +`; + + const result = Iso20022Service.parseCamt053Xml(xml, 'SYNTHETIC-ACCOUNT'); + + expect(result).toHaveLength(1); + expect(result[0].amount).toBe(10); + }); +}); + +describe('Iso20022Service strict Bank Frick camt.053 contract', () => { + const accountIban = 'SYNTHETIC-FRICK-ACCOUNT'; + + function strictStatement(entry?: object, statementIban = accountIban) { + return { + BkToCstmrStmt: { + Stmt: { + Acct: { Id: { IBAN: statementIban } }, + ...(entry ? { Ntry: entry } : {}), + }, + }, + }; + } + + function strictEntry(overrides: object = {}) { + return { + Amt: { Value: '12.34', Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + Sts: 'BOOK', + BookgDt: { Dt: '2018-04-17+02:00' }, + ValDt: { DtTm: '2018-04-18T09:10:11+02:00' }, + // A real Bank Frick entry always carries a bank reference; tests that specifically exercise the + // missing-reference guard (#5) explicitly unset this with `AcctSvcrRef: undefined`. + AcctSvcrRef: 'SYNTHETIC-DEFAULT-REF', + ...overrides, + }; + } + + it('accepts the official offset-date, DateTime, Pty wrapper and entry-level BkTxCd shapes', () => { + const camt053 = strictStatement( + strictEntry({ + NtryDtls: { + TxDtls: { + RltdPties: { + Dbtr: { + Pty: { + Nm: 'Official Shape Sender', + PstlAdr: { AdrLine: ['Official Street 1'], Ctry: 'LI' }, + }, + }, + DbtrAcct: { Id: { IBAN: 'SYNTHETIC-SENDER-ACCOUNT' } }, + UltmtDbtr: { Pty: { Nm: 'Official Ultimate Sender' } }, + }, + RmtInf: { Strd: { CdtrRefInf: { Ref: 'SYNTHETIC-STRUCTURED-REFERENCE' } } }, + }, + }, + BkTxCd: { Domn: { Cd: 'PMNT', Fmly: { Cd: 'RCDT', SubFmlyCd: 'ESCT' } } }, + }), + ); + + const [result] = Iso20022Service.parseCamt053Json(camt053, ` ${accountIban.toLowerCase()} `, true); + + expect(result).toEqual( + expect.objectContaining({ + name: 'Official Shape Sender', + addressLine1: 'Official Street 1', + country: 'LI', + ultimateName: 'Official Ultimate Sender', + remittanceInfo: 'SYNTHETIC-STRUCTURED-REFERENCE', + domainCode: 'PMNT', + familyCode: 'RCDT', + subFamilyCode: 'ESCT', + }), + ); + expect(result.bookingDate).toEqual(new Date('2018-04-17')); + expect(result.valueDate).toEqual(new Date('2018-04-18T09:10:11+02:00')); + }); + + it('interprets a timezone-less DateTime deterministically as UTC', () => { + const [result] = Iso20022Service.parseCamt053Json( + strictStatement(strictEntry({ BookgDt: { DtTm: '2018-04-17T09:10:11' } })), + accountIban, + true, + ); + + expect(result.bookingDate).toEqual(new Date('2018-04-17T09:10:11Z')); + }); + + it('accepts exact XML-decimal amounts supplied as numbers or strings', () => { + const message = strictStatement(); + message.BkToCstmrStmt.Stmt.Ntry = [ + strictEntry({ Amt: { Value: 0.5, Ccy: 'CHF' } }), + strictEntry({ Amt: { Value: '.75', Ccy: 'EUR' } }), + ]; + + const result = Iso20022Service.parseCamt053Json(message, accountIban, true); + + expect(result.map(({ amount }) => amount)).toEqual([0.5, 0.75]); + }); + + it('accepts a valid empty statement but rejects a missing statement root', () => { + expect(Iso20022Service.parseCamt053Json(strictStatement(), accountIban, true)).toEqual([]); + expect(() => Iso20022Service.parseCamt053Json({}, accountIban, true)).toThrow( + 'Invalid camt.053 format: missing Stmt', + ); + }); + + it.each([ + [{ BkToCstmrStmt: { Stmt: {} } }, 'Invalid camt.053 format: missing account IBAN'], + [strictStatement(undefined, 'SYNTHETIC-OTHER-ACCOUNT'), 'Invalid camt.053 format: account IBAN mismatch'], + ])('rejects a statement that cannot be assigned to the requested account', (message, expectedError) => { + expect(() => Iso20022Service.parseCamt053Json(message, accountIban, true)).toThrow(expectedError); + }); + + it.each([ + [strictEntry({ Sts: undefined }), 'Invalid status in CAMT entry'], + [strictEntry({ Sts: { Cd: 'PDNG' } }), 'Invalid status in CAMT entry'], + [strictEntry({ Amt: { Value: '12.34garbage', Ccy: 'EUR' } }), 'Invalid amount in CAMT entry'], + [strictEntry({ Amt: { Value: '1e2', Ccy: 'EUR' } }), 'Invalid amount in CAMT entry'], + [strictEntry({ Amt: { Value: '12.34' } }), 'Invalid currency in CAMT entry'], + [strictEntry({ NtryDtls: [{}, {}] }), 'Ambiguous NtryDtls in CAMT entry'], + [strictEntry({ NtryDtls: { TxDtls: [{}, {}] } }), 'Ambiguous TxDtls in CAMT entry'], + [strictEntry({ BookgDt: { Dt: '2018-04-17+15:00' } }), 'Invalid booking date in CAMT entry'], + [strictEntry({ BookgDt: { Dt: '2018-04-17+14:01' } }), 'Invalid booking date in CAMT entry'], + [strictEntry({ BookgDt: { DtTm: '2018-04-17T99:00:00Z' } }), 'Invalid booking date in CAMT entry'], + ])( + 'rejects the entry (not the whole statement) for a lossy or unsafe money-critical strict field: %#', + (entry, expectedError) => { + const rejections: Error[] = []; + + const result = Iso20022Service.parseCamt053Json(strictStatement(entry), accountIban, true, (error) => + rejections.push(error), + ); + + expect(result).toEqual([]); + expect(rejections).toHaveLength(1); + expect(rejections[0].message).toBe(expectedError); + }, + ); + + it('fails closed on a strict entry with no bank-provided reference at all, instead of synthesizing one from the payload', () => { + // Same money-identifying fields as the reference-less non-strict fixture used elsewhere in this + // file - only the missing reference differs - to isolate exactly the behaviour #5 changes. + const entryWithoutReference = strictEntry({ + AcctSvcrRef: undefined, + NtryDtls: { TxDtls: { RltdPties: { Dbtr: { Nm: 'Synthetic Sender' } } } }, + }); + const rejections: Error[] = []; + + const result = Iso20022Service.parseCamt053Json( + strictStatement(entryWithoutReference), + accountIban, + true, + (error) => rejections.push(error), + ); + + expect(result).toEqual([]); + expect(rejections.map((error) => error.message)).toEqual(['Bank Frick CAMT entry missing bank reference']); + }); + + it.each([ + [ + { RmtInf: { Ustrd: { unexpected: true } } }, + (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined(), + ], + [ + { RmtInf: { Strd: { unsupported: true } } }, + (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined(), + ], + [ + { RmtInf: { Ustrd: [' ', ''] } }, + (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined(), + ], + [{ RmtInf: { Strd: ' ' } }, (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined()], + [ + { Refs: { EndToEndId: { malformed: true } } }, + (result: { endToEndId?: string }) => expect(result.endToEndId).toBe(''), + ], + ])( + 'drops a cosmetic field to undefined instead of rejecting the entry, in strict mode: %#', + (txDtlsOverrides, assertField) => { + const refsOverride = (txDtlsOverrides as { Refs?: object }).Refs; + const entry = strictEntry({ + NtryDtls: { + TxDtls: { ...txDtlsOverrides, Refs: { AcctSvcrRef: 'REAL-BANK-REFERENCE', ...refsOverride } }, + }, + }); + const rejections: Error[] = []; + + const [result] = Iso20022Service.parseCamt053Json(strictStatement(entry), accountIban, true, (error) => + rejections.push(error), + ); + + expect(rejections).toEqual([]); + expect(result.accountServiceRef).toBe('REAL-BANK-REFERENCE'); + assertField(result); + }, + ); +}); + +describe('Iso20022Service pain.001 generation', () => { + const payment: Pain001Payment = { + messageId: 'SYNTHETIC-MSG-ID', + endToEndId: 'SYNTHETIC-E2E-ID', + amount: 123.45, + currency: 'EUR', + debtor: { name: 'Synthetic Debtor', country: 'CH', iban: 'SYNTHETIC-DEBTOR-IBAN', bic: 'SYNTHETICDBIC' }, + creditor: { + name: 'Synthetic Creditor & Co ', + country: 'LI', + iban: 'SYNTHETIC-CREDITOR-IBAN', + bic: 'SYNTHETICCBIC', + address: 'Synthetic Street', + houseNumber: '5', + zip: '9490', + city: 'Synthetic City', + }, + remittanceInfo: 'Synthetic remittance note', + executionDate: new Date('2026-07-15'), + }; + + it('builds a complete pain.001 JSON structure including the optional remittance info', () => { + const json = Iso20022Service.createPain001Json(payment); + + expect(json.CstmrCdtTrfInitn.GrpHdr).toEqual( + expect.objectContaining({ MsgId: 'SYNTHETIC-MSG-ID', CtrlSum: 123.45, InitgPty: { Nm: 'Synthetic Debtor' } }), + ); + const txInfo = json.CstmrCdtTrfInitn.PmtInf[0].CdtTrfTxInf[0]; + expect(txInfo.PmtId).toEqual({ EndToEndId: 'SYNTHETIC-E2E-ID' }); + expect(txInfo.Amt.InstdAmt).toEqual({ Ccy: 'EUR', value: 123.45 }); + expect(txInfo.Cdtr.PstlAdr).toEqual({ + StrtNm: 'Synthetic Street', + BldgNb: '5', + PstCd: '9490', + TwnNm: 'Synthetic City', + Ctry: 'LI', + }); + expect(txInfo.CdtrAcct).toEqual({ Id: { IBAN: 'SYNTHETIC-CREDITOR-IBAN' } }); + expect(txInfo.RmtInf).toEqual({ Ustrd: 'Synthetic remittance note' }); + }); + + it('omits the optional creditor address fields and remittance info when absent', () => { + const minimalPayment: Pain001Payment = { + ...payment, + creditor: { name: 'Synthetic Minimal Creditor', country: 'LI', iban: 'SYNTHETIC-CREDITOR-IBAN' }, + remittanceInfo: undefined, + }; + + const json = Iso20022Service.createPain001Json(minimalPayment); + const txInfo = json.CstmrCdtTrfInitn.PmtInf[0].CdtTrfTxInf[0]; + + expect(txInfo.Cdtr.PstlAdr).toEqual({ Ctry: 'LI' }); + expect(txInfo).not.toHaveProperty('RmtInf'); + }); + + it('builds a complete pain.001 XML document, escaping unsafe characters and including remittance info', () => { + const xml = Iso20022Service.createPain001Xml(payment); + + expect(xml).toContain('SYNTHETIC-MSG-ID'); + expect(xml).toContain('Synthetic Creditor & Co <Ltd>'); + expect(xml).toContain('123.45'); + expect(xml).toContain('2026-07-15'); + expect(xml).toContain('Synthetic remittance note'); + }); + + it('omits the RmtInf element from the pain.001 XML document when no remittance info is given', () => { + const xml = Iso20022Service.createPain001Xml({ ...payment, remittanceInfo: undefined }); + + expect(xml).not.toContain(''); + }); + + it('defaults the pain.001 execution date to today when none is given', () => { + const xml = Iso20022Service.createPain001Xml({ ...payment, executionDate: undefined }); + + expect(xml).toMatch(/\d{4}-\d{2}-\d{2}<\/ReqdExctnDt>/); + }); +}); diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts new file mode 100644 index 0000000000..185cdc75aa --- /dev/null +++ b/src/integration/bank/services/frick.service.ts @@ -0,0 +1,645 @@ +import { Injectable } from '@nestjs/common'; +import { AxiosResponse, Method } from 'axios'; +import * as IbanTools from 'ibantools'; +import { Config } from 'src/config/config'; +import { HttpService } from 'src/shared/services/http.service'; +import { Util } from 'src/shared/utils/util'; +import { BankTx, BankTxIndicator } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { + FrickAccountsResponse, + FrickApproveWithoutTanRequest, + FrickAuthorizeRequest, + FrickAuthorizeResponse, + FrickBalance, + FrickCreateTransaction, + FrickCreateTransactionsRequest, + FrickPaymentAccount, + FrickPaymentCharge, + FrickPaymentOrder, + FrickPaymentOrderInput, + FrickPaymentOrderNotFoundError, + FrickPaymentState, + FrickPaymentType, + FrickSignatureVerificationError, + FrickTransactionsResponse, +} from '../dto/frick.dto'; +import { CamtTransaction, Iso20022Service } from './iso20022.service'; + +type FrickResponseType = 'json' | 'text'; + +export interface FrickTransactionsFetchResult { + transactions: Partial[]; + // False when at least one CAMT entry in this fetch failed strict validation (bad money-critical + // field, or no bank-provided reference) and was dropped. The caller must not advance its watermark + // past a fetch that is not fully parsed, even though the other, well-formed entries are returned and + // should still be imported this cycle. + fullyParsed: boolean; +} + +@Injectable() +export class BankFrickService { + private static readonly TOKEN_REFRESH_SKEW_MS = 30_000; + // Bank Frick's transaction search silently limits results to a short recent window unless a fromDate + // is supplied. Every customId lookup must send this wide fromDate, not only the BOOKED fallback, so a + // stale or slow-settling order can never be missed and re-submitted as a duplicate payout. + private static readonly EARLIEST_FROM_DATE = '1970-01-01'; + // Every Bank Frick request combined with an unbounded cron lock (see checkFrickOrderStatus's + // DfxCron timeout) means a single hung connection would otherwise stall the payout status poller + // permanently and silently. + private static readonly HTTP_TIMEOUT_MS = 30_000; + + private accessToken?: string; + private tokenExpiryMs = 0; + private authorizeInFlight?: Promise; + + constructor(private readonly http: HttpService) {} + + isAvailable(): boolean { + const { baseUrl, apiKey, privateKey, serverPublicKey, customer } = Config.bank.frick; + return !!(baseUrl && apiKey && privateKey && serverPublicKey && customer); + } + + async getBalances(): Promise { + this.assertAvailable(); + + const customer = this.validateCustomer(); + const response = await this.callApi(`accounts/${encodeURIComponent(customer)}`); + this.validateAccountsResponse(response); + // Pagination is deliberately not implemented yet. Returning only the first page would understate the customer's + // balances, so this integration fails closed until every result page can be fetched deterministically. + if (response.moreResults) throw new Error('Incomplete Bank Frick accounts response'); + + return response.accounts + .filter((account) => account.iban) + .map((account) => ({ + iban: this.normalizeAndValidateIban(account.iban, 'account response IBAN'), + currency: account.currency, + balance: account.balance, + availableBalance: account.available, + })); + } + + async getFrickTransactions(lastModificationTime: Date, accountIban: string): Promise { + this.assertAvailable(); + if (!(lastModificationTime instanceof Date) || Number.isNaN(lastModificationTime.getTime())) + throw new Error('Invalid Bank Frick transaction start date'); + + const iban = this.normalizeAndValidateIban(accountIban, 'account IBAN'); + const params = new URLSearchParams({ + iban, + // Bank Frick applies banking dates in Liechtenstein local time. Deriving both boundaries in that zone avoids + // a one-day lag around CET/CEST midnight when the API host itself runs in UTC. + fromDate: Util.isoDateInTimeZone('Europe/Vaduz', lastModificationTime), + toDate: Util.isoDateInTimeZone('Europe/Vaduz'), + }); + + const statement = await this.callApi( + `camt053?${params.toString()}`, + 'GET', + undefined, + 'application/xml', + 'text', + ); + if (typeof statement !== 'string') throw new Error('Invalid Bank Frick camt.053 response'); + if (!statement.trim()) return { transactions: [], fullyParsed: true }; + + let rejectedCount = 0; + const transactions = Iso20022Service.parseCamt053Xml(statement, iban, true, () => { + rejectedCount++; + }).map((tx) => this.parseTransaction(tx, iban)); + + return { transactions, fullyParsed: rejectedCount === 0 }; + } + + async createPaymentOrder(input: FrickPaymentOrderInput): Promise { + this.assertAvailable(); + this.assertPayoutEnabled(); + const transaction = this.createTransaction(input); + + const existing = await this.getPaymentOrderOrUndefined(transaction.customId); + if (existing) { + this.assertSamePayment(existing, transaction); + return existing; + } + + const request: FrickCreateTransactionsRequest = { transactions: [transaction] }; + const response = await this.callApi('transactions', 'PUT', request); + const created = this.getSinglePayment(response, transaction.customId); + this.assertSamePayment(created, transaction); + return created; + } + + async getPaymentOrder(customId: string): Promise { + this.assertAvailable(); + this.validateString(customId, 'customId', 50, true); + + const payment = await this.getPaymentOrderOrUndefined(customId); + if (!payment) throw new FrickPaymentOrderNotFoundError(customId); + return payment; + } + + async approvePaymentWithoutTan(payment: FrickPaymentOrder): Promise { + this.assertAvailable(); + this.assertPayoutEnabled(); + if (!Config.bank.frick.approveWithoutTan) + throw new Error('Bank Frick approval without TAN is not explicitly enabled'); + const customId = payment?.customId; + this.validateString(customId, 'customId', 50, true); + + const safeOrderId = this.getSafeOrderId(payment); + const request: FrickApproveWithoutTanRequest = safeOrderId + ? { orderIds: [Number(safeOrderId)] } + : { customIds: [customId] }; + const response = await this.callApi('signTransactionWithoutTan', 'POST', request); + return this.getSinglePayment(response, customId); + } + + getSafeOrderId(payment: FrickPaymentOrder): string | undefined { + return Number.isSafeInteger(payment.orderId) && payment.orderId > 0 ? payment.orderId.toString() : undefined; + } + + private parseTransaction(tx: CamtTransaction, accountIban: string): Partial { + return { + accountServiceRef: `FRICK-${Util.createHash(`${accountIban}:${tx.accountServiceRef}`, 'sha256', 'hex')}`, + bookingDate: tx.bookingDate, + valueDate: tx.valueDate, + txCount: 1, + txId: tx.accountServiceRef, + amount: tx.amount, + instructedAmount: tx.instructedAmount ?? tx.amount, + txAmount: tx.txAmount ?? tx.amount, + // Real, parsed Ntry/Chrgs total (0 only when the entry genuinely carries no charge) - a booked + // debit that included a bank charge must reconcile net of it, not against the full booked amount. + chargeAmount: tx.chargeAmount, + currency: tx.currency, + instructedCurrency: tx.instructedCurrency ?? tx.currency, + txCurrency: tx.txCurrency ?? tx.currency, + chargeCurrency: tx.chargeCurrency, + creditDebitIndicator: + tx.creditDebitIndicator === BankTxIndicator.CREDIT ? BankTxIndicator.CREDIT : BankTxIndicator.DEBIT, + iban: tx.iban, + bic: tx.bic, + name: tx.name, + addressLine1: tx.addressLine1, + addressLine2: tx.addressLine2, + country: tx.country, + ultimateName: tx.ultimateName, + ultimateAddressLine1: tx.ultimateAddressLine1, + ultimateAddressLine2: tx.ultimateAddressLine2, + ultimateCountry: tx.ultimateCountry, + remittanceInfo: tx.remittanceInfo, + endToEndId: tx.endToEndId, + accountIban, + domainCode: tx.domainCode, + familyCode: tx.familyCode, + subFamilyCode: tx.subFamilyCode, + type: null, + }; + } + + private createTransaction(input: FrickPaymentOrderInput): FrickCreateTransaction { + this.validateString(input.customId, 'customId', 50, true); + this.validateAmount(input.amount); + if (!['CHF', 'EUR'].includes(input.currency)) throw new Error(`Unsupported Bank Frick currency: ${input.currency}`); + + const debtorIban = this.normalizeAndValidateIban(input.debtorIban, 'debtor IBAN'); + const creditor = this.validateCreditor(input.creditor); + const reference = input.reference?.trim(); + if (reference) this.validateString(reference, 'reference', 140); + + if (input.currency === 'EUR') { + if (!IbanTools.isSEPACountry(creditor.iban.substring(0, 2))) + throw new Error('Bank Frick EUR payout requires a SEPA creditor IBAN'); + const type = input.instant ? FrickPaymentType.SEPA_INSTANT : FrickPaymentType.SEPA; + return { + customId: input.customId, + type, + amount: input.amount, + currency: input.currency, + ...(!input.instant && { express: false }), + ...(reference && { reference }), + debitor: { iban: debtorIban }, + creditor: { name: creditor.name, iban: creditor.iban }, + }; + } + + if (input.instant) throw new Error('Bank Frick instant payments are only supported for EUR'); + + if (!creditor.bic) throw new Error('Bank Frick FOREIGN payment requires creditor BIC'); + if (!input.charge || !Object.values(FrickPaymentCharge).includes(input.charge)) + throw new Error('Bank Frick FOREIGN payment requires a valid charge'); + + return { + customId: input.customId, + type: FrickPaymentType.FOREIGN, + amount: input.amount, + currency: input.currency, + express: false, + ...(reference && { reference }), + charge: input.charge, + debitor: { iban: debtorIban }, + creditor, + }; + } + + private validateCreditor(account: FrickPaymentAccount): FrickPaymentAccount { + if (!account || typeof account !== 'object') throw new Error('Bank Frick creditor is required'); + + const name = account.name?.trim(); + this.validateString(name, 'creditor name', 35, true); + const iban = this.normalizeAndValidateIban(account.iban, 'creditor IBAN'); + + const address = account.address?.trim(); + const postalcode = account.postalcode?.trim(); + const city = account.city?.trim(); + const country = account.country?.trim(); + const bic = account.bic?.replace(/\s/g, '').toUpperCase(); + const creditInstitution = account.creditInstitution?.trim(); + + if (address) this.validateString(address, 'creditor address', 70); + if (postalcode) this.validateString(postalcode, 'creditor postal code', 11); + if (city) this.validateString(city, 'creditor city', 70); + if (country) this.validateString(country, 'creditor country', 70); + if (bic && !/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic)) throw new Error('Invalid creditor BIC'); + if (creditInstitution) this.validateString(creditInstitution, 'credit institution', 50); + + return { + name, + iban, + ...(address && { address }), + ...(postalcode && { postalcode }), + ...(city && { city }), + ...(country && { country }), + ...(bic && { bic }), + ...(creditInstitution && { creditInstitution }), + }; + } + + private validateAmount(amount: number): void { + const cents = amount * 100; + if ( + !Number.isFinite(amount) || + amount < 0.01 || + amount > 999_999_999_999.99 || + Math.abs(cents - Math.round(cents)) > Number.EPSILON * Math.max(1, Math.abs(cents)) * 2 + ) + throw new Error('Invalid Bank Frick payment amount'); + } + + private validateString(value: unknown, field: string, maxLength: number, required = false): void { + if (typeof value !== 'string' || (required && !value.trim())) throw new Error(`Invalid Bank Frick ${field}`); + if (value.length > maxLength) throw new Error(`Bank Frick ${field} exceeds ${maxLength} characters`); + } + + private normalizeAndValidateIban(value: string | undefined, field: string): string { + if (typeof value !== 'string') throw new Error(`Invalid Bank Frick ${field}`); + const iban = value.replace(/\s/g, '').toUpperCase(); + if (iban.length > 34 || !IbanTools.validateIBAN(iban).valid) throw new Error(`Invalid Bank Frick ${field}`); + return iban; + } + + private async getPaymentOrderOrUndefined(customId: string): Promise { + const active = await this.getFilteredPaymentOrder( + new URLSearchParams({ customId, fromDate: BankFrickService.EARLIEST_FROM_DATE }), + customId, + ); + if (active) return active; + + // BOOKED orders are deliberately excluded from Bank Frick's default transaction search. Query them + // explicitly as well so a process crash after a successful PUT can never make the retry create a second + // payout. + return this.getFilteredPaymentOrder( + new URLSearchParams({ + customId, + status: FrickPaymentState.BOOKED, + fromDate: BankFrickService.EARLIEST_FROM_DATE, + }), + customId, + ); + } + + private async getFilteredPaymentOrder( + params: URLSearchParams, + customId: string, + ): Promise { + const response = await this.callApi(`transactions?${params.toString()}`); + // This lookup is already scoped to customId by the request itself (the ?customId= query param). + // Bank Frick's real BOOKED transaction objects carry neither customId nor type - requiring them + // here would make every settled payout throw and never reach a terminal state. Trust the filter: + // customId/type are validated when present, but their absence is not itself an error. + this.validateTransactionsResponse(response, false); + + if (response.moreResults) throw new Error(`Ambiguous Bank Frick payment lookup for ${customId}`); + if (response.transactions.some((payment) => payment.customId !== undefined && payment.customId !== customId)) + throw new Error(`Invalid Bank Frick payment lookup response for ${customId}`); + if (response.transactions.length > 1) throw new Error(`Duplicate Bank Frick payment orders for ${customId}`); + return response.transactions[0]; + } + + private getSinglePayment(response: FrickTransactionsResponse, customId: string): FrickPaymentOrder { + // Unlike the filtered lookup above, this reads the response to a PUT/signTransactionWithoutTan + // request DFX itself just issued for this exact customId - Bank Frick returns type/customId here, + // so the stricter shape stays required as a defense against a malformed or mismatched response. + this.validateTransactionsResponse(response, true); + if (response.moreResults) throw new Error(`Ambiguous Bank Frick payment response for ${customId}`); + + const matches = response.transactions.filter((payment) => payment.customId === customId); + if (matches.length !== 1) throw new Error(`Invalid Bank Frick payment response for ${customId}`); + return matches[0]; + } + + private validateTransactionsResponse(response: FrickTransactionsResponse, requireTypeAndCustomId: boolean): void { + if ( + !response || + typeof response !== 'object' || + typeof response.moreResults !== 'boolean' || + !Number.isInteger(response.resultSetSize) || + !Array.isArray(response.transactions) || + response.resultSetSize !== response.transactions.length + ) + throw new Error('Invalid Bank Frick transactions response'); + + for (const payment of response.transactions) { + const hasValidCustomId = + typeof payment?.customId === 'string' || (!requireTypeAndCustomId && payment?.customId === undefined); + const hasValidType = + Object.values(FrickPaymentType).includes(payment?.type) || + (!requireTypeAndCustomId && payment?.type === undefined); + + if ( + !payment || + typeof payment !== 'object' || + !hasValidCustomId || + !hasValidType || + !Object.values(FrickPaymentState).includes(payment.state) || + typeof payment.currency !== 'string' || + !payment.debitor || + !payment.creditor + ) + throw new Error('Invalid Bank Frick payment order response'); + + if ( + payment.orderId !== undefined && + (typeof payment.orderId !== 'number' || !Number.isInteger(payment.orderId) || payment.orderId <= 0) + ) + throw new Error('Invalid Bank Frick orderId response'); + this.parseResponseAmount(payment.amount); + } + } + + private validateAccountsResponse(response: FrickAccountsResponse): void { + if ( + !response || + typeof response !== 'object' || + typeof response.date !== 'string' || + typeof response.moreResults !== 'boolean' || + !Number.isInteger(response.resultSetSize) || + !Array.isArray(response.accounts) || + response.resultSetSize !== response.accounts.length + ) + throw new Error('Invalid Bank Frick accounts response'); + + for (const account of response.accounts) { + if ( + !account || + typeof account.account !== 'string' || + typeof account.type !== 'string' || + typeof account.customer !== 'string' || + typeof account.currency !== 'string' || + !Number.isFinite(account.balance) || + (account.available !== undefined && !Number.isFinite(account.available)) || + (account.iban !== undefined && typeof account.iban !== 'string') + ) + throw new Error('Invalid Bank Frick account response'); + } + } + + private assertSamePayment(existing: FrickPaymentOrder, requested: FrickCreateTransaction): void { + const existingAmount = this.parseResponseAmount(existing.amount); + const same = + // A BOOKED order returned by the customId-scoped lookup carries neither field (see + // getFilteredPaymentOrder's "trust the filter" comment) - their absence was already verified + // against the request there and is not itself a mismatch here. + (existing.customId === undefined || existing.customId === requested.customId) && + (existing.type === undefined || existing.type === requested.type) && + existing.currency === requested.currency && + Math.abs(existingAmount - requested.amount) < 0.005 && + existing.debitor?.iban?.replace(/\s/g, '').toUpperCase() === requested.debitor.iban && + existing.creditor?.iban?.replace(/\s/g, '').toUpperCase() === requested.creditor.iban && + existing.creditor?.name?.trim() === requested.creditor.name && + (existing.reference?.trim() ?? '') === (requested.reference ?? '') && + this.matchesSentValue(existing.express, requested.express) && + this.matchesSentValue(existing.charge, requested.charge) && + this.matchesSentString(existing.creditor.address, requested.creditor.address) && + this.matchesSentString(existing.creditor.postalcode, requested.creditor.postalcode) && + this.matchesSentString(existing.creditor.city, requested.creditor.city) && + this.matchesSentString(existing.creditor.country, requested.creditor.country) && + this.matchesSentString( + existing.creditor.creditInstitution ?? existing.creditor.creditInsitution, + requested.creditor.creditInstitution, + ) && + this.matchesSentString(existing.creditor.bic, requested.creditor.bic, (value) => { + const bic = value.replace(/\s/g, '').toUpperCase(); + return bic.length === 8 ? bic.padEnd(11, 'X') : bic; + }); + + if (!same) throw new Error(`Bank Frick customId collision for ${requested.customId}`); + } + + private matchesSentString( + existing: string | undefined, + requested: string | undefined, + normalize: (value: string) => string = (value) => value.trim(), + ): boolean { + return requested === undefined || (typeof existing === 'string' && normalize(existing) === normalize(requested)); + } + + private matchesSentValue(existing: T | undefined, requested: T | undefined): boolean { + return requested === undefined || existing === requested; + } + + private parseResponseAmount(value: number | string): number { + if (typeof value === 'number' && Number.isFinite(value)) return Math.abs(value); + if (typeof value !== 'string') throw new Error('Invalid Bank Frick payment amount response'); + + const normalized = /^-?\d{1,3}(\.\d{3})*,\d{2}$/.test(value) ? value.replace(/\./g, '').replace(',', '.') : value; + if (!/^-?\d+(\.\d{1,2})?$/.test(normalized)) throw new Error('Invalid Bank Frick payment amount response'); + const amount = Math.abs(Number(normalized)); + if (!Number.isFinite(amount)) throw new Error('Invalid Bank Frick payment amount response'); + return amount; + } + + private async callApi( + path: string, + method: Method = 'GET', + body?: unknown, + accept = 'application/json', + responseType: FrickResponseType = 'json', + allowUnauthorizedRetry = true, + ): Promise { + this.assertAvailable(); + const token = await this.getAccessToken(); + const bodyString = body === undefined ? '' : JSON.stringify(body); + + try { + return await this.http.request({ + url: this.createUrl(path), + method, + data: bodyString, + responseType, + tryCount: 1, + timeout: BankFrickService.HTTP_TIMEOUT_MS, + headers: { + Accept: accept, + 'Content-Type': body === undefined ? '*/*' : 'application/json', + Authorization: `Bearer ${token}`, + Signature: this.sign(bodyString), + algorithm: 'rsa-sha512', + }, + responseVerifier: (rawBody, headers) => this.verifyResponse(rawBody, headers), + }); + } catch (error) { + if (error instanceof FrickSignatureVerificationError) + throw new Error(`Bank Frick response signature verification failed (${method} ${path}): ${error.message}`); + + if (allowUnauthorizedRetry && error?.response?.status === 401) { + await this.refreshAfterUnauthorized(token); + return this.callApi(path, method, body, accept, responseType, false); + } + + throw new Error(`Bank Frick API request failed (${method} ${path}): ${this.getHttpFailureReason(error)}`); + } + } + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() + BankFrickService.TOKEN_REFRESH_SKEW_MS < this.tokenExpiryMs) + return this.accessToken; + + if (!this.authorizeInFlight) { + this.authorizeInFlight = this.authorize().finally(() => { + this.authorizeInFlight = undefined; + }); + } + return this.authorizeInFlight; + } + + private async refreshAfterUnauthorized(rejectedToken: string): Promise { + if (this.accessToken === rejectedToken) { + this.accessToken = undefined; + this.tokenExpiryMs = 0; + } + await this.getAccessToken(); + } + + private async authorize(): Promise { + this.assertAvailable(); + const request: FrickAuthorizeRequest = { key: Config.bank.frick.apiKey }; + const bodyString = JSON.stringify(request); + + let response: FrickAuthorizeResponse; + try { + response = await this.http.request({ + url: this.createUrl('authorize'), + method: 'POST', + data: bodyString, + responseType: 'json', + tryCount: 1, + timeout: BankFrickService.HTTP_TIMEOUT_MS, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Signature: this.sign(bodyString), + algorithm: 'rsa-sha512', + }, + responseVerifier: (rawBody, headers) => this.verifyResponse(rawBody, headers), + }); + } catch (error) { + if (error instanceof FrickSignatureVerificationError) + throw new Error(`Bank Frick authorization response signature verification failed: ${error.message}`); + + throw new Error(`Bank Frick authorization failed: ${this.getHttpFailureReason(error)}`); + } + + if (!response || typeof response.token !== 'string' || !response.token) { + throw new Error('Invalid Bank Frick authorization response'); + } + + this.accessToken = response.token; + this.tokenExpiryMs = this.getTokenExpiry(response.token); + return response.token; + } + + private getTokenExpiry(token: string): number { + try { + const tokenParts = token.split('.'); + if (tokenParts.length !== 3) throw new Error('invalid JWT structure'); + if (tokenParts.some((part) => !part)) throw new Error('empty JWT segment'); + const payloadPart = tokenParts[1]; + const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')) as { exp?: unknown }; + if (payload.exp === undefined) return Number.POSITIVE_INFINITY; + if (typeof payload.exp !== 'number' || !Number.isSafeInteger(payload.exp) || payload.exp <= 0) + throw new Error('invalid expiry'); + const expiryMs = payload.exp * 1000; + if (!Number.isSafeInteger(expiryMs)) throw new Error('invalid expiry'); + return expiryMs; + } catch { + throw new Error('Invalid Bank Frick JWT'); + } + } + + private sign(bodyString: string): string { + try { + return Util.createSign(bodyString, Config.bank.frick.privateKey, 'sha512', 'base64'); + } catch { + throw new Error('Invalid Bank Frick signing configuration'); + } + } + + private verifyResponse(rawBody: Buffer, headers: AxiosResponse['headers']): void { + const signature = headers?.signature ?? headers?.Signature; + const algorithm = String(headers?.algorithm ?? headers?.Algorithm ?? '').toLowerCase(); + const algorithms = { 'rsa-sha512': 'sha512', 'rsa-sha384': 'sha384', 'rsa-sha256': 'sha256' } as const; + const hashAlgorithm = algorithms[algorithm as keyof typeof algorithms]; + if (typeof signature !== 'string' || !signature || !hashAlgorithm) + throw new FrickSignatureVerificationError('Invalid Bank Frick response signature headers'); + + try { + if (!Util.verifySign(rawBody, Config.bank.frick.serverPublicKey, signature, hashAlgorithm, 'base64')) + throw new Error('signature mismatch'); + } catch { + throw new FrickSignatureVerificationError('Invalid Bank Frick response signature'); + } + } + + private createUrl(path: string): string { + return `${Config.bank.frick.baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; + } + + private getHttpFailureReason(error: any): string { + const status = error?.response?.status; + if (Number.isInteger(status) && status >= 100 && status <= 599) return `HTTP ${status}`; + + // Never propagate an arbitrary upstream message: it can contain serialized request data, including + // the API key used by /authorize. Axios transport codes are bounded and contain no request payload. + const code = error?.code; + return typeof code === 'string' && /^[A-Z0-9_]{1,64}$/.test(code) ? code : 'request failed'; + } + + private validateCustomer(): string { + const customer = Config.bank.frick.customer; + // Bank Frick's OpenAPI bounds the {customer} path segment to at most 7 digits (([0-9]{0,7})?). + // Enforcing that here makes a misconfigured customer fail closed at the config check instead of + // silently sending a path segment the API would reject. + if (typeof customer !== 'string' || !/^\d{1,7}$/.test(customer)) + throw new Error('Invalid Bank Frick customer configuration'); + return customer; + } + + private assertAvailable(): void { + if (!this.isAvailable()) throw new Error('Bank Frick is not configured'); + } + + private assertPayoutEnabled(): void { + if (!Config.bank.frick.payoutEnabled) throw new Error('Bank Frick payout is not explicitly enabled'); + } +} diff --git a/src/integration/bank/services/iso20022.service.ts b/src/integration/bank/services/iso20022.service.ts index cad6e14687..cce512598e 100644 --- a/src/integration/bank/services/iso20022.service.ts +++ b/src/integration/bank/services/iso20022.service.ts @@ -24,6 +24,10 @@ export interface CamtTransaction { exchangeSourceCurrency?: string; exchangeTargetCurrency?: string; exchangeRate?: number; + // The entry-booked amount can include bank charges deducted from a debit; chargeAmount is the real, + // parsed Ntry/Chrgs total (0 when the entry genuinely carries no charge, never a stand-in default). + chargeAmount?: number; + chargeCurrency?: string; name?: string; addressLine1?: string; @@ -215,19 +219,44 @@ export class Iso20022Service { } // --- CAMT.053 PARSING --- // - static parseCamt053Json(camt053: any, accountIban: string): CamtTransaction[] { - const statements = camt053?.BkToCstmrStmt?.Stmt; - if (!statements || !Array.isArray(statements)) return []; + static parseCamt053Json( + camt053: any, + accountIban: string, + strict = false, + onEntryRejected?: (error: Error, entry: unknown) => void, + ): CamtTransaction[] { + const rawStatements = camt053?.BkToCstmrStmt?.Stmt; + if (!rawStatements) { + if (strict) throw new Error('Invalid camt.053 format: missing Stmt'); + return []; + } + const statements = Array.isArray(rawStatements) ? rawStatements : [rawStatements]; const transactions: CamtTransaction[] = []; + const fallbackOccurrences = new Map(); for (const stmt of statements) { - if (!stmt.Ntry || !Array.isArray(stmt.Ntry)) continue; - - for (const entry of stmt.Ntry) { + // A statement whose account IBAN does not match the requested one is a structural mismatch - + // this fetch does not belong to the requested account at all, so it stays fatal for the whole + // statement. An individual entry failing its own validation, below, is never allowed to do the + // same: it is reported and dropped so every other, well-formed entry is still imported. + if (strict) this.assertValidCamtStatement(stmt, accountIban); + if (!stmt.Ntry) continue; + const entries = Array.isArray(stmt.Ntry) ? stmt.Ntry : [stmt.Ntry]; + + for (const entry of entries) { try { - transactions.push(Iso20022Service.parseCamt053JsonEntry(entry, accountIban)); - } catch { + const stableReference = this.createStableCamtReference(accountIban, entry); + const occurrence = fallbackOccurrences.get(stableReference) ?? 0; + // Equal reference-less entries can be separate transfers. Preserve deterministic deduplication while + // assigning each occurrence a distinct reference instead of silently collapsing a valid payment. + const fallbackReference = occurrence === 0 ? stableReference : `${stableReference}-${occurrence + 1}`; + + const transaction = Iso20022Service.parseCamt053JsonEntry(entry, accountIban, strict, fallbackReference); + fallbackOccurrences.set(stableReference, occurrence + 1); + transactions.push(transaction); + } catch (error) { + onEntryRejected?.(error, entry); continue; } } @@ -236,34 +265,58 @@ export class Iso20022Service { return transactions; } - private static parseCamt053JsonEntry(entry: any, accountIban: string): CamtTransaction { + private static parseCamt053JsonEntry( + entry: any, + accountIban: string, + strict = false, + fallbackReference = this.createStableCamtReference(accountIban, entry), + ): CamtTransaction { // amount and currency const amtObj = entry.Amt; - const amount = parseFloat(amtObj?.Value || amtObj?.['#text'] || amtObj || '0'); - const currency = amtObj?.Ccy || 'CHF'; + const rawAmount = amtObj?.Value ?? amtObj?.['#text'] ?? amtObj; + const amount = strict ? this.parseStrictCamtAmount(rawAmount) : parseFloat(rawAmount || '0'); + const currency = strict ? amtObj?.Ccy : amtObj?.Ccy || 'CHF'; + if (strict && (!Number.isFinite(amount) || amount <= 0)) throw new Error('Invalid amount in CAMT entry'); + if (strict && (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency))) + throw new Error('Invalid currency in CAMT entry'); // credit/debit indicator const cdtDbtInd = entry.CdtDbtInd; if (!cdtDbtInd) throw new Error('Missing CdtDbtInd in CAMT entry'); + if (strict && !['CRDT', 'DBIT'].includes(cdtDbtInd)) throw new Error('Invalid CdtDbtInd in CAMT entry'); const creditDebitIndicator = cdtDbtInd === 'CRDT' ? BankTxIndicator.CREDIT : BankTxIndicator.DEBIT; + // bank charges - Bank Frick (and other banks) can book a debit entry's amount inclusive of the + // charge it deducted. Charges are only ever taken from an outgoing (debit) entry. + const { chargeAmount, chargeCurrency } = + creditDebitIndicator === BankTxIndicator.DEBIT + ? this.parseCamtCharge(entry.Chrgs, currency) + : { chargeAmount: 0, chargeCurrency: currency }; + // dates - const bookingDateStr = entry.BookgDt?.Dt; - const valueDateStr = entry.ValDt?.Dt; + const bookingDateStr = this.getCamtDate(entry.BookgDt); + const valueDateStr = this.getCamtDate(entry.ValDt); + if (strict) this.assertValidCamtDate(bookingDateStr, 'booking'); + if (strict && valueDateStr !== undefined) this.assertValidCamtDate(valueDateStr, 'value'); const bookingDate = bookingDateStr ? this.parseDate(bookingDateStr) : new Date(); const valueDate = valueDateStr ? this.parseDate(valueDateStr) : bookingDate; + const status = typeof entry.Sts === 'string' ? entry.Sts : entry.Sts?.Cd; + if (strict && status !== CamtStatus.BOOKED) throw new Error('Invalid status in CAMT entry'); + // transaction details const entryDtls = entry.NtryDtls; const txDtlsArray = Array.isArray(entryDtls) ? entryDtls : entryDtls ? [entryDtls] : []; + if (strict && txDtlsArray.length > 1) throw new Error('Ambiguous NtryDtls in CAMT entry'); const firstDetail = txDtlsArray[0]; const txDtlsList = firstDetail?.TxDtls; + if (strict && Array.isArray(txDtlsList) && txDtlsList.length > 1) throw new Error('Ambiguous TxDtls in CAMT entry'); const txDtls = Array.isArray(txDtlsList) ? txDtlsList[0] : txDtlsList || {}; // party information - determine counterparty based on credit/debit const isCredit = creditDebitIndicator === BankTxIndicator.CREDIT; const parties = txDtls.RltdPties || {}; - const counterparty = isCredit ? parties.Dbtr : parties.Cdtr; + const counterparty = this.unwrapCamtParty(isCredit ? parties.Dbtr : parties.Cdtr); const counterpartyAcct = isCredit ? parties.DbtrAcct : parties.CdtrAcct; const counterpartyAgent = isCredit ? txDtls.RltdAgts?.DbtrAgt : txDtls.RltdAgts?.CdtrAgt; @@ -277,7 +330,7 @@ export class Iso20022Service { const country = postalAddress?.Ctry; // ultimate party info (actual sender/receiver behind intermediary banks like Wise/Revolut) - const ultimateParty = isCredit ? parties.UltmtDbtr : parties.UltmtCdtr; + const ultimateParty = this.unwrapCamtParty(isCredit ? parties.UltmtDbtr : parties.UltmtCdtr); const ultimateName = ultimateParty?.Nm; const ultimatePostalAddress = ultimateParty?.PstlAdr; const { addressLine1: ultimateAddressLine1, addressLine2: ultimateAddressLine2 } = @@ -285,29 +338,28 @@ export class Iso20022Service { const ultimateCountry = ultimatePostalAddress?.Ctry; // remittance information - let remittanceInfo: string | undefined; - if (txDtls.RmtInf?.Ustrd) { - const ustrd = txDtls.RmtInf.Ustrd; - remittanceInfo = Array.isArray(ustrd) ? ustrd.join(' ') : ustrd; - } else if (txDtls.RmtInf?.Strd) { - remittanceInfo = txDtls.RmtInf.Strd; - } else if (entry.AddtlNtryInf) { - remittanceInfo = entry.AddtlNtryInf; - } + const remittanceInfo = this.parseCamtRemittanceInfo(txDtls.RmtInf, entry.AddtlNtryInf); // reference - check transaction-level refs first (matches camt.054), then entry-level AcctSvcrRef - const accountServiceRef = - txDtls.Refs?.AcctSvcrRef || - txDtls.Refs?.TxId || - entry.AcctSvcrRef || - entry.NtryRef || - Util.createUniqueId(accountIban); + const transactionAccountServiceRef = this.parseOptionalCamtString(txDtls.Refs?.AcctSvcrRef); + const transactionId = this.parseOptionalCamtString(txDtls.Refs?.TxId); + const entryAccountServiceRef = this.parseOptionalCamtString(entry.AcctSvcrRef); + const entryReference = this.parseOptionalCamtString(entry.NtryRef); + const realReference = transactionAccountServiceRef || transactionId || entryAccountServiceRef || entryReference; + // A synthetic, content-derived reference is the deliberate, already-shipped fix for reference-less + // Yapeal/Raiffeisen entries (see docs/bank-frick-operations.md §2) and must stay for non-strict + // callers. In strict mode (Bank Frick), reconciliation matches on this reference exactly, so a + // content hash that changes on a cosmetic bank-side amendment (e.g. a re-sent AddtlNtryInf) would + // silently mint a second identity for the same money and risk a double credit. Fail closed instead: + // reject the entry so it is retried, never assign it a synthesized identity. + if (!realReference && strict) throw new Error('Bank Frick CAMT entry missing bank reference'); + const accountServiceRef = realReference || fallbackReference; // end-to-end ID - const endToEndId = txDtls.Refs?.EndToEndId || ''; + const endToEndId = this.parseOptionalCamtString(txDtls.Refs?.EndToEndId) || ''; // bank transaction codes - const bkTxCd = txDtls.BkTxCd?.Domn; + const bkTxCd = (txDtls.BkTxCd ?? entry.BkTxCd)?.Domn; const domainCode = bkTxCd?.Cd; const familyCode = bkTxCd?.Fmly?.Cd; const subFamilyCode = bkTxCd?.Fmly?.SubFmlyCd; @@ -318,6 +370,8 @@ export class Iso20022Service { valueDate, amount, currency, + chargeAmount, + chargeCurrency, creditDebitIndicator, name, addressLine1, @@ -340,15 +394,149 @@ export class Iso20022Service { }; } - static parseCamt053Xml(xmlData: string, accountIban: string): CamtTransaction[] { + static parseCamt053Xml( + xmlData: string, + accountIban: string, + strict = false, + onEntryRejected?: (error: Error, entry: unknown) => void, + ): CamtTransaction[] { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseAttributeValue: true, + // Identifiers such as AcctSvcrRef and EndToEndId may contain only digits. Keep element text as text so + // parsing can never lose leading zeroes or precision before the strict CAMT validator sees it. + parseTagValue: false, }); const jsonData = parser.parse(xmlData); - return this.parseCamt053Json(jsonData.Document || jsonData, accountIban); + return this.parseCamt053Json(jsonData.Document || jsonData, accountIban, strict, onEntryRejected); + } + + private static createStableCamtReference(accountIban: string, entry: unknown): string { + const canonicalEntry = JSON.stringify(this.canonicalize(entry)); + return `CAMT-${Util.createHash(`${accountIban}:${canonicalEntry}`, 'sha256', 'hex')}`; + } + + private static canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map((entry) => this.canonicalize(entry)); + if (!value || typeof value !== 'object') return value; + + return Object.keys(value) + .sort() + .reduce>((result, key) => { + const entry = (value as Record)[key]; + if (entry !== undefined) result[key] = this.canonicalize(entry); + return result; + }, {}); + } + + private static assertValidCamtStatement(statement: any, requestedAccountIban: string): void { + const statementIban = statement?.Acct?.Id?.IBAN; + if (typeof statementIban !== 'string' || !statementIban.trim()) + throw new Error('Invalid camt.053 format: missing account IBAN'); + + const normalizeIban = (iban: string) => iban.replace(/\s/g, '').toUpperCase(); + if (normalizeIban(statementIban) !== normalizeIban(requestedAccountIban)) + throw new Error('Invalid camt.053 format: account IBAN mismatch'); + } + + private static getCamtDate(dateChoice: any): string | undefined { + return dateChoice?.Dt ?? dateChoice?.DtTm; + } + + private static unwrapCamtParty(party: any): any { + return party?.Pty ?? party ?? {}; + } + + // Remittance information is cosmetic (customer-facing text, not a money-identifying field): an + // unsupported or malformed shape drops the field to undefined instead of rejecting the entry, in + // both strict and non-strict mode. + private static parseCamtRemittanceInfo(remittance: any, additionalEntryInfo: unknown): string | undefined { + if (remittance?.Ustrd !== undefined) { + const values = Array.isArray(remittance.Ustrd) ? remittance.Ustrd : [remittance.Ustrd]; + if (values.every((value) => typeof value === 'string')) { + const normalizedValues = values.map((value) => value.trim()).filter(Boolean); + if (normalizedValues.length > 0) return normalizedValues.join(' '); + } + } + + if (remittance?.Strd !== undefined) { + if (typeof remittance.Strd === 'string' && remittance.Strd.trim()) return remittance.Strd.trim(); + const structures = Array.isArray(remittance.Strd) ? remittance.Strd : [remittance.Strd]; + const references = structures + .map((structure) => structure?.CdtrRefInf?.Ref) + .filter((reference): reference is string => typeof reference === 'string' && !!reference.trim()) + .map((reference) => reference.trim()); + if (references.length > 0) return references.join(' '); + } + + return typeof additionalEntryInfo === 'string' && additionalEntryInfo.trim() + ? additionalEntryInfo.trim() + : undefined; + } + + private static parseStrictCamtAmount(value: unknown): number { + if (typeof value === 'number') return value; + if (typeof value !== 'string' || !/^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))$/.test(value.trim())) return Number.NaN; + return Number(value.trim()); + } + + private static parseAmtValue(amtObj: any): number { + const raw = amtObj?.Value ?? amtObj?.['#text'] ?? amtObj; + return parseFloat(raw ?? '0'); + } + + // ISO 20022's Ntry/Chrgs (ChargesInformation8): either a single TtlChrgsAndTaxAmt total, or one or + // more Rcrd entries each carrying their own Amt - mirrors what SepaParser.getTotalCharge already + // does for the SEPA-file format, at the entry level instead of that format's own attribute + // convention. A genuinely absent Chrgs element is a real 0, not a fallback masking an unread value. + private static parseCamtCharge( + chrgs: any, + fallbackCurrency: string, + ): { chargeAmount: number; chargeCurrency: string } { + if (!chrgs) return { chargeAmount: 0, chargeCurrency: fallbackCurrency }; + + if (chrgs.TtlChrgsAndTaxAmt !== undefined) { + const amount = this.parseAmtValue(chrgs.TtlChrgsAndTaxAmt); + return { + chargeAmount: Number.isFinite(amount) ? amount : 0, + chargeCurrency: chrgs.TtlChrgsAndTaxAmt?.Ccy || fallbackCurrency, + }; + } + + const records: any[] = Array.isArray(chrgs.Rcrd) ? chrgs.Rcrd : chrgs.Rcrd ? [chrgs.Rcrd] : []; + const chargeAmount = records.reduce((sum, record) => { + const recordAmount = this.parseAmtValue(record?.Amt); + return sum + (Number.isFinite(recordAmount) ? recordAmount : 0); + }, 0); + const chargeCurrency = records[0]?.Amt?.Ccy || fallbackCurrency; + return { chargeAmount, chargeCurrency }; + } + + // A present-but-blank optional element (e.g. an empty ) is cosmetic, never money-critical + // - it is always dropped to undefined rather than rejecting the entry, in both strict and non-strict mode. + private static parseOptionalCamtString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) return value.trim(); + return undefined; + } + + private static assertValidCamtDate(value: unknown, field: string): void { + if ( + typeof value !== 'string' || + !/^\d{4}-\d{2}-\d{2}(?:(?:Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))|(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))?))?$/.test( + value, + ) + ) + throw new Error(`Invalid ${field} date in CAMT entry`); + + const isoDate = value.substring(0, 10); + const parsed = new Date(isoDate); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().substring(0, 10) !== isoDate) + throw new Error(`Invalid ${field} date in CAMT entry`); + + if (value.includes('T') && Number.isNaN(new Date(value).getTime())) + throw new Error(`Invalid ${field} date in CAMT entry`); } // --- PAIN.001 GENERATION --- // @@ -490,6 +678,13 @@ export class Iso20022Service { // --- XML HELPER METHODS --- // private static parseDate(dateStr: string): Date { + if (dateStr.includes('T')) { + // XML Schema permits DateTime values without a timezone. Interpret those consistently as UTC instead of + // letting the server's local timezone change the imported value. + const normalizedDateTime = /(?:Z|[+-]\d{2}:\d{2})$/.test(dateStr) ? dateStr : `${dateStr}Z`; + const dateTime = new Date(normalizedDateTime); + if (!Number.isNaN(dateTime.getTime())) return dateTime; + } const dateMatch = dateStr.match(/(\d{4}-\d{2}-\d{2})/); return dateMatch ? new Date(dateMatch[1]) : new Date(); } diff --git a/src/shared/models/setting/__tests__/setting.repository.spec.ts b/src/shared/models/setting/__tests__/setting.repository.spec.ts new file mode 100644 index 0000000000..3a93abc107 --- /dev/null +++ b/src/shared/models/setting/__tests__/setting.repository.spec.ts @@ -0,0 +1,78 @@ +import { Setting } from '../setting.entity'; +import { SettingRepository } from '../setting.repository'; + +describe('SettingRepository.setDateMax', () => { + function repositoryWithTransaction(transactionManager: Record) { + const repository = Object.create(SettingRepository.prototype) as SettingRepository; + const outerManager = { + transaction: jest.fn(async (callback: (manager: unknown) => Promise) => callback(transactionManager)), + }; + Object.defineProperty(repository, 'manager', { value: outerManager }); + jest.spyOn(repository, 'invalidateCache').mockImplementation(); + return { repository, outerManager }; + } + + function transactionManager(existing?: Setting) { + return { + query: jest.fn().mockResolvedValue(undefined), + findOneBy: jest.fn().mockResolvedValue(existing), + create: jest.fn((_target, value) => Object.assign(new Setting(), value)), + save: jest.fn().mockImplementation(async (value) => value), + }; + } + + it('locks before inserting the first value', async () => { + const manager = transactionManager(); + const { repository } = repositoryWithTransaction(manager); + const candidate = new Date('2026-07-11T12:00:00.000Z'); + + await repository.setDateMax('lastBankFrickDate:1', candidate); + + expect(manager.query).toHaveBeenCalledWith('SELECT pg_advisory_xact_lock(hashtext($1))', ['lastBankFrickDate:1']); + expect(manager.create).toHaveBeenCalledWith(Setting, { + key: 'lastBankFrickDate:1', + value: candidate.toISOString(), + }); + expect(manager.query.mock.invocationCallOrder[0]).toBeLessThan(manager.findOneBy.mock.invocationCallOrder[0]); + expect(repository.invalidateCache).toHaveBeenCalledTimes(1); + }); + + it('updates an older value while holding the transaction lock', async () => { + const existing = Object.assign(new Setting(), { + key: 'lastBankFrickDate:1', + value: '2026-07-10T12:00:00.000Z', + }); + const manager = transactionManager(existing); + const { repository } = repositoryWithTransaction(manager); + + await repository.setDateMax('lastBankFrickDate:1', new Date('2026-07-11T12:00:00.000Z')); + + expect(manager.save).toHaveBeenCalledWith(existing); + expect(existing.value).toBe('2026-07-11T12:00:00.000Z'); + }); + + it.each(['2026-07-11T12:00:00.000Z', '2026-07-12T12:00:00.000Z'])( + 'does not regress an equal or newer value (%s)', + async (current) => { + const manager = transactionManager(Object.assign(new Setting(), { key: 'lastBankFrickDate:1', value: current })); + const { repository } = repositoryWithTransaction(manager); + + await repository.setDateMax('lastBankFrickDate:1', new Date('2026-07-11T12:00:00.000Z')); + + expect(manager.save).not.toHaveBeenCalled(); + }, + ); + + it('fails closed instead of overwriting a corrupt stored watermark', async () => { + const manager = transactionManager( + Object.assign(new Setting(), { key: 'lastBankFrickDate:1', value: 'not-a-date' }), + ); + const { repository } = repositoryWithTransaction(manager); + + await expect(repository.setDateMax('lastBankFrickDate:1', new Date('2026-07-11T12:00:00.000Z'))).rejects.toThrow( + "Setting 'lastBankFrickDate:1' does not contain a valid ISO date", + ); + expect(manager.save).not.toHaveBeenCalled(); + expect(repository.invalidateCache).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/models/setting/__tests__/setting.service.spec.ts b/src/shared/models/setting/__tests__/setting.service.spec.ts index 427114219d..43a5a44d63 100644 --- a/src/shared/models/setting/__tests__/setting.service.spec.ts +++ b/src/shared/models/setting/__tests__/setting.service.spec.ts @@ -24,6 +24,23 @@ describe('SettingService', () => { service = module.get(SettingService); }); + describe('setDateMax', () => { + it('delegates a valid candidate to the atomic repository operation', async () => { + const candidate = new Date('2026-07-11T12:00:00.000Z'); + + await service.setDateMax('lastBankFrickDate:1', candidate); + + expect(settingRepo.setDateMax).toHaveBeenCalledWith('lastBankFrickDate:1', candidate); + }); + + it.each([new Date('invalid'), undefined])('rejects an invalid candidate %s', async (candidate) => { + await expect(service.setDateMax('lastBankFrickDate:1', candidate as Date)).rejects.toThrow( + "Setting 'lastBankFrickDate:1' requires a valid date", + ); + expect(settingRepo.setDateMax).not.toHaveBeenCalled(); + }); + }); + describe('getDeniedJwtAccounts', () => { it('returns the deduped union of the manual and auto denylists as numbers', async () => { mockSettings({ jwtAccountDenylist: [1], jwtAccountDenylistAuto: [2, 2, 3] }); diff --git a/src/shared/models/setting/setting.repository.ts b/src/shared/models/setting/setting.repository.ts index 2d75ba7a91..25666f97eb 100644 --- a/src/shared/models/setting/setting.repository.ts +++ b/src/shared/models/setting/setting.repository.ts @@ -8,4 +8,27 @@ export class SettingRepository extends CachedRepository { constructor(manager: EntityManager) { super(Setting, manager); } + + /** + * Atomically advances an ISO date setting without allowing a stale worker to move it backwards. + * The transaction-scoped advisory lock also covers the first insert, where no row exists to lock yet. + */ + async setDateMax(key: string, candidate: Date): Promise { + await this.manager.transaction(async (manager) => { + await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [key]); + + const setting = await manager.findOneBy(Setting, { key }); + if (setting) { + const current = new Date(setting.value); + if (Number.isNaN(current.getTime())) throw new Error(`Setting '${key}' does not contain a valid ISO date`); + if (current >= candidate) return; + setting.value = candidate.toISOString(); + await manager.save(setting); + } else { + await manager.save(manager.create(Setting, { key, value: candidate.toISOString() })); + } + }); + + this.invalidateCache(); + } } diff --git a/src/shared/models/setting/setting.service.ts b/src/shared/models/setting/setting.service.ts index 39da8a4865..a0b17f0fef 100644 --- a/src/shared/models/setting/setting.service.ts +++ b/src/shared/models/setting/setting.service.ts @@ -29,6 +29,13 @@ export class SettingService { await this.settingRepo.save(entity); } + async setDateMax(key: string, candidate: Date): Promise { + if (!(candidate instanceof Date) || Number.isNaN(candidate.getTime())) + throw new BadRequestException(`Setting '${key}' requires a valid date`); + + await this.settingRepo.setDateMax(key, candidate); + } + private async validateSettingValue(key: string, value: string): Promise { const schema = SettingSchemaRegistry[key]; if (!schema) return; diff --git a/src/shared/services/__tests__/http.service.spec.ts b/src/shared/services/__tests__/http.service.spec.ts new file mode 100644 index 0000000000..99b2db93a4 --- /dev/null +++ b/src/shared/services/__tests__/http.service.spec.ts @@ -0,0 +1,192 @@ +import { HttpService as NestHttpService } from '@nestjs/axios'; +import { of } from 'rxjs'; +import { Environment } from 'src/config/config'; +import { HttpService } from '../http.service'; + +describe('HttpService signed responses', () => { + const previousEnvironment = process.env.ENVIRONMENT; + let nestHttp: { request: jest.Mock }; + let service: HttpService; + + beforeEach(() => { + process.env.ENVIRONMENT = Environment.DEV; + nestHttp = { request: jest.fn() }; + service = new HttpService(nestHttp as unknown as NestHttpService); + }); + + afterAll(() => { + if (previousEnvironment === undefined) delete process.env.ENVIRONMENT; + else process.env.ENVIRONMENT = previousEnvironment; + }); + + it('verifies exact raw JSON bytes before parsing them', async () => { + const rawBody = '{ "answer": 42 }'; + const headers = { signature: 'synthetic-signature', algorithm: 'rsa-sha512' }; + nestHttp.request.mockReturnValue(of({ data: Buffer.from(rawBody), headers })); + const responseVerifier = jest.fn(); + + await expect( + service.request<{ answer: number }>({ + url: 'https://synthetic.example/signed', + responseType: 'json', + responseVerifier, + }), + ).resolves.toEqual({ answer: 42 }); + + expect(responseVerifier).toHaveBeenCalledWith(Buffer.from(rawBody), headers); + const transportConfig = nestHttp.request.mock.calls[0][0]; + expect(transportConfig.responseType).toBe('arraybuffer'); + expect(transportConfig.transformResponse[0](rawBody)).toBe(rawBody); + expect(transportConfig).not.toHaveProperty('responseVerifier'); + }); + + it('verifies a response containing a UTF-8 BOM and non-UTF-8 bytes against a signature computed over the exact raw bytes', async () => { + // A legitimately signed response that includes a UTF-8 BOM and a byte sequence that is not valid + // UTF-8 (0xFF) must still verify - decoding to a string (even correctly) before verification would + // strip the BOM and mangle the invalid sequence, breaking a signature computed over the raw bytes. + const rawBytes = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from('{"answer":42}'), + Buffer.from([0xff]), + ]); + const headers = { signature: 'synthetic-signature', algorithm: 'rsa-sha512' }; + nestHttp.request.mockReturnValue(of({ data: rawBytes, headers })); + const responseVerifier = jest.fn(); + + await service.request({ url: 'https://synthetic.example/signed', responseType: 'text', responseVerifier }); + + expect(responseVerifier).toHaveBeenCalledWith(rawBytes, headers); + expect(Buffer.isBuffer(responseVerifier.mock.calls[0][0])).toBe(true); + }); + + it('returns verified text without JSON parsing', async () => { + nestHttp.request.mockReturnValue(of({ data: Buffer.from(''), headers: {} })); + + await expect( + service.request({ + url: 'https://synthetic.example/signed.xml', + responseType: 'text', + responseVerifier: jest.fn(), + }), + ).resolves.toBe(''); + }); + + it('propagates verification failure before exposing the response body', async () => { + nestHttp.request.mockReturnValue(of({ data: Buffer.from('{"unsafe":true}'), headers: {} })); + + await expect( + service.request({ + url: 'https://synthetic.example/signed', + responseVerifier: () => { + throw new Error('synthetic signature mismatch'); + }, + }), + ).rejects.toThrow('synthetic signature mismatch'); + }); + + it('rejects a signed response that was not preserved as a raw byte buffer', async () => { + nestHttp.request.mockReturnValue(of({ data: { already: 'parsed' }, headers: {} })); + + await expect( + service.request({ + url: 'https://synthetic.example/signed', + responseVerifier: jest.fn(), + }), + ).rejects.toThrow('Signed HTTP response body is not a raw byte buffer'); + }); + + it('preserves the existing parsed-response path when no verifier is configured', async () => { + const parsed = { answer: 42 }; + nestHttp.request.mockReturnValue(of({ data: parsed, headers: {} })); + + await expect(service.request({ url: 'https://synthetic.example/unsigned' })).resolves.toBe(parsed); + expect(nestHttp.request.mock.calls[0][0].transformResponse).toBeUndefined(); + }); +}); + +describe('HttpService local Bank Frick mocks', () => { + const previousEnvironment = process.env.ENVIRONMENT; + + afterAll(() => { + if (previousEnvironment === undefined) delete process.env.ENVIRONMENT; + else process.env.ENVIRONMENT = previousEnvironment; + }); + + it('supports authorize, accounts, CAMT, create, lookup and approval as one coherent local flow', async () => { + process.env.ENVIRONMENT = Environment.LOC; + const nestHttp = { request: jest.fn() }; + const service = new HttpService(nestHttp as unknown as NestHttpService); + const baseUrl = 'https://olbtest.bankfrick.li/webapi/v2'; + + const authorization = await service.request<{ token: string }>({ url: `${baseUrl}/authorize`, method: 'POST' }); + expect(authorization.token.split('.')).toHaveLength(3); + await expect(service.request({ url: `${baseUrl}/accounts/0000000` })).resolves.toEqual( + expect.objectContaining({ resultSetSize: 0, accounts: [] }), + ); + await expect(service.request({ url: `${baseUrl}/camt053?iban=LI00SYNTHETIC` })).resolves.toContain( + 'LI00SYNTHETIC', + ); + + const createResponse = await service.request({ + url: `${baseUrl}/transactions`, + method: 'PUT', + data: JSON.stringify({ + transactions: [ + { + customId: 'DFX-FO-LOCAL-1', + type: 'SEPA', + amount: 1, + currency: 'EUR', + debitor: { iban: 'LI00SYNTHETIC' }, + creditor: { iban: 'DE00SYNTHETIC', name: 'Local Recipient' }, + }, + ], + }), + }); + expect(createResponse.transactions[0]).toEqual( + expect.objectContaining({ customId: 'DFX-FO-LOCAL-1', state: 'PREPARED', orderId: expect.any(Number) }), + ); + + await expect(service.request({ url: `${baseUrl}/transactions?customId=DFX-FO-LOCAL-1` })).resolves.toEqual( + expect.objectContaining({ resultSetSize: 1 }), + ); + await expect( + service.request({ + url: `${baseUrl}/signTransactionWithoutTan`, + method: 'POST', + data: JSON.stringify({ orderIds: [createResponse.transactions[0].orderId] }), + }), + ).resolves.toEqual(expect.objectContaining({ transactions: [expect.objectContaining({ state: 'IN_PROGRESS' })] })); + expect(nestHttp.request).not.toHaveBeenCalled(); + }); + + it('does not leak mock Frick order state across separately-constructed HttpService instances', async () => { + process.env.ENVIRONMENT = Environment.LOC; + const baseUrl = 'https://olbtest.bankfrick.li/webapi/v2'; + const firstInstance = new HttpService({ request: jest.fn() } as unknown as NestHttpService); + const secondInstance = new HttpService({ request: jest.fn() } as unknown as NestHttpService); + + await firstInstance.request({ + url: `${baseUrl}/transactions`, + method: 'PUT', + data: JSON.stringify({ + transactions: [ + { + customId: 'DFX-FO-ISOLATED-1', + type: 'SEPA', + amount: 1, + currency: 'EUR', + debitor: { iban: 'LI00SYNTHETIC' }, + creditor: { iban: 'DE00SYNTHETIC', name: 'Local Recipient' }, + }, + ], + }), + }); + + await expect( + secondInstance.request<{ resultSetSize: number }>({ + url: `${baseUrl}/transactions?customId=DFX-FO-ISOLATED-1`, + }), + ).resolves.toEqual(expect.objectContaining({ resultSetSize: 0 })); + }); +}); diff --git a/src/shared/services/frick-mock-gateway.ts b/src/shared/services/frick-mock-gateway.ts new file mode 100644 index 0000000000..aacc939ea4 --- /dev/null +++ b/src/shared/services/frick-mock-gateway.ts @@ -0,0 +1,95 @@ +import { HttpRequestConfig } from './http.service'; + +// Local-development mock for the Bank Frick WebAPI. Bank Frick is endpoint-aware (unlike the generic +// `{ mock: true }` responses in http.service.ts) because a bare stub response makes the integration +// appear reachable while every validator fails, hiding local wiring errors instead of simulating them. +// This state is genuinely stateful (created orders must be findable/approvable across subsequent +// calls), which is the wrong shape for the shared, stateless HttpService - kept in its own class with +// its own instance-scoped Map so it never leaks state across separately-constructed HttpService +// instances (e.g. across test files). + +export interface FrickMockOrder { + customId: string; + orderId?: number; + state: string; + [field: string]: unknown; +} + +interface FrickMockTransactionsRequest { + transactions?: FrickMockOrder[]; +} + +interface FrickMockApproveRequest { + orderIds?: (number | string)[]; + customIds?: string[]; +} + +function parseMockRequestBody(config?: HttpRequestConfig): Partial { + if (typeof config?.data === 'string') return JSON.parse(config.data) as Partial; + return (config?.data as Partial) ?? {}; +} + +function frickTransactionsResponse(transactions: FrickMockOrder[]): { + moreResults: boolean; + resultSetSize: number; + transactions: FrickMockOrder[]; +} { + return { moreResults: false, resultSetSize: transactions.length, transactions }; +} + +export class FrickMockGateway { + private readonly orders = new Map(); + + matches(url: string): boolean { + return /bankfrick\.li\/webapi\/v2\//.test(url); + } + + resolve(url: string, config?: HttpRequestConfig): unknown { + if (/\/authorize(?:\?|$)/.test(url)) return this.handleAuthorize(); + if (/\/camt053(?:\?|$)/.test(url)) return this.handleCamt053(url); + if (/\/accounts(?:\/|\?|$)/.test(url)) return this.handleAccounts(); + if (/\/signTransactionWithoutTan(?:\?|$)/.test(url)) return this.handleSignTransactionWithoutTan(config); + if (/\/transactions(?:\?|$)/.test(url)) return this.handleTransactions(url, config); + return { mock: true }; + } + + private handleAuthorize(): { token: string } { + const header = Buffer.from(JSON.stringify({ alg: 'RS512', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url'); + return { token: `${header}.${payload}.local-mock-signature` }; + } + + private handleCamt053(url: string): string { + const iban = new URL(url).searchParams.get('iban') ?? 'SYNTHETIC-UNCONFIGURED-IBAN'; + return `${iban}`; + } + + private handleAccounts(): { date: string; moreResults: boolean; resultSetSize: number; accounts: unknown[] } { + return { date: '2026-01-01', moreResults: false, resultSetSize: 0, accounts: [] }; + } + + private handleSignTransactionWithoutTan(config?: HttpRequestConfig) { + const request = parseMockRequestBody(config); + const orderIds = new Set((request.orderIds ?? []).map(String)); + const customIds = new Set(request.customIds ?? []); + const orders = [...this.orders.values()] + .filter((order) => orderIds.has(String(order.orderId)) || customIds.has(String(order.customId))) + .map((order) => ({ ...order, state: 'IN_PROGRESS' })); + orders.forEach((order) => this.orders.set(String(order.customId), order)); + return frickTransactionsResponse(orders); + } + + private handleTransactions(url: string, config?: HttpRequestConfig) { + if (config?.method?.toString().toUpperCase() === 'PUT') { + const transaction = parseMockRequestBody(config).transactions?.[0]; + if (!transaction) return frickTransactionsResponse([]); + const order: FrickMockOrder = { ...transaction, orderId: this.orders.size + 1, state: 'PREPARED' }; + this.orders.set(String(order.customId), order); + return frickTransactionsResponse([order]); + } + + const customId = new URL(url).searchParams.get('customId'); + const order = customId ? this.orders.get(customId) : undefined; + return frickTransactionsResponse(order ? [order] : []); + } +} diff --git a/src/shared/services/http.service.ts b/src/shared/services/http.service.ts index 557fb7bd13..02a44b5056 100644 --- a/src/shared/services/http.service.ts +++ b/src/shared/services/http.service.ts @@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs'; import { Environment, GetConfig } from 'src/config/config'; import { Util } from '../utils/util'; import { DfxLogger } from './dfx-logger'; +import { FrickMockGateway } from './frick-mock-gateway'; export interface HttpError { response?: { @@ -14,10 +15,20 @@ export interface HttpError { }; } -export type HttpRequestConfig = AxiosRequestConfig & { tryCount?: number; retryDelay?: number }; +export type HttpRequestConfig = AxiosRequestConfig & { + tryCount?: number; + retryDelay?: number; + // Raw response bytes, exactly as received - never decoded/transcoded before the caller verifies + // them, so a legitimately signed response can never fail verification due to axios' own text decoding. + responseVerifier?: (rawBody: Buffer, headers: AxiosResponse['headers']) => void; +}; -// Mock responses for local development -const MOCK_RESPONSES: { pattern: RegExp; response: any }[] = [ +type MockResponseFactory = (url: string, config?: HttpRequestConfig) => unknown; + +// Mock responses for local development. Bank Frick's own mock is stateful and endpoint-aware - +// handled by the dedicated FrickMockGateway (its own file, its own instance-scoped state) rather than +// living inline here; every other integration below is a simple stateless stub. +const MOCK_RESPONSES: { pattern: RegExp; response: unknown | MockResponseFactory }[] = [ { pattern: /alchemy\.com/, response: { result: '0x0', jsonrpc: '2.0', id: 1 } }, { pattern: /tatum\.io/, response: { balance: '0', transactions: [] } }, { pattern: /api\.sift\.com/, response: { status: 0, score: 0.1 } }, @@ -46,6 +57,9 @@ const MOCK_RESPONSES: { pattern: RegExp; response: any }[] = [ export class HttpService { private readonly logger = new DfxLogger(HttpService); private readonly isMockMode: boolean; + // Instance-scoped, not shared across separately-constructed HttpService instances (e.g. across test + // files) - a fresh gateway (and its stateful order Map) is created lazily only when actually mocking. + private frickMockGateway?: FrickMockGateway; constructor(private readonly http: Http) { this.isMockMode = GetConfig().environment === Environment.LOC; @@ -61,12 +75,17 @@ export class HttpService { return true; } - private getMockResponse(url: string): T { - const mock = MOCK_RESPONSES.find((m) => m.pattern.test(url)); - this.logger.verbose( - `Mock HTTP: ${url.substring(0, 80)}... → ${JSON.stringify(mock?.response ?? {}).substring(0, 50)}`, - ); - return (mock?.response ?? { mock: true }) as T; + private getMockResponse(url: string, config?: HttpRequestConfig): T { + this.frickMockGateway ??= new FrickMockGateway(); + let response: unknown; + if (this.frickMockGateway.matches(url)) { + response = this.frickMockGateway.resolve(url, config); + } else { + const mock = MOCK_RESPONSES.find((m) => m.pattern.test(url)); + response = typeof mock?.response === 'function' ? mock.response(url, config) : (mock?.response ?? { mock: true }); + } + this.logger.verbose(`Mock HTTP: ${url.substring(0, 80)}... → ${JSON.stringify(response).substring(0, 50)}`); + return response as T; } public async get(url: string, config?: HttpRequestConfig): Promise { @@ -137,10 +156,30 @@ export class HttpService { } public async request(config: HttpRequestConfig): Promise { - if (config.url && this.shouldMock(config.url)) return this.getMockResponse(config.url); - return ( - await Util.retry(() => firstValueFrom(this.http.request(config)), config?.tryCount ?? 1, config?.retryDelay) - ).data; + if (config.url && this.shouldMock(config.url)) return this.getMockResponse(config.url, config); + const { tryCount, retryDelay, responseVerifier, ...axiosConfig } = config; + const requestedResponseType = axiosConfig.responseType; + if (responseVerifier) { + // Preserve the exact response bytes as a Buffer until the detached signature was verified. Any + // string decoding (even axios' own default UTF-8 text handling) can strip a BOM or lossily + // transcode non-UTF-8 bytes before the verifier ever sees them - arraybuffer is the only + // responseType that hands back the untouched bytes. + axiosConfig.responseType = 'arraybuffer'; + axiosConfig.transformResponse = [(data) => data]; + } + + const response = await Util.retry( + () => firstValueFrom(this.http.request(axiosConfig)), + tryCount ?? 1, + retryDelay, + ); + if (!responseVerifier) return response.data; + if (!Buffer.isBuffer(response.data)) throw new Error('Signed HTTP response body is not a raw byte buffer'); + + responseVerifier(response.data, response.headers); + const decoded = response.data.toString('utf8'); + if (requestedResponseType === 'text') return decoded as T; + return JSON.parse(decoded) as T; } async downloadFile(fileUrl: string, filePath: string) { diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index 90e2ae883f..ebf2e9e795 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -72,6 +72,8 @@ export enum Process { FIAT_OUTPUT_YAPEAL_STATUS_CHECK = 'FiatOutputYapealStatusCheck', FIAT_OUTPUT_OLKYPAY_TRANSMISSION = 'FiatOutputOlkypayTransmission', FIAT_OUTPUT_OLKYPAY_STATUS_CHECK = 'FiatOutputOlkypayStatusCheck', + FIAT_OUTPUT_FRICK_TRANSMISSION = 'FiatOutputFrickTransmission', + FIAT_OUTPUT_FRICK_STATUS_CHECK = 'FiatOutputFrickStatusCheck', BLOCKCHAIN_FEE_UPDATE = 'BlockchainFeeUpdate', TX_REQUEST = 'TxRequest', TX_REQUEST_WAITING_EXPIRY = 'TxRequestWaitingExpiry', diff --git a/src/shared/utils/util.ts b/src/shared/utils/util.ts index 1d46ad5fac..d12fe4b8eb 100644 --- a/src/shared/utils/util.ts +++ b/src/shared/utils/util.ts @@ -13,7 +13,7 @@ export type KeyType = { [K in keyof T]: T[K] extends U ? K : never; }[keyof T]; -type CryptoAlgorithm = 'md5' | 'sha256' | 'sha512'; +type CryptoAlgorithm = 'md5' | 'sha256' | 'sha384' | 'sha512'; export enum AmountType { ASSET = 'Asset', diff --git a/src/subdomains/core/liquidity-management/adapters/balances/__tests__/bank.adapter.spec.ts b/src/subdomains/core/liquidity-management/adapters/balances/__tests__/bank.adapter.spec.ts new file mode 100644 index 0000000000..a79914bee9 --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/balances/__tests__/bank.adapter.spec.ts @@ -0,0 +1,71 @@ +import { createMock } from '@golevelup/ts-jest'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; +import { YapealService } from 'src/integration/bank/services/yapeal.service'; +import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { BankTxBatchService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; +import { LiquidityManagementAsset } from '../../../interfaces'; +import { BankAdapter } from '../bank.adapter'; + +describe('BankAdapter', () => { + let adapter: BankAdapter; + let bankService: BankService; + let bankTxBatchService: BankTxBatchService; + let olkypayService: OlkypayService; + let checkoutService: CheckoutService; + let yapealService: YapealService; + let frickService: BankFrickService; + + beforeEach(() => { + bankService = createMock(); + bankTxBatchService = createMock(); + olkypayService = createMock(); + checkoutService = createMock(); + yapealService = createMock(); + frickService = createMock(); + + adapter = new BankAdapter( + bankService, + bankTxBatchService, + olkypayService, + checkoutService, + yapealService, + frickService, + ); + }); + + function frickAsset(dexName: string): LiquidityManagementAsset { + return Object.assign(createCustomAsset({ dexName }), { context: IbanBankName.FRICK }); + } + + it('routes FRICK to frickService.getBalances() and creates a LiquidityBalance per matching asset currency, mirroring the Yapeal case', async () => { + jest + .spyOn(frickService, 'getBalances') + .mockResolvedValue([{ iban: 'LI-EUR', currency: 'EUR', balance: 1000, availableBalance: 900 }]); + const eurAsset = frickAsset('EUR'); + const chfAsset = frickAsset('CHF'); + + const result = await adapter.getForBank(IbanBankName.FRICK, [eurAsset, chfAsset]); + + expect(result).toHaveLength(1); + expect(result[0].asset).toBe(eurAsset); + expect(result[0].amount).toBe(900); + }); + + it('fails closed when Bank Frick reports no available balance for an account, instead of silently using the booked balance', async () => { + jest.spyOn(frickService, 'getBalances').mockResolvedValue([{ iban: 'LI-EUR', currency: 'EUR', balance: 1000 }]); + + await expect(adapter.getForBank(IbanBankName.FRICK, [frickAsset('EUR')])).rejects.toThrow( + 'Missing available balance for Bank Frick account LI-EUR', + ); + }); + + it('propagates and logs a getBalances() failure the same way as the other cases', async () => { + jest.spyOn(frickService, 'getBalances').mockRejectedValue(new Error('Bank Frick unavailable')); + + await expect(adapter.getForBank(IbanBankName.FRICK, [frickAsset('EUR')])).rejects.toThrow('Bank Frick unavailable'); + }); +}); diff --git a/src/subdomains/core/liquidity-management/adapters/balances/bank.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/bank.adapter.ts index 2fd0607e37..db95b3ad52 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/bank.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/bank.adapter.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; @@ -22,6 +23,7 @@ export class BankAdapter implements LiquidityBalanceIntegration { private readonly olkypayService: OlkypayService, private readonly checkoutService: CheckoutService, private readonly yapealService: YapealService, + private readonly frickService: BankFrickService, ) {} async getBalances(assets: LiquidityManagementAsset[]): Promise { @@ -74,6 +76,24 @@ export class BankAdapter implements LiquidityBalanceIntegration { break; } + case IbanBankName.FRICK: { + const frickBalances = await this.frickService.getBalances(); + + for (const balance of frickBalances) { + // Bank Frick's `available` field is optional in its own account-listing contract. Falling + // back to the booked `balance` would overstate spendable liquidity (it ignores pending + // debits) - exactly the overdraft risk this case exists to close - so a missing available + // balance fails loud instead of silently substituting a different, unsafe number. + if (!Number.isFinite(balance.availableBalance)) + throw new Error(`Missing available balance for Bank Frick account ${balance.iban}`); + + const matchingAssets = assets.filter((asset) => asset.dexName === balance.currency); + matchingAssets.forEach((asset) => balances.push(LiquidityBalance.create(asset, balance.availableBalance))); + } + + break; + } + case CardBankName.CHECKOUT: { const checkoutBalances = await this.checkoutService.getBalances(); diff --git a/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts b/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts new file mode 100644 index 0000000000..80e7b67c84 --- /dev/null +++ b/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts @@ -0,0 +1,142 @@ +import { createMock } from '@golevelup/ts-jest'; +import { FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; +import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; +import { MonitoringService } from 'src/subdomains/core/monitoring/monitoring.service'; +import { FindOperator, In, IsNull, LessThan, Not } from 'typeorm'; +import { PaymentObserver } from '../payment.observer'; + +// Evaluates a TypeORM FindOptionsWhere-shaped clause (with real FindOperator instances, e.g. from +// IsNull()/Not()/LessThan()) against a plain synthetic row, so tests can assert on-clause-matching +// behavior instead of just the constructed query shape. +function matchesOperator(op: FindOperator, actual: unknown): boolean { + switch (op.type) { + case 'isNull': + return actual == null; + case 'not': { + const child = op.child; + return child ? !matchesOperator(child, actual) : actual !== op.value; + } + case 'lessThan': + return actual != null && (actual as number | Date) < (op.value as number | Date); + case 'in': + return Array.isArray(op.value) && op.value.includes(actual); + default: + throw new Error(`Unsupported operator "${op.type}" in test matcher`); + } +} + +function matchesClause(clause: Record, row: Record): boolean { + return Object.keys(clause).every((key) => { + const expected = clause[key]; + return expected instanceof FindOperator ? matchesOperator(expected, row[key]) : row[key] === expected; + }); +} + +function countMatching( + where: Record | Record[], + rows: Record[], +): number { + const clauses = Array.isArray(where) ? where : [where]; + return rows.filter((row) => clauses.some((clause) => matchesClause(clause, row))).length; +} + +describe('PaymentObserver', () => { + let observer: PaymentObserver; + let repos: RepositoryFactory; + + beforeEach(() => { + const chainableQuery: Record = { + select: jest.fn(), + addSelect: jest.fn(), + leftJoin: jest.fn(), + where: jest.fn(), + groupBy: jest.fn(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + for (const method of ['select', 'addSelect', 'leftJoin', 'where', 'groupBy']) { + chainableQuery[method].mockReturnValue(chainableQuery); + } + + // RepositoryFactory is a concrete class whose nested repositories are plain instance properties, + // not something @golevelup/ts-jest's createMock deep-mocks automatically - build only the surface + // getPayment() actually touches. + repos = { + deposit: { createQueryBuilder: jest.fn().mockReturnValue(chainableQuery) }, + buyCrypto: { findOne: jest.fn().mockResolvedValue(undefined), countBy: jest.fn().mockResolvedValue(0) }, + buyFiat: { findOne: jest.fn().mockResolvedValue(undefined), countBy: jest.fn().mockResolvedValue(0) }, + bankTx: { countBy: jest.fn().mockResolvedValue(0) }, + payIn: { countBy: jest.fn().mockResolvedValue(0) }, + refReward: { countBy: jest.fn().mockResolvedValue(0) }, + paymentQuote: { countBy: jest.fn().mockResolvedValue(0) }, + custodyOrder: { countBy: jest.fn().mockResolvedValue(0) }, + fiatOutput: { countBy: jest.fn().mockResolvedValue(0) }, + } as unknown as RepositoryFactory; + + observer = new PaymentObserver(createMock(), repos); + }); + + it('counts stuckFiatOutputs across all three independent conditions, OR-ed together', async () => { + const data = await observer['getPayment'](); + + expect(repos.fiatOutput.countBy).toHaveBeenCalledWith([ + { isReadyDate: LessThan(expect.any(Date)), isTransmittedDate: IsNull(), isComplete: false }, + { + frickOrderStatus: In([ + FrickPaymentState.REJECTED, + FrickPaymentState.EXPIRED, + FrickPaymentState.DELETED, + FrickPaymentState.ERROR, + ]), + isComplete: false, + }, + { frickCustomId: Not(IsNull()), isTransmittedDate: LessThan(expect.any(Date)), isComplete: false }, + ]); + expect(data.stuckFiatOutputs).toBe(0); + }); + + it('surfaces a Frick-terminal-but-incomplete row even though it was already transmitted', async () => { + jest.spyOn(repos.fiatOutput, 'countBy').mockImplementation(async (where) => { + const clauses = Array.isArray(where) ? where : [where]; + // Only the second (Frick-terminal) clause matches this synthetic row + return clauses.some((clause: never) => 'frickOrderStatus' in (clause as object)) ? 1 : 0; + }); + + const data = await observer['getPayment'](); + + expect(data.stuckFiatOutputs).toBe(1); + }); + + it('does NOT count a non-Frick row that was merely transmitted >48h ago and is still incomplete (clause 3 must not fire on cross-bank noise)', async () => { + const nonFrickStaleRow = { + frickCustomId: null, + frickOrderStatus: null, + isReadyDate: null, + isTransmittedDate: new Date(Date.now() - 50 * 60 * 60 * 1000), + isComplete: false, + }; + jest + .spyOn(repos.fiatOutput, 'countBy') + .mockImplementation(async (where) => countMatching(where as never, [nonFrickStaleRow])); + + const data = await observer['getPayment'](); + + expect(data.stuckFiatOutputs).toBe(0); + }); + + it('counts a Frick row transmitted >48h ago and still incomplete', async () => { + const frickStaleRow = { + frickCustomId: 'DFX-FO-1', + frickOrderStatus: null, + isReadyDate: null, + isTransmittedDate: new Date(Date.now() - 50 * 60 * 60 * 1000), + isComplete: false, + }; + jest + .spyOn(repos.fiatOutput, 'countBy') + .mockImplementation(async (where) => countMatching(where as never, [frickStaleRow])); + + const data = await observer['getPayment'](); + + expect(data.stuckFiatOutputs).toBe(1); + }); +}); diff --git a/src/subdomains/core/monitoring/observers/payment.observer.ts b/src/subdomains/core/monitoring/observers/payment.observer.ts index 8b48d32f0d..bbcbf820d7 100644 --- a/src/subdomains/core/monitoring/observers/payment.observer.ts +++ b/src/subdomains/core/monitoring/observers/payment.observer.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; +import { FRICK_TERMINAL_STATES } from 'src/integration/bank/dto/frick.dto'; import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Process } from 'src/shared/services/process.service'; @@ -109,11 +110,27 @@ export class PaymentObserver extends MetricObserver { ), created: LessThan(Util.hoursBefore(3)), }), - stuckFiatOutputs: await this.repos.fiatOutput.countBy({ - isReadyDate: LessThan(Util.hoursBefore(1)), - isTransmittedDate: IsNull(), - isComplete: false, - }), + // Three independent stuck conditions, OR'd together. `health.controller.ts` turns any + // stuckFiatOutputs > 0 into DEGRADED, so every clause here must be scoped as tightly as the + // condition it actually names - a clause that matches unrelated, long-settled non-Frick rows + // would misreport deploy-time health with zero Frick activity involved. + // 1. Never transmitted (the original condition) - stale readiness, any bank. + // 2. A Bank Frick order reached a definitive terminal state without ever completing - the + // row already has isTransmittedDate set, so clause 1 alone can never see it. + // 3. Frick-specific safety net: a Frick payout (frickCustomId set) transmitted and still not + // complete after 48h - catches an ambiguous/unmatched outgoing bank_tx for Frick that + // clauses 1 and 2 don't name explicitly. Scoped to frickCustomId, not every bank: production + // has months-old, perfectly settled non-Frick rows that would otherwise match this clause on + // every check and report DEGRADED with no Frick activity at all. Deliberately 48h, not 24h: + // a legitimate SEPA/instant payout can still be mid-settlement across a weekend or bank + // holiday at the 24h mark, and this clause is a monitoring signal a human checks, not an + // automated action - a few extra hours of detection latency is the right trade against + // false-positive alert noise. + stuckFiatOutputs: await this.repos.fiatOutput.countBy([ + { isReadyDate: LessThan(Util.hoursBefore(1)), isTransmittedDate: IsNull(), isComplete: false }, + { frickOrderStatus: In(FRICK_TERMINAL_STATES), isComplete: false }, + { frickCustomId: Not(IsNull()), isTransmittedDate: LessThan(Util.hoursBefore(48)), isComplete: false }, + ]), pendingCustodyOrders: await this.repos.custodyOrder.countBy({ status: CustodyOrderStatus.CONFIRMED, type: Not(In(CustodyIncomingTypes)), diff --git a/src/subdomains/supporting/bank-tx/bank-tx.module.ts b/src/subdomains/supporting/bank-tx/bank-tx.module.ts index 1efb31bb83..377ccd4896 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx.module.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx.module.ts @@ -25,6 +25,8 @@ import { BankTx } from './bank-tx/entities/bank-tx.entity'; import { BankTxBatchRepository } from './bank-tx/repositories/bank-tx-batch.repository'; import { BankTxRepository } from './bank-tx/repositories/bank-tx.repository'; import { BankTxBatchService } from './bank-tx/services/bank-tx-batch.service'; +import { BankTxFrickService } from './bank-tx/services/bank-tx-frick.service'; +import { BankTxOutgoingMatchService } from './bank-tx/services/bank-tx-outgoing-match.service'; import { BankTxService } from './bank-tx/services/bank-tx.service'; import { BankTransactionHandler } from './bank-tx/services/bank-transaction-handler.service'; import { SepaParser } from './bank-tx/services/sepa-parser.service'; @@ -51,6 +53,8 @@ import { SepaParser } from './bank-tx/services/sepa-parser.service'; BankTxReturnRepository, BankTxRepeatRepository, BankTxService, + BankTxFrickService, + BankTxOutgoingMatchService, BankTxReturnService, BankTxRepeatService, BankTxBatchService, @@ -58,6 +62,6 @@ import { SepaParser } from './bank-tx/services/sepa-parser.service'; BankTxReturnNotificationService, BankTransactionHandler, ], - exports: [BankTxService, BankTxRepeatService, BankTxBatchService, BankTxReturnService], + exports: [BankTxService, BankTxOutgoingMatchService, BankTxRepeatService, BankTxBatchService, BankTxReturnService], }) export class BankTxModule {} diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts new file mode 100644 index 0000000000..7469e4050a --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts @@ -0,0 +1,476 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConflictException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { BankTx, BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankTxRepository } from 'src/subdomains/supporting/bank-tx/bank-tx/repositories/bank-tx.repository'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; +import { SpecialExternalAccount } from 'src/subdomains/supporting/payment/entities/special-external-account.entity'; +import { TransactionSourceType } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; +import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { BankTxFrickService } from '../bank-tx-frick.service'; +import { BankTxService } from '../bank-tx.service'; + +function fetchResult(transactions: Partial[], fullyParsed = true) { + return { transactions, fullyParsed }; +} + +describe('BankTxFrickService', () => { + let frickTxService: BankTxFrickService; + let frickService: jest.Mocked>; + let bankService: jest.Mocked>; + let settingService: jest.Mocked>; + let specialAccountService: jest.Mocked>; + let loggerWarn: jest.SpyInstance; + let loggerError: jest.SpyInstance; + + beforeEach(async () => { + frickService = { isAvailable: jest.fn().mockReturnValue(true), getFrickTransactions: jest.fn() }; + bankService = { getBanksByName: jest.fn() }; + settingService = { + get: jest.fn().mockResolvedValue(new Date(0).toISOString()), + setDateMax: jest.fn().mockResolvedValue(undefined), + }; + specialAccountService = { getMultiAccounts: jest.fn().mockResolvedValue([]) }; + loggerWarn = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(); + loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ providers: [BankTxFrickService] }) + .useMocker((token) => { + if (token === BankFrickService) return frickService; + if (token === BankService) return bankService; + if (token === SettingService) return settingService; + if (token === SpecialExternalAccountService) return specialAccountService; + return createMock(); + }) + .compile(); + + frickTxService = module.get(BankTxFrickService); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('polls every receiving account with an account-specific watermark and loads multi-accounts once', async () => { + bankService.getBanksByName.mockResolvedValue([ + bank(101, 'SYNTHETIC-FRICK-EUR', true), + bank(102, 'SYNTHETIC-FRICK-CHF', true), + bank(103, 'SYNTHETIC-NON-RECEIVING', false), + ]); + frickService.getFrickTransactions + .mockResolvedValueOnce(fetchResult([{ accountServiceRef: 'FRICK-EUR-1', bookingDate: new Date('2026-07-10') }])) + .mockResolvedValueOnce(fetchResult([{ accountServiceRef: 'FRICK-CHF-1', bookingDate: new Date('2026-07-11') }])); + const createTx = jest.fn().mockResolvedValue({}); + + await frickTxService.checkTransactions(createTx); + + expect(bankService.getBanksByName).toHaveBeenCalledWith(IbanBankName.FRICK); + expect(frickService.getFrickTransactions).toHaveBeenCalledTimes(2); + expect(frickService.getFrickTransactions.mock.calls.map((call) => call[1])).toEqual([ + 'SYNTHETIC-FRICK-EUR', + 'SYNTHETIC-FRICK-CHF', + ]); + expect(settingService.get.mock.calls.map((call) => call[0])).toEqual([ + 'lastBankFrickDate:101', + 'lastBankFrickDate:102', + ]); + expect(settingService.setDateMax.mock.calls.map((call) => call[0])).toEqual([ + 'lastBankFrickDate:101', + 'lastBankFrickDate:102', + ]); + expect(specialAccountService.getMultiAccounts).toHaveBeenCalledTimes(1); + }); + + it('does not advance the watermark after an empty response or a fetch failure', async () => { + bankService.getBanksByName.mockResolvedValue([ + bank(101, 'SYNTHETIC-FRICK-EUR', true), + bank(102, 'SYNTHETIC-FRICK-CHF', true), + ]); + frickService.getFrickTransactions + .mockResolvedValueOnce(fetchResult([])) + .mockRejectedValueOnce(new Error('synthetic outage')); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledTimes(1); + }); + + it('treats duplicate conflicts as successfully processed but blocks advancement on other import errors', async () => { + bankService.getBanksByName.mockResolvedValue([ + bank(101, 'SYNTHETIC-FRICK-EUR', true), + bank(102, 'SYNTHETIC-FRICK-CHF', true), + ]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'SYNTHETIC-REF', bookingDate: new Date('2026-07-10') }]), + ); + const createTx = jest + .fn() + .mockRejectedValueOnce(new ConflictException('duplicate')) + .mockRejectedValueOnce(new Error('synthetic persistence failure')); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).toHaveBeenCalledTimes(1); + expect(settingService.setDateMax).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(Date)); + expect(loggerError).toHaveBeenCalledTimes(1); + }); + + it('does not advance the watermark when the parser dropped an entry, but still imports the other, well-formed entries in the same fetch', async () => { + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'FRICK-GOOD-ENTRY', bookingDate: new Date('2026-07-10') }], false), + ); + const createTx = jest.fn().mockResolvedValue({}); + + await frickTxService.checkTransactions(createTx); + + expect(createTx).toHaveBeenCalledTimes(1); + expect(settingService.setDateMax).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Bank Frick camt.053 fetch for bank row 101 contained at least one entry that failed strict validation and was dropped; the watermark will not advance past this window until it is fixed.', + ); + }); + + it('warns only once while the integration is unconfigured', async () => { + frickService.isAvailable.mockReturnValue(false); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + await frickTxService.checkTransactions(createTx); + + expect(loggerWarn).toHaveBeenCalledTimes(1); + expect(bankService.getBanksByName).not.toHaveBeenCalled(); + }); + + it('stops safely when the Bank Frick registry cannot be loaded', async () => { + bankService.getBanksByName.mockRejectedValue(new Error('synthetic registry outage')); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(loggerError).toHaveBeenCalledWith('Failed to load Bank Frick account registry:', expect.any(Error)); + expect(specialAccountService.getMultiAccounts).not.toHaveBeenCalled(); + expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); + }); + + it('warns and stops when no receiving Bank Frick account is registered', async () => { + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-NON-RECEIVING', false)]); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(loggerWarn).toHaveBeenCalledWith( + 'No receiving Bank Frick accounts configured - skipping transaction import', + ); + expect(specialAccountService.getMultiAccounts).not.toHaveBeenCalled(); + expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); + }); + + it('stops safely when special accounts cannot be loaded', async () => { + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + specialAccountService.getMultiAccounts.mockRejectedValue(new Error('synthetic special-account outage')); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(loggerError).toHaveBeenCalledWith( + 'Failed to load special accounts for Bank Frick transaction import:', + expect.any(Error), + ); + expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); + }); + + it.each([0, Number.NaN, 1.5])('skips a Bank Frick row with invalid id %s', async (id) => { + bankService.getBanksByName.mockResolvedValue([bank(id, 'SYNTHETIC-FRICK-EUR', true)]); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(loggerError).toHaveBeenCalledWith('Failed to import Bank Frick transactions: invalid bank row id'); + expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); + expect(settingService.setDateMax).not.toHaveBeenCalled(); + }); + + describe('watermark overlap', () => { + const OVERLAP_DAYS = (BankTxFrickService as unknown as { FRICK_WATERMARK_OVERLAP_DAYS: number }) + .FRICK_WATERMARK_OVERLAP_DAYS; + + function overlapOf(date: Date): Date { + const result = new Date(date); + result.setUTCDate(result.getUTCDate() - OVERLAP_DAYS); + return result; + } + + it('sets the watermark to the newest processed booking date minus the overlap', async () => { + jest.useFakeTimers().setSystemTime(new Date('2024-06-15T12:00:00.000Z')); + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + const olderBookingDate = new Date('2024-01-05T00:00:00.000Z'); + const maxBookingDate = new Date('2024-01-10T00:00:00.000Z'); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([ + { accountServiceRef: 'FRICK-OLD', bookingDate: olderBookingDate }, + { accountServiceRef: 'FRICK-NEW', bookingDate: maxBookingDate }, + ]), + ); + const createTx = jest.fn().mockResolvedValue({}); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).toHaveBeenCalledWith('lastBankFrickDate:101', overlapOf(maxBookingDate)); + }); + + it('clamps a future booking date to wall-clock now before applying the overlap', async () => { + const now = new Date('2024-06-15T12:00:00.000Z'); + jest.useFakeTimers().setSystemTime(now); + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'FRICK-FUTURE', bookingDate: new Date('2024-06-20T00:00:00.000Z') }]), + ); + const createTx = jest.fn().mockResolvedValue({}); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).toHaveBeenCalledWith('lastBankFrickDate:101', overlapOf(now)); + }); + + it('leaves the watermark unchanged on an empty response', async () => { + jest.useFakeTimers().setSystemTime(new Date('2024-06-15T12:00:00.000Z')); + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + frickService.getFrickTransactions.mockResolvedValue(fetchResult([])); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).not.toHaveBeenCalled(); + }); + + it('delegates monotonicity to the atomic setting update', async () => { + jest.useFakeTimers().setSystemTime(new Date('2024-06-15T12:00:00.000Z')); + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + const aheadWatermark = new Date('2024-05-01T00:00:00.000Z'); + settingService.get.mockResolvedValue(aheadWatermark.toISOString()); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'FRICK-OLD', bookingDate: new Date('2024-01-01T00:00:00.000Z') }]), + ); + const createTx = jest.fn().mockResolvedValue({}); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).toHaveBeenCalledWith( + 'lastBankFrickDate:101', + overlapOf(new Date('2024-01-01T00:00:00.000Z')), + ); + }); + + it('fails before importing when a parsed transaction has no valid booking date', async () => { + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'FRICK-MALFORMED', bookingDate: undefined }]), + ); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(createTx).not.toHaveBeenCalled(); + expect(settingService.setDateMax).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Failed to fetch Bank Frick transactions for bank row 101:', + expect.objectContaining({ message: 'Invalid booking date in parsed Bank Frick transaction' }), + ); + }); + + it('leaves the watermark unchanged when the statement fetch itself fails', async () => { + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + frickService.getFrickTransactions.mockRejectedValue(new Error('synthetic outage')); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Failed to fetch Bank Frick transactions for bank row 101:', + expect.any(Error), + ); + }); + + it('leaves the watermark unchanged when an import fails with a non-conflict error', async () => { + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'FRICK-1', bookingDate: new Date('2024-01-01T00:00:00.000Z') }]), + ); + const createTx = jest.fn().mockRejectedValue(new Error('synthetic persistence failure')); + + await frickTxService.checkTransactions(createTx); + + expect(settingService.setDateMax).not.toHaveBeenCalled(); + }); + }); + + describe('send=true/receive=false deadlock guard', () => { + it('logs a loud error for a Frick row with send=true and receive=false but still polls other correctly-configured rows in the same cycle', async () => { + bankService.getBanksByName.mockResolvedValue([ + bank(101, 'SYNTHETIC-FRICK-EUR', true), + bank(102, 'SYNTHETIC-FRICK-DEADLOCKED', false, true), + ]); + frickService.getFrickTransactions.mockResolvedValue(fetchResult([])); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(loggerError).toHaveBeenCalledWith( + "Bank Frick row(s) 102 have send=true and receive=false - payout reconciliation will deadlock. Fix the row's flags before further payouts are processed.", + ); + // still polls the other, correctly-configured receiving row + expect(frickService.getFrickTransactions).toHaveBeenCalledTimes(1); + expect(frickService.getFrickTransactions).toHaveBeenCalledWith(expect.any(Date), 'SYNTHETIC-FRICK-EUR'); + }); + + it('does not log the deadlock warning for a normally-configured row', async () => { + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true, true)]); + frickService.getFrickTransactions.mockResolvedValue(fetchResult([])); + const createTx = jest.fn(); + + await frickTxService.checkTransactions(createTx); + + expect(loggerError).not.toHaveBeenCalledWith(expect.stringContaining('deadlock')); + }); + }); + + function bank(id: number, iban: string, receive: boolean, send = false): Bank { + return Object.assign(new Bank(), { id, iban, receive, send, name: IbanBankName.FRICK }); + } +}); + +describe('BankTxService Bank Frick wiring', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('still checks Bank Frick when the Olkypay poll fails', async () => { + const bankService = { getBankInternal: jest.fn().mockRejectedValue(new Error('synthetic Olkypay outage')) }; + const frickTxServiceMock: jest.Mocked> = { + checkTransactions: jest.fn().mockResolvedValue(undefined), + }; + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ providers: [BankTxService] }) + .useMocker((token) => { + if (token === BankService) return bankService; + if (token === BankTxFrickService) return frickTxServiceMock; + return createMock(); + }) + .compile(); + + const service = module.get(BankTxService); + + await service.checkBankTx(); + + expect(frickTxServiceMock.checkTransactions).toHaveBeenCalledTimes(1); + expect(frickTxServiceMock.checkTransactions).toHaveBeenCalledWith(expect.any(Function)); + expect(loggerError).toHaveBeenCalledWith('Failed to check Olkypay transactions:', expect.any(Error)); + }); + + it('still assigns and fills bank transactions when the Bank Frick poll fails', async () => { + const bankService = { getBankInternal: jest.fn().mockResolvedValue(undefined) }; + const frickTxServiceMock: jest.Mocked> = { + checkTransactions: jest.fn().mockRejectedValue(new Error('synthetic Frick outage')), + }; + const bankTxRepo = { find: jest.fn().mockResolvedValue([]) }; + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); + jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ providers: [BankTxService] }) + .useMocker((token) => { + if (token === BankService) return bankService; + if (token === BankTxFrickService) return frickTxServiceMock; + if (token === BankTxRepository) return bankTxRepo; + return createMock(); + }) + .compile(); + + const service = module.get(BankTxService); + + await expect(service.checkBankTx()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + 'Failed to check Bank Frick transactions:', + expect.objectContaining({ message: 'synthetic Frick outage' }), + ); + + // assignTransactions (relations: transaction) and fillBankTx (relations: buyCrypto/buyFiats) + // must both still query for work after the failed Frick poll + expect(bankTxRepo.find).toHaveBeenCalledTimes(2); + expect(bankTxRepo.find).toHaveBeenNthCalledWith(1, expect.objectContaining({ relations: { transaction: true } })); + expect(bankTxRepo.find).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ relations: { buyCrypto: true, buyFiats: true } }), + ); + expect(Math.min(...bankTxRepo.find.mock.invocationCallOrder)).toBeGreaterThan( + frickTxServiceMock.checkTransactions.mock.invocationCallOrder[0], + ); + }); + + it('hands Bank Frick a callback that performs the real BankTx creation', async () => { + const bankService = { getBankInternal: jest.fn().mockResolvedValue(undefined) }; + const frickTxServiceMock: jest.Mocked> = { + checkTransactions: jest.fn().mockResolvedValue(undefined), + }; + const createdTransaction = { id: 4711 }; + const transactionService = { create: jest.fn().mockResolvedValue(createdTransaction) }; + const getSenderAccount = jest.fn().mockReturnValue('SYNTHETIC-SENDER'); + const bankTxRepo = { + find: jest.fn().mockResolvedValue([]), + findOneBy: jest.fn().mockResolvedValue(null), + create: jest.fn((entity) => ({ ...entity, getSenderAccount })), + save: jest.fn((entity) => Promise.resolve({ ...entity, id: 4242 })), + }; + jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); + jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ providers: [BankTxService] }) + .useMocker((token) => { + if (token === BankService) return bankService; + if (token === BankTxFrickService) return frickTxServiceMock; + if (token === BankTxRepository) return bankTxRepo; + if (token === TransactionService) return transactionService; + return createMock(); + }) + .compile(); + + const service = module.get(BankTxService); + + await service.checkBankTx(); + + const [createCallback] = frickTxServiceMock.checkTransactions.mock.calls[0]; + const multiAccounts = [{ id: 7 } as SpecialExternalAccount]; + + const created = await createCallback( + { accountServiceRef: 'FRICK-CB-1', name: 'Payward Trading Ltd' }, + multiAccounts, + ); + + // the callback must run the real create: duplicate check, sender-account resolution, + // type mapping, transaction creation, and persistence + expect(bankTxRepo.findOneBy).toHaveBeenCalledWith({ accountServiceRef: 'FRICK-CB-1' }); + expect(getSenderAccount).toHaveBeenCalledWith(multiAccounts); + expect(transactionService.create).toHaveBeenCalledWith({ sourceType: TransactionSourceType.BANK_TX }); + expect(bankTxRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + accountServiceRef: 'FRICK-CB-1', + senderAccount: 'SYNTHETIC-SENDER', + type: BankTxType.KRAKEN, + transaction: createdTransaction, + }), + ); + expect(created).toEqual(expect.objectContaining({ id: 4242, accountServiceRef: 'FRICK-CB-1' })); + }); +}); diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts new file mode 100644 index 0000000000..8bce8d9e2a --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts @@ -0,0 +1,137 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Brackets } from 'typeorm'; +import { createCustomBankTx } from '../../__mocks__/bank-tx.entity.mock'; +import { BankTxIndicator } from '../../entities/bank-tx.entity'; +import { BankTxRepository } from '../../repositories/bank-tx.repository'; +import { BankTxOutgoingMatchService, OutgoingBankTxMatch } from '../bank-tx-outgoing-match.service'; + +describe('BankTxOutgoingMatchService.getUniqueOutgoingBankTx', () => { + let service: BankTxOutgoingMatchService; + let bankTxRepo: jest.Mocked; + let query: Record; + + const completeMatch: OutgoingBankTxMatch = { + remittanceInfo: ' DFX-FO-42 Synthetic payout ', + endToEndId: ' E2E-42 ', + accountIban: ' li00 synthetic account ', + amount: 12.34, + currency: ' eur ', + earliestDate: new Date('2026-07-01T00:00:00.000Z'), + }; + + beforeEach(async () => { + query = { + select: jest.fn(), + leftJoinAndSelect: jest.fn(), + where: jest.fn(), + andWhere: jest.fn(), + orderBy: jest.fn(), + take: jest.fn(), + getMany: jest.fn().mockResolvedValue([]), + }; + for (const method of ['select', 'leftJoinAndSelect', 'where', 'andWhere', 'orderBy', 'take']) { + query[method].mockReturnValue(query); + } + bankTxRepo = createMock(); + bankTxRepo.createQueryBuilder.mockReturnValue(query as never); + + const module: TestingModule = await Test.createTestingModule({ providers: [BankTxOutgoingMatchService] }) + .useMocker((token) => (token === BankTxRepository ? bankTxRepo : createMock())) + .compile(); + service = module.get(BankTxOutgoingMatchService); + }); + + it.each([ + ['a stable reference', { ...completeMatch, remittanceInfo: undefined, endToEndId: undefined }], + ['the source account', { ...completeMatch, accountIban: undefined }], + ['the currency', { ...completeMatch, currency: undefined }], + ['a finite amount', { ...completeMatch, amount: Number.NaN }], + ['a positive amount', { ...completeMatch, amount: 0 }], + ['a readiness date', { ...completeMatch, earliestDate: undefined }], + ['a valid readiness date', { ...completeMatch, earliestDate: new Date('invalid') }], + ])('returns no match when the payout lacks %s', async (_description, match) => { + await expect(service.getUniqueOutgoingBankTx(match as OutgoingBankTxMatch)).resolves.toBeUndefined(); + expect(bankTxRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('constrains matches to debit, account, currency, amount, readiness date and either reference', async () => { + const bankTx = createCustomBankTx({ id: 42 }); + query.getMany.mockResolvedValue([bankTx]); + + await expect(service.getUniqueOutgoingBankTx(completeMatch)).resolves.toBe(bankTx); + + expect(query.where).toHaveBeenCalledWith('bankTx.creditDebitIndicator = :indicator', { + indicator: BankTxIndicator.DEBIT, + }); + expect(query.andWhere).toHaveBeenCalledWith(`UPPER(REPLACE(bankTx.accountIban, ' ', '')) = :accountIban`, { + accountIban: 'LI00SYNTHETICACCOUNT', + }); + expect(query.andWhere).toHaveBeenCalledWith('UPPER(bankTx.currency) = :currency', { currency: 'EUR' }); + expect(query.andWhere).toHaveBeenCalledWith( + 'ABS((bankTx.amount - COALESCE(bankTx.chargeAmount, 0)) - :amount) < :amountTolerance', + { amount: 12.34, amountTolerance: 0.005 }, + ); + expect(query.andWhere).toHaveBeenCalledWith('bankTx.created >= :earliestDate', { + earliestDate: completeMatch.earliestDate, + }); + expect(query.take).toHaveBeenCalledWith(2); + + const referenceBrackets = query.andWhere.mock.calls + .map(([condition]) => condition) + .find((value) => value instanceof Brackets); + const references = { where: jest.fn(), orWhere: jest.fn() }; + referenceBrackets.whereFactory(references as never); + expect(references.where).toHaveBeenCalledWith(`REPLACE(bankTx.remittanceInfo, ' ', '') = :remittanceInfo`, { + remittanceInfo: 'DFX-FO-42Syntheticpayout', + }); + expect(references.orWhere).toHaveBeenCalledWith('bankTx.endToEndId = :endToEndId', { endToEndId: 'E2E-42' }); + }); + + it('matches a charged Bank Frick debit (Amt=1005.00, Chrgs=5.00) against a fiat_output.amount of 1000.00', async () => { + // The literal #8 regression case: a booked debit entry included a 5.00 bank charge, so + // bank_tx.amount is the gross 1005.00 and bank_tx.chargeAmount holds the real, parsed charge - the + // query must ask Postgres to compare net-of-charge, not the full booked amount, against the + // customer-facing fiat_output.amount. + const chargedBankTx = createCustomBankTx({ id: 99, amount: 1005, chargeAmount: 5 }); + query.getMany.mockResolvedValue([chargedBankTx]); + + await expect(service.getUniqueOutgoingBankTx({ ...completeMatch, amount: 1000 })).resolves.toBe(chargedBankTx); + + expect(query.andWhere).toHaveBeenCalledWith( + 'ABS((bankTx.amount - COALESCE(bankTx.chargeAmount, 0)) - :amount) < :amountTolerance', + { amount: 1000, amountTolerance: 0.005 }, + ); + }); + + it('still matches a charge-less bank_tx (chargeAmount=0) unchanged', async () => { + const chargeLessBankTx = createCustomBankTx({ id: 100, amount: 1000, chargeAmount: 0 }); + query.getMany.mockResolvedValue([chargeLessBankTx]); + + await expect(service.getUniqueOutgoingBankTx({ ...completeMatch, amount: 1000 })).resolves.toBe(chargeLessBankTx); + }); + + it.each([ + [{ ...completeMatch, endToEndId: undefined }, `REPLACE(bankTx.remittanceInfo, ' ', '') = :remittanceInfo`], + [{ ...completeMatch, remittanceInfo: undefined }, 'bankTx.endToEndId = :endToEndId'], + ])('supports a single correlation key without weakening the other constraints', async (match, expectedWhere) => { + await service.getUniqueOutgoingBankTx(match); + + const referenceBrackets = query.andWhere.mock.calls + .map(([condition]) => condition) + .find((value) => value instanceof Brackets); + const references = { where: jest.fn(), orWhere: jest.fn() }; + referenceBrackets.whereFactory(references as never); + expect(references.where).toHaveBeenCalledWith(expectedWhere, expect.any(Object)); + expect(references.orWhere).not.toHaveBeenCalled(); + }); + + it('returns undefined for no match and rejects ambiguous matches', async () => { + await expect(service.getUniqueOutgoingBankTx(completeMatch)).resolves.toBeUndefined(); + + query.getMany.mockResolvedValue([createCustomBankTx({ id: 1 }), createCustomBankTx({ id: 2 })]); + await expect(service.getUniqueOutgoingBankTx(completeMatch)).rejects.toThrow( + 'Ambiguous outgoing bank transaction match', + ); + }); +}); diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx.service.spec.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx.service.spec.ts new file mode 100644 index 0000000000..20757be78d --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx.service.spec.ts @@ -0,0 +1,71 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createCustomBankTx } from '../../__mocks__/bank-tx.entity.mock'; +import { BankTxIndicator, BankTxType } from '../../entities/bank-tx.entity'; +import { BankTxRepository } from '../../repositories/bank-tx.repository'; +import { BankTxService } from '../bank-tx.service'; + +describe('BankTxService.fillBankTx', () => { + let service: BankTxService; + let bankTxRepo: jest.Mocked; + + beforeEach(async () => { + bankTxRepo = createMock(); + + const module: TestingModule = await Test.createTestingModule({ providers: [BankTxService] }) + .useMocker((token) => (token === BankTxRepository ? bankTxRepo : createMock())) + .compile(); + service = module.get(BankTxService); + }); + + it('does NOT add the charge back on top of a DEBIT (outgoing, Bank Frick payout) row - amount is already gross/charge-inclusive, matching the #8 matcher convention', async () => { + // The literal NEW-3 regression case: before this fix, `entity.amount + entity.chargeAmount` was used + // unconditionally, so a charged DEBIT row (amount=1005, chargeAmount=5) computed + // accountingAmountBeforeFee=1010 - overbooked, and inconsistent with BankTxOutgoingMatchService's + // matcher, which already treats `amount` as gross for DEBIT and subtracts chargeAmount to match. + const debitTx = createCustomBankTx({ + id: 1, + type: BankTxType.BUY_FIAT, + creditDebitIndicator: BankTxIndicator.DEBIT, + amount: 1005, + chargeAmount: 5, + buyFiats: [{ percentFee: 0.01, amountInChf: 995 } as never], + }); + bankTxRepo.find.mockResolvedValue([debitTx]); + + await service['fillBankTx'](); + + expect(bankTxRepo.update).toHaveBeenCalledWith(1, expect.objectContaining({ accountingAmountBeforeFee: 1005 })); + }); + + it('still adds the charge back on top of a CREDIT (incoming customer deposit) row - amount arrives charge-exclusive and chargeAmount must be recovered (unchanged from before this PR)', async () => { + const creditTx = createCustomBankTx({ + id: 2, + type: BankTxType.BANK_TX_REPEAT, + creditDebitIndicator: BankTxIndicator.CREDIT, + amount: 1000, + chargeAmount: 5, + }); + bankTxRepo.find.mockResolvedValue([creditTx]); + + await service['fillBankTx'](); + + expect(bankTxRepo.update).toHaveBeenCalledWith(2, { accountingAmountBeforeFee: 1005 }); + }); + + it('treats a DEBIT BUY_CRYPTO row the same way - no charge added back on top', async () => { + const debitTx = createCustomBankTx({ + id: 3, + type: BankTxType.BUY_CRYPTO, + creditDebitIndicator: BankTxIndicator.DEBIT, + amount: 1005, + chargeAmount: 5, + buyCrypto: { percentFee: 0.01, amountInChf: 995 } as never, + }); + bankTxRepo.find.mockResolvedValue([debitTx]); + + await service['fillBankTx'](); + + expect(bankTxRepo.update).toHaveBeenCalledWith(3, expect.objectContaining({ accountingAmountBeforeFee: 1005 })); + }); +}); diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts new file mode 100644 index 0000000000..6f4e2ff99e --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts @@ -0,0 +1,126 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Bank } from '../../../bank/bank/bank.entity'; +import { BankService } from '../../../bank/bank/bank.service'; +import { IbanBankName } from '../../../bank/bank/dto/bank.dto'; +import { SpecialExternalAccount } from '../../../payment/entities/special-external-account.entity'; +import { SpecialExternalAccountService } from '../../../payment/services/special-external-account.service'; +import { BankTx } from '../entities/bank-tx.entity'; + +@Injectable() +export class BankTxFrickService { + private readonly logger = new DfxLogger(BankTxFrickService); + + private frickUnavailableWarningLogged = false; + + // Bank Frick statement fetches overlap the watermark by this many days so delayed bank-side reporting and + // multi-instance polling races cannot silently advance past entries that only become visible later. + private static readonly FRICK_WATERMARK_OVERLAP_DAYS = 2; + + constructor( + private readonly frickService: BankFrickService, + private readonly bankService: BankService, + private readonly settingService: SettingService, + private readonly specialAccountService: SpecialExternalAccountService, + ) {} + + async checkTransactions( + createTx: (bankTx: Partial, multiAccounts: SpecialExternalAccount[]) => Promise>, + ): Promise { + if (!this.frickService.isAvailable()) { + this.warnFrickUnavailableOnce('Bank Frick service not configured - skipping transaction import'); + return; + } + + let banks: Bank[]; + try { + const allFrickBanks = await this.bankService.getBanksByName(IbanBankName.FRICK); + + // A send=true/receive=false row can never see its own debit come back, so its reserved liquidity + // deadlocks silently. This row is invisible to the filter below by construction, so check for it + // here, every cycle, before filtering it out - loud and repeated instead of never observed. + const misconfigured = allFrickBanks.filter((bank) => !bank.isReconcilable); + if (misconfigured.length) + this.logger.error( + `Bank Frick row(s) ${misconfigured.map((bank) => bank.id).join(',')} have send=true and receive=false - payout reconciliation will deadlock. Fix the row's flags before further payouts are processed.`, + ); + + banks = allFrickBanks.filter((bank) => bank.receive); + } catch (error) { + this.logger.error('Failed to load Bank Frick account registry:', error); + return; + } + if (!banks.length) { + this.warnFrickUnavailableOnce('No receiving Bank Frick accounts configured - skipping transaction import'); + return; + } + + let multiAccounts: SpecialExternalAccount[]; + try { + multiAccounts = await this.specialAccountService.getMultiAccounts(); + } catch (error) { + this.logger.error('Failed to load special accounts for Bank Frick transaction import:', error); + return; + } + + for (const bank of banks) { + if (!Number.isSafeInteger(bank.id) || bank.id <= 0) { + this.logger.error('Failed to import Bank Frick transactions: invalid bank row id'); + continue; + } + + const settingKey = `lastBankFrickDate:${bank.id}`; + const lastModificationTime = new Date(await this.settingService.get(settingKey, new Date(0).toISOString())); + const now = new Date(); + + try { + const { transactions, fullyParsed } = await this.frickService.getFrickTransactions( + lastModificationTime, + bank.iban, + ); + const bookingTimes = transactions.map((transaction) => transaction.bookingDate?.getTime()); + if (bookingTimes.some((bookingTime) => !Number.isFinite(bookingTime))) + throw new Error('Invalid booking date in parsed Bank Frick transaction'); + if (!fullyParsed) + this.logger.error( + `Bank Frick camt.053 fetch for bank row ${bank.id} contained at least one entry that failed strict validation and was dropped; the watermark will not advance past this window until it is fixed.`, + ); + // Fetches that dropped an entry never count as fully processed, even if every remaining, + // well-formed entry in this batch imports cleanly - the watermark must not skip past a window + // that still contains an entry Bank Frick has not yet resolved. + let fullyProcessed = fullyParsed; + + for (const transaction of transactions) { + try { + await createTx(transaction, multiAccounts); + } catch (error) { + if (!(error instanceof ConflictException)) { + fullyProcessed = false; + this.logger.error(`Failed to import Bank Frick transaction for bank row ${bank.id}:`, error); + } + } + } + + // The issue contract deliberately advances only after a non-empty response was fully persisted. Base the + // candidate on the newest processed booking date (clamped to wall-clock now) and retain a fixed overlap. + // SettingService performs an atomic monotonic update, preventing a stale concurrent worker from moving the + // cursor backwards. Empty/malformed/partially persisted responses never advance it. + if (fullyProcessed && transactions.length > 0) { + const candidate = new Date(Math.min(now.getTime(), Math.max(...bookingTimes))); + candidate.setUTCDate(candidate.getUTCDate() - BankTxFrickService.FRICK_WATERMARK_OVERLAP_DAYS); + await this.settingService.setDateMax(settingKey, candidate); + } + } catch (error) { + this.logger.error(`Failed to fetch Bank Frick transactions for bank row ${bank.id}:`, error); + } + } + } + + private warnFrickUnavailableOnce(message: string): void { + if (this.frickUnavailableWarningLogged) return; + this.logger.warn(message); + this.frickUnavailableWarningLogged = true; + } +} diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts new file mode 100644 index 0000000000..9b034ecf98 --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts @@ -0,0 +1,69 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { BankTx, BankTxIndicator } from '../entities/bank-tx.entity'; +import { BankTxRepository } from '../repositories/bank-tx.repository'; + +export interface OutgoingBankTxMatch { + remittanceInfo?: string; + endToEndId?: string; + accountIban?: string; + amount?: number; + currency?: string; + earliestDate?: Date; +} + +@Injectable() +export class BankTxOutgoingMatchService { + constructor(private readonly bankTxRepo: BankTxRepository) {} + + async getUniqueOutgoingBankTx(match: OutgoingBankTxMatch): Promise { + const remittanceInfo = match.remittanceInfo?.trim(); + const endToEndId = match.endToEndId?.trim(); + const accountIban = match.accountIban?.replace(/\s/g, '').toUpperCase(); + const currency = match.currency?.trim().toUpperCase(); + if ( + (!remittanceInfo && !endToEndId) || + !accountIban || + !currency || + !Number.isFinite(match.amount) || + match.amount <= 0 || + !(match.earliestDate instanceof Date) || + Number.isNaN(match.earliestDate.getTime()) + ) + return undefined; + + const query = this.bankTxRepo + .createQueryBuilder('bankTx') + .select('bankTx') + .leftJoinAndSelect('bankTx.transaction', 'transaction') + .where('bankTx.creditDebitIndicator = :indicator', { indicator: BankTxIndicator.DEBIT }) + .andWhere(`UPPER(REPLACE(bankTx.accountIban, ' ', '')) = :accountIban`, { accountIban }) + .andWhere('UPPER(bankTx.currency) = :currency', { currency }) + // Net of any bank charge deducted from the booked debit (0 for every non-charged bank/entry, so + // this is a no-op everywhere except a charged Bank Frick FOREIGN payout). + .andWhere('ABS((bankTx.amount - COALESCE(bankTx.chargeAmount, 0)) - :amount) < :amountTolerance', { + amount: match.amount, + amountTolerance: 0.005, + }) + .andWhere('bankTx.created >= :earliestDate', { earliestDate: match.earliestDate }) + .orderBy('bankTx.id', 'DESC') + .take(2); + + query.andWhere( + new Brackets((references) => { + if (remittanceInfo) + references.where(`REPLACE(bankTx.remittanceInfo, ' ', '') = :remittanceInfo`, { + remittanceInfo: remittanceInfo.replace(/ /g, ''), + }); + if (endToEndId) { + if (remittanceInfo) references.orWhere('bankTx.endToEndId = :endToEndId', { endToEndId }); + else references.where('bankTx.endToEndId = :endToEndId', { endToEndId }); + } + }), + ); + + const matches = await query.getMany(); + if (matches.length > 1) throw new ConflictException('Ambiguous outgoing bank transaction match'); + return matches[0]; + } +} diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 6ea7ee5f78..64c7c74838 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -58,6 +58,7 @@ import { } from '../entities/bank-tx.entity'; import { BankTxBatchRepository } from '../repositories/bank-tx-batch.repository'; import { BankTxRepository } from '../repositories/bank-tx.repository'; +import { BankTxFrickService } from './bank-tx-frick.service'; import { SepaParser } from './sepa-parser.service'; export const TransactionBankTxTypeMapper: { @@ -99,6 +100,7 @@ export class BankTxService implements OnModuleInit { private readonly notificationService: NotificationService, private readonly settingService: SettingService, private readonly olkyService: OlkypayService, + private readonly frickTxService: BankTxFrickService, private readonly bankTxReturnService: BankTxReturnService, private readonly bankTxRepeatService: BankTxRepeatService, private readonly buyService: BuyService, @@ -123,7 +125,16 @@ export class BankTxService implements OnModuleInit { // --- TRANSACTION HANDLING --- // @DfxCron(CronExpression.EVERY_30_SECONDS, { timeout: 3600, process: Process.BANK_TX }) async checkBankTx(): Promise { - await this.checkTransactions(); + try { + await this.checkTransactions(); + } catch (error) { + this.logger.error('Failed to check Olkypay transactions:', error); + } + try { + await this.frickTxService.checkTransactions(this.create.bind(this)); + } catch (error) { + this.logger.error('Failed to check Bank Frick transactions:', error); + } await this.assignTransactions(); await this.fillBankTx(); } @@ -257,9 +268,17 @@ export class BankTxService implements OnModuleInit { for (const entity of entities) { try { + // The matcher (BankTxOutgoingMatchService) treats a DEBIT row's `amount` as already + // charge-inclusive/gross (net-of-charge matching subtracts chargeAmount from it) - accounting + // must use the same convention instead of adding the charge back on top, or a charged Frick + // payout is double-counted. A CREDIT row's `amount` is charge-exclusive as received (the charge + // was already deducted before it arrived), so it still needs chargeAmount added back to recover + // the original, pre-charge amount - unchanged from before this PR. + const accountingCharge = entity.creditDebitIndicator === BankTxIndicator.CREDIT ? entity.chargeAmount : 0; + if (![BankTxType.BUY_CRYPTO, BankTxType.BUY_FIAT].includes(entity.type)) { await this.bankTxRepo.update(entity.id, { - accountingAmountBeforeFee: Util.roundReadable(entity.amount + entity.chargeAmount, AmountType.FIAT), + accountingAmountBeforeFee: Util.roundReadable(entity.amount + accountingCharge, AmountType.FIAT), }); continue; } @@ -269,21 +288,21 @@ export class BankTxService implements OnModuleInit { if (entity.type === BankTxType.BUY_CRYPTO) { update.accountingFeePercent = entity.buyCrypto.percentFee; - update.accountingFeeAmount = update.accountingFeePercent * (entity.amount + entity.chargeAmount); - update.accountingAmountAfterFee = entity.amount + entity.chargeAmount - update.accountingFeeAmount; + update.accountingFeeAmount = update.accountingFeePercent * (entity.amount + accountingCharge); + update.accountingAmountAfterFee = entity.amount + accountingCharge - update.accountingFeeAmount; update.accountingAmountBeforeFeeChf = entity.buyCrypto.amountInChf; update.accountingAmountAfterFeeChf = entity.buyCrypto.amountInChf * (1 - update.accountingFeePercent); } else { update.accountingFeePercent = entity.buyFiats[0].percentFee; update.accountingFeeAmount = - update.accountingFeePercent * ((entity.amount + entity.chargeAmount) / (1 - update.accountingFeePercent)); - update.accountingAmountAfterFee = entity.amount + entity.chargeAmount; + update.accountingFeePercent * ((entity.amount + accountingCharge) / (1 - update.accountingFeePercent)); + update.accountingAmountAfterFee = entity.amount + accountingCharge; update.accountingAmountBeforeFeeChf = entity.buyFiats[0].amountInChf / (1 - update.accountingFeePercent); update.accountingAmountAfterFeeChf = entity.buyFiats[0].amountInChf; } await this.bankTxRepo.update(entity.id, { - accountingAmountBeforeFee: Util.roundReadable(entity.amount + entity.chargeAmount, AmountType.FIAT), + accountingAmountBeforeFee: Util.roundReadable(entity.amount + accountingCharge, AmountType.FIAT), accountingFeePercent: Util.roundReadable(update.accountingFeePercent, AmountType.FIAT), accountingFeeAmount: Util.roundReadable(update.accountingFeeAmount, AmountType.FIAT), accountingAmountAfterFee: Util.roundReadable(update.accountingAmountAfterFee, AmountType.FIAT), @@ -385,26 +404,6 @@ export class BankTxService implements OnModuleInit { return query.getOne(); } - async getBankTxByRemittanceInfo(remittanceInfo: string): Promise { - return this.bankTxRepo - .createQueryBuilder('bankTx') - .select('bankTx', 'bankTx') - .leftJoinAndSelect('bankTx.transaction', 'transaction') - .where(`REPLACE(bankTx.remittanceInfo, ' ', '') = :remittanceInfo`, { - remittanceInfo: remittanceInfo.replace(/ /g, ''), - }) - .orderBy('bankTx.id', 'DESC') - .getOne(); - } - - async getBankTxByEndToEndId(endToEndId: string): Promise { - return this.bankTxRepo.findOne({ - where: { endToEndId, creditDebitIndicator: BankTxIndicator.DEBIT }, - relations: { transaction: true }, - order: { id: 'DESC' }, - }); - } - async getBankTxByTransactionId(transactionId: number, relations?: FindOptionsRelations): Promise { return this.bankTxRepo.findOne({ where: { transaction: { id: transactionId } }, relations }); } diff --git a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts index 5dba56f986..3f3ab33b4f 100644 --- a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts +++ b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts @@ -18,8 +18,10 @@ import { yapealEUR, olkyEUR, } from '../__mocks__/bank.entity.mock'; +import { Bank } from '../bank.entity'; import { BankRepository } from '../bank.repository'; import { BankSelectorInput, BankService } from '../bank.service'; +import { IbanBankName } from '../dto/bank.dto'; function createBankSelectorInput( currency = 'EUR', @@ -130,3 +132,103 @@ describe('BankService', () => { expect(result.bic).toBe(yapealEUR.bic); }); }); + +describe('Bank Frick country routing', () => { + const bank = Object.assign(new Bank(), { name: IbanBankName.FRICK }); + + it('uses the existing automated-bank country allowlist', () => { + expect(bank.isCountryEnabled(createCustomCountry({ yapealEnable: true }))).toBe(true); + expect(bank.isCountryEnabled(createCustomCountry({ yapealEnable: false }))).toBe(false); + }); +}); + +describe('Bank.isReconcilable', () => { + it('is false only for a Frick row with send=true and receive=false', () => { + expect(Object.assign(new Bank(), { name: IbanBankName.FRICK, send: true, receive: false }).isReconcilable).toBe( + false, + ); + }); + + it.each([ + [true, true], + [false, true], + [false, false], + ])('is true for a Frick row with send=%s and receive=%s', (send, receive) => { + expect(Object.assign(new Bank(), { name: IbanBankName.FRICK, send, receive }).isReconcilable).toBe(true); + }); + + it('is always true for a non-Frick bank, regardless of send/receive', () => { + expect(Object.assign(new Bank(), { name: IbanBankName.YAPEAL, send: true, receive: false }).isReconcilable).toBe( + true, + ); + }); +}); + +describe('Bank (name, currency) collision tie-break', () => { + let service: BankService; + let bankRepo: BankRepository; + + beforeEach(async () => { + bankRepo = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + BankService, + { provide: BankRepository, useValue: bankRepo }, + { provide: UserService, useValue: createMock() }, + { provide: BuyCryptoService, useValue: createMock() }, + { provide: FiatService, useValue: createMock() }, + { provide: CountryService, useValue: createMock() }, + { provide: BankAccountService, useValue: createMock() }, + TestUtil.provideConfig(), + ], + }).compile(); + + service = module.get(BankService); + (BankService as unknown as { ibanCache: Map }).ibanCache.clear(); + }); + + it('resolves a (name, currency) collision by preferring the newest row (highest id)', async () => { + jest + .spyOn(bankRepo, 'findOneCached') + .mockResolvedValue( + Object.assign(new Bank(), { id: 99, name: IbanBankName.FRICK, currency: 'EUR', iban: 'NEW-ROW-IBAN' }), + ); + + const result = await service.getBankInternal(IbanBankName.FRICK, 'EUR'); + + expect(result.iban).toBe('NEW-ROW-IBAN'); + expect(bankRepo.findOneCached).toHaveBeenCalledWith(`${IbanBankName.FRICK}-EUR`, { + where: { name: IbanBankName.FRICK, currency: 'EUR' }, + order: { id: 'DESC' }, + }); + }); + + it('loads the iban cache ordered by id descending, so the newest row per (name, currency) wins', async () => { + const legacyRow = Object.assign(new Bank(), { + id: 3, + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'LEGACY-DEAD-ACCOUNT-IBAN', + }); + const newRow = Object.assign(new Bank(), { + id: 99, + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'NEW-ROW-IBAN', + }); + // Requested in descending order - the mock returns them already sorted, mirroring what an + // `order: { id: 'DESC' }` query would produce, so "first row per key wins" picks the newest. + jest.spyOn(bankRepo, 'find').mockResolvedValue([newRow, legacyRow]); + + await service.onModuleInit(); + // onModuleInit fires the load without awaiting it; give the microtask queue a turn to settle. + await new Promise(process.nextTick); + + expect(bankRepo.find).toHaveBeenCalledWith({ order: { id: 'DESC' } }); + expect((BankService as unknown as { ibanCache: Map }).ibanCache.get('Bank Frick-EUR')).toBe( + 'NEW-ROW-IBAN', + ); + }); +}); diff --git a/src/subdomains/supporting/bank/bank/bank.entity.ts b/src/subdomains/supporting/bank/bank/bank.entity.ts index e671c6dfd8..93baec1856 100644 --- a/src/subdomains/supporting/bank/bank/bank.entity.ts +++ b/src/subdomains/supporting/bank/bank/bank.entity.ts @@ -31,6 +31,11 @@ export class Bank extends IEntity { @Column({ default: true }) amlEnabled: boolean; + // Deterministic sender tie-breaker for currencies with more than one eligible send=true bank: lower + // value is tried first. An operational input (set by Ops), never inferred from bank name in code. + @Column({ type: 'int', default: 1000 }) + sendPriority: number = 1000; + @OneToOne(() => Asset, (asset) => asset.bank, { nullable: true }) @JoinColumn() asset: Asset; @@ -39,6 +44,7 @@ export class Bank extends IEntity { isCountryEnabled(country: Country): boolean { switch (this.name) { + case IbanBankName.FRICK: case IbanBankName.YAPEAL: case IbanBankName.OLKY: return country.yapealEnable; @@ -46,4 +52,11 @@ export class Bank extends IEntity { return true; } } + + // A Frick row used for payouts (send=true) that never receives (receive=false) can never see its own + // booked debit come back in a camt.053 statement, so it can never reach isComplete and its reserved + // liquidity is never released - a silent deadlock. Every other bank is trivially reconcilable. + get isReconcilable(): boolean { + return this.name !== IbanBankName.FRICK || !this.send || this.receive; + } } diff --git a/src/subdomains/supporting/bank/bank/bank.service.ts b/src/subdomains/supporting/bank/bank/bank.service.ts index 0add97c4fa..701e1e82ee 100644 --- a/src/subdomains/supporting/bank/bank/bank.service.ts +++ b/src/subdomains/supporting/bank/bank/bank.service.ts @@ -33,7 +33,10 @@ export class BankService implements OnModuleInit { } async getBankInternal(name: IbanBankName, currency: string): Promise { - return this.bankRepo.findOneCachedBy(`${name}-${currency}`, { name, currency }); + // A (name, currency) pair can match more than one row (e.g. a retired legacy Bank Frick account + // alongside its replacement). Ordering by id descending deterministically prefers the newest row + // instead of an arbitrary one, so a stale/legacy row can never silently win. + return this.bankRepo.findOneCached(`${name}-${currency}`, { where: { name, currency }, order: { id: 'DESC' } }); } async getBankById(id: number): Promise { @@ -48,8 +51,8 @@ export class BankService implements OnModuleInit { return this.bankRepo.findCachedBy(`receive`, { receive: true }); } - async getSenderBank(currency: string): Promise { - return this.bankRepo.findOneCachedBy(`send-${currency}`, { currency, send: true }); + async getSenderBanks(currency: string): Promise { + return this.bankRepo.findCachedBy(`send-${currency}`, { currency, send: true }); } // --- BANK SELECTOR --- // @@ -98,7 +101,9 @@ export class BankService implements OnModuleInit { // --- HELPER METHODS --- // private async loadIbanCache(): Promise { - const banks = await this.bankRepo.find(); + // Ordering by id descending makes the "first row per key wins" rule below deterministically prefer + // the newest row for a given (name, currency), instead of relying on implicit DB row order. + const banks = await this.bankRepo.find({ order: { id: 'DESC' } }); for (const bank of banks) { const key = `${bank.name}-${bank.currency}`; diff --git a/src/subdomains/supporting/bank/bank/dto/bank.dto.ts b/src/subdomains/supporting/bank/bank/dto/bank.dto.ts index c0eff73e9d..6d847c950d 100644 --- a/src/subdomains/supporting/bank/bank/dto/bank.dto.ts +++ b/src/subdomains/supporting/bank/bank/dto/bank.dto.ts @@ -15,6 +15,7 @@ export class BankDto { } export enum IbanBankName { + FRICK = 'Bank Frick', OLKY = 'Olkypay', MAERKI = 'Maerki Baumann', RAIFFEISEN = 'Raiffeisen', diff --git a/src/subdomains/supporting/fiat-output/__tests__/bank-frick-payout-tracking.migration.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/bank-frick-payout-tracking.migration.spec.ts new file mode 100644 index 0000000000..bf4e39dd95 --- /dev/null +++ b/src/subdomains/supporting/fiat-output/__tests__/bank-frick-payout-tracking.migration.spec.ts @@ -0,0 +1,238 @@ +import { DataSource, getMetadataArgsStorage, QueryRunner } from 'typeorm'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as IbanTools from 'ibantools'; +import { FiatOutput } from '../fiat-output.entity'; + +const PG_URL = process.env.MIGRATION_TEST_PG; +const describeDb = PG_URL ? describe : describe.skip; +const SCHEMA = 'bank_frick_tracking_spec'; + +let AddBankFrickPayoutTracking: new () => { + up(queryRunner: QueryRunner): Promise; + down(queryRunner: QueryRunner): Promise; +}; + +describe('FiatOutput Bank Frick column metadata', () => { + it('keeps every tracking column aligned with the migration length', () => { + const trackingProperties = ['frickOrderId', 'frickCustomId', 'frickOrderStatus', 'frickError', 'frickReference']; + const trackingColumns = Object.fromEntries( + getMetadataArgsStorage() + .columns.filter((column) => column.target === FiatOutput && trackingProperties.includes(column.propertyName)) + .map((column) => [column.propertyName, { length: column.options.length, nullable: column.options.nullable }]), + ); + + expect(trackingColumns).toEqual({ + frickOrderId: { length: 64, nullable: true }, + frickCustomId: { length: 256, nullable: true }, + frickOrderStatus: { length: 256, nullable: true }, + frickError: { length: 256, nullable: true }, + frickReference: { length: 256, nullable: true }, + }); + }); +}); + +describe('Bank Frick registry rollout data', () => { + it('ships exactly two checksum-valid, clearly synthetic local seed accounts', () => { + const csv = readFileSync(join(__dirname, '../../../../../migration/seed/bank.csv'), 'utf8'); + const frickRows = csv + .split('\n') + .filter((line) => line.includes(',Bank Frick,')) + .map((line) => line.split(',')); + + expect(frickRows).toHaveLength(2); + expect(frickRows.map((row) => row[6]).sort()).toEqual(['CHF', 'EUR']); + for (const row of frickRows) { + expect(row[4]).toContain('FRICK'); + expect(IbanTools.validateIBAN(row[4]).valid).toBe(true); + expect(row.slice(7, 11)).toEqual(['FALSE', 'FALSE', 'FALSE', 'TRUE']); + } + }); + + it('only changes schema and process switches, and never touches bank rows', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Migration = require('../../../../../migration/1783944000000-AddBankFrickPayoutTracking'); + const queryRunner = { query: jest.fn().mockResolvedValue(undefined) }; + + await new Migration().up(queryRunner); + + const sql = queryRunner.query.mock.calls.map(([statement]) => statement).join('\n'); + expect(sql).toContain('ADD "sendPriority" integer NOT NULL DEFAULT 1000'); + expect(sql).toContain('FiatOutputFrickTransmission'); + expect(sql).toContain('FiatOutputFrickStatusCheck'); + // The only migration that ever inserted a bank row (Yapeal EUR) was reverted (f897b98a2) because + // the row is a manual production step - this migration must never repeat that mistake. + expect(sql.toLowerCase()).not.toContain('insert into "bank"'); + expect(sql.toLowerCase()).not.toContain('delete from "bank"'); + expect(sql.toLowerCase()).not.toContain('update "bank"'); + + const rollbackQueryRunner = { query: jest.fn().mockResolvedValue(undefined) }; + await new Migration().down(rollbackQueryRunner); + const rollbackSql = rollbackQueryRunner.query.mock.calls.map(([statement]) => statement).join('\n'); + expect(rollbackSql.toLowerCase()).not.toContain('insert into "bank"'); + expect(rollbackSql.toLowerCase()).not.toContain('delete from "bank"'); + expect(rollbackSql.toLowerCase()).not.toContain('update "bank"'); + expect(rollbackSql).toContain('DROP COLUMN "sendPriority"'); + }); +}); + +describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { + let dataSource: DataSource; + let queryRunner: QueryRunner; + + beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + AddBankFrickPayoutTracking = require('../../../../../migration/1783944000000-AddBankFrickPayoutTracking'); + dataSource = new DataSource({ type: 'postgres', url: PG_URL }); + await dataSource.initialize(); + }); + + beforeEach(async () => { + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.query(`CREATE SCHEMA "${SCHEMA}"`); + await queryRunner.query(`SET search_path TO "${SCHEMA}"`); + await queryRunner.query(`CREATE TABLE "fiat_output" ("id" SERIAL PRIMARY KEY)`); + await queryRunner.query(` + CREATE TABLE "bank" ( + "id" SERIAL PRIMARY KEY, + "updated" timestamp NOT NULL DEFAULT NOW(), + "created" timestamp NOT NULL DEFAULT NOW(), + "name" varchar(256) NOT NULL, + "iban" varchar(256) NOT NULL, + "bic" varchar(256) NOT NULL, + "currency" varchar(256) NOT NULL, + "receive" boolean NOT NULL DEFAULT TRUE, + "send" boolean NOT NULL DEFAULT TRUE, + "sctInst" boolean NOT NULL DEFAULT FALSE, + "amlEnabled" boolean NOT NULL DEFAULT TRUE, + UNIQUE ("iban", "bic") + ) + `); + await queryRunner.query(` + CREATE TABLE "setting" ( + "id" SERIAL PRIMARY KEY, + "updated" timestamp NOT NULL DEFAULT NOW(), + "created" timestamp NOT NULL DEFAULT NOW(), + "key" varchar(256) UNIQUE NOT NULL, + "value" text NOT NULL + ) + `); + await queryRunner.query(` + INSERT INTO "bank" ("name", "iban", "bic", "currency") + VALUES ('Existing Bank', 'SYNTHETIC-EXISTING-ACCOUNT', 'SYNTHETICBIC', 'CHF') + `); + await queryRunner.query(` + INSERT INTO "setting" ("key", "value") + VALUES ('disabledProcess', '["ExistingProcess","FiatOutputFrickTransmission"]') + `); + }); + + afterEach(async () => { + if (queryRunner.isTransactionActive) await queryRunner.rollbackTransaction(); + await queryRunner.query(`SET search_path TO public`); + await queryRunner.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`); + await queryRunner.release(); + }); + + afterAll(async () => { + if (dataSource?.isInitialized) await dataSource.destroy(); + }); + + it('adds nullable tracking columns at the entity length and removes them on rollback', async () => { + const migration = new AddBankFrickPayoutTracking(); + + await queryRunner.startTransaction(); + await migration.up(queryRunner); + const added = await getTrackingColumns(); + const bankRowCountAfterUp = await getBankRowCount(); + const existingBankPriority = await getSendPriority('Existing Bank'); + const disabledProcesses = await getDisabledProcesses(); + await queryRunner.commitTransaction(); + + expect(added).toEqual([ + { + column_name: 'frickCustomId', + data_type: 'character varying', + character_maximum_length: 256, + is_nullable: 'YES', + }, + { column_name: 'frickError', data_type: 'character varying', character_maximum_length: 256, is_nullable: 'YES' }, + { + column_name: 'frickOrderId', + data_type: 'character varying', + character_maximum_length: 64, + is_nullable: 'YES', + }, + { + column_name: 'frickOrderStatus', + data_type: 'character varying', + character_maximum_length: 256, + is_nullable: 'YES', + }, + { + column_name: 'frickReference', + data_type: 'character varying', + character_maximum_length: 256, + is_nullable: 'YES', + }, + ]); + // The migration must never create, update or delete a bank row - the two new Bank Frick accounts + // and any legacy-row cleanup are manual production steps (docs/bank-frick-operations.md §3). + expect(bankRowCountAfterUp).toBe(1); + // Every pre-existing row is backfilled to the neutral default so enabling Frick's send flag alone + // cannot change existing routing - Ops must deliberately lower Frick's priority to cut over. + expect(existingBankPriority).toBe(1000); + expect(disabledProcesses).toEqual(['ExistingProcess', 'FiatOutputFrickTransmission', 'FiatOutputFrickStatusCheck']); + + await queryRunner.startTransaction(); + await migration.down(queryRunner); + const removed = await getTrackingColumns(); + const bankRowCountAfterDown = await getBankRowCount(); + const rolledBackDisabledProcesses = await getDisabledProcesses(); + const sendPriorityColumnExists = await hasSendPriorityColumn(); + await queryRunner.commitTransaction(); + + expect(removed).toEqual([]); + expect(bankRowCountAfterDown).toBe(1); + expect(rolledBackDisabledProcesses).toEqual(['ExistingProcess']); + expect(sendPriorityColumnExists).toBe(false); + }); + + function getTrackingColumns(): Promise< + { column_name: string; data_type: string; character_maximum_length: number; is_nullable: string }[] + > { + return queryRunner.query( + `SELECT column_name, data_type, character_maximum_length, is_nullable + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'fiat_output' + AND column_name IN ('frickOrderId', 'frickCustomId', 'frickOrderStatus', 'frickError', 'frickReference') + ORDER BY column_name`, + [SCHEMA], + ); + } + + async function getBankRowCount(): Promise { + const [{ count }] = await queryRunner.query(`SELECT COUNT(*) AS count FROM "bank"`); + return Number(count); + } + + async function getSendPriority(name: string): Promise { + const [{ sendPriority }] = await queryRunner.query(`SELECT "sendPriority" FROM "bank" WHERE "name" = $1`, [name]); + return sendPriority; + } + + async function hasSendPriorityColumn(): Promise { + const rows = await queryRunner.query( + `SELECT 1 FROM information_schema.columns WHERE table_schema = $1 AND table_name = 'bank' AND column_name = 'sendPriority'`, + [SCHEMA], + ); + return rows.length > 0; + } + + async function getDisabledProcesses(): Promise { + const [{ value }] = await queryRunner.query(`SELECT "value" FROM "setting" WHERE "key" = 'disabledProcess'`); + return JSON.parse(value); + } +}); diff --git a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts new file mode 100644 index 0000000000..82b9cbca45 --- /dev/null +++ b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts @@ -0,0 +1,967 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Config } from 'src/config/config'; +import { + FrickPaymentCharge, + FrickPaymentOrderNotFoundError, + FrickPaymentState, + FrickPaymentType, +} from 'src/integration/bank/dto/frick.dto'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { IbanService } from 'src/integration/bank/services/iban.service'; +import * as processServiceModule from 'src/shared/services/process.service'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { IsNull } from 'typeorm'; +import { createCustomBank } from '../../bank/bank/__mocks__/bank.entity.mock'; +import { IbanBankName } from '../../bank/bank/dto/bank.dto'; +import { createCustomFiatOutput } from '../__mocks__/fiat-output.entity.mock'; +import { FiatOutputFrickService } from '../fiat-output-frick.service'; +import { FiatOutputRepository } from '../fiat-output.repository'; +import { TransactionCharge } from '../fiat-output.entity'; + +describe('FiatOutputFrickService', () => { + let service: FiatOutputFrickService; + + let fiatOutputRepo: FiatOutputRepository; + let frickService: BankFrickService; + let ibanService: IbanService; + + const order = { + orderId: 4242, + customId: 'DFX-FO-42', + type: FrickPaymentType.SEPA, + state: FrickPaymentState.PREPARED, + amount: 10, + currency: 'EUR', + reference: 'Synthetic payout', + debitor: { iban: 'SYNTHETIC-DEBTOR' }, + creditor: { name: 'Synthetic Recipient', iban: 'SYNTHETIC-CREDITOR' }, + }; + + beforeEach(async () => { + fiatOutputRepo = createMock(); + frickService = createMock(); + ibanService = createMock(); + jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + // Default: the atomic reservation update always "succeeds" (affected: 1), matching a real, + // uncontended TypeORM update. Tests exercising the reservation race override this per-call. + jest.spyOn(fiatOutputRepo, 'update').mockResolvedValue({ affected: 1, raw: {}, generatedMaps: [] }); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + FiatOutputFrickService, + { provide: FiatOutputRepository, useValue: fiatOutputRepo }, + { provide: BankFrickService, useValue: frickService }, + { provide: IbanService, useValue: ibanService }, + + TestUtil.provideConfig(), + ], + }).compile(); + + service = module.get(FiatOutputFrickService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('keeps transmission disabled unless the explicit payout flag is true', async () => { + Config.bank.frick.payoutEnabled = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + + await service.transmitPayments(); + + expect(fiatOutputRepo.find).not.toHaveBeenCalled(); + expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); + }); + + it('honors the dedicated DisabledProcess kill-switch even when payout configuration is enabled', async () => { + Config.bank.frick.payoutEnabled = true; + jest + .spyOn(processServiceModule, 'DisabledProcess') + .mockImplementation((process) => process === processServiceModule.Process.FIAT_OUTPUT_FRICK_TRANSMISSION); + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + + await service.transmitPayments(); + + expect(fiatOutputRepo.find).not.toHaveBeenCalled(); + expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); + }); + + it('does not create payouts while the signed Bank Frick client is unavailable', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(false); + + await service.transmitPayments(); + + expect(service.canCreatePayments()).toBe(false); + expect(fiatOutputRepo.find).not.toHaveBeenCalled(); + }); + + it('honors the dedicated status-check kill-switch', async () => { + jest + .spyOn(processServiceModule, 'DisabledProcess') + .mockImplementation((process) => process === processServiceModule.Process.FIAT_OUTPUT_FRICK_STATUS_CHECK); + + await service.checkFrickOrderStatus(); + + expect(frickService.isAvailable).not.toHaveBeenCalled(); + expect(fiatOutputRepo.find).not.toHaveBeenCalled(); + expect(frickService.approvePaymentWithoutTan).not.toHaveBeenCalled(); + }); + + it('skips status polling while Bank Frick is unavailable', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(false); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.find).not.toHaveBeenCalled(); + expect(frickService.getPaymentOrder).not.toHaveBeenCalled(); + }); + + it('uses a stable customId and persists it before any optional approval', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockResolvedValue(order); + jest.spyOn(frickService, 'getSafeOrderId').mockReturnValue('4242'); + jest + .spyOn(frickService, 'approvePaymentWithoutTan') + .mockResolvedValue({ ...order, state: FrickPaymentState.IN_PROGRESS }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'EUR', + isInstant: false, + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + remittanceInfo: 'Synthetic payout', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(frickService.createPaymentOrder).toHaveBeenCalledWith( + expect.objectContaining({ customId: 'DFX-FO-42', reference: 'DFX-FO-42 Synthetic payout' }), + ); + // Reserved atomically, before the Bank Frick call itself. frickReference is folded into this same + // write (not deferred to the post-createPaymentOrder update below) so it can never be stranded by a + // crash between the two writes. + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 1, + { id: 42, frickCustomId: IsNull() }, + { frickCustomId: 'DFX-FO-42', frickReference: 'DFX-FO-42 Synthetic payout' }, + ); + expect((fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + (frickService.createPaymentOrder as jest.Mock).mock.invocationCallOrder[0], + ); + // remittanceInfo already had a value ('Synthetic payout') and must stay untouched - the bank-bound + // reference goes into frickReference instead, which is already durably set by the reserve above and + // must NOT be repeated/overwritten here. + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 2, + 42, + expect.objectContaining({ + frickOrderId: '4242', + isTransmittedDate: expect.any(Date), + }), + ); + expect((fiatOutputRepo.update as jest.Mock).mock.calls[1][1]).not.toHaveProperty('remittanceInfo'); + expect((fiatOutputRepo.update as jest.Mock).mock.calls[1][1]).not.toHaveProperty('frickReference'); + expect((fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[1]).toBeLessThan( + (frickService.approvePaymentWithoutTan as jest.Mock).mock.invocationCallOrder[0], + ); + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 3, + 42, + expect.objectContaining({ isApprovedDate: expect.any(Date) }), + ); + }); + + it('fills remittanceInfo with a default only when it was never set at all, and still keeps frickReference separate', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockResolvedValue(order); + jest.spyOn(frickService, 'getSafeOrderId').mockReturnValue('4242'); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'EUR', + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + remittanceInfo: undefined, + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + { id: 42, frickCustomId: IsNull() }, + expect.objectContaining({ frickReference: 'DFX-FO-42' }), + ); + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 42, + expect.objectContaining({ remittanceInfo: 'DFX Payout 42' }), + ); + }); + + it('persists frickReference in the atomic reserve so it is never stranded by a failure in the later, non-atomic update (crash/DB-blip after the order was already created at Bank Frick)', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockResolvedValue(order); + jest.spyOn(frickService, 'getSafeOrderId').mockReturnValue('4242'); + // The reserve (1st update) still succeeds; the post-createPaymentOrder (2nd) update is what fails here. + (fiatOutputRepo.update as jest.Mock) + .mockReset() + .mockResolvedValueOnce({ affected: 1, raw: {}, generatedMaps: [] }) + .mockRejectedValueOnce(new Error('synthetic DB blip')); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'EUR', + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + remittanceInfo: 'Synthetic payout', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + // The order WAS already created at Bank Frick (createPaymentOrder resolved) by the time the 2nd + // update fails - frickReference must already be durably persisted from the 1st (reserve) call, or + // reconciliation (frickReference ?? remittanceInfo) can never match the resulting bank debit. + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 1, + { id: 42, frickCustomId: IsNull() }, + { frickCustomId: 'DFX-FO-42', frickReference: 'DFX-FO-42 Synthetic payout' }, + ); + }); + + it("re-persists isTransmittedDate and frickReference when a found order reveals a gap left by a crash between transmitPayments' two writes", async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue({ ...order, state: FrickPaymentState.IN_PROGRESS }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + remittanceInfo: 'Synthetic payout', + frickReference: undefined, + isTransmittedDate: undefined, + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + // Bank Frick actually has this order (getPaymentOrder resolved), so the crash must have happened + // between transmitPayments' reserve and its second write - heal both gaps here instead of leaving + // the row permanently unmatched by reconciliation and permanently "not yet transmitted" for the + // stuckFiatOutputs monitor. + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + isTransmittedDate: expect.any(Date), + frickReference: 'DFX-FO-42 Synthetic payout', + }), + ); + }); + + it('does not overwrite an already-set isTransmittedDate/frickReference during a routine status poll', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue({ ...order, state: FrickPaymentState.IN_PROGRESS }); + const alreadyTransmitted = new Date('2026-01-01'); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + frickReference: 'DFX-FO-42 already-set', + isTransmittedDate: alreadyTransmitted, + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + const call = (fiatOutputRepo.update as jest.Mock).mock.calls.find((c) => c[0] === 42); + expect(call[1]).not.toHaveProperty('isTransmittedDate'); + expect(call[1]).not.toHaveProperty('frickReference'); + }); + + it('does not create a second payment order when the reservation update reports zero affected rows', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + // Simulates a concurrent worker (an overlapping cron tick or a second instance) having already + // claimed this row between the SELECT and this UPDATE. + jest.spyOn(fiatOutputRepo, 'update').mockResolvedValueOnce({ affected: 0, raw: {}, generatedMaps: [] }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'EUR', + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(fiatOutputRepo.update).toHaveBeenCalledTimes(1); + expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); + }); + + it('clears frickCustomId and retries cleanly after a definitive not-found from the status poller when transmission was never confirmed', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue(new FrickPaymentOrderNotFoundError('DFX-FO-42')); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: undefined, + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + // Conditional on the exact reserved state, not an unconditional-by-id clear: guards against the + // clear firing after a concurrent transmitPayments reserved/transmitted the very same row in + // between this snapshot and this write (see the dedicated race test below). + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + { id: 42, frickCustomId: 'DFX-FO-42', isTransmittedDate: IsNull() }, + { frickCustomId: null, frickError: null }, + ); + expect(frickService.approvePaymentWithoutTan).not.toHaveBeenCalled(); + }); + + it('scopes the not-found self-heal clear to a conditional update instead of clearing unconditionally by id (guards against racing with a concurrent transmitPayments reservation)', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue(new FrickPaymentOrderNotFoundError('DFX-FO-42')); + // Simulates a concurrent transmitPayments tick having already reserved+transmitted this row between + // this job's SELECT and this UPDATE: the conditional WHERE no longer matches, so the clear is a no-op. + (fiatOutputRepo.update as jest.Mock).mockReset().mockResolvedValueOnce({ affected: 0, raw: {}, generatedMaps: [] }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: undefined, + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + { id: 42, frickCustomId: 'DFX-FO-42', isTransmittedDate: IsNull() }, + { frickCustomId: null, frickError: null }, + ); + // Not called with the old unconditional-by-id form. + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(42, { frickCustomId: null, frickError: null }); + }); + + it('keeps frickCustomId and does not roll back on a non-not-found status error, even before transmission was confirmed', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue(new Error('synthetic transport failure')); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: undefined, + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(expect.anything(), { + frickCustomId: null, + frickError: null, + }); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK status error: synthetic transport failure', + }); + }); + + it('does not self-heal a not-found error once transmission was already confirmed (defense in depth)', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue(new FrickPaymentOrderNotFoundError('DFX-FO-42')); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: new Date(), + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(expect.anything(), { + frickCustomId: null, + frickError: null, + }); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: `FRICK status error: Bank Frick payment order DFX-FO-42 not found`, + }); + }); + + it('resolves a missing CHF creditor BIC and defaults the charge to SHA', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockResolvedValue({ ...order, currency: 'CHF' }); + jest.spyOn(frickService, 'getSafeOrderId').mockReturnValue('4242'); + jest.spyOn(ibanService, 'getIbanInfos').mockResolvedValue({ + result: 'passed', + bic_candidates: [{ bic: ' testli22xxx ' } as never], + all_bic_candidates: [{ bic: 'TESTLI22XXX' } as never], + }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'CHF', + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(ibanService.getIbanInfos).toHaveBeenCalledWith('SYNTHETIC-CREDITOR'); + expect(frickService.createPaymentOrder).toHaveBeenCalledWith( + expect.objectContaining({ + charge: FrickPaymentCharge.SHARED, + creditor: expect.objectContaining({ bic: 'TESTLI22XXX' }), + }), + ); + // The defaulted SHA decision is persisted onto the entity, not left as a NULL that looks unset. + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, expect.objectContaining({ charge: TransactionCharge.SHA })); + }); + + it.each([ + [{ result: 'failed' }, 'Unable to resolve creditor BIC'], + [ + { + result: 'passed', + bic_candidates: [{ bic: 'TESTLI22' }], + all_bic_candidates: [{ bic: 'OTHERLI2X' }], + }, + 'Ambiguous creditor BIC', + ], + ])('fails closed when CHF BIC resolution is not unique', async (ibanDetails, expectedError) => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(ibanService, 'getIbanInfos').mockResolvedValue(ibanDetails as never); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + currency: 'CHF', + iban: 'SYNTHETIC-CREDITOR', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: expect.stringContaining(expectedError), + }); + }); + + it('fails closed when a CHF output has neither BIC nor creditor IBAN', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + currency: 'CHF', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(ibanService.getIbanInfos).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK error: Bank Frick CHF payout requires creditor IBAN for BIC resolution', + }); + }); + + it('always keeps the stable custom id at the start of the bounded bank reference', () => { + expect(service['createUniqueReference']('DFX-FO-42')).toBe('DFX-FO-42'); + expect(service['createUniqueReference']('DFX-FO-42', ' DFX-FO-42 ')).toBe('DFX-FO-42'); + const reference = service['createUniqueReference']('DFX-FO-42', 'x'.repeat(200)); + expect(reference).toHaveLength(140); + expect(reference.startsWith('DFX-FO-42 ')).toBe(true); + expect(Array.from(service['createUniqueReference']('DFX-FO-42', '😀'.repeat(200)))).toHaveLength(140); + }); + + it.each([ + [TransactionCharge.BEN, FrickPaymentCharge.BENEFICIARY], + [TransactionCharge.OUR, FrickPaymentCharge.OUR], + [TransactionCharge.SHA, FrickPaymentCharge.SHARED], + ])('maps fiat-output charge %s to Bank Frick charge %s', async (charge, expectedCharge) => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockResolvedValue(order); + jest.spyOn(frickService, 'getSafeOrderId').mockReturnValue(undefined); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'CHF', + charge, + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + bic: 'TESTLI22', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(frickService.createPaymentOrder).toHaveBeenCalledWith(expect.objectContaining({ charge: expectedCharge })); + }); + + it('polls existing orders while payout creation is disabled and preserves a FRICK-prefixed operations note', async () => { + Config.bank.frick.payoutEnabled = false; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue(order); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: new Date('2026-01-01'), + frickReference: 'DFX-FO-42 existing', + isComplete: false, + info: 'FRICK manual operations hold', + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(frickService.getPaymentOrder).toHaveBeenCalledWith('DFX-FO-42'); + expect(frickService.approvePaymentWithoutTan).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.PREPARED, + frickError: null, + }); + expect((fiatOutputRepo.update as jest.Mock).mock.calls[0][1]).not.toHaveProperty('info'); + + const statusQuery = (fiatOutputRepo.find as jest.Mock).mock.calls[0][0].where; + expect(statusQuery.every((where: object) => 'frickOrderStatus' in where)).toBe(true); + expect(statusQuery.some((where: object) => 'info' in where)).toBe(false); + }); + + it('automatically approves a PREPARED order during status polling', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue(order); + jest + .spyOn(frickService, 'approvePaymentWithoutTan') + .mockResolvedValue({ ...order, state: FrickPaymentState.BOOKED }); + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith(order); + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + frickOrderStatus: FrickPaymentState.BOOKED, + frickError: null, + isApprovedDate: expect.any(Date), + isConfirmedDate: expect.any(Date), + }), + ); + }); + + it.each([ + [FrickPaymentState.IN_PROGRESS, false], + [FrickPaymentState.EXECUTED, false], + [FrickPaymentState.BOOKED, true], + ])('persists the %s status transition', async (state, confirms) => { + Config.bank.frick.payoutEnabled = false; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue({ ...order, state }); + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + frickOrderStatus: state, + frickError: null, + isApprovedDate: expect.any(Date), + ...(confirms && { isConfirmedDate: expect.any(Date) }), + }), + ); + if (!confirms) + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith( + 42, + expect.objectContaining({ isConfirmedDate: expect.any(Date) }), + ); + }); + + it('persists the status when an IN_PROGRESS order was already approved', async () => { + Config.bank.frick.payoutEnabled = false; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue({ + ...order, + state: FrickPaymentState.IN_PROGRESS, + }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isApprovedDate: new Date('2026-07-01'), + isTransmittedDate: new Date('2026-01-01'), + frickReference: 'DFX-FO-42 existing', + isComplete: false, + info: undefined, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(frickService.getPaymentOrder).toHaveBeenCalledWith('DFX-FO-42'); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.IN_PROGRESS, + frickError: null, + }); + }); + + it('preserves an operations note when a status request fails', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue(new Error('synthetic status failure')); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isComplete: false, + info: 'Manual operations note', + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK status error: synthetic status failure', + }); + expect((fiatOutputRepo.update as jest.Mock).mock.calls[0][1]).not.toHaveProperty('info'); + }); + + it('records a bounded Bank Frick status error when no operations note exists', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue(new Error('synthetic status failure')); + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK status error: synthetic status failure', + }); + }); + + it('records a generic status error message when the status request rejects with a non-Error value', async () => { + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue('synthetic non-error rejection'); + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { frickError: 'FRICK status error: unknown error' }); + }); + + it('does not use a lone house number as the creditor address', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockResolvedValue(order); + jest.spyOn(frickService, 'getSafeOrderId').mockReturnValue('4242'); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'EUR', + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + address: undefined, + houseNumber: '12', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(frickService.createPaymentOrder).toHaveBeenCalledWith( + expect.objectContaining({ creditor: expect.objectContaining({ address: undefined }) }), + ); + }); + + it('joins the street address and house number as the creditor address', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockResolvedValue(order); + jest.spyOn(frickService, 'getSafeOrderId').mockReturnValue('4242'); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + amount: 10, + currency: 'EUR', + accountIban: 'SYNTHETIC-DEBTOR', + name: 'Synthetic Recipient', + iban: 'SYNTHETIC-CREDITOR', + address: 'Synthetic Street', + houseNumber: '12', + bank: createCustomBank({ name: IbanBankName.FRICK }), + }), + ]); + + await service.transmitPayments(); + + expect(frickService.createPaymentOrder).toHaveBeenCalledWith( + expect.objectContaining({ creditor: expect.objectContaining({ address: 'Synthetic Street 12' }) }), + ); + }); + + it('preserves an operations note when transmission fails', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockRejectedValue(new Error('synthetic transmission failure')); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + bank: createCustomBank({ name: IbanBankName.FRICK }), + info: 'Manual operations note', + }), + ]); + + await service.transmitPayments(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK error: synthetic transmission failure', + }); + // The error-update call specifically (not the earlier atomic reservation call) must not carry `info` + const errorUpdateCall = (fiatOutputRepo.update as jest.Mock).mock.calls.find( + (call) => call[0] === 42 && 'frickError' in call[1], + ); + expect(errorUpdateCall[1]).not.toHaveProperty('info'); + }); + + it('records a bounded Bank Frick transmission error when no operations note exists', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockRejectedValue(new Error('synthetic transmission failure')); + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 42, bank: createCustomBank({ name: IbanBankName.FRICK }) })]); + + await service.transmitPayments(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK error: synthetic transmission failure', + }); + }); + + it('records a generic transmission error message when order creation rejects with a non-Error value', async () => { + Config.bank.frick.payoutEnabled = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'createPaymentOrder').mockRejectedValue('synthetic non-error rejection'); + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 42, bank: createCustomBank({ name: IbanBankName.FRICK }) })]); + + await service.transmitPayments(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { frickError: 'FRICK error: unknown error' }); + }); + + it('does not recreate a rejected order and records the terminal state', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue({ + ...order, + state: FrickPaymentState.REJECTED, + }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: new Date('2026-01-01'), + frickReference: 'DFX-FO-42 existing', + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.REJECTED, + frickError: 'Bank Frick order terminated: REJECTED', + }); + }); + + it.each([FrickPaymentState.REJECTED, FrickPaymentState.EXPIRED, FrickPaymentState.DELETED, FrickPaymentState.ERROR])( + 'maps terminal state %s to the dedicated order status and records a descriptive reason when none existed', + (state) => { + expect( + service['getFrickStatusUpdate']( + { ...order, state }, + createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42' }), + ), + ).toEqual({ frickOrderStatus: state, frickError: `Bank Frick order terminated: ${state}` }); + }, + ); + + it('preserves an existing operations note on a terminal status instead of erasing it', () => { + expect( + service['getFrickStatusUpdate']( + { ...order, state: FrickPaymentState.REJECTED }, + createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', frickError: 'FRICK error: prior failure note' }), + ), + ).toEqual({ frickOrderStatus: FrickPaymentState.REJECTED, frickError: 'FRICK error: prior failure note' }); + }); + + it('treats DELETION_REQUESTED as a non-terminal status transition (no liquidity release, no isComplete)', () => { + expect( + service['getFrickStatusUpdate']( + { ...order, state: FrickPaymentState.DELETION_REQUESTED }, + createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42' }), + ), + ).toEqual({ frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, frickError: null }); + }); + + it.each([ + [FrickPaymentState.REJECTED, true], + [FrickPaymentState.EXPIRED, true], + [FrickPaymentState.DELETED, true], + [FrickPaymentState.ERROR, true], + [FrickPaymentState.DELETION_REQUESTED, false], + [FrickPaymentState.PREPARED, false], + [FrickPaymentState.IN_PROGRESS, false], + [FrickPaymentState.BOOKED, false], + [FrickPaymentState.EXECUTED, false], + ])('classifies %s as terminal=%s for liquidity release and status-poll exclusion', (state, terminal) => { + expect(service.isFrickTerminalState(state)).toBe(terminal); + }); + + it('never re-approves and keeps polling an order stuck in DELETION_REQUESTED', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = true; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest + .spyOn(frickService, 'getPaymentOrder') + .mockResolvedValue({ ...order, state: FrickPaymentState.DELETION_REQUESTED }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: new Date('2026-01-01'), + frickReference: 'DFX-FO-42 existing', + isComplete: false, + frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, + frickError: null, + }); + }); + + it('approves a PREPARED order in the status job even when the transmission process is disabled', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = true; + jest + .spyOn(processServiceModule, 'DisabledProcess') + .mockImplementation((process) => process === processServiceModule.Process.FIAT_OUTPUT_FRICK_TRANSMISSION); + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue(order); // order.state === PREPARED + jest + .spyOn(frickService, 'approvePaymentWithoutTan') + .mockResolvedValue({ ...order, state: FrickPaymentState.BOOKED }); + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith(order); + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 42, + expect.objectContaining({ frickOrderStatus: FrickPaymentState.BOOKED }), + ); + }); + + it('never approves in the status job when approveWithoutTan is disabled', async () => { + Config.bank.frick.payoutEnabled = true; + Config.bank.frick.approveWithoutTan = false; + jest.spyOn(frickService, 'isAvailable').mockReturnValue(true); + jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue(order); // order.state === PREPARED + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickCustomId: 'DFX-FO-42', + isTransmittedDate: new Date('2026-01-01'), + frickReference: 'DFX-FO-42 existing', + isComplete: false, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.PREPARED, + frickError: null, + }); + }); + + it('rejects an unsupported Bank Frick status instead of guessing', () => { + expect(() => + service['getFrickStatusUpdate']( + { ...order, state: 'UNKNOWN' as FrickPaymentState }, + createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42' }), + ), + ).toThrow('Unsupported Bank Frick payment state'); + }); +}); diff --git a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts index 496c5fa2cf..b1aad5ac11 100644 --- a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts +++ b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts @@ -1,5 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { IbanService } from 'src/integration/bank/services/iban.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; @@ -17,11 +20,13 @@ import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-f import { createCustomBuyFiat } from 'src/subdomains/core/sell-crypto/process/__mocks__/buy-fiat.entity.mock'; import { createCustomSell } from 'src/subdomains/core/sell-crypto/route/__mocks__/sell.entity.mock'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; +import { BankTxOutgoingMatchService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service'; import { BankTxRepeatService } from '../../bank-tx/bank-tx-repeat/bank-tx-repeat.service'; import { BankTxReturnService } from '../../bank-tx/bank-tx-return/bank-tx-return.service'; import { createCustomBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; -import { createCustomBank, yapealEUR } from '../../bank/bank/__mocks__/bank.entity.mock'; +import { createCustomBank, olkyEUR, yapealEUR } from '../../bank/bank/__mocks__/bank.entity.mock'; import { BankService } from '../../bank/bank/bank.service'; +import { IbanBankName } from '../../bank/bank/dto/bank.dto'; import { createCustomVirtualIban } from '../../bank/virtual-iban/__mocks__/virtual-iban.entity.mock'; import { VirtualIbanService } from '../../bank/virtual-iban/virtual-iban.service'; import { createCustomLog } from '../../log/__mocks__/log.entity.mock'; @@ -29,6 +34,7 @@ import { LogService } from '../../log/log.service'; import { createCustomCryptoInput } from '../../payin/entities/__mocks__/crypto-input.entity.mock'; import { createCustomFiatOutput } from '../__mocks__/fiat-output.entity.mock'; import { Ep2ReportService } from '../ep2-report.service'; +import { FiatOutputFrickService } from '../fiat-output-frick.service'; import { FiatOutputJobService } from '../fiat-output-job.service'; import { FiatOutputType } from '../fiat-output.entity'; import { FiatOutputRepository } from '../fiat-output.repository'; @@ -38,6 +44,7 @@ describe('FiatOutputJobService', () => { let fiatOutputRepo: FiatOutputRepository; let bankTxService: BankTxService; + let bankTxOutgoingMatchService: BankTxOutgoingMatchService; let ep2ReportService: Ep2ReportService; let bankService: BankService; let countryService: CountryService; @@ -49,10 +56,12 @@ describe('FiatOutputJobService', () => { let olkypayService: OlkypayService; let virtualIbanService: VirtualIbanService; let scryptService: ScryptService; + let frickPayoutService: FiatOutputFrickService; beforeEach(async () => { fiatOutputRepo = createMock(); bankTxService = createMock(); + bankTxOutgoingMatchService = createMock(); ep2ReportService = createMock(); countryService = createMock(); bankService = createMock(); @@ -69,6 +78,7 @@ describe('FiatOutputJobService', () => { // Default mock: no virtual IBANs jest.spyOn(virtualIbanService, 'getActiveForUserAndCurrency').mockResolvedValue(null); jest.spyOn(virtualIbanService, 'getBaseAccountIban').mockResolvedValue(undefined); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([]); const module: TestingModule = await Test.createTestingModule({ imports: [TestSharedModule], @@ -77,6 +87,7 @@ describe('FiatOutputJobService', () => { { provide: FiatOutputRepository, useValue: fiatOutputRepo }, { provide: BuyFiatRepository, useValue: createMock() }, { provide: BankTxService, useValue: bankTxService }, + { provide: BankTxOutgoingMatchService, useValue: bankTxOutgoingMatchService }, { provide: Ep2ReportService, useValue: ep2ReportService }, { provide: CountryService, useValue: countryService }, { provide: BankService, useValue: bankService }, @@ -86,6 +97,11 @@ describe('FiatOutputJobService', () => { { provide: BankTxRepeatService, useValue: bankTxRepeatService }, { provide: YapealService, useValue: yapealService }, { provide: OlkypayService, useValue: olkypayService }, + // Real FiatOutputFrickService instance so setReadyDate's pending-liquidity filter exercises the + // actual isFrickTerminalState logic instead of a hand-duplicated mock. + FiatOutputFrickService, + { provide: BankFrickService, useValue: createMock() }, + { provide: IbanService, useValue: createMock() }, { provide: VirtualIbanService, useValue: virtualIbanService }, { provide: ScryptService, useValue: scryptService }, @@ -94,6 +110,8 @@ describe('FiatOutputJobService', () => { }).compile(); service = module.get(FiatOutputJobService); + frickPayoutService = module.get(FiatOutputFrickService); + jest.spyOn(frickPayoutService, 'canCreatePayments').mockReturnValue(true); }); it('should be defined', () => { @@ -128,7 +146,7 @@ describe('FiatOutputJobService', () => { jest.spyOn(countryService, 'getCountryWithSymbol').mockResolvedValue(createCustomCountry({ yapealEnable: true })); - jest.spyOn(bankService, 'getSenderBank').mockResolvedValue(yapealEUR); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([yapealEUR]); await service['assignBankAccount'](); @@ -191,6 +209,141 @@ describe('FiatOutputJobService', () => { expect(updateCalls[0][0]).toBe(1); expect(updateCalls[0][1]).toMatchObject({ originEntityId: 100, accountIban: virtualIban }); }); + + it('skips an unavailable Bank Frick sender and selects the next eligible sender', async () => { + const frick = createCustomBank({ + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'SYNTHETIC-FRICK-ACCOUNT', + send: true, + }); + jest.spyOn(frickPayoutService, 'canCreatePayments').mockReturnValue(false); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([frick, yapealEUR]); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: yapealEUR.iban, bank: yapealEUR }); + }); + + it('does not select Bank Frick without the configured instant-payment capability', async () => { + const frick = createCustomBank({ + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'SYNTHETIC-FRICK-ACCOUNT', + send: true, + sctInst: false, + }); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([frick, olkyEUR]); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', isInstant: true, buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: olkyEUR.iban, bank: olkyEUR }); + }); + + it('still routes an EUR payout to Olkypay while Frick EUR is send=true with the seeded (worse) default priority', async () => { + const frick = createCustomBank({ + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'SYNTHETIC-FRICK-ACCOUNT', + send: true, + sendPriority: 2000, + }); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([frick, olkyEUR]); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: olkyEUR.iban, bank: olkyEUR }); + }); + + it('routes to Frick once its priority is lowered below the incumbent sender', async () => { + const frick = createCustomBank({ + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'SYNTHETIC-FRICK-ACCOUNT', + send: true, + sendPriority: 500, + }); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([frick, yapealEUR]); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: frick.iban, bank: frick }); + }); + + it('throws only when two eligible banks share the exact same priority, not merely because Frick coexists', async () => { + const frick = createCustomBank({ + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'SYNTHETIC-FRICK-ACCOUNT', + send: true, + sendPriority: 1000, + }); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([frick, yapealEUR]); + + await expect( + service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ), + ).rejects.toThrow('Ambiguous sender bank priority for EUR'); + }); + + it('routes to the highest-priority (lowest number) sender when multiple non-Frick banks are eligible', async () => { + const highPriorityOlky = createCustomBank({ ...olkyEUR, sendPriority: 500 }); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([yapealEUR, highPriorityOlky]); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: highPriorityOlky.iban, bank: highPriorityOlky }); + }); + + it('routes an EUR payout to the first eligible non-Frick sender when Olkypay EUR and Yapeal EUR are both send=true at the default priority (no throw)', async () => { + // Regression: a tie between two non-Frick incumbents at the shared default priority (1000) must + // never throw - only a tie that involves Frick itself is a genuine ambiguity. Throwing here would + // silently strand every EUR payout the moment a second non-Frick sender is enabled for EUR. + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([olkyEUR, yapealEUR]); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: olkyEUR.iban, bank: olkyEUR }); + }); + + it('does not use an unavailable Bank Frick virtual IBAN', async () => { + const frick = createCustomBank({ name: IbanBankName.FRICK, send: true }); + jest.spyOn(frickPayoutService, 'canCreatePayments').mockReturnValue(false); + jest + .spyOn(virtualIbanService, 'getActiveForUserAndCurrency') + .mockResolvedValue(createCustomVirtualIban({ iban: 'SYNTHETIC-FRICK-VIBAN', bank: frick })); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ + currency: 'EUR', + type: FiatOutputType.BUY_FIAT, + buyFiats: [createCustomBuyFiat({})], + }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: undefined, bank: undefined }); + }); }); describe('setReadyDate', () => { @@ -276,6 +429,156 @@ describe('FiatOutputJobService', () => { expect(updatedIds).toContain(2); expect(updatedIds).toContain(4); }); + + it('allows EUR transactions routed through Bank Frick to become ready', async () => { + const bank = createCustomBank({ name: IbanBankName.FRICK, iban: 'SYNTHETIC-FRICK-ACCOUNT' }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 5, + bank, + iban: 'SYNTHETIC-CREDITOR-ACCOUNT', + isReadyDate: null, + buyFiats: [ + createCustomBuyFiat({ + cryptoInput: createCustomCryptoInput({ isConfirmed: true, asset: createDefaultAsset() }), + }), + ], + amount: 100, + currency: 'EUR', + type: FiatOutputType.BUY_FIAT, + }), + ]); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([ + createCustomAsset({ + id: 1, + type: AssetType.CUSTODY, + bank, + name: 'EUR', + balance: createCustomLiquidityBalance({ amount: 9000 }), + }), + ]); + + await service['setReadyDate'](); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(5, { isReadyDate: expect.any(Date) }); + }); + + it('does not make an assigned Bank Frick payout ready while creation is disabled', async () => { + const bank = createCustomBank({ name: IbanBankName.FRICK, iban: 'SYNTHETIC-FRICK-ACCOUNT' }); + jest.spyOn(frickPayoutService, 'canCreatePayments').mockReturnValue(false); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 5, + bank, + iban: 'SYNTHETIC-CREDITOR-ACCOUNT', + amount: 100, + currency: 'EUR', + type: FiatOutputType.BUY_FIAT, + buyFiats: [ + createCustomBuyFiat({ + cryptoInput: createCustomCryptoInput({ isConfirmed: true, asset: createDefaultAsset() }), + }), + ], + }), + ]); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([ + createCustomAsset({ + type: AssetType.CUSTODY, + bank, + balance: createCustomLiquidityBalance({ amount: 9000 }), + }), + ]); + + await service['setReadyDate'](); + + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(5, { isReadyDate: expect.any(Date) }); + }); + + it('reserves liquidity for transmitted Bank Frick orders awaiting completion', async () => { + const bank = createCustomBank({ name: IbanBankName.FRICK, iban: 'SYNTHETIC-FRICK-ACCOUNT' }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 5, + bank, + amount: 5000, + currency: 'EUR', + isReadyDate: new Date('2026-07-01'), + isTransmittedDate: new Date('2026-07-01'), + frickCustomId: 'DFX-FO-5', + }), + createCustomFiatOutput({ + id: 6, + bank, + iban: 'SYNTHETIC-CREDITOR-ACCOUNT', + amount: 4000, + currency: 'EUR', + isReadyDate: null, + buyFiats: [ + createCustomBuyFiat({ + cryptoInput: createCustomCryptoInput({ isConfirmed: true, asset: createDefaultAsset() }), + }), + ], + type: FiatOutputType.BUY_FIAT, + }), + ]); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([ + createCustomAsset({ + id: 1, + type: AssetType.CUSTODY, + bank, + name: 'EUR', + balance: createCustomLiquidityBalance({ amount: 9000 }), + }), + ]); + + await service['setReadyDate'](); + + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(6, { isReadyDate: expect.any(Date) }); + }); + + it('releases Bank Frick liquidity from a terminal status regardless of the operations note', async () => { + const bank = createCustomBank({ name: IbanBankName.FRICK, iban: 'SYNTHETIC-FRICK-ACCOUNT' }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 5, + bank, + amount: 5000, + currency: 'EUR', + isReadyDate: new Date('2026-07-01'), + isTransmittedDate: new Date('2026-07-01'), + frickCustomId: 'DFX-FO-5', + frickOrderStatus: FrickPaymentState.REJECTED, + info: 'Manual operations follow-up', + }), + createCustomFiatOutput({ + id: 6, + bank, + iban: 'SYNTHETIC-CREDITOR-ACCOUNT', + amount: 4000, + currency: 'EUR', + isReadyDate: null, + buyFiats: [ + createCustomBuyFiat({ + cryptoInput: createCustomCryptoInput({ isConfirmed: true, asset: createDefaultAsset() }), + }), + ], + type: FiatOutputType.BUY_FIAT, + }), + ]); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([ + createCustomAsset({ + id: 1, + type: AssetType.CUSTODY, + bank, + name: 'EUR', + balance: createCustomLiquidityBalance({ amount: 9000 }), + }), + ]); + + await service['setReadyDate'](); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(6, { isReadyDate: expect.any(Date) }); + }); }); describe('createBatches', () => { @@ -365,6 +668,30 @@ describe('FiatOutputJobService', () => { }), ]); }); + + it('never includes a Bank Frick payout in a payment-file batch', async () => { + const regular = createCustomFiatOutput({ + id: 1, + accountIban: 'CH123456789', + amount: 200, + isComplete: false, + bank: createCustomBank({ name: IbanBankName.RAIFFEISEN }), + }); + const frick = createCustomFiatOutput({ + id: 2, + accountIban: 'LI123456789', + amount: 300, + isComplete: false, + bank: createCustomBank({ name: IbanBankName.FRICK }), + }); + jest.spyOn(fiatOutputRepo, 'findBy').mockResolvedValue([regular, frick]); + jest.spyOn(fiatOutputRepo, 'findOne').mockResolvedValue(createCustomFiatOutput({ batchId: 0 })); + + await service['createBatches'](); + + expect(fiatOutputRepo.save).toHaveBeenCalledWith([expect.objectContaining({ id: 1, batchId: 1 })]); + expect(frick.batchId).toBeUndefined(); + }); }); describe('checkTransmission', () => { @@ -392,6 +719,17 @@ describe('FiatOutputJobService', () => { }); describe('searchOutgoingBankTx', () => { + it('does not attempt reconciliation before an output is ready', async () => { + jest + .spyOn(fiatOutputRepo, 'find') + .mockResolvedValue([createCustomFiatOutput({ id: 99, isReadyDate: undefined, isComplete: false })]); + + await service['searchOutgoingBankTx'](); + + expect(bankTxOutgoingMatchService.getUniqueOutgoingBankTx).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + }); + it('should match FiatOutput via remittanceInfo', async () => { const bankTx = createCustomBankTx({ id: 100, created: new Date('2024-01-01') }); const fiatOutput = createCustomFiatOutput({ @@ -402,14 +740,36 @@ describe('FiatOutputJobService', () => { }); jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); - jest.spyOn(bankTxService, 'getBankTxByRemittanceInfo').mockResolvedValue(bankTx); + jest.spyOn(bankTxOutgoingMatchService, 'getUniqueOutgoingBankTx').mockResolvedValue(bankTx); await service['searchOutgoingBankTx'](); - expect(bankTxService.getBankTxByRemittanceInfo).toHaveBeenCalledWith('DFX-123'); + expect(bankTxOutgoingMatchService.getUniqueOutgoingBankTx).toHaveBeenCalledWith( + expect.objectContaining({ remittanceInfo: 'DFX-123', earliestDate: new Date('2024-01-01') }), + ); expect(fiatOutputRepo.update).toHaveBeenCalledWith(1, expect.objectContaining({ isComplete: true, bankTx })); }); + it('matches a Bank Frick payout via frickReference (the bank-echoed reference), not the untouched customer remittanceInfo', async () => { + const bankTx = createCustomBankTx({ id: 101, created: new Date('2024-01-01') }); + const fiatOutput = createCustomFiatOutput({ + id: 3, + remittanceInfo: 'Original customer text', + frickReference: 'DFX-FO-3 Original customer text', + isComplete: false, + isReadyDate: new Date('2024-01-01'), + }); + + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); + jest.spyOn(bankTxOutgoingMatchService, 'getUniqueOutgoingBankTx').mockResolvedValue(bankTx); + + await service['searchOutgoingBankTx'](); + + expect(bankTxOutgoingMatchService.getUniqueOutgoingBankTx).toHaveBeenCalledWith( + expect.objectContaining({ remittanceInfo: 'DFX-FO-3 Original customer text' }), + ); + }); + it('should match FiatOutput via endToEndId when remittanceInfo is not set', async () => { const bankTx = createCustomBankTx({ id: 200, created: new Date('2024-01-01') }); const fiatOutput = createCustomFiatOutput({ @@ -422,17 +782,17 @@ describe('FiatOutputJobService', () => { }); jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); - jest.spyOn(bankTxService, 'getBankTxByRemittanceInfo').mockResolvedValue(null); - jest.spyOn(bankTxService, 'getBankTxByEndToEndId').mockResolvedValue(bankTx); + jest.spyOn(bankTxOutgoingMatchService, 'getUniqueOutgoingBankTx').mockResolvedValue(bankTx); await service['searchOutgoingBankTx'](); - expect(bankTxService.getBankTxByEndToEndId).toHaveBeenCalledWith('E2E-79057'); + expect(bankTxOutgoingMatchService.getUniqueOutgoingBankTx).toHaveBeenCalledWith( + expect.objectContaining({ remittanceInfo: undefined, endToEndId: 'E2E-79057' }), + ); expect(fiatOutputRepo.update).toHaveBeenCalledWith(2, expect.objectContaining({ isComplete: true, bankTx })); }); it('should not match if BankTx created before FiatOutput isReadyDate', async () => { - const bankTx = createCustomBankTx({ id: 300, created: new Date('2024-01-01') }); const fiatOutput = createCustomFiatOutput({ id: 3, endToEndId: 'E2E-79058', @@ -441,11 +801,83 @@ describe('FiatOutputJobService', () => { }); jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); - jest.spyOn(bankTxService, 'getBankTxByEndToEndId').mockResolvedValue(bankTx); + jest.spyOn(bankTxOutgoingMatchService, 'getUniqueOutgoingBankTx').mockResolvedValue(undefined); await service['searchOutgoingBankTx'](); + expect(bankTxOutgoingMatchService.getUniqueOutgoingBankTx).toHaveBeenCalledWith( + expect.objectContaining({ earliestDate: new Date('2024-01-02') }), + ); expect(fiatOutputRepo.update).not.toHaveBeenCalled(); }); + + it('marks a reconciled Bank Frick payout approved and confirmed', async () => { + const bankTx = createCustomBankTx({ id: 400, created: new Date('2026-07-02') }); + const fiatOutput = createCustomFiatOutput({ + id: 4, + frickCustomId: 'DFX-FO-4', + remittanceInfo: 'Synthetic Frick payout', + isComplete: false, + isReadyDate: new Date('2026-07-01'), + }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); + jest.spyOn(bankTxOutgoingMatchService, 'getUniqueOutgoingBankTx').mockResolvedValue(bankTx); + + await service['searchOutgoingBankTx'](); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 4, + expect.objectContaining({ + isComplete: true, + isApprovedDate: bankTx.created, + isConfirmedDate: bankTx.created, + }), + ); + }); + }); + + describe('Bank Frick liquidity', () => { + it('reserves liquidity for a Bank Frick order stuck in DELETION_REQUESTED', async () => { + const bank = createCustomBank({ name: IbanBankName.FRICK, iban: 'SYNTHETIC-FRICK-ACCOUNT' }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 5, + bank, + amount: 5000, + currency: 'EUR', + isReadyDate: new Date('2026-07-01'), + isTransmittedDate: new Date('2026-07-01'), + frickCustomId: 'DFX-FO-5', + frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, + }), + createCustomFiatOutput({ + id: 6, + bank, + iban: 'SYNTHETIC-CREDITOR-ACCOUNT', + amount: 4000, + currency: 'EUR', + isReadyDate: null, + buyFiats: [ + createCustomBuyFiat({ + cryptoInput: createCustomCryptoInput({ isConfirmed: true, asset: createDefaultAsset() }), + }), + ], + type: FiatOutputType.BUY_FIAT, + }), + ]); + jest.spyOn(assetService, 'getAssetsWith').mockResolvedValue([ + createCustomAsset({ + id: 1, + type: AssetType.CUSTODY, + bank, + name: 'EUR', + balance: createCustomLiquidityBalance({ amount: 9000 }), + }), + ]); + + await service['setReadyDate'](); + + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(6, { isReadyDate: expect.any(Date) }); + }); }); }); diff --git a/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts new file mode 100644 index 0000000000..cf267260cd --- /dev/null +++ b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts @@ -0,0 +1,284 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { + FRICK_TERMINAL_STATES, + FrickPaymentCharge, + FrickPaymentOrder, + FrickPaymentOrderNotFoundError, + FrickPaymentState, +} from 'src/integration/bank/dto/frick.dto'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { IbanService } from 'src/integration/bank/services/iban.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { DisabledProcess, Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; +import { IbanBankName } from '../bank/bank/dto/bank.dto'; +import { FiatOutput, TransactionCharge } from './fiat-output.entity'; +import { FiatOutputRepository } from './fiat-output.repository'; + +@Injectable() +export class FiatOutputFrickService { + private readonly logger = new DfxLogger(FiatOutputFrickService); + + constructor( + private readonly fiatOutputRepo: FiatOutputRepository, + private readonly frickService: BankFrickService, + private readonly ibanService: IbanService, + ) {} + + @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT, timeout: 1800 }) + async checkFrickOrderStatus(): Promise { + if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_STATUS_CHECK)) return; + if (!this.frickService.isAvailable()) return; + + const statusRequest: FindOptionsWhere = { frickCustomId: Not(IsNull()), isComplete: false }; + const entities = await this.fiatOutputRepo.find({ + where: [ + { ...statusRequest, frickOrderStatus: IsNull() }, + { ...statusRequest, frickOrderStatus: Not(In(FRICK_TERMINAL_STATES)) }, + ], + }); + + for (const entity of entities) { + try { + let order: FrickPaymentOrder; + try { + order = await this.frickService.getPaymentOrder(entity.frickCustomId); + } catch (error) { + if (error instanceof FrickPaymentOrderNotFoundError && !entity.isTransmittedDate) { + // The lookup searches Bank Frick's full history (never a narrow recent window), so "not + // found" is reliable evidence the PUT that reserved this customId never actually reached + // (or was never accepted by) Bank Frick. Release the claim so transmitPayments can safely + // retry with the same, still-deterministic customId - its own idempotent lookup-before-PUT + // then either finds the order after all or creates it fresh. + // Conditional, not unconditional-by-id: this snapshot's `entity.isTransmittedDate` can be + // stale by the time this write runs (the minutely transmitPayments may have reserved and + // transmitted this very row in between). Re-checking frickCustomId + isTransmittedDate IS + // NULL against the live row means the clear only takes effect if the row is still in exactly + // the state this branch observed - otherwise it's a no-op and the concurrent reservation + // stays visible to this status poller. + await this.fiatOutputRepo.update( + { id: entity.id, frickCustomId: entity.frickCustomId, isTransmittedDate: IsNull() }, + { frickCustomId: null, frickError: null }, + ); + continue; + } + throw error; + } + + if (order.state === FrickPaymentState.PREPARED && this.isFrickAutomaticApprovalEnabled()) { + order = await this.frickService.approvePaymentWithoutTan(order); + } + + // Defense-in-depth for the atomic reserve+transmit write in transmitPayments: an order being + // found here proves it was actually created at Bank Frick, so if isTransmittedDate/frickReference + // are still missing on our side (a crash/DB blip hit between transmitPayments' two writes), heal + // them here instead of leaving the row permanently unmatched by reconciliation. + const reservationGapHeal: Partial = { + ...(!entity.isTransmittedDate && { isTransmittedDate: new Date() }), + ...(!entity.frickReference && { + frickReference: this.createUniqueReference(entity.frickCustomId, entity.remittanceInfo), + }), + }; + + const updateData = { ...reservationGapHeal, ...this.getFrickStatusUpdate(order, entity) }; + if (Object.keys(updateData).length > 0) await this.fiatOutputRepo.update(entity.id, updateData); + } catch (error) { + this.logger.error(`Failed to check Bank Frick order status for fiat output ${entity.id}:`, error); + const message = error instanceof Error ? error.message : 'unknown error'; + await this.fiatOutputRepo.update(entity.id, { + frickError: `FRICK status error: ${message}`.substring(0, 256), + }); + } + } + } + + async transmitPayments(): Promise { + if (!this.canCreatePayments()) return; + + const entities = await this.fiatOutputRepo.find({ + where: { + isReadyDate: Not(IsNull()), + isTransmittedDate: IsNull(), + frickCustomId: IsNull(), + isComplete: false, + bank: { name: IbanBankName.FRICK }, + }, + }); + + for (const entity of entities) { + try { + const customId = `DFX-FO-${entity.id}`; + // The exact, bank-bound reference sent to Bank Frick - kept in its own column (frickReference) + // rather than overwriting the customer-facing remittanceInfo, which chain report history reads + // verbatim and must never be retroactively rewritten. + const frickReference = this.createUniqueReference(customId, entity.remittanceInfo); + const address = entity.address ? [entity.address, entity.houseNumber].filter(Boolean).join(' ') : undefined; + // Pre-flight computation only (no Bank Frick HTTP call yet): a data problem here (e.g. no + // unique creditor BIC) must not touch frickCustomId, so it keeps retrying every minute via this + // same method instead of only every hour via the status poller's self-heal. + const creditorBic = await this.resolveCreditorBic(entity); + // CHF (Bank Frick FOREIGN) requires some charge value - SHA is the documented default when the + // business never set one. Persisted onto the entity below instead of staying a NULL that looks + // unset, so the actual decision is durable and visible on the row, not just implicit in code. + const outputCharge = entity.currency === 'CHF' ? (entity.charge ?? TransactionCharge.SHA) : entity.charge; + const charge = outputCharge + ? { + [TransactionCharge.BEN]: FrickPaymentCharge.BENEFICIARY, + [TransactionCharge.OUR]: FrickPaymentCharge.OUR, + [TransactionCharge.SHA]: FrickPaymentCharge.SHARED, + }[outputCharge] + : undefined; + + // Reserve this row atomically immediately before the actual Bank Frick call. customId is + // deterministic (derived only from entity.id), so re-setting it to the same value on a + // legitimate retry is inherently idempotent - the WHERE clause is what turns this into a + // mutex: a concurrent tick (an overlapping cron window, or a second instance) sees affected=0 + // and skips, so at most one caller ever reaches createPaymentOrder for this row. frickReference + // is folded into this same atomic write (not deferred to the post-createPaymentOrder update + // below): it's already fully computed and is required for reconciliation + // (getMatchingBankTx uses frickReference ?? remittanceInfo) - if it only landed after the order + // was created at Bank Frick, a crash/DB blip between the two writes would leave the order live + // at the bank but frickReference NULL on our side, permanently stranding reconciliation. + const reserved = await this.fiatOutputRepo.update( + { id: entity.id, frickCustomId: IsNull() }, + { frickCustomId: customId, frickReference }, + ); + if (reserved.affected !== 1) continue; + + const order = await this.frickService.createPaymentOrder({ + customId, + amount: entity.amount, + currency: entity.currency as 'CHF' | 'EUR', + instant: entity.isInstant, + reference: frickReference, + charge, + debtorIban: entity.accountIban, + creditor: { + name: entity.name, + iban: entity.iban, + bic: creditorBic, + address, + postalcode: entity.zip, + city: entity.city, + country: entity.country, + creditInstitution: entity.creditInstitution, + }, + }); + const safeOrderId = this.frickService.getSafeOrderId(order); + + // frickCustomId and frickReference are already durably reserved above. Persist the rest of the + // bank-side identity before any optional approval call - if approval fails, the status job + // continues with the stable customId and never creates another payment order. remittanceInfo is + // only ever filled when the business never set one at all (matching the Yapeal/Olkypay fallback + // pattern) - never overwritten with the bank-bound reference. + await this.fiatOutputRepo.update(entity.id, { + ...(safeOrderId && { frickOrderId: safeOrderId }), + charge: outputCharge, + ...(!entity.remittanceInfo && { remittanceInfo: `DFX Payout ${entity.id}` }), + isTransmittedDate: new Date(), + ...this.getFrickStatusUpdate(order, entity), + }); + + if (this.isFrickAutomaticApprovalEnabled() && order.state === FrickPaymentState.PREPARED) { + const approvedOrder = await this.frickService.approvePaymentWithoutTan(order); + const updateData = this.getFrickStatusUpdate(approvedOrder, entity); + if (Object.keys(updateData).length > 0) await this.fiatOutputRepo.update(entity.id, updateData); + } + } catch (error) { + this.logger.error(`Failed to transmit Bank Frick payment for fiat output ${entity.id}:`, error); + const message = error instanceof Error ? error.message : 'unknown error'; + await this.fiatOutputRepo.update(entity.id, { + frickError: `FRICK error: ${message}`.substring(0, 256), + }); + } + } + } + + isFrickTerminalState(status: FrickPaymentState | undefined): boolean { + return status !== undefined && FRICK_TERMINAL_STATES.includes(status); + } + + canCreatePayments(): boolean { + return ( + !DisabledProcess(Process.FIAT_OUTPUT_FRICK_TRANSMISSION) && + Config.bank.frick.payoutEnabled && + this.frickService.isAvailable() + ); + } + + private createUniqueReference(customId: string, requestedReference?: string): string { + const normalizedReference = requestedReference?.trim(); + if (!normalizedReference || normalizedReference === customId) return customId; + return Array.from(`${customId} ${normalizedReference}`).slice(0, 140).join(''); + } + + private async resolveCreditorBic(entity: FiatOutput): Promise { + if (entity.currency !== 'CHF' || entity.bic) return entity.bic; + if (!entity.iban) throw new Error('Bank Frick CHF payout requires creditor IBAN for BIC resolution'); + + const details = await this.ibanService.getIbanInfos(entity.iban); + const candidates = [...(details.bic_candidates ?? []), ...(details.all_bic_candidates ?? [])] + .map(({ bic }) => bic?.replace(/\s/g, '').toUpperCase()) + .filter((bic): bic is string => !!bic); + const uniqueCandidates = [...new Set(candidates)]; + if (uniqueCandidates.length !== 1) + throw new Error( + uniqueCandidates.length === 0 + ? 'Unable to resolve creditor BIC for Bank Frick CHF payout' + : 'Ambiguous creditor BIC for Bank Frick CHF payout', + ); + return uniqueCandidates[0]; + } + + private getFrickStatusUpdate(order: FrickPaymentOrder, entity: FiatOutput): Partial { + const now = new Date(); + + switch (order.state) { + case FrickPaymentState.IN_PROGRESS: + case FrickPaymentState.EXECUTED: + return { + frickOrderStatus: order.state, + frickError: null, + ...(!entity.isApprovedDate && { isApprovedDate: now }), + }; + + case FrickPaymentState.BOOKED: + return { + frickOrderStatus: order.state, + frickError: null, + ...(!entity.isApprovedDate && { isApprovedDate: now }), + ...(!entity.isConfirmedDate && { isConfirmedDate: now }), + }; + + case FrickPaymentState.PREPARED: + case FrickPaymentState.DELETION_REQUESTED: + // Non-terminal: persist the status change only, no liquidity release, no isComplete. + return { frickOrderStatus: order.state, frickError: null }; + + case FrickPaymentState.REJECTED: + case FrickPaymentState.EXPIRED: + case FrickPaymentState.DELETED: + case FrickPaymentState.ERROR: + // Terminal and unpaid: persist the failure reason instead of erasing it - an operator (or the + // stuckFiatOutputs monitor) must be able to see why this payout never completed. + return { + frickOrderStatus: order.state, + frickError: entity.frickError ?? `Bank Frick order terminated: ${order.state}`, + }; + + default: + throw new Error('Unsupported Bank Frick payment state'); + } + } + + private isFrickAutomaticApprovalEnabled(): boolean { + // Process gating is the caller's responsibility: checkFrickOrderStatus runs under + // FIAT_OUTPUT_FRICK_STATUS_CHECK, transmitPayments under FIAT_OUTPUT_FRICK_TRANSMISSION. + // Disabling the transmission switch must not also stop approval of already-created PREPARED + // orders in the status job, or their liquidity stays stranded. + return Config.bank.frick.payoutEnabled && Config.bank.frick.approveWithoutTan; + } +} diff --git a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts index 6978f74484..4674b892f3 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -21,6 +21,7 @@ import { BankTxRepeatService } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.se import { BankTxReturnService } from '../bank-tx/bank-tx-return/bank-tx-return.service'; import { BankTx, BankTxType, BankTxTypeUnassigned } from '../bank-tx/bank-tx/entities/bank-tx.entity'; import { BankTxService } from '../bank-tx/bank-tx/services/bank-tx.service'; +import { BankTxOutgoingMatchService } from '../bank-tx/bank-tx/services/bank-tx-outgoing-match.service'; import { Bank } from '../bank/bank/bank.entity'; import { BankService } from '../bank/bank/bank.service'; import { IbanBankName } from '../bank/bank/dto/bank.dto'; @@ -31,6 +32,7 @@ import { BuyFiatRepository } from 'src/subdomains/core/sell-crypto/process/buy-f import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; import { LogService } from '../log/log.service'; import { Ep2ReportService } from './ep2-report.service'; +import { FiatOutputFrickService } from './fiat-output-frick.service'; import { FiatOutput, FiatOutputType } from './fiat-output.entity'; import { FiatOutputRepository } from './fiat-output.repository'; @@ -43,6 +45,7 @@ export class FiatOutputJobService { private readonly buyFiatRepo: BuyFiatRepository, @Inject(forwardRef(() => BankTxService)) private readonly bankTxService: BankTxService, + private readonly bankTxOutgoingMatchService: BankTxOutgoingMatchService, private readonly ep2ReportService: Ep2ReportService, private readonly bankService: BankService, private readonly countryService: CountryService, @@ -52,6 +55,7 @@ export class FiatOutputJobService { private readonly bankTxRepeatService: BankTxRepeatService, private readonly yapealService: YapealService, private readonly olkypayService: OlkypayService, + private readonly frickPayoutService: FiatOutputFrickService, private readonly virtualIbanService: VirtualIbanService, private readonly scryptService: ScryptService, ) {} @@ -64,6 +68,7 @@ export class FiatOutputJobService { await this.checkTransmission(); await this.transmitYapealPayments(); await this.transmitOlkypayPayments(); + await this.frickPayoutService.transmitPayments(); await this.searchOutgoingBankTx(); } @@ -123,38 +128,63 @@ export class FiatOutputJobService { // --- HELPER METHODS --- // private async getMatchingBankTx(entity: FiatOutput): Promise { - // Try remittanceInfo first - if (entity.remittanceInfo) { - const bankTx = await this.bankTxService.getBankTxByRemittanceInfo(entity.remittanceInfo); - if (bankTx) return bankTx; - } - - // Fallback to endToEndId (used for Yapeal LiqManagement payments) - if (entity.endToEndId) { - const bankTx = await this.bankTxService.getBankTxByEndToEndId(entity.endToEndId); - if (bankTx) return bankTx; - } - - return undefined; + return this.bankTxOutgoingMatchService.getUniqueOutgoingBankTx({ + // Frick's bank-echoed reference lives in frickReference - the untouched, customer-facing + // remittanceInfo is never what the bank actually echoes back for a Frick payout. Every other + // bank never sets frickReference, so this falls straight through to remittanceInfo for them. + remittanceInfo: entity.frickReference ?? entity.remittanceInfo, + endToEndId: entity.endToEndId, + accountIban: entity.sourceIban, + amount: entity.amount, + currency: entity.currency, + earliestDate: entity.isReadyDate, + }); } private async getPayoutAccount(entity: FiatOutput, country: Country): Promise<{ accountIban: string; bank: Bank }> { + const currency = entity.currency ?? entity.bankAccountCurrency; + + // A Frick instant payout is only ever supported for EUR (Bank Frick rejects instant CHF/FOREIGN + // orders outright) - gate on both the capability flag and the currency so an instant CHF output can + // never be assigned to Frick in the first place, rather than failing on every transmit retry. + const isEligibleFrickCandidate = (bank: Bank): boolean => + bank.name !== IbanBankName.FRICK || !entity.isInstant || (bank.sctInst && currency === 'EUR'); + // use virtual IBAN if existing if (entity.userData && [FiatOutputType.BUY_FIAT, FiatOutputType.BUY_CRYPTO_FAIL].includes(entity.type)) { - const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency( - entity.userData, - entity.currency ?? entity.bankAccountCurrency, - ); - - if (virtualIban?.bank?.send && virtualIban.bank.isCountryEnabled(country)) + const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(entity.userData, currency); + + if ( + virtualIban?.bank?.send && + isEligibleFrickCandidate(virtualIban.bank) && + virtualIban.bank.isCountryEnabled(country) && + (virtualIban.bank.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()) + ) return { accountIban: virtualIban.iban, bank: virtualIban.bank }; } // fallback to standard bank account selection - const bank = await this.bankService.getSenderBank(entity.currency ?? entity.bankAccountCurrency); - return bank?.isCountryEnabled(country) - ? { accountIban: bank.iban, bank } - : { accountIban: undefined, bank: undefined }; + const banks = await this.bankService.getSenderBanks(currency); + const eligibleBanks = banks.filter( + (candidate) => + isEligibleFrickCandidate(candidate) && + candidate.isCountryEnabled(country) && + (candidate.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()), + ); + + // Sender priority (lower wins) is the deterministic tie-breaker between multiple eligible senders for + // the same currency - an operational input (Bank.sendPriority), not a hardcoded bank-name preference. + // A throw is reserved for a genuine priority tie that involves Frick itself, never for a tie between + // two non-Frick incumbents (e.g. Olkypay EUR and Yapeal EUR both send=true at the shared default + // priority): Array.prototype.sort is stable, so when every candidate shares the same priority, the + // pre-existing first-match order is used instead of throwing away an otherwise-workable route. + const sortedBanks = [...eligibleBanks].sort((a, b) => a.sendPriority - b.sendPriority); + const tiedForTop = sortedBanks.filter((candidate) => candidate.sendPriority === sortedBanks[0]?.sendPriority); + if (tiedForTop.length > 1 && tiedForTop.some((candidate) => candidate.name === IbanBankName.FRICK)) + throw new Error(`Ambiguous sender bank priority for ${currency}`); + + const bank = sortedBanks[0]; + return bank ? { accountIban: bank.iban, bank } : { accountIban: undefined, bank: undefined }; } private async assignBankAccount(): Promise { @@ -232,6 +262,10 @@ export class FiatOutputJobService { switch (tx.bank?.name) { case IbanBankName.YAPEAL: return !tx.isTransmittedDate; + case IbanBankName.FRICK: + // A PREPARED Frick order can wait for manual approval for days. Keep its amount reserved until + // reconciliation or a terminal failure state, otherwise later payouts can overdraw the account. + return !this.frickPayoutService.isFrickTerminalState(tx.frickOrderStatus); case IbanBankName.OLKY: return !tx.bankTx || tx.bankTx.created > Util.minutesBefore(5); default: @@ -242,6 +276,7 @@ export class FiatOutputJobService { for (const entity of sortedEntities.filter((e) => !e.isReadyDate)) { try { + if (entity.bank?.name === IbanBankName.FRICK && !this.frickPayoutService.canCreatePayments()) continue; if ( (entity.user?.isBlockedOrDeleted || entity.userData?.isBlocked) && entity.type === FiatOutputType.BUY_FIAT @@ -266,8 +301,9 @@ export class FiatOutputJobService { const availableBalance = asset.balance.amount - pendingBalance - updatedFiatOutputAmount - Config.liquidityManagement.bankMinBalance; - // Skip EUR transactions that are not routed through Olkypay - if (entity.currency === 'EUR' && entity.bank?.name !== IbanBankName.OLKY) continue; + // EUR is only automated through the dedicated REST payout rails. + if (entity.currency === 'EUR' && ![IbanBankName.OLKY, IbanBankName.FRICK].includes(entity.bank?.name)) + continue; if (availableBalance > entity.bankAmount) { updatedFiatOutputAmount += entity.bankAmount; @@ -313,13 +349,16 @@ export class FiatOutputJobService { ) return; - const entities = await this.fiatOutputRepo.findBy({ - amount: Not(IsNull()), - isReadyDate: Not(IsNull()), - batchId: IsNull(), - isComplete: false, - bank: { name: Not(In([IbanBankName.YAPEAL, IbanBankName.OLKY])) }, - }); + const automatedBanks = [IbanBankName.YAPEAL, IbanBankName.OLKY, IbanBankName.FRICK]; + const entities = ( + await this.fiatOutputRepo.findBy({ + amount: Not(IsNull()), + isReadyDate: Not(IsNull()), + batchId: IsNull(), + isComplete: false, + bank: { name: Not(In(automatedBanks)) }, + }) + ).filter((entity) => !automatedBanks.includes(entity.bank?.name)); let currentBatch: FiatOutput[] = []; let currentBatchId = (await this.getLastBatchId()) + 1; @@ -507,6 +546,7 @@ export class FiatOutputJobService { const entities = await this.fiatOutputRepo.find({ where: { amount: Not(IsNull()), + isReadyDate: Not(IsNull()), isComplete: false, bankTx: { id: IsNull() }, }, @@ -515,8 +555,9 @@ export class FiatOutputJobService { for (const entity of entities) { try { + if (!entity.isReadyDate) continue; const bankTx = await this.getMatchingBankTx(entity); - if (!bankTx || (entity.isReadyDate && entity.isReadyDate > bankTx.created)) continue; + if (!bankTx) continue; const updateData: Partial = { bankTx, @@ -524,10 +565,16 @@ export class FiatOutputJobService { isComplete: true, }; - if ((entity.yapealMsgId || entity.olkyOrderId) && !entity.isConfirmedDate) { + if ( + (entity.yapealMsgId || entity.olkyOrderId || entity.frickOrderId || entity.frickCustomId) && + !entity.isConfirmedDate + ) { updateData.isConfirmedDate = bankTx.created; } + if ((entity.frickOrderId || entity.frickCustomId) && !entity.isApprovedDate) + updateData.isApprovedDate = bankTx.created; + await this.fiatOutputRepo.update(entity.id, updateData); await this.notifyScryptDepositIfApplicable(entity); diff --git a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts index ac6a6fdcf3..dc29133fe8 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts @@ -3,6 +3,7 @@ import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-c import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { BankTxRepeat } from '../bank-tx/bank-tx-repeat/bank-tx-repeat.entity'; import { BankTxReturn } from '../bank-tx/bank-tx-return/bank-tx-return.entity'; @@ -152,6 +153,25 @@ export class FiatOutput extends IEntity { @Column({ nullable: true }) olkyOrderId?: string; + // Bank Frick's own numeric orderId (max 16 digits) - stringified for the safely-representable case. + @Column({ length: 64, nullable: true }) + frickOrderId?: string; + + // DFX's own generated customId (e.g. "DFX-FO-42"), not a Bank Frick transaction id. + @Column({ length: 256, nullable: true }) + frickCustomId?: string; + + @Column({ type: 'varchar', length: 256, nullable: true }) + frickOrderStatus?: FrickPaymentState; + + @Column({ length: 256, nullable: true }) + frickError?: string; + + // The exact, bank-bound reference string sent to Bank Frick (customId-prefixed, truncated to 140 + // chars). Kept separate from remittanceInfo so the customer-facing text is never overwritten. + @Column({ length: 256, nullable: true }) + frickReference?: string; + // --- ENTITY METHODS --- // setBatch(batchId?: number, batchAmount?: number): UpdateResult { diff --git a/src/subdomains/supporting/fiat-output/fiat-output.module.ts b/src/subdomains/supporting/fiat-output/fiat-output.module.ts index 9406cc452a..17ee531e2b 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.module.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.module.ts @@ -15,6 +15,7 @@ import { FiatOutputRepository } from '../fiat-output/fiat-output.repository'; import { FiatOutputService } from '../fiat-output/fiat-output.service'; import { LogModule } from '../log/log.module'; import { Ep2ReportService } from './ep2-report.service'; +import { FiatOutputFrickService } from './fiat-output-frick.service'; import { FiatOutputJobService } from './fiat-output-job.service'; @Module({ @@ -37,6 +38,7 @@ import { FiatOutputJobService } from './fiat-output-job.service'; SellRepository, FiatOutputService, Ep2ReportService, + FiatOutputFrickService, FiatOutputJobService, ], exports: [FiatOutputService],