From c6783395a708a4514ad7623139557bfb441c7670 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 13:40:00 +0200 Subject: [PATCH 01/19] feat(bank): integrate Bank Frick WebAPI --- .env.example | 9 +- infrastructure/bicep/api/dfx-api.bicep | 34 ++ infrastructure/bicep/api/parameters/dev.json | 18 + infrastructure/bicep/api/parameters/loc.json | 18 + infrastructure/bicep/api/parameters/prd.json | 18 + ...783944000000-AddBankFrickPayoutTracking.js | 34 ++ src/config/config.ts | 8 + src/integration/bank/bank.module.ts | 4 +- src/integration/bank/dto/frick.dto.ts | 119 ++++ .../services/__tests__/frick.service.spec.ts | 377 ++++++++++++ .../__tests__/iso20022.service.spec.ts | 66 +++ .../bank/services/frick.service.ts | 553 ++++++++++++++++++ .../bank/services/iso20022.service.ts | 59 +- src/shared/services/process.service.ts | 2 + .../__tests__/bank-tx-frick.service.spec.ts | 130 ++++ .../bank-tx/services/bank-tx.service.ts | 80 ++- .../supporting/bank/bank/dto/bank.dto.ts | 1 + ...nk-frick-payout-tracking.migration.spec.ts | 80 +++ .../__tests__/fiat-output-job.service.spec.ts | 226 +++++++ .../fiat-output/fiat-output-job.service.ts | 184 +++++- .../fiat-output/fiat-output.entity.ts | 6 + 21 files changed, 2007 insertions(+), 19 deletions(-) create mode 100644 migration/1783944000000-AddBankFrickPayoutTracking.js create mode 100644 src/integration/bank/dto/frick.dto.ts create mode 100644 src/integration/bank/services/__tests__/frick.service.spec.ts create mode 100644 src/integration/bank/services/__tests__/iso20022.service.spec.ts create mode 100644 src/integration/bank/services/frick.service.ts create mode 100644 src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts create mode 100644 src/subdomains/supporting/fiat-output/__tests__/bank-frick-payout-tracking.migration.spec.ts diff --git a/.env.example b/.env.example index f62fa5f470..9de58db79e 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,13 @@ RAIFFEISEN_USER_ID= RAIFFEISEN_PASSPHRASE= RAIFFEISEN_IV= +FRICK_BASE_URL= +FRICK_API_KEY= +FRICK_PRIVATE_KEY= +FRICK_CUSTOMER= +FRICK_PAYOUT_ENABLED= +FRICK_APPROVE_WITHOUT_TAN= + YAPEAL_BASE_URL= YAPEAL_PARTNERSHIP_UID= YAPEAL_ADMIN_UID= @@ -309,4 +316,4 @@ REALUNIT_BANK_NAME= REQUEST_KNOWN_IPS= -CRON_JOB_DELAY= \ No newline at end of file +CRON_JOB_DELAY= diff --git a/infrastructure/bicep/api/dfx-api.bicep b/infrastructure/bicep/api/dfx-api.bicep index 784ce53c86..370465b03d 100644 --- a/infrastructure/bicep/api/dfx-api.bicep +++ b/infrastructure/bicep/api/dfx-api.bicep @@ -235,6 +235,16 @@ param olkyUser string @secure() param olkyPassword string +param frickBaseUrl string +@secure() +param frickApiKey string +@secure() +param frickPrivateKey string +@secure() +param frickCustomer string +param frickPayoutEnabled string +param frickApproveWithoutTan string + param letterUrl string param letterUser string @secure() @@ -1209,6 +1219,30 @@ 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_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..9015f4425e 100644 --- a/infrastructure/bicep/api/parameters/dev.json +++ b/infrastructure/bicep/api/parameters/dev.json @@ -434,6 +434,24 @@ "olkyPassword": { "value": "xxx" }, + "frickBaseUrl": { + "value": "https://olbtest.bankfrick.li/webapi/v2" + }, + "frickApiKey": { + "value": "xxx" + }, + "frickPrivateKey": { + "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..7d5e04a6df 100644 --- a/infrastructure/bicep/api/parameters/loc.json +++ b/infrastructure/bicep/api/parameters/loc.json @@ -434,6 +434,24 @@ "olkyPassword": { "value": "xxx" }, + "frickBaseUrl": { + "value": "https://olbtest.bankfrick.li/webapi/v2" + }, + "frickApiKey": { + "value": "xxx" + }, + "frickPrivateKey": { + "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..8545c3eac0 100644 --- a/infrastructure/bicep/api/parameters/prd.json +++ b/infrastructure/bicep/api/parameters/prd.json @@ -434,6 +434,24 @@ "olkyPassword": { "value": "xxx" }, + "frickBaseUrl": { + "value": "https://olb.bankfrick.li/webapi/v2" + }, + "frickApiKey": { + "value": "xxx" + }, + "frickPrivateKey": { + "value": "xxx" + }, + "frickCustomer": { + "value": "xxx" + }, + "frickPayoutEnabled": { + "value": "false" + }, + "frickApproveWithoutTan": { + "value": "false" + }, "letterUrl": { "value": "https://api.letterxpress.de/v1" }, diff --git a/migration/1783944000000-AddBankFrickPayoutTracking.js b/migration/1783944000000-AddBankFrickPayoutTracking.js new file mode 100644 index 0000000000..9595f8af40 --- /dev/null +++ b/migration/1783944000000-AddBankFrickPayoutTracking.js @@ -0,0 +1,34 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * The concrete Bank Frick account rows are deliberately not part of this migration: their IBANs + * are deployment secrets and were not supplied for this issue. DFX operations must insert those + * rows separately before enabling either account polling or the default-off payout rail. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddBankFrickPayoutTracking1783944000000 { + name = 'AddBankFrickPayoutTracking1783944000000'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderId" character varying(256)`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickTxId" character varying(256)`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickTxId"`); + await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickOrderId"`); + } +}; diff --git a/src/config/config.ts b/src/config/config.ts index 56ef49ed3c..e0a1e1cace 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1195,6 +1195,14 @@ export class Configuration { clientSecret: process.env.OLKY_CLIENT_SECRET, }, }, + frick: { + baseUrl: process.env.FRICK_BASE_URL, + apiKey: process.env.FRICK_API_KEY, + privateKey: process.env.FRICK_PRIVATE_KEY?.split('
').join('\n'), + customer: process.env.FRICK_CUSTOMER, + payoutEnabled: process.env.FRICK_PAYOUT_ENABLED === 'true', + approveWithoutTan: process.env.FRICK_APPROVE_WITHOUT_TAN === 'true', + }, raiffeisen: { credentials: { url: process.env.RAIFFEISEN_EBICS_URL, 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..3db70e70f1 --- /dev/null +++ b/src/integration/bank/dto/frick.dto.ts @@ -0,0 +1,119 @@ +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', +} + +export interface FrickPaymentAccount { + accountNumber?: string; + iban?: string; + name?: string; + address?: string; + postalcode?: string; + city?: string; + country?: string; + bic?: string; + creditInstitution?: 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 interface FrickApproveWithoutTanRequest { + customIds: string[]; +} 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..b7b0364937 --- /dev/null +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -0,0 +1,377 @@ +import { 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, + FrickPaymentState, + FrickPaymentType, + 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, + 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'); + }); + + 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('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('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('DFX-FO-42')).rejects.toThrow('payout 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'); + }); + + 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[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('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 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('approves by stable customId and never converts an order id', 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('DFX-FO-42'); + + 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('maps a signed camt.053 response completely into BankTx fields', async () => { + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(camt053Fixture()); + + const [transaction] = await service.getFrickTransactions(new Date('2026-07-01T00:00:00Z'), debtorIban); + + 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); + }); + + 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 { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, sequence })).toString( + 'base64url', + ); + return `${header}.${payload}.synthetic-signature`; + } + + function camt053Fixture(): string { + return ` + + + + + 12.34 + CRDT +
2026-07-02
+
2026-07-02
+ SYNTHETIC-REF-1 + + + SYNTHETIC-E2E-1 + + Synthetic Sender + ${creditorIban} + + TESTDEFF + Synthetic transfer + + +
+
+
+
`; + } +}); + +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..16abe75f69 --- /dev/null +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -0,0 +1,66 @@ +import { Iso20022Service } from '../iso20022.service'; + +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('throws on malformed entries in strict mode so a poller cannot advance past dropped data', () => { + const malformedEntry = { ...entry, CdtDbtInd: undefined }; + + expect(Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A')).toEqual([]); + expect(() => Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A', true)).toThrow( + 'Missing CdtDbtInd', + ); + }); + + 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'], + ])('rejects unsafe CAMT defaults in strict mode', (malformedEntry, expectedError) => { + expect(() => Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A', true)).toThrow( + expectedError, + ); + }); + + function statement(transactionEntry: object) { + return { BkToCstmrStmt: { Stmt: { Ntry: transactionEntry } } }; + } +}); diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts new file mode 100644 index 0000000000..f775be0da9 --- /dev/null +++ b/src/integration/bank/services/frick.service.ts @@ -0,0 +1,553 @@ +import { Injectable } from '@nestjs/common'; +import { 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, + FrickPaymentState, + FrickPaymentType, + FrickTransactionsResponse, +} from '../dto/frick.dto'; +import { CamtTransaction, Iso20022Service } from './iso20022.service'; + +type FrickResponseType = 'json' | 'text'; + +@Injectable() +export class BankFrickService { + private static readonly TOKEN_REFRESH_SKEW_MS = 30_000; + + private accessToken?: string; + private tokenExpiryMs = 0; + private authorizeInFlight?: Promise; + + constructor(private readonly http: HttpService) {} + + isAvailable(): boolean { + const { baseUrl, apiKey, privateKey, customer } = Config.bank.frick; + return !!(baseUrl && apiKey && privateKey && customer); + } + + async getBalances(): Promise { + this.assertAvailable(); + + const customer = this.validateCustomer(); + const response = await this.callApi(`accounts/${encodeURIComponent(customer)}`); + this.validateAccountsResponse(response); + 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, + fromDate: Util.isoDate(lastModificationTime), + toDate: Util.isoDate(new Date()), + }); + + 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 []; + + return Iso20022Service.parseCamt053Xml(statement, iban, true).map((tx) => this.parseTransaction(tx, iban)); + } + + 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 Error(`Bank Frick payment order ${customId} not found`); + return payment; + } + + async approvePaymentWithoutTan(customId: string): Promise { + this.assertAvailable(); + this.assertPayoutEnabled(); + if (!Config.bank.frick.approveWithoutTan) + throw new Error('Bank Frick approval without TAN is not explicitly enabled'); + this.validateString(customId, 'customId', 50, true); + + const request: FrickApproveWithoutTanRequest = { 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, + chargeAmount: 0, + currency: tx.currency, + instructedCurrency: tx.instructedCurrency ?? tx.currency, + txCurrency: tx.txCurrency ?? tx.currency, + chargeCurrency: tx.currency, + 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') { + 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 }), 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. The API also documents that a fromDate is required for reliable BOOKED lookups. + return this.getFilteredPaymentOrder( + new URLSearchParams({ customId, status: FrickPaymentState.BOOKED, fromDate: '1970-01-01' }), + customId, + ); + } + + private async getFilteredPaymentOrder( + params: URLSearchParams, + customId: string, + ): Promise { + const response = await this.callApi(`transactions?${params.toString()}`); + this.validateTransactionsResponse(response); + + if (response.moreResults) throw new Error(`Ambiguous Bank Frick payment lookup for ${customId}`); + if (response.transactions.some((payment) => 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 { + this.validateTransactionsResponse(response); + 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): 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) { + if ( + !payment || + typeof payment !== 'object' || + typeof payment.customId !== 'string' || + !Object.values(FrickPaymentType).includes(payment.type) || + !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 = + existing.customId === requested.customId && + 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, requested.creditor.creditInstitution) && + this.matchesSentString(existing.creditor.bic, requested.creditor.bic, (value) => + value.replace(/\s/g, '').toUpperCase(), + ); + + 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, + headers: { + Accept: accept, + 'Content-Type': body === undefined ? '*/*' : 'application/json', + Authorization: `Bearer ${token}`, + Signature: this.sign(bodyString), + algorithm: 'rsa-sha512', + }, + }); + } catch (error) { + 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, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Signature: this.sign(bodyString), + algorithm: 'rsa-sha512', + }, + }); + } catch (error) { + 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 payloadPart = token.split('.')[1]; + if (!payloadPart) throw new Error('missing payload'); + 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 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 published schema currently says seven digits while its own examples contain eight. + // Restrict the path segment to digits without enforcing that contradictory length at our boundary. + if (typeof customer !== 'string' || !/^\d{1,16}$/.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..743abdfa6a 100644 --- a/src/integration/bank/services/iso20022.service.ts +++ b/src/integration/bank/services/iso20022.service.ts @@ -215,19 +215,22 @@ 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): CamtTransaction[] { + const rawStatements = camt053?.BkToCstmrStmt?.Stmt; + if (!rawStatements) return []; + const statements = Array.isArray(rawStatements) ? rawStatements : [rawStatements]; const transactions: CamtTransaction[] = []; for (const stmt of statements) { - if (!stmt.Ntry || !Array.isArray(stmt.Ntry)) continue; + if (!stmt.Ntry) continue; + const entries = Array.isArray(stmt.Ntry) ? stmt.Ntry : [stmt.Ntry]; - for (const entry of stmt.Ntry) { + for (const entry of entries) { try { - transactions.push(Iso20022Service.parseCamt053JsonEntry(entry, accountIban)); - } catch { + transactions.push(Iso20022Service.parseCamt053JsonEntry(entry, accountIban, strict)); + } catch (error) { + if (strict) throw error; continue; } } @@ -236,20 +239,26 @@ export class Iso20022Service { return transactions; } - private static parseCamt053JsonEntry(entry: any, accountIban: string): CamtTransaction { + private static parseCamt053JsonEntry(entry: any, accountIban: string, strict = false): CamtTransaction { // amount and currency const amtObj = entry.Amt; const amount = parseFloat(amtObj?.Value || amtObj?.['#text'] || amtObj || '0'); const currency = 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; // dates const bookingDateStr = entry.BookgDt?.Dt; const valueDateStr = entry.ValDt?.Dt; + 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; @@ -301,7 +310,7 @@ export class Iso20022Service { txDtls.Refs?.TxId || entry.AcctSvcrRef || entry.NtryRef || - Util.createUniqueId(accountIban); + this.createStableCamtReference(accountIban, entry); // end-to-end ID const endToEndId = txDtls.Refs?.EndToEndId || ''; @@ -340,7 +349,7 @@ export class Iso20022Service { }; } - static parseCamt053Xml(xmlData: string, accountIban: string): CamtTransaction[] { + static parseCamt053Xml(xmlData: string, accountIban: string, strict = false): CamtTransaction[] { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', @@ -348,7 +357,35 @@ export class Iso20022Service { }); const jsonData = parser.parse(xmlData); - return this.parseCamt053Json(jsonData.Document || jsonData, accountIban); + return this.parseCamt053Json(jsonData.Document || jsonData, accountIban, strict); + } + + 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 assertValidCamtDate(value: unknown, field: string): void { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T|$)/.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`); } // --- PAIN.001 GENERATION --- // 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/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..7af911a59c --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts @@ -0,0 +1,130 @@ +import { ConflictException } from '@nestjs/common'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; +import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; +import { BankTxService } from '../bank-tx.service'; + +describe('BankTxService Bank Frick polling', () => { + let service: BankTxService; + let frickService: jest.Mocked>; + let bankService: jest.Mocked>; + let settingService: jest.Mocked>; + let specialAccountService: jest.Mocked>; + let logger: { warn: jest.Mock; error: jest.Mock }; + + beforeEach(() => { + frickService = { isAvailable: jest.fn().mockReturnValue(true), getFrickTransactions: jest.fn() }; + bankService = { getBanksByName: jest.fn() }; + settingService = { + get: jest.fn().mockResolvedValue(new Date(0).toISOString()), + set: jest.fn().mockResolvedValue(undefined), + }; + specialAccountService = { getMultiAccounts: jest.fn().mockResolvedValue([]) }; + logger = { warn: jest.fn(), error: jest.fn() }; + + service = Object.create(BankTxService.prototype); + Object.assign(service, { + frickService, + bankService, + settingService, + specialAccountService, + logger, + frickUnavailableWarningLogged: false, + }); + }); + + 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([{ accountServiceRef: 'FRICK-EUR-1' }]) + .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-CHF-1' }]); + Object.assign(service, { create: jest.fn().mockResolvedValue({}) }); + + await service['checkFrickTransactions'](); + + 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.set.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 fetch failure', async () => { + bankService.getBanksByName.mockResolvedValue([ + bank(101, 'SYNTHETIC-FRICK-EUR', true), + bank(102, 'SYNTHETIC-FRICK-CHF', true), + ]); + frickService.getFrickTransactions.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('synthetic outage')); + Object.assign(service, { create: jest.fn() }); + + await service['checkFrickTransactions'](); + + expect(settingService.set).not.toHaveBeenCalled(); + expect(logger.error).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([{ accountServiceRef: 'SYNTHETIC-REF' }]); + const create = jest + .fn() + .mockRejectedValueOnce(new ConflictException('duplicate')) + .mockRejectedValueOnce(new Error('synthetic persistence failure')); + Object.assign(service, { create }); + + await service['checkFrickTransactions'](); + + expect(settingService.set).toHaveBeenCalledTimes(1); + expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(String)); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + it('warns only once while the integration is unconfigured', async () => { + frickService.isAvailable.mockReturnValue(false); + + await service['checkFrickTransactions'](); + await service['checkFrickTransactions'](); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(bankService.getBanksByName).not.toHaveBeenCalled(); + }); + + it('still checks Bank Frick when the Olkypay poll fails', async () => { + const checkTransactions = jest.fn().mockRejectedValue(new Error('synthetic Olkypay outage')); + const checkFrickTransactions = jest.fn().mockResolvedValue(undefined); + Object.assign(service, { + checkTransactions, + checkFrickTransactions, + assignTransactions: jest.fn().mockResolvedValue(undefined), + fillBankTx: jest.fn().mockResolvedValue(undefined), + }); + + await service.checkBankTx(); + + expect(checkFrickTransactions).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith('Failed to check Olkypay transactions:', expect.any(Error)); + }); + + function bank(id: number, iban: string, receive: boolean) { + return { id, iban, receive } as any; + } +}); 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..ba033de054 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 @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Observable, Subject } from 'rxjs'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -39,6 +40,7 @@ import { Not, } from 'typeorm'; import { OlkypayService } from '../../../../../integration/bank/services/olkypay.service'; +import { Bank } from '../../../bank/bank/bank.entity'; import { BankService } from '../../../bank/bank/bank.service'; import { VirtualIbanService } from '../../../bank/virtual-iban/virtual-iban.service'; import { TransactionSourceType, TransactionTypeInternal } from '../../../payment/entities/transaction.entity'; @@ -90,6 +92,7 @@ export class BankTxService implements OnModuleInit { private readonly bankBalanceSubject: Subject = new Subject(); private olkyUnavailableWarningLogged = false; + private frickUnavailableWarningLogged = false; constructor( private readonly bankTxRepo: BankTxRepository, @@ -99,6 +102,7 @@ export class BankTxService implements OnModuleInit { private readonly notificationService: NotificationService, private readonly settingService: SettingService, private readonly olkyService: OlkypayService, + private readonly frickService: BankFrickService, private readonly bankTxReturnService: BankTxReturnService, private readonly bankTxRepeatService: BankTxRepeatService, private readonly buyService: BuyService, @@ -123,7 +127,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.checkFrickTransactions(); + } catch (error) { + this.logger.error('Failed to check Bank Frick transactions:', error); + } await this.assignTransactions(); await this.fillBankTx(); } @@ -199,6 +212,71 @@ export class BankTxService implements OnModuleInit { if (olkyTransactions.length > 0) await this.settingService.set(settingKeyOlky, newModificationTime); } + private async checkFrickTransactions(): Promise { + if (!this.frickService.isAvailable()) { + this.warnFrickUnavailableOnce('Bank Frick service not configured - skipping transaction import'); + return; + } + + let banks: Bank[]; + try { + banks = await this.bankService + .getBanksByName(IbanBankName.FRICK) + .then((rows) => rows.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 newModificationTime = new Date().toISOString(); + + try { + const transactions = await this.frickService.getFrickTransactions(lastModificationTime, bank.iban); + let fullyProcessed = true; + + for (const transaction of transactions) { + try { + await this.create(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); + } + } + } + + if (transactions.length > 0 && fullyProcessed) await this.settingService.set(settingKey, newModificationTime); + } 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; + } + private async assignTransactions(): Promise { const unassignedBankTx = await this.bankTxRepo.find({ where: [ 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..262de3e1ad --- /dev/null +++ b/src/subdomains/supporting/fiat-output/__tests__/bank-frick-payout-tracking.migration.spec.ts @@ -0,0 +1,80 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +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; +}; + +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)`); + }); + + 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 both 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(); + await queryRunner.commitTransaction(); + + expect(added).toEqual([ + { + column_name: 'frickOrderId', + data_type: 'character varying', + character_maximum_length: 256, + is_nullable: 'YES', + }, + { column_name: 'frickTxId', data_type: 'character varying', character_maximum_length: 256, is_nullable: 'YES' }, + ]); + + await queryRunner.startTransaction(); + await migration.down(queryRunner); + const removed = await getTrackingColumns(); + await queryRunner.commitTransaction(); + + expect(removed).toEqual([]); + }); + + 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', 'frickTxId') + ORDER BY column_name`, + [SCHEMA], + ); + } +}); 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..4343535668 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 { Config } from 'src/config/config'; +import { FrickPaymentState, FrickPaymentType } from 'src/integration/bank/dto/frick.dto'; +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 { ScryptService } from 'src/integration/exchange/services/scrypt.service'; @@ -22,6 +25,7 @@ import { BankTxReturnService } from '../../bank-tx/bank-tx-return/bank-tx-return import { createCustomBankTx } from '../../bank-tx/bank-tx/__mocks__/bank-tx.entity.mock'; import { createCustomBank, 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'; @@ -47,6 +51,7 @@ describe('FiatOutputJobService', () => { let bankTxRepeatService: BankTxRepeatService; let yapealService: YapealService; let olkypayService: OlkypayService; + let frickService: BankFrickService; let virtualIbanService: VirtualIbanService; let scryptService: ScryptService; @@ -62,6 +67,7 @@ describe('FiatOutputJobService', () => { bankTxRepeatService = createMock(); yapealService = createMock(); olkypayService = createMock(); + frickService = createMock(); virtualIbanService = createMock(); scryptService = createMock(); jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); @@ -86,6 +92,7 @@ describe('FiatOutputJobService', () => { { provide: BankTxRepeatService, useValue: bankTxRepeatService }, { provide: YapealService, useValue: yapealService }, { provide: OlkypayService, useValue: olkypayService }, + { provide: BankFrickService, useValue: frickService }, { provide: VirtualIbanService, useValue: virtualIbanService }, { provide: ScryptService, useValue: scryptService }, @@ -276,6 +283,81 @@ 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('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'), + frickTxId: '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) }); + }); }); describe('createBatches', () => { @@ -447,5 +529,149 @@ describe('FiatOutputJobService', () => { 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, + frickTxId: 'DFX-FO-4', + remittanceInfo: 'Synthetic Frick payout', + isComplete: false, + isReadyDate: new Date('2026-07-01'), + }); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); + jest.spyOn(bankTxService, 'getBankTxByRemittanceInfo').mockResolvedValue(bankTx); + + await service['searchOutgoingBankTx'](); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 4, + expect.objectContaining({ + isComplete: true, + isApprovedDate: bankTx.created, + isConfirmedDate: bankTx.created, + }), + ); + }); + }); + + describe('Bank Frick payouts', () => { + 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' }, + }; + + 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['transmitFrickPayments'](); + + 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['transmitFrickPayments'](); + + expect(fiatOutputRepo.find).not.toHaveBeenCalled(); + expect(frickService.createPaymentOrder).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['transmitFrickPayments'](); + + expect(frickService.createPaymentOrder).toHaveBeenCalledWith( + expect.objectContaining({ customId: 'DFX-FO-42', reference: 'Synthetic payout' }), + ); + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 1, + 42, + expect.objectContaining({ + frickTxId: 'DFX-FO-42', + frickOrderId: '4242', + isTransmittedDate: expect.any(Date), + }), + ); + expect((fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + (frickService.approvePaymentWithoutTan as jest.Mock).mock.invocationCallOrder[0], + ); + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 2, + 42, + expect.objectContaining({ isApprovedDate: expect.any(Date) }), + ); + }); + + it('polls existing orders while payout creation is disabled and leaves PREPARED manual', 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.getPaymentOrder).toHaveBeenCalledWith('DFX-FO-42'); + expect(frickService.approvePaymentWithoutTan).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + info: 'FRICK order PREPARED: manual approval required', + }); + }); + + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { info: 'FRICK order REJECTED' }); + }); }); }); 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..51699c924c 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -3,6 +3,8 @@ import { CronExpression } from '@nestjs/schedule'; import { isLiechtensteinBankHoliday } from 'src/config/bank-holiday.config'; import { Config } from 'src/config/config'; import { OlkypayOrderStatus } from 'src/integration/bank/dto/olkypay.dto'; +import { FrickPaymentCharge, FrickPaymentOrder, FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; +import { BankFrickService } from 'src/integration/bank/services/frick.service'; import { Pain001Payment } from 'src/integration/bank/services/iso20022.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; @@ -31,11 +33,19 @@ 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 { FiatOutput, FiatOutputType } from './fiat-output.entity'; +import { FiatOutput, FiatOutputType, TransactionCharge } from './fiat-output.entity'; import { FiatOutputRepository } from './fiat-output.repository'; @Injectable() export class FiatOutputJobService { + private static readonly FRICK_TERMINAL_STATES = [ + FrickPaymentState.REJECTED, + FrickPaymentState.EXPIRED, + FrickPaymentState.DELETED, + FrickPaymentState.DELETION_REQUESTED, + FrickPaymentState.ERROR, + ]; + private readonly logger = new DfxLogger(FiatOutputJobService); constructor( @@ -52,6 +62,7 @@ export class FiatOutputJobService { private readonly bankTxRepeatService: BankTxRepeatService, private readonly yapealService: YapealService, private readonly olkypayService: OlkypayService, + private readonly frickService: BankFrickService, private readonly virtualIbanService: VirtualIbanService, private readonly scryptService: ScryptService, ) {} @@ -64,6 +75,7 @@ export class FiatOutputJobService { await this.checkTransmission(); await this.transmitYapealPayments(); await this.transmitOlkypayPayments(); + await this.transmitFrickPayments(); await this.searchOutgoingBankTx(); } @@ -93,6 +105,39 @@ export class FiatOutputJobService { } } + @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT }) + async checkFrickOrderStatus(): Promise { + if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_STATUS_CHECK)) return; + if (!this.frickService.isAvailable()) return; + + const terminalInfos = FiatOutputJobService.FRICK_TERMINAL_STATES.map((state) => `FRICK order ${state}`); + const statusRequest: FindOptionsWhere = { frickTxId: Not(IsNull()), isComplete: false }; + const entities = await this.fiatOutputRepo.find({ + where: [ + { ...statusRequest, info: IsNull() }, + { ...statusRequest, info: Not(In(terminalInfos)) }, + ], + }); + + for (const entity of entities) { + try { + let order = await this.frickService.getPaymentOrder(entity.frickTxId); + + if (order.state === FrickPaymentState.PREPARED && this.isFrickAutomaticApprovalEnabled()) { + order = await this.frickService.approvePaymentWithoutTan(entity.frickTxId); + } + + await this.fiatOutputRepo.update(entity.id, this.getFrickStatusUpdate(order, entity)); + } 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, { + info: `FRICK status error: ${message}`.substring(0, 256), + }); + } + } + } + @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT, timeout: 1800 }) async generateReports() { const entities = await this.fiatOutputRepo.find({ @@ -232,6 +277,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.isFrickTerminalStateInfo(tx.info); case IbanBankName.OLKY: return !tx.bankTx || tx.bankTx.created > Util.minutesBefore(5); default: @@ -266,8 +315,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; @@ -318,7 +368,7 @@ export class FiatOutputJobService { isReadyDate: Not(IsNull()), batchId: IsNull(), isComplete: false, - bank: { name: Not(In([IbanBankName.YAPEAL, IbanBankName.OLKY])) }, + bank: { name: Not(In([IbanBankName.YAPEAL, IbanBankName.OLKY, IbanBankName.FRICK])) }, }); let currentBatch: FiatOutput[] = []; @@ -501,6 +551,124 @@ export class FiatOutputJobService { } } + private async transmitFrickPayments(): Promise { + if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_TRANSMISSION)) return; + if (!Config.bank.frick.payoutEnabled || !this.frickService.isAvailable()) return; + + const entities = await this.fiatOutputRepo.find({ + where: { + isReadyDate: Not(IsNull()), + isTransmittedDate: IsNull(), + frickTxId: IsNull(), + isComplete: false, + bank: { name: IbanBankName.FRICK }, + }, + }); + + for (const entity of entities) { + try { + const customId = `DFX-FO-${entity.id}`; + const remittanceInfo = entity.remittanceInfo ?? `DFX Payout ${entity.id}`; + const address = [entity.address, entity.houseNumber].filter(Boolean).join(' ') || undefined; + const charge = entity.charge + ? { + [TransactionCharge.BEN]: FrickPaymentCharge.BENEFICIARY, + [TransactionCharge.OUR]: FrickPaymentCharge.OUR, + [TransactionCharge.SHA]: FrickPaymentCharge.SHARED, + }[entity.charge] + : undefined; + + const order = await this.frickService.createPaymentOrder({ + customId, + amount: entity.amount, + currency: entity.currency as 'CHF' | 'EUR', + instant: entity.isInstant, + reference: remittanceInfo, + charge, + debtorIban: entity.accountIban, + creditor: { + name: entity.name, + iban: entity.iban, + bic: entity.bic, + address, + postalcode: entity.zip, + city: entity.city, + country: entity.country, + creditInstitution: entity.creditInstitution, + }, + }); + const safeOrderId = this.frickService.getSafeOrderId(order); + + // Persist 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. + await this.fiatOutputRepo.update(entity.id, { + frickTxId: customId, + ...(safeOrderId && { frickOrderId: safeOrderId }), + remittanceInfo, + isTransmittedDate: new Date(), + ...(entity.info?.startsWith('FRICK') && { info: null }), + ...this.getFrickStatusUpdate(order, entity), + }); + + if (this.isFrickAutomaticApprovalEnabled() && order.state === FrickPaymentState.PREPARED) { + const approvedOrder = await this.frickService.approvePaymentWithoutTan(customId); + await this.fiatOutputRepo.update(entity.id, this.getFrickStatusUpdate(approvedOrder, entity)); + } + } 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, { + info: `FRICK error: ${message}`.substring(0, 256), + }); + } + } + } + + private getFrickStatusUpdate(order: FrickPaymentOrder, entity: FiatOutput): Partial { + const now = new Date(); + + switch (order.state) { + case FrickPaymentState.IN_PROGRESS: + case FrickPaymentState.EXECUTED: + return { + ...(!entity.isApprovedDate && { isApprovedDate: now }), + ...(entity.info?.startsWith('FRICK') && { info: null }), + }; + + case FrickPaymentState.BOOKED: + return { + ...(!entity.isApprovedDate && { isApprovedDate: now }), + ...(!entity.isConfirmedDate && { isConfirmedDate: now }), + ...(entity.info?.startsWith('FRICK') && { info: null }), + }; + + case FrickPaymentState.PREPARED: + return this.isFrickAutomaticApprovalEnabled() ? {} : { info: 'FRICK order PREPARED: manual approval required' }; + + case FrickPaymentState.REJECTED: + case FrickPaymentState.EXPIRED: + case FrickPaymentState.DELETED: + case FrickPaymentState.DELETION_REQUESTED: + case FrickPaymentState.ERROR: + return { info: `FRICK order ${order.state}`.substring(0, 256) }; + + default: + throw new Error('Unsupported Bank Frick payment state'); + } + } + + private isFrickAutomaticApprovalEnabled(): boolean { + return ( + Config.bank.frick.payoutEnabled && + Config.bank.frick.approveWithoutTan && + !DisabledProcess(Process.FIAT_OUTPUT_FRICK_TRANSMISSION) + ); + } + + private isFrickTerminalStateInfo(info: string | undefined): boolean { + return FiatOutputJobService.FRICK_TERMINAL_STATES.some((state) => info === `FRICK order ${state}`); + } + private async searchOutgoingBankTx(): Promise { if (DisabledProcess(Process.FIAT_OUTPUT_BANK_TX_SEARCH)) return; @@ -524,10 +692,16 @@ export class FiatOutputJobService { isComplete: true, }; - if ((entity.yapealMsgId || entity.olkyOrderId) && !entity.isConfirmedDate) { + if ( + (entity.yapealMsgId || entity.olkyOrderId || entity.frickOrderId || entity.frickTxId) && + !entity.isConfirmedDate + ) { updateData.isConfirmedDate = bankTx.created; } + if ((entity.frickOrderId || entity.frickTxId) && !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..ac07a9ad37 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts @@ -152,6 +152,12 @@ export class FiatOutput extends IEntity { @Column({ nullable: true }) olkyOrderId?: string; + @Column({ length: 256, nullable: true }) + frickOrderId?: string; + + @Column({ length: 256, nullable: true }) + frickTxId?: string; + // --- ENTITY METHODS --- // setBatch(batchId?: number, batchAmount?: number): UpdateResult { From f5c02706cf41e1cba641999eb389430b289d76cc Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 14:32:00 +0200 Subject: [PATCH 02/19] fix(bank): harden Bank Frick status polling --- ...783944000000-AddBankFrickPayoutTracking.js | 4 +- .../services/__tests__/frick.service.spec.ts | 11 +++ .../__tests__/iso20022.service.spec.ts | 13 +++ .../bank/services/iso20022.service.ts | 23 +++-- ...nk-frick-payout-tracking.migration.spec.ts | 4 +- .../__tests__/fiat-output-job.service.spec.ts | 85 +++++++++++++++++++ .../fiat-output/fiat-output-job.service.ts | 28 +++--- .../fiat-output/fiat-output.entity.ts | 4 +- 8 files changed, 148 insertions(+), 24 deletions(-) diff --git a/migration/1783944000000-AddBankFrickPayoutTracking.js b/migration/1783944000000-AddBankFrickPayoutTracking.js index 9595f8af40..60fc631e56 100644 --- a/migration/1783944000000-AddBankFrickPayoutTracking.js +++ b/migration/1783944000000-AddBankFrickPayoutTracking.js @@ -19,8 +19,8 @@ module.exports = class AddBankFrickPayoutTracking1783944000000 { */ async up(queryRunner) { await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); - await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderId" character varying(256)`); - await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickTxId" character varying(256)`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderId" character varying(255)`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickTxId" character varying(255)`); } /** diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts index b7b0364937..4f38af4fff 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -204,6 +204,17 @@ describe('BankFrickService', () => { await expect(service.createPaymentOrder(inputWithBic)).rejects.toThrow('requires a valid charge'); }); + 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('rejects an existing customId when any sent payment detail differs', async () => { Config.bank.frick.payoutEnabled = true; const input = { diff --git a/src/integration/bank/services/__tests__/iso20022.service.spec.ts b/src/integration/bank/services/__tests__/iso20022.service.spec.ts index 16abe75f69..e0af207f96 100644 --- a/src/integration/bank/services/__tests__/iso20022.service.spec.ts +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -40,6 +40,19 @@ describe('Iso20022Service camt.053 references', () => { 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('throws on malformed entries in strict mode so a poller cannot advance past dropped data', () => { const malformedEntry = { ...entry, CdtDbtInd: undefined }; diff --git a/src/integration/bank/services/iso20022.service.ts b/src/integration/bank/services/iso20022.service.ts index 743abdfa6a..75eec99764 100644 --- a/src/integration/bank/services/iso20022.service.ts +++ b/src/integration/bank/services/iso20022.service.ts @@ -221,6 +221,7 @@ export class Iso20022Service { const statements = Array.isArray(rawStatements) ? rawStatements : [rawStatements]; const transactions: CamtTransaction[] = []; + const fallbackOccurrences = new Map(); for (const stmt of statements) { if (!stmt.Ntry) continue; @@ -228,7 +229,14 @@ export class Iso20022Service { for (const entry of entries) { try { - transactions.push(Iso20022Service.parseCamt053JsonEntry(entry, accountIban, strict)); + const stableReference = this.createStableCamtReference(accountIban, entry); + const occurrence = fallbackOccurrences.get(stableReference) ?? 0; + fallbackOccurrences.set(stableReference, occurrence + 1); + // 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}`; + + transactions.push(Iso20022Service.parseCamt053JsonEntry(entry, accountIban, strict, fallbackReference)); } catch (error) { if (strict) throw error; continue; @@ -239,7 +247,12 @@ export class Iso20022Service { return transactions; } - private static parseCamt053JsonEntry(entry: any, accountIban: string, strict = false): 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'); @@ -306,11 +319,7 @@ export class Iso20022Service { // 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 || - this.createStableCamtReference(accountIban, entry); + txDtls.Refs?.AcctSvcrRef || txDtls.Refs?.TxId || entry.AcctSvcrRef || entry.NtryRef || fallbackReference; // end-to-end ID const endToEndId = txDtls.Refs?.EndToEndId || ''; 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 index 262de3e1ad..2555126f82 100644 --- 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 @@ -52,10 +52,10 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { { column_name: 'frickOrderId', data_type: 'character varying', - character_maximum_length: 256, + character_maximum_length: 255, is_nullable: 'YES', }, - { column_name: 'frickTxId', data_type: 'character varying', character_maximum_length: 256, is_nullable: 'YES' }, + { column_name: 'frickTxId', data_type: 'character varying', character_maximum_length: 255, is_nullable: 'YES' }, ]); await queryRunner.startTransaction(); 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 4343535668..3d59da3762 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 @@ -656,6 +656,91 @@ describe('FiatOutputJobService', () => { }); }); + it('polls an already-approved IN_PROGRESS order without issuing an empty update', 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, + frickTxId: 'DFX-FO-42', + isApprovedDate: new Date('2026-07-01'), + isComplete: false, + info: undefined, + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(frickService.getPaymentOrder).toHaveBeenCalledWith('DFX-FO-42'); + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + }); + + 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, + frickTxId: 'DFX-FO-42', + isComplete: false, + info: 'Manual operations note', + }), + ]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + }); + + 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['transmitFrickPayments'](); + + expect(frickService.createPaymentOrder).toHaveBeenCalledWith( + expect.objectContaining({ creditor: expect.objectContaining({ address: undefined }) }), + ); + }); + + 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['transmitFrickPayments'](); + + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + }); + it('does not recreate a rejected order and records the terminal state', async () => { Config.bank.frick.payoutEnabled = true; Config.bank.frick.approveWithoutTan = false; 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 51699c924c..1c5558af55 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -127,13 +127,16 @@ export class FiatOutputJobService { order = await this.frickService.approvePaymentWithoutTan(entity.frickTxId); } - await this.fiatOutputRepo.update(entity.id, this.getFrickStatusUpdate(order, entity)); + const updateData = 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, { - info: `FRICK status error: ${message}`.substring(0, 256), - }); + if (!entity.info || entity.info.startsWith('FRICK')) { + const message = error instanceof Error ? error.message : 'unknown error'; + await this.fiatOutputRepo.update(entity.id, { + info: `FRICK status error: ${message}`.substring(0, 256), + }); + } } } } @@ -569,7 +572,7 @@ export class FiatOutputJobService { try { const customId = `DFX-FO-${entity.id}`; const remittanceInfo = entity.remittanceInfo ?? `DFX Payout ${entity.id}`; - const address = [entity.address, entity.houseNumber].filter(Boolean).join(' ') || undefined; + const address = entity.address ? [entity.address, entity.houseNumber].filter(Boolean).join(' ') : undefined; const charge = entity.charge ? { [TransactionCharge.BEN]: FrickPaymentCharge.BENEFICIARY, @@ -612,14 +615,17 @@ export class FiatOutputJobService { if (this.isFrickAutomaticApprovalEnabled() && order.state === FrickPaymentState.PREPARED) { const approvedOrder = await this.frickService.approvePaymentWithoutTan(customId); - await this.fiatOutputRepo.update(entity.id, this.getFrickStatusUpdate(approvedOrder, entity)); + 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, { - info: `FRICK error: ${message}`.substring(0, 256), - }); + if (!entity.info || entity.info.startsWith('FRICK')) { + const message = error instanceof Error ? error.message : 'unknown error'; + await this.fiatOutputRepo.update(entity.id, { + info: `FRICK error: ${message}`.substring(0, 256), + }); + } } } } diff --git a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts index ac07a9ad37..1ee26f60c2 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts @@ -152,10 +152,10 @@ export class FiatOutput extends IEntity { @Column({ nullable: true }) olkyOrderId?: string; - @Column({ length: 256, nullable: true }) + @Column({ nullable: true }) frickOrderId?: string; - @Column({ length: 256, nullable: true }) + @Column({ nullable: true }) frickTxId?: string; // --- ENTITY METHODS --- // From d532ffbb5f1aa662db9103d8fd9283ce20ea7ff9 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 15:47:51 +0200 Subject: [PATCH 03/19] test(bank): enforce Bank Frick coverage --- .github/workflows/api-pr.yaml | 4 +- package.json | 14 + .../__tests__/bank-frick-config.spec.ts | 15 + .../services/__tests__/frick.service.spec.ts | 314 +++++++++++++++++- .../__tests__/iso20022.service.spec.ts | 1 + .../__tests__/bank-tx-frick.service.spec.ts | 45 +++ .../__tests__/fiat-output-job.service.spec.ts | 183 +++++++++- .../fiat-output/fiat-output-job.service.ts | 17 +- 8 files changed, 578 insertions(+), 15 deletions(-) create mode 100644 src/config/__tests__/bank-frick-config.spec.ts diff --git a/.github/workflows/api-pr.yaml b/.github/workflows/api-pr.yaml index 1952d72143..84f8fbcbfa 100644 --- a/.github/workflows/api-pr.yaml +++ b/.github/workflows/api-pr.yaml @@ -59,8 +59,8 @@ jobs: - name: Build code run: npm run build - - name: Run tests - run: npm run test + - name: Run tests with coverage gates + run: npm run test:cov env: MIGRATION_TEST_PG: postgres://postgres:postgres@localhost:5432/postgres diff --git a/package.json b/package.json index bbe145ee8b..fdf9b1e192 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,20 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", + "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 + } + }, "testEnvironment": "node", "forceExit": true }, 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..77cf61708a --- /dev/null +++ b/src/config/__tests__/bank-frick-config.spec.ts @@ -0,0 +1,15 @@ +import { GetConfig } from '../config'; + +describe('Bank Frick config', () => { + it('restores PEM line breaks in the private key', () => { + const previousPrivateKey = process.env.FRICK_PRIVATE_KEY; + process.env.FRICK_PRIVATE_KEY = 'synthetic-line-one
synthetic-line-two'; + + try { + expect(GetConfig().bank.frick.privateKey).toBe('synthetic-line-one\nsynthetic-line-two'); + } finally { + if (previousPrivateKey === undefined) delete process.env.FRICK_PRIVATE_KEY; + else process.env.FRICK_PRIVATE_KEY = previousPrivateKey; + } + }); +}); diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts index 4f38af4fff..65a1d149ea 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -131,12 +131,30 @@ describe('BankFrickService', () => { expect(http.request).not.toHaveBeenCalled(); }); + it.each(['baseUrl', 'apiKey', 'privateKey', 'customer'] as const)( + 'reports the integration unavailable when %s is missing', + (field) => { + Config.bank.frick[field] = undefined; + + expect(service.isAvailable()).toBe(false); + }, + ); + 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('DFX-FO-42')).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('DFX-FO-42')).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(); @@ -193,6 +211,33 @@ describe('BankFrickService', () => { 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 }; @@ -215,6 +260,68 @@ describe('BankFrickService', () => { 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 = { @@ -235,6 +342,49 @@ describe('BankFrickService', () => { 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: ' test de ff ', + address: ' Synthetic Street 42 ', + postalcode: ' 8000 ', + city: ' Zurich ', + country: ' CH ', + creditInstitution: ' 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('approves by stable customId and never converts an order id', async () => { Config.bank.frick.payoutEnabled = true; Config.bank.frick.approveWithoutTan = true; @@ -249,6 +399,160 @@ describe('BankFrickService', () => { 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('payment order DFX-FO-42 not found'); + }); + + 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)).toThrow( + 'Invalid Bank Frick transactions response', + ); + expect(() => + service['validateTransactionsResponse'](transactionsResponse([{ ...paymentOrder(), state: 'UNKNOWN' as never }])), + ).toThrow('Invalid Bank Frick payment order response'); + expect(() => + service['validateTransactionsResponse'](transactionsResponse([paymentOrder({ orderId: -1 })])), + ).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([]); + }); + + it('maps debit CAMT entries as debit transactions', async () => { + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(camt053Fixture().replace('CRDT', 'DBIT')); + + const [transaction] = await service.getFrickTransactions(new Date('2026-07-01'), debtorIban); + + expect(transaction.creditDebitIndicator).toBe(BankTxIndicator.DEBIT); + }); + + 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'], + ['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('maps a signed camt.053 response completely into BankTx fields', async () => { http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(camt053Fixture()); @@ -339,11 +643,13 @@ describe('BankFrickService', () => { } 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 payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, sequence })).toString( - 'base64url', - ); - return `${header}.${payload}.synthetic-signature`; + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${header}.${encodedPayload}.synthetic-signature`; } function camt053Fixture(): string { diff --git a/src/integration/bank/services/__tests__/iso20022.service.spec.ts b/src/integration/bank/services/__tests__/iso20022.service.spec.ts index e0af207f96..f3da377b2c 100644 --- a/src/integration/bank/services/__tests__/iso20022.service.spec.ts +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -67,6 +67,7 @@ describe('Iso20022Service camt.053 references', () => { [{ ...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 unsafe CAMT defaults in strict mode', (malformedEntry, expectedError) => { expect(() => Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A', true)).toThrow( expectedError, 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 index 7af911a59c..653617310c 100644 --- 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 @@ -108,6 +108,51 @@ describe('BankTxService Bank Frick polling', () => { 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')); + + await service['checkFrickTransactions'](); + + expect(logger.error).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)]); + + await service['checkFrickTransactions'](); + + expect(logger.warn).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')); + + await service['checkFrickTransactions'](); + + expect(logger.error).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)]); + + await service['checkFrickTransactions'](); + + expect(logger.error).toHaveBeenCalledWith('Failed to import Bank Frick transactions: invalid bank row id'); + expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); + expect(settingService.set).not.toHaveBeenCalled(); + }); + it('still checks Bank Frick when the Olkypay poll fails', async () => { const checkTransactions = jest.fn().mockRejectedValue(new Error('synthetic Olkypay outage')); const checkFrickTransactions = jest.fn().mockResolvedValue(undefined); 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 3d59da3762..411e1c6c94 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,7 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { Config } from 'src/config/config'; -import { FrickPaymentState, FrickPaymentType } from 'src/integration/bank/dto/frick.dto'; +import { FrickPaymentCharge, FrickPaymentState, FrickPaymentType } from 'src/integration/bank/dto/frick.dto'; 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'; @@ -34,7 +34,7 @@ import { createCustomCryptoInput } from '../../payin/entities/__mocks__/crypto-i import { createCustomFiatOutput } from '../__mocks__/fiat-output.entity.mock'; import { Ep2ReportService } from '../ep2-report.service'; import { FiatOutputJobService } from '../fiat-output-job.service'; -import { FiatOutputType } from '../fiat-output.entity'; +import { FiatOutputType, TransactionCharge } from '../fiat-output.entity'; import { FiatOutputRepository } from '../fiat-output.repository'; describe('FiatOutputJobService', () => { @@ -447,6 +447,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', () => { @@ -591,6 +615,26 @@ describe('FiatOutputJobService', () => { expect(frickService.createPaymentOrder).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(); + }); + + 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; @@ -638,6 +682,35 @@ describe('FiatOutputJobService', () => { ); }); + 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['transmitFrickPayments'](); + + expect(frickService.createPaymentOrder).toHaveBeenCalledWith(expect.objectContaining({ charge: expectedCharge })); + }); + it('polls existing orders while payout creation is disabled and leaves PREPARED manual', async () => { Config.bank.frick.payoutEnabled = false; Config.bank.frick.approveWithoutTan = false; @@ -656,6 +729,59 @@ describe('FiatOutputJobService', () => { }); }); + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + isApprovedDate: expect.any(Date), + ...(confirms && { isConfirmedDate: expect.any(Date) }), + }), + ); + if (!confirms) + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith( + 42, + expect.objectContaining({ isConfirmedDate: expect.any(Date) }), + ); + }); + it('polls an already-approved IN_PROGRESS order without issuing an empty update', async () => { Config.bank.frick.payoutEnabled = false; Config.bank.frick.approveWithoutTan = false; @@ -697,6 +823,20 @@ describe('FiatOutputJobService', () => { expect(fiatOutputRepo.update).not.toHaveBeenCalled(); }); + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + info: 'FRICK status error: synthetic status failure', + }); + }); + it('does not use a lone house number as the creditor address', async () => { Config.bank.frick.payoutEnabled = true; Config.bank.frick.approveWithoutTan = false; @@ -741,6 +881,21 @@ describe('FiatOutputJobService', () => { expect(fiatOutputRepo.update).not.toHaveBeenCalled(); }); + 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['transmitFrickPayments'](); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + info: 'FRICK error: synthetic transmission failure', + }); + }); + it('does not recreate a rejected order and records the terminal state', async () => { Config.bank.frick.payoutEnabled = true; Config.bank.frick.approveWithoutTan = false; @@ -758,5 +913,29 @@ describe('FiatOutputJobService', () => { expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { info: 'FRICK order REJECTED' }); }); + + it.each([ + FrickPaymentState.REJECTED, + FrickPaymentState.EXPIRED, + FrickPaymentState.DELETED, + FrickPaymentState.DELETION_REQUESTED, + FrickPaymentState.ERROR, + ])('maps terminal state %s to an operations status', (state) => { + expect( + service['getFrickStatusUpdate']( + { ...order, state }, + createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42' }), + ), + ).toEqual({ info: `FRICK order ${state}` }); + }); + + it('rejects an unsupported Bank Frick status instead of guessing', () => { + expect(() => + service['getFrickStatusUpdate']( + { ...order, state: 'UNKNOWN' as FrickPaymentState }, + createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42' }), + ), + ).toThrow('Unsupported Bank Frick payment state'); + }); }); }); 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 1c5558af55..b26b7e553e 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -366,13 +366,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, IbanBankName.FRICK])) }, - }); + 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; From 2dfb94d3a3493b190b9212a01306161da576879f Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 16:38:14 +0200 Subject: [PATCH 04/19] fix(bank): harden Bank Frick state tracking --- .github/workflows/api-pr.yaml | 7 +- ...783944000000-AddBankFrickPayoutTracking.js | 8 +- package.json | 1 + .../__tests__/iso20022.service.spec.ts | 17 ++++ .../bank/services/frick.service.ts | 2 + .../bank/services/iso20022.service.ts | 5 +- .../__tests__/bank-tx-frick.service.spec.ts | 88 +++++++++-------- .../bank-tx/services/bank-tx.service.ts | 5 +- ...nk-frick-payout-tracking.migration.spec.ts | 16 ++- .../__tests__/fiat-output-job.service.spec.ts | 99 ++++++++++++++++--- .../fiat-output/fiat-output-job.service.ts | 42 ++++---- .../fiat-output/fiat-output.entity.ts | 11 ++- 12 files changed, 212 insertions(+), 89 deletions(-) diff --git a/.github/workflows/api-pr.yaml b/.github/workflows/api-pr.yaml index 84f8fbcbfa..0f720ad483 100644 --- a/.github/workflows/api-pr.yaml +++ b/.github/workflows/api-pr.yaml @@ -59,10 +59,13 @@ jobs: - name: Build code run: npm run build - - name: Run tests with coverage gates - run: npm run test:cov + - name: Run tests + run: npm run test 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/migration/1783944000000-AddBankFrickPayoutTracking.js b/migration/1783944000000-AddBankFrickPayoutTracking.js index 60fc631e56..58e69ad247 100644 --- a/migration/1783944000000-AddBankFrickPayoutTracking.js +++ b/migration/1783944000000-AddBankFrickPayoutTracking.js @@ -19,8 +19,10 @@ module.exports = class AddBankFrickPayoutTracking1783944000000 { */ async up(queryRunner) { await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); - await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderId" character varying(255)`); - await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickTxId" character varying(255)`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderId" character varying(256)`); + await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickTxId" 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)`); } /** @@ -28,6 +30,8 @@ module.exports = class AddBankFrickPayoutTracking1783944000000 { */ async down(queryRunner) { await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + 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 "frickTxId"`); await queryRunner.query(`ALTER TABLE "fiat_output" DROP COLUMN "frickOrderId"`); } diff --git a/package.json b/package.json index fdf9b1e192..16f7025490 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 integration/bank/services/__tests__/frick.service.spec.ts --coverage --runInBand --collectCoverageFrom=integration/bank/dto/frick.dto.ts --collectCoverageFrom=integration/bank/services/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/integration/bank/services/__tests__/iso20022.service.spec.ts b/src/integration/bank/services/__tests__/iso20022.service.spec.ts index f3da377b2c..9786f053af 100644 --- a/src/integration/bank/services/__tests__/iso20022.service.spec.ts +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -62,6 +62,23 @@ describe('Iso20022Service camt.053 references', () => { ); }); + 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'], diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index f775be0da9..b6b14ae672 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -46,6 +46,8 @@ export class BankFrickService { 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 diff --git a/src/integration/bank/services/iso20022.service.ts b/src/integration/bank/services/iso20022.service.ts index 75eec99764..15f9c20cd2 100644 --- a/src/integration/bank/services/iso20022.service.ts +++ b/src/integration/bank/services/iso20022.service.ts @@ -231,12 +231,13 @@ export class Iso20022Service { try { const stableReference = this.createStableCamtReference(accountIban, entry); const occurrence = fallbackOccurrences.get(stableReference) ?? 0; - fallbackOccurrences.set(stableReference, occurrence + 1); // 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}`; - transactions.push(Iso20022Service.parseCamt053JsonEntry(entry, accountIban, strict, fallbackReference)); + const transaction = Iso20022Service.parseCamt053JsonEntry(entry, accountIban, strict, fallbackReference); + fallbackOccurrences.set(stableReference, occurrence + 1); + transactions.push(transaction); } catch (error) { if (strict) throw error; continue; 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 index 653617310c..23c8ceb864 100644 --- 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 @@ -1,40 +1,54 @@ +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 { 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 { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; +import { BankTxRepository } from '../../repositories/bank-tx.repository'; import { BankTxService } from '../bank-tx.service'; describe('BankTxService Bank Frick polling', () => { let service: BankTxService; let frickService: jest.Mocked>; - let bankService: jest.Mocked>; + let bankService: jest.Mocked>; let settingService: jest.Mocked>; let specialAccountService: jest.Mocked>; - let logger: { warn: jest.Mock; error: jest.Mock }; + let bankTxRepo: jest.Mocked>; + let loggerWarn: jest.SpyInstance; + let loggerError: jest.SpyInstance; - beforeEach(() => { + beforeEach(async () => { frickService = { isAvailable: jest.fn().mockReturnValue(true), getFrickTransactions: jest.fn() }; - bankService = { getBanksByName: jest.fn() }; + bankService = { getBanksByName: jest.fn(), getBankInternal: jest.fn() }; settingService = { get: jest.fn().mockResolvedValue(new Date(0).toISOString()), set: jest.fn().mockResolvedValue(undefined), }; specialAccountService = { getMultiAccounts: jest.fn().mockResolvedValue([]) }; - logger = { warn: jest.fn(), error: jest.fn() }; - - service = Object.create(BankTxService.prototype); - Object.assign(service, { - frickService, - bankService, - settingService, - specialAccountService, - logger, - frickUnavailableWarningLogged: false, - }); + bankTxRepo = { find: jest.fn().mockResolvedValue([]) }; + loggerWarn = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(); + loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); + + const module: TestingModule = await Test.createTestingModule({ providers: [BankTxService] }) + .useMocker((token) => { + if (token === BankFrickService) return frickService; + if (token === BankService) return bankService; + if (token === SettingService) return settingService; + if (token === SpecialExternalAccountService) return specialAccountService; + if (token === BankTxRepository) return bankTxRepo; + return createMock(); + }) + .compile(); + + service = module.get(BankTxService); }); + afterEach(() => 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), @@ -44,7 +58,7 @@ describe('BankTxService Bank Frick polling', () => { frickService.getFrickTransactions .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-EUR-1' }]) .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-CHF-1' }]); - Object.assign(service, { create: jest.fn().mockResolvedValue({}) }); + jest.spyOn(service, 'create').mockResolvedValue({}); await service['checkFrickTransactions'](); @@ -65,18 +79,19 @@ describe('BankTxService Bank Frick polling', () => { expect(specialAccountService.getMultiAccounts).toHaveBeenCalledTimes(1); }); - it('does not advance the watermark after an empty response or fetch failure', async () => { + it('advances the watermark after an empty response but not after a fetch failure', async () => { bankService.getBanksByName.mockResolvedValue([ bank(101, 'SYNTHETIC-FRICK-EUR', true), bank(102, 'SYNTHETIC-FRICK-CHF', true), ]); frickService.getFrickTransactions.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('synthetic outage')); - Object.assign(service, { create: jest.fn() }); + jest.spyOn(service, 'create'); await service['checkFrickTransactions'](); - expect(settingService.set).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledTimes(1); + expect(settingService.set).toHaveBeenCalledTimes(1); + expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(String)); + expect(loggerError).toHaveBeenCalledTimes(1); }); it('treats duplicate conflicts as successfully processed but blocks advancement on other import errors', async () => { @@ -89,13 +104,13 @@ describe('BankTxService Bank Frick polling', () => { .fn() .mockRejectedValueOnce(new ConflictException('duplicate')) .mockRejectedValueOnce(new Error('synthetic persistence failure')); - Object.assign(service, { create }); + jest.spyOn(service, 'create').mockImplementation(create); await service['checkFrickTransactions'](); expect(settingService.set).toHaveBeenCalledTimes(1); expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(String)); - expect(logger.error).toHaveBeenCalledTimes(1); + expect(loggerError).toHaveBeenCalledTimes(1); }); it('warns only once while the integration is unconfigured', async () => { @@ -104,7 +119,7 @@ describe('BankTxService Bank Frick polling', () => { await service['checkFrickTransactions'](); await service['checkFrickTransactions'](); - expect(logger.warn).toHaveBeenCalledTimes(1); + expect(loggerWarn).toHaveBeenCalledTimes(1); expect(bankService.getBanksByName).not.toHaveBeenCalled(); }); @@ -113,7 +128,7 @@ describe('BankTxService Bank Frick polling', () => { await service['checkFrickTransactions'](); - expect(logger.error).toHaveBeenCalledWith('Failed to load Bank Frick account registry:', expect.any(Error)); + expect(loggerError).toHaveBeenCalledWith('Failed to load Bank Frick account registry:', expect.any(Error)); expect(specialAccountService.getMultiAccounts).not.toHaveBeenCalled(); expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); }); @@ -123,7 +138,7 @@ describe('BankTxService Bank Frick polling', () => { await service['checkFrickTransactions'](); - expect(logger.warn).toHaveBeenCalledWith( + expect(loggerWarn).toHaveBeenCalledWith( 'No receiving Bank Frick accounts configured - skipping transaction import', ); expect(specialAccountService.getMultiAccounts).not.toHaveBeenCalled(); @@ -136,7 +151,7 @@ describe('BankTxService Bank Frick polling', () => { await service['checkFrickTransactions'](); - expect(logger.error).toHaveBeenCalledWith( + expect(loggerError).toHaveBeenCalledWith( 'Failed to load special accounts for Bank Frick transaction import:', expect.any(Error), ); @@ -148,28 +163,23 @@ describe('BankTxService Bank Frick polling', () => { await service['checkFrickTransactions'](); - expect(logger.error).toHaveBeenCalledWith('Failed to import Bank Frick transactions: invalid bank row id'); + expect(loggerError).toHaveBeenCalledWith('Failed to import Bank Frick transactions: invalid bank row id'); expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); expect(settingService.set).not.toHaveBeenCalled(); }); it('still checks Bank Frick when the Olkypay poll fails', async () => { - const checkTransactions = jest.fn().mockRejectedValue(new Error('synthetic Olkypay outage')); - const checkFrickTransactions = jest.fn().mockResolvedValue(undefined); - Object.assign(service, { - checkTransactions, - checkFrickTransactions, - assignTransactions: jest.fn().mockResolvedValue(undefined), - fillBankTx: jest.fn().mockResolvedValue(undefined), - }); + bankService.getBankInternal.mockRejectedValue(new Error('synthetic Olkypay outage')); + bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); + frickService.getFrickTransactions.mockResolvedValue([]); await service.checkBankTx(); - expect(checkFrickTransactions).toHaveBeenCalledTimes(1); - expect(logger.error).toHaveBeenCalledWith('Failed to check Olkypay transactions:', expect.any(Error)); + expect(frickService.getFrickTransactions).toHaveBeenCalledTimes(1); + expect(loggerError).toHaveBeenCalledWith('Failed to check Olkypay transactions:', expect.any(Error)); }); - function bank(id: number, iban: string, receive: boolean) { - return { id, iban, receive } as any; + function bank(id: number, iban: string, receive: boolean): Bank { + return { id, iban, receive } as Bank; } }); 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 ba033de054..ea2d723ee5 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 @@ -264,7 +264,10 @@ export class BankTxService implements OnModuleInit { } } - if (transactions.length > 0 && fullyProcessed) await this.settingService.set(settingKey, newModificationTime); + // Advance after every fully processed response, including an empty statement, so an idle account does not + // request the complete epoch-to-today history forever. Entries exposed later with a booking date before this + // watermark require an explicit operational backfill because Bank Frick does not provide an ingestion cursor. + if (fullyProcessed) await this.settingService.set(settingKey, newModificationTime); } catch (error) { this.logger.error(`Failed to fetch Bank Frick transactions for bank row ${bank.id}:`, error); } 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 index 2555126f82..daf583931d 100644 --- 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 @@ -40,7 +40,7 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { if (dataSource?.isInitialized) await dataSource.destroy(); }); - it('adds both nullable tracking columns at the entity length and removes them on rollback', async () => { + it('adds nullable tracking columns at the entity length and removes them on rollback', async () => { const migration = new AddBankFrickPayoutTracking(); await queryRunner.startTransaction(); @@ -49,13 +49,20 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { await queryRunner.commitTransaction(); expect(added).toEqual([ + { column_name: 'frickError', data_type: 'character varying', character_maximum_length: 256, is_nullable: 'YES' }, { column_name: 'frickOrderId', data_type: 'character varying', - character_maximum_length: 255, + character_maximum_length: 256, is_nullable: 'YES', }, - { column_name: 'frickTxId', data_type: 'character varying', character_maximum_length: 255, is_nullable: 'YES' }, + { + column_name: 'frickOrderStatus', + data_type: 'character varying', + character_maximum_length: 256, + is_nullable: 'YES', + }, + { column_name: 'frickTxId', data_type: 'character varying', character_maximum_length: 256, is_nullable: 'YES' }, ]); await queryRunner.startTransaction(); @@ -72,7 +79,8 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { 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', 'frickTxId') + WHERE table_schema = $1 AND table_name = 'fiat_output' + AND column_name IN ('frickOrderId', 'frickTxId', 'frickOrderStatus', 'frickError') ORDER BY column_name`, [SCHEMA], ); 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 411e1c6c94..e0493e6922 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 @@ -358,6 +358,50 @@ describe('FiatOutputJobService', () => { 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'), + frickTxId: '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', () => { @@ -711,22 +755,33 @@ describe('FiatOutputJobService', () => { expect(frickService.createPaymentOrder).toHaveBeenCalledWith(expect.objectContaining({ charge: expectedCharge })); }); - it('polls existing orders while payout creation is disabled and leaves PREPARED manual', async () => { + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ + createCustomFiatOutput({ + id: 42, + frickTxId: 'DFX-FO-42', + 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, { - info: 'FRICK order PREPARED: manual approval required', + 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 () => { @@ -747,6 +802,8 @@ describe('FiatOutputJobService', () => { expect(fiatOutputRepo.update).toHaveBeenCalledWith( 42, expect.objectContaining({ + frickOrderStatus: FrickPaymentState.BOOKED, + frickError: null, isApprovedDate: expect.any(Date), isConfirmedDate: expect.any(Date), }), @@ -771,6 +828,8 @@ describe('FiatOutputJobService', () => { expect(fiatOutputRepo.update).toHaveBeenCalledWith( 42, expect.objectContaining({ + frickOrderStatus: state, + frickError: null, isApprovedDate: expect.any(Date), ...(confirms && { isConfirmedDate: expect.any(Date) }), }), @@ -782,7 +841,7 @@ describe('FiatOutputJobService', () => { ); }); - it('polls an already-approved IN_PROGRESS order without issuing an empty update', async () => { + 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); @@ -803,7 +862,10 @@ describe('FiatOutputJobService', () => { await service.checkFrickOrderStatus(); expect(frickService.getPaymentOrder).toHaveBeenCalledWith('DFX-FO-42'); - expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.IN_PROGRESS, + frickError: null, + }); }); it('preserves an operations note when a status request fails', async () => { @@ -820,7 +882,10 @@ describe('FiatOutputJobService', () => { await service.checkFrickOrderStatus(); - expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + 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 () => { @@ -833,7 +898,7 @@ describe('FiatOutputJobService', () => { await service.checkFrickOrderStatus(); expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { - info: 'FRICK status error: synthetic status failure', + frickError: 'FRICK status error: synthetic status failure', }); }); @@ -878,7 +943,10 @@ describe('FiatOutputJobService', () => { await service['transmitFrickPayments'](); - expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK error: synthetic transmission failure', + }); + expect((fiatOutputRepo.update as jest.Mock).mock.calls[0][1]).not.toHaveProperty('info'); }); it('records a bounded Bank Frick transmission error when no operations note exists', async () => { @@ -892,7 +960,7 @@ describe('FiatOutputJobService', () => { await service['transmitFrickPayments'](); expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { - info: 'FRICK error: synthetic transmission failure', + frickError: 'FRICK error: synthetic transmission failure', }); }); @@ -911,7 +979,10 @@ describe('FiatOutputJobService', () => { await service.checkFrickOrderStatus(); expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); - expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { info: 'FRICK order REJECTED' }); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.REJECTED, + frickError: null, + }); }); it.each([ @@ -920,13 +991,13 @@ describe('FiatOutputJobService', () => { FrickPaymentState.DELETED, FrickPaymentState.DELETION_REQUESTED, FrickPaymentState.ERROR, - ])('maps terminal state %s to an operations status', (state) => { + ])('maps terminal state %s to the dedicated order status', (state) => { expect( service['getFrickStatusUpdate']( { ...order, state }, createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42' }), ), - ).toEqual({ info: `FRICK order ${state}` }); + ).toEqual({ frickOrderStatus: state, frickError: null }); }); it('rejects an unsupported Bank Frick status instead of guessing', () => { 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 b26b7e553e..203470168f 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -110,12 +110,11 @@ export class FiatOutputJobService { if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_STATUS_CHECK)) return; if (!this.frickService.isAvailable()) return; - const terminalInfos = FiatOutputJobService.FRICK_TERMINAL_STATES.map((state) => `FRICK order ${state}`); const statusRequest: FindOptionsWhere = { frickTxId: Not(IsNull()), isComplete: false }; const entities = await this.fiatOutputRepo.find({ where: [ - { ...statusRequest, info: IsNull() }, - { ...statusRequest, info: Not(In(terminalInfos)) }, + { ...statusRequest, frickOrderStatus: IsNull() }, + { ...statusRequest, frickOrderStatus: Not(In(FiatOutputJobService.FRICK_TERMINAL_STATES)) }, ], }); @@ -131,12 +130,10 @@ export class FiatOutputJobService { 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); - if (!entity.info || entity.info.startsWith('FRICK')) { - const message = error instanceof Error ? error.message : 'unknown error'; - await this.fiatOutputRepo.update(entity.id, { - info: `FRICK status error: ${message}`.substring(0, 256), - }); - } + const message = error instanceof Error ? error.message : 'unknown error'; + await this.fiatOutputRepo.update(entity.id, { + frickError: `FRICK status error: ${message}`.substring(0, 256), + }); } } } @@ -283,7 +280,7 @@ export class FiatOutputJobService { 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.isFrickTerminalStateInfo(tx.info); + return !this.isFrickTerminalState(tx.frickOrderStatus); case IbanBankName.OLKY: return !tx.bankTx || tx.bankTx.created > Util.minutesBefore(5); default: @@ -612,7 +609,6 @@ export class FiatOutputJobService { ...(safeOrderId && { frickOrderId: safeOrderId }), remittanceInfo, isTransmittedDate: new Date(), - ...(entity.info?.startsWith('FRICK') && { info: null }), ...this.getFrickStatusUpdate(order, entity), }); @@ -623,12 +619,10 @@ export class FiatOutputJobService { } } catch (error) { this.logger.error(`Failed to transmit Bank Frick payment for fiat output ${entity.id}:`, error); - if (!entity.info || entity.info.startsWith('FRICK')) { - const message = error instanceof Error ? error.message : 'unknown error'; - await this.fiatOutputRepo.update(entity.id, { - info: `FRICK error: ${message}`.substring(0, 256), - }); - } + const message = error instanceof Error ? error.message : 'unknown error'; + await this.fiatOutputRepo.update(entity.id, { + frickError: `FRICK error: ${message}`.substring(0, 256), + }); } } } @@ -640,26 +634,28 @@ export class FiatOutputJobService { case FrickPaymentState.IN_PROGRESS: case FrickPaymentState.EXECUTED: return { + frickOrderStatus: order.state, + frickError: null, ...(!entity.isApprovedDate && { isApprovedDate: now }), - ...(entity.info?.startsWith('FRICK') && { info: null }), }; case FrickPaymentState.BOOKED: return { + frickOrderStatus: order.state, + frickError: null, ...(!entity.isApprovedDate && { isApprovedDate: now }), ...(!entity.isConfirmedDate && { isConfirmedDate: now }), - ...(entity.info?.startsWith('FRICK') && { info: null }), }; case FrickPaymentState.PREPARED: - return this.isFrickAutomaticApprovalEnabled() ? {} : { info: 'FRICK order PREPARED: manual approval required' }; + return { frickOrderStatus: order.state, frickError: null }; case FrickPaymentState.REJECTED: case FrickPaymentState.EXPIRED: case FrickPaymentState.DELETED: case FrickPaymentState.DELETION_REQUESTED: case FrickPaymentState.ERROR: - return { info: `FRICK order ${order.state}`.substring(0, 256) }; + return { frickOrderStatus: order.state, frickError: null }; default: throw new Error('Unsupported Bank Frick payment state'); @@ -674,8 +670,8 @@ export class FiatOutputJobService { ); } - private isFrickTerminalStateInfo(info: string | undefined): boolean { - return FiatOutputJobService.FRICK_TERMINAL_STATES.some((state) => info === `FRICK order ${state}`); + private isFrickTerminalState(status: FrickPaymentState | undefined): boolean { + return status !== undefined && FiatOutputJobService.FRICK_TERMINAL_STATES.includes(status); } private async searchOutgoingBankTx(): Promise { diff --git a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts index 1ee26f60c2..9866b05c07 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,12 +153,18 @@ export class FiatOutput extends IEntity { @Column({ nullable: true }) olkyOrderId?: string; - @Column({ nullable: true }) + @Column({ length: 256, nullable: true }) frickOrderId?: string; - @Column({ nullable: true }) + @Column({ length: 256, nullable: true }) frickTxId?: string; + @Column({ type: 'varchar', length: 256, nullable: true }) + frickOrderStatus?: FrickPaymentState; + + @Column({ length: 256, nullable: true }) + frickError?: string; + // --- ENTITY METHODS --- // setBatch(batchId?: number, batchAmount?: number): UpdateResult { From 0eceaffb5d8df11cfd4bce24485f6eafb10b08ed Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 16:54:33 +0200 Subject: [PATCH 05/19] test(bank): lock Frick tracking metadata --- ...nk-frick-payout-tracking.migration.spec.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 index daf583931d..cb1bce6ac9 100644 --- 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 @@ -1,4 +1,5 @@ -import { DataSource, QueryRunner } from 'typeorm'; +import { DataSource, getMetadataArgsStorage, QueryRunner } from 'typeorm'; +import { FiatOutput } from '../fiat-output.entity'; const PG_URL = process.env.MIGRATION_TEST_PG; const describeDb = PG_URL ? describe : describe.skip; @@ -9,6 +10,24 @@ let AddBankFrickPayoutTracking: new () => { down(queryRunner: QueryRunner): Promise; }; +describe('FiatOutput Bank Frick column metadata', () => { + it('keeps every tracking column aligned with the migration length', () => { + const trackingProperties = ['frickOrderId', 'frickTxId', 'frickOrderStatus', 'frickError']; + 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: 256, nullable: true }, + frickTxId: { length: 256, nullable: true }, + frickOrderStatus: { length: 256, nullable: true }, + frickError: { length: 256, nullable: true }, + }); + }); +}); + describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { let dataSource: DataSource; let queryRunner: QueryRunner; From 47e4289670195651b3cde1a5dd39418ee163bf1b Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 18:11:24 +0200 Subject: [PATCH 06/19] fix(bank): widen Frick payment order lookups --- .../services/__tests__/frick.service.spec.ts | 3 +++ src/integration/bank/services/frick.service.ts | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts index 65a1d149ea..cea18bff24 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -164,6 +164,7 @@ describe('BankFrickService', () => { 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 () => { @@ -177,6 +178,8 @@ describe('BankFrickService', () => { 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); diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index b6b14ae672..a736b5af0d 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -28,6 +28,10 @@ type FrickResponseType = 'json' | 'text'; @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'; private accessToken?: string; private tokenExpiryMs = 0; @@ -265,14 +269,21 @@ export class BankFrickService { } private async getPaymentOrderOrUndefined(customId: string): Promise { - const active = await this.getFilteredPaymentOrder(new URLSearchParams({ customId }), customId); + 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. The API also documents that a fromDate is required for reliable BOOKED lookups. + // payout. return this.getFilteredPaymentOrder( - new URLSearchParams({ customId, status: FrickPaymentState.BOOKED, fromDate: '1970-01-01' }), + new URLSearchParams({ + customId, + status: FrickPaymentState.BOOKED, + fromDate: BankFrickService.EARLIEST_FROM_DATE, + }), customId, ); } From a9195fbc1436f92a10735fa44f0313b272a4af1f Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 18:11:24 +0200 Subject: [PATCH 07/19] fix(bank): tighten Frick customer validation --- .../bank/services/__tests__/frick.service.spec.ts | 6 ++++++ src/integration/bank/services/frick.service.ts | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts index cea18bff24..66e250633a 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -556,6 +556,12 @@ describe('BankFrickService', () => { 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()); diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index a736b5af0d..dc11c7b2c6 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -549,9 +549,10 @@ export class BankFrickService { private validateCustomer(): string { const customer = Config.bank.frick.customer; - // Bank Frick's published schema currently says seven digits while its own examples contain eight. - // Restrict the path segment to digits without enforcing that contradictory length at our boundary. - if (typeof customer !== 'string' || !/^\d{1,16}$/.test(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; } From 9293d249aa3f3d0dc5c1107311d198dd590dc64e Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 18:12:25 +0200 Subject: [PATCH 08/19] fix(bank): keep liquidity reserved for deletion-requested Frick orders --- .../__tests__/fiat-output-job.service.spec.ts | 92 ++++++++++++++++++- .../fiat-output/fiat-output-job.service.ts | 7 +- 2 files changed, 96 insertions(+), 3 deletions(-) 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 e0493e6922..e27d21863d 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 @@ -989,7 +989,6 @@ describe('FiatOutputJobService', () => { FrickPaymentState.REJECTED, FrickPaymentState.EXPIRED, FrickPaymentState.DELETED, - FrickPaymentState.DELETION_REQUESTED, FrickPaymentState.ERROR, ])('maps terminal state %s to the dedicated order status', (state) => { expect( @@ -1000,6 +999,97 @@ describe('FiatOutputJobService', () => { ).toEqual({ frickOrderStatus: state, frickError: null }); }); + 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, frickTxId: '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('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'), + frickTxId: '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) }); + }); + + 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, + frickTxId: 'DFX-FO-42', + 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('rejects an unsupported Bank Frick status instead of guessing', () => { expect(() => service['getFrickStatusUpdate']( 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 203470168f..2dac675e1e 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -38,11 +38,13 @@ import { FiatOutputRepository } from './fiat-output.repository'; @Injectable() export class FiatOutputJobService { + // 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. private static readonly FRICK_TERMINAL_STATES = [ FrickPaymentState.REJECTED, FrickPaymentState.EXPIRED, FrickPaymentState.DELETED, - FrickPaymentState.DELETION_REQUESTED, FrickPaymentState.ERROR, ]; @@ -648,12 +650,13 @@ export class FiatOutputJobService { }; 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.DELETION_REQUESTED: case FrickPaymentState.ERROR: return { frickOrderStatus: order.state, frickError: null }; From 3a428229b9a13a3aa64f49dcd5b0592d88ee6896 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 18:12:26 +0200 Subject: [PATCH 09/19] fix(bank): decouple Frick auto-approval from transmission switch --- .../__tests__/fiat-output-job.service.spec.ts | 43 +++++++++++++++++++ .../fiat-output/fiat-output-job.service.ts | 10 ++--- 2 files changed, 48 insertions(+), 5 deletions(-) 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 e27d21863d..7e10fa16ae 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 @@ -668,6 +668,7 @@ describe('FiatOutputJobService', () => { 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 () => { @@ -1090,6 +1091,48 @@ describe('FiatOutputJobService', () => { }); }); + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); + 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, frickTxId: 'DFX-FO-42', 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']( 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 2dac675e1e..3d01380e1b 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -666,11 +666,11 @@ export class FiatOutputJobService { } private isFrickAutomaticApprovalEnabled(): boolean { - return ( - Config.bank.frick.payoutEnabled && - Config.bank.frick.approveWithoutTan && - !DisabledProcess(Process.FIAT_OUTPUT_FRICK_TRANSMISSION) - ); + // Process gating is the caller's responsibility: checkFrickOrderStatus runs under + // FIAT_OUTPUT_FRICK_STATUS_CHECK, transmitFrickPayments 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; } private isFrickTerminalState(status: FrickPaymentState | undefined): boolean { From 7cdc9bba4be2a085a35507a96d77555c28865001 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 18:12:26 +0200 Subject: [PATCH 10/19] fix(bank): overlap Bank Frick statement watermark --- docs/bank-frick-operations.md | 93 +++++++++++++++++++ .../__tests__/bank-tx-frick.service.spec.ts | 83 ++++++++++++++++- .../bank-tx/services/bank-tx.service.ts | 27 +++++- 3 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 docs/bank-frick-operations.md diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md new file mode 100644 index 0000000000..228a79355f --- /dev/null +++ b/docs/bank-frick-operations.md @@ -0,0 +1,93 @@ +# Bank Frick — Operations Runbook + +Operational notes for the Bank Frick statement import (`BankTxService.checkFrickTransactions`, +`src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts`). Covers the watermark +backfill procedure and a one-time monitoring note for the reference-less-entry deploy. + +## 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). After every fully +processed fetch — including an empty statement — the watermark advances to +`min(now, latest booking date of the processed entries) − FRICK_WATERMARK_OVERLAP_DAYS`, and +never moves backwards. The overlap (currently 2 days) exists so that ordinary bank-side +reporting lag or a race between multiple running instances does not skip entries. Duplicate +re-fetches inside the overlap window are expected and are absorbed by the existing `create()` +dedup, which rejects re-inserts of the same `accountServiceRef` with a `ConflictException`. + +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. + +### 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 — the service will attempt to +pull the account's entire history. For an account with material transaction volume, seed the +setting key explicitly **before** flipping `Bank.receive` to `true`, to the earliest date you +actually want imported (or `now`, if only new activity should be picked up going forward), so +the first poll does not trigger an unbounded historical pull. + +## 2. Raiffeisen/Yapeal reference-less entries after deploy + +Before this change, Raiffeisen/Yapeal statement entries without a bank-provided reference could +be assigned a randomly generated `accountServiceRef` on each import, so the same underlying +entry could get a different synthetic reference every time it was seen. That randomness is now +removed (`Iso20022Service`), but any **already-imported** rows created under the old logic still +carry the old, non-reproducible references. + +Because the new logic derives `accountServiceRef` deterministically for the same entry, the +first overlapping poll after this deploy will, for reference-less Raiffeisen/Yapeal entries that +fall inside the poll's overlap window, compute a reference that differs from what was already +stored in `bank_tx`. The `create()` dedup keys off `accountServiceRef`, so it will not recognize +these as duplicates of the old rows — expect a one-time batch of duplicate `bank_tx` entries for +those accounts until the overlap window has fully rolled past the old imports. + +### Monitoring + +- After deploy, watch `bank_tx` insert volume on Raiffeisen and Yapeal accounts for the first + several poll cycles. A one-off bump limited to reference-less entries within the overlap + window is expected; anything larger or sustained is not and should be investigated. +- Cross-check new rows against the pre-existing ones by amount, value date and counterparty to + confirm they are the expected duplicates and not a genuine new-data issue. + +### Cleanup + +- Historical reference-less rows imported under the old random-reference logic are not + retroactively corrected by this deploy. Once the one-time duplicate batch above has been + confirmed, plan a manual cleanup pass (dedup/merge or archive the superseded rows) for the + affected Raiffeisen/Yapeal accounts; do not run it automatically as part of the deploy. 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 index 23c8ceb864..8562243b7a 100644 --- 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 @@ -47,7 +47,10 @@ describe('BankTxService Bank Frick polling', () => { service = module.get(BankTxService); }); - afterEach(() => jest.restoreAllMocks()); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); it('polls every receiving account with an account-specific watermark and loads multi-accounts once', async () => { bankService.getBanksByName.mockResolvedValue([ @@ -179,6 +182,84 @@ describe('BankTxService Bank Frick polling', () => { expect(loggerError).toHaveBeenCalledWith('Failed to check Olkypay transactions:', expect.any(Error)); }); + describe('watermark overlap', () => { + const OVERLAP_DAYS = (BankTxService as unknown as { FRICK_WATERMARK_OVERLAP_DAYS: number }) + .FRICK_WATERMARK_OVERLAP_DAYS; + + function overlapOf(date: Date): Date { + const result = new Date(date); + result.setDate(result.getDate() - OVERLAP_DAYS); + return result; + } + + it('sets the watermark to the newest processed booking date minus the overlap when it precedes now', async () => { + 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([ + { accountServiceRef: 'FRICK-OLD', bookingDate: olderBookingDate }, + { accountServiceRef: 'FRICK-NEW', bookingDate: maxBookingDate }, + ]); + jest.spyOn(service, 'create').mockResolvedValue({}); + + await service['checkFrickTransactions'](); + + expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', overlapOf(maxBookingDate).toISOString()); + }); + + it('sets the watermark to now minus the overlap 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([]); + + await service['checkFrickTransactions'](); + + expect(settingService.set).toHaveBeenCalledWith( + 'lastBankFrickDate:101', + overlapOf(new Date('2024-06-15T12:00:00.000Z')).toISOString(), + ); + }); + + it('never moves the watermark backwards when it is already ahead of the computed candidate', async () => { + 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([ + { accountServiceRef: 'FRICK-OLD', bookingDate: new Date('2024-01-01T00:00:00.000Z') }, + ]); + jest.spyOn(service, 'create').mockResolvedValue({}); + + await service['checkFrickTransactions'](); + + expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', aheadWatermark.toISOString()); + }); + + 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')); + + await service['checkFrickTransactions'](); + + expect(settingService.set).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([ + { accountServiceRef: 'FRICK-1', bookingDate: new Date('2024-01-01T00:00:00.000Z') }, + ]); + jest.spyOn(service, 'create').mockRejectedValue(new Error('synthetic persistence failure')); + + await service['checkFrickTransactions'](); + + expect(settingService.set).not.toHaveBeenCalled(); + }); + }); + function bank(id: number, iban: string, receive: boolean): Bank { return { id, iban, receive } as Bank; } 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 ea2d723ee5..cc03cc9348 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 @@ -94,6 +94,10 @@ export class BankTxService implements OnModuleInit { private olkyUnavailableWarningLogged = false; 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 bankTxRepo: BankTxRepository, private readonly bankTxBatchRepo: BankTxBatchRepository, @@ -247,13 +251,18 @@ export class BankTxService implements OnModuleInit { const settingKey = `lastBankFrickDate:${bank.id}`; const lastModificationTime = new Date(await this.settingService.get(settingKey, new Date(0).toISOString())); - const newModificationTime = new Date().toISOString(); + const now = new Date(); try { const transactions = await this.frickService.getFrickTransactions(lastModificationTime, bank.iban); let fullyProcessed = true; + let maxBookingDate: Date | undefined; for (const transaction of transactions) { + if (transaction.bookingDate && (!maxBookingDate || transaction.bookingDate > maxBookingDate)) { + maxBookingDate = transaction.bookingDate; + } + try { await this.create(transaction, multiAccounts); } catch (error) { @@ -265,9 +274,19 @@ export class BankTxService implements OnModuleInit { } // Advance after every fully processed response, including an empty statement, so an idle account does not - // request the complete epoch-to-today history forever. Entries exposed later with a booking date before this - // watermark require an explicit operational backfill because Bank Frick does not provide an ingestion cursor. - if (fullyProcessed) await this.settingService.set(settingKey, newModificationTime); + // request the complete epoch-to-today history forever. The new watermark is backdated by a fixed overlap + // (candidate = min(now, max booking date of the processed entries) - FRICK_WATERMARK_OVERLAP_DAYS) and can + // only ever move forward, so delayed Bank Frick reporting or a race between multiple instances polling the + // same account cannot silently skip entries. The resulting re-fetch of the overlap window is intentional - + // duplicates are absorbed by the create() dedup (ConflictException) above. Entries exposed even later than + // the overlap window still require an explicit operational backfill because Bank Frick does not provide an + // ingestion cursor. + if (fullyProcessed) { + const reference = maxBookingDate && maxBookingDate < now ? maxBookingDate : now; + const candidate = Util.daysBefore(BankTxService.FRICK_WATERMARK_OVERLAP_DAYS, reference); + const newWatermark = candidate > lastModificationTime ? candidate : lastModificationTime; + await this.settingService.set(settingKey, newWatermark.toISOString()); + } } catch (error) { this.logger.error(`Failed to fetch Bank Frick transactions for bank row ${bank.id}:`, error); } From fa2162bb25426efef762e0c6af6ed05e9302c73f Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 18:28:19 +0200 Subject: [PATCH 11/19] refactor(bank): isolate Bank Frick code for coverage gating --- src/config/__tests__/frick.config.spec.ts | 53 ++ src/config/config.ts | 10 +- src/config/frick.config.ts | 19 + .../supporting/bank-tx/bank-tx.module.ts | 2 + .../__tests__/bank-tx-frick.service.spec.ts | 108 ++-- .../bank-tx/services/bank-tx-frick.service.ts | 115 ++++ .../bank-tx/services/bank-tx.service.ts | 95 +--- .../fiat-output-frick.service.spec.ts | 517 ++++++++++++++++++ .../__tests__/fiat-output-job.service.spec.ts | 490 +---------------- .../fiat-output/fiat-output-frick.service.ts | 186 +++++++ .../fiat-output/fiat-output-job.service.ts | 175 +----- .../fiat-output/fiat-output.module.ts | 2 + 12 files changed, 977 insertions(+), 795 deletions(-) create mode 100644 src/config/__tests__/frick.config.spec.ts create mode 100644 src/config/frick.config.ts create mode 100644 src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts create mode 100644 src/subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts create mode 100644 src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts diff --git a/src/config/__tests__/frick.config.spec.ts b/src/config/__tests__/frick.config.spec.ts new file mode 100644 index 0000000000..7ae04182e0 --- /dev/null +++ b/src/config/__tests__/frick.config.spec.ts @@ -0,0 +1,53 @@ +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 the private key is set', () => { + const config = buildFrickConfig(env({ FRICK_PRIVATE_KEY: 'synthetic-line-one
synthetic-line-two' })); + + expect(config.privateKey).toBe('synthetic-line-one\nsynthetic-line-two'); + }); + + it('leaves the private key undefined when it is not set', () => { + const config = buildFrickConfig(env()); + + expect(config.privateKey).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 e0a1e1cace..b6152b7e1b 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'; @@ -1195,14 +1196,7 @@ export class Configuration { clientSecret: process.env.OLKY_CLIENT_SECRET, }, }, - frick: { - baseUrl: process.env.FRICK_BASE_URL, - apiKey: process.env.FRICK_API_KEY, - privateKey: process.env.FRICK_PRIVATE_KEY?.split('
').join('\n'), - customer: process.env.FRICK_CUSTOMER, - payoutEnabled: process.env.FRICK_PAYOUT_ENABLED === 'true', - approveWithoutTan: process.env.FRICK_APPROVE_WITHOUT_TAN === 'true', - }, + 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..6f730c9641 --- /dev/null +++ b/src/config/frick.config.ts @@ -0,0 +1,19 @@ +export interface FrickConfig { + baseUrl: string; + apiKey: string; + privateKey: 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'), + customer: env.FRICK_CUSTOMER, + payoutEnabled: env.FRICK_PAYOUT_ENABLED === 'true', + approveWithoutTan: env.FRICK_APPROVE_WITHOUT_TAN === 'true', + }; +} diff --git a/src/subdomains/supporting/bank-tx/bank-tx.module.ts b/src/subdomains/supporting/bank-tx/bank-tx.module.ts index 1efb31bb83..d9b1aa5c46 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx.module.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx.module.ts @@ -25,6 +25,7 @@ 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 { 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 +52,7 @@ import { SepaParser } from './bank-tx/services/sepa-parser.service'; BankTxReturnRepository, BankTxRepeatRepository, BankTxService, + BankTxFrickService, BankTxReturnService, BankTxRepeatService, BankTxBatchService, 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 index 8562243b7a..b191d2a7bd 100644 --- 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 @@ -8,43 +8,40 @@ 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 { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; -import { BankTxRepository } from '../../repositories/bank-tx.repository'; +import { BankTxFrickService } from '../bank-tx-frick.service'; import { BankTxService } from '../bank-tx.service'; -describe('BankTxService Bank Frick polling', () => { - let service: BankTxService; +describe('BankTxFrickService', () => { + let frickTxService: BankTxFrickService; let frickService: jest.Mocked>; - let bankService: jest.Mocked>; + let bankService: jest.Mocked>; let settingService: jest.Mocked>; let specialAccountService: jest.Mocked>; - let bankTxRepo: 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(), getBankInternal: jest.fn() }; + bankService = { getBanksByName: jest.fn() }; settingService = { get: jest.fn().mockResolvedValue(new Date(0).toISOString()), set: jest.fn().mockResolvedValue(undefined), }; specialAccountService = { getMultiAccounts: jest.fn().mockResolvedValue([]) }; - bankTxRepo = { find: jest.fn().mockResolvedValue([]) }; loggerWarn = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(); loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(); - const module: TestingModule = await Test.createTestingModule({ providers: [BankTxService] }) + 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; - if (token === BankTxRepository) return bankTxRepo; return createMock(); }) .compile(); - service = module.get(BankTxService); + frickTxService = module.get(BankTxFrickService); }); afterEach(() => { @@ -61,9 +58,9 @@ describe('BankTxService Bank Frick polling', () => { frickService.getFrickTransactions .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-EUR-1' }]) .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-CHF-1' }]); - jest.spyOn(service, 'create').mockResolvedValue({}); + const createTx = jest.fn().mockResolvedValue({}); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(bankService.getBanksByName).toHaveBeenCalledWith(IbanBankName.FRICK); expect(frickService.getFrickTransactions).toHaveBeenCalledTimes(2); @@ -88,9 +85,9 @@ describe('BankTxService Bank Frick polling', () => { bank(102, 'SYNTHETIC-FRICK-CHF', true), ]); frickService.getFrickTransactions.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('synthetic outage')); - jest.spyOn(service, 'create'); + const createTx = jest.fn(); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(settingService.set).toHaveBeenCalledTimes(1); expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(String)); @@ -103,13 +100,12 @@ describe('BankTxService Bank Frick polling', () => { bank(102, 'SYNTHETIC-FRICK-CHF', true), ]); frickService.getFrickTransactions.mockResolvedValue([{ accountServiceRef: 'SYNTHETIC-REF' }]); - const create = jest + const createTx = jest .fn() .mockRejectedValueOnce(new ConflictException('duplicate')) .mockRejectedValueOnce(new Error('synthetic persistence failure')); - jest.spyOn(service, 'create').mockImplementation(create); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(settingService.set).toHaveBeenCalledTimes(1); expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(String)); @@ -118,9 +114,10 @@ describe('BankTxService Bank Frick polling', () => { it('warns only once while the integration is unconfigured', async () => { frickService.isAvailable.mockReturnValue(false); + const createTx = jest.fn(); - await service['checkFrickTransactions'](); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); + await frickTxService.checkTransactions(createTx); expect(loggerWarn).toHaveBeenCalledTimes(1); expect(bankService.getBanksByName).not.toHaveBeenCalled(); @@ -128,8 +125,9 @@ describe('BankTxService Bank Frick polling', () => { 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 service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(loggerError).toHaveBeenCalledWith('Failed to load Bank Frick account registry:', expect.any(Error)); expect(specialAccountService.getMultiAccounts).not.toHaveBeenCalled(); @@ -138,8 +136,9 @@ describe('BankTxService Bank Frick polling', () => { 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 service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(loggerWarn).toHaveBeenCalledWith( 'No receiving Bank Frick accounts configured - skipping transaction import', @@ -151,8 +150,9 @@ describe('BankTxService Bank Frick polling', () => { 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 service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(loggerError).toHaveBeenCalledWith( 'Failed to load special accounts for Bank Frick transaction import:', @@ -163,27 +163,17 @@ describe('BankTxService Bank Frick polling', () => { 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 service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(loggerError).toHaveBeenCalledWith('Failed to import Bank Frick transactions: invalid bank row id'); expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); expect(settingService.set).not.toHaveBeenCalled(); }); - it('still checks Bank Frick when the Olkypay poll fails', async () => { - bankService.getBankInternal.mockRejectedValue(new Error('synthetic Olkypay outage')); - bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); - frickService.getFrickTransactions.mockResolvedValue([]); - - await service.checkBankTx(); - - expect(frickService.getFrickTransactions).toHaveBeenCalledTimes(1); - expect(loggerError).toHaveBeenCalledWith('Failed to check Olkypay transactions:', expect.any(Error)); - }); - describe('watermark overlap', () => { - const OVERLAP_DAYS = (BankTxService as unknown as { FRICK_WATERMARK_OVERLAP_DAYS: number }) + const OVERLAP_DAYS = (BankTxFrickService as unknown as { FRICK_WATERMARK_OVERLAP_DAYS: number }) .FRICK_WATERMARK_OVERLAP_DAYS; function overlapOf(date: Date): Date { @@ -200,9 +190,9 @@ describe('BankTxService Bank Frick polling', () => { { accountServiceRef: 'FRICK-OLD', bookingDate: olderBookingDate }, { accountServiceRef: 'FRICK-NEW', bookingDate: maxBookingDate }, ]); - jest.spyOn(service, 'create').mockResolvedValue({}); + const createTx = jest.fn().mockResolvedValue({}); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', overlapOf(maxBookingDate).toISOString()); }); @@ -211,8 +201,9 @@ describe('BankTxService Bank Frick polling', () => { jest.useFakeTimers().setSystemTime(new Date('2024-06-15T12:00:00.000Z')); bankService.getBanksByName.mockResolvedValue([bank(101, 'SYNTHETIC-FRICK-EUR', true)]); frickService.getFrickTransactions.mockResolvedValue([]); + const createTx = jest.fn(); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(settingService.set).toHaveBeenCalledWith( 'lastBankFrickDate:101', @@ -227,9 +218,9 @@ describe('BankTxService Bank Frick polling', () => { frickService.getFrickTransactions.mockResolvedValue([ { accountServiceRef: 'FRICK-OLD', bookingDate: new Date('2024-01-01T00:00:00.000Z') }, ]); - jest.spyOn(service, 'create').mockResolvedValue({}); + const createTx = jest.fn().mockResolvedValue({}); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', aheadWatermark.toISOString()); }); @@ -237,8 +228,9 @@ describe('BankTxService Bank Frick polling', () => { 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 service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(settingService.set).not.toHaveBeenCalled(); expect(loggerError).toHaveBeenCalledWith( @@ -252,9 +244,9 @@ describe('BankTxService Bank Frick polling', () => { frickService.getFrickTransactions.mockResolvedValue([ { accountServiceRef: 'FRICK-1', bookingDate: new Date('2024-01-01T00:00:00.000Z') }, ]); - jest.spyOn(service, 'create').mockRejectedValue(new Error('synthetic persistence failure')); + const createTx = jest.fn().mockRejectedValue(new Error('synthetic persistence failure')); - await service['checkFrickTransactions'](); + await frickTxService.checkTransactions(createTx); expect(settingService.set).not.toHaveBeenCalled(); }); @@ -264,3 +256,33 @@ describe('BankTxService Bank Frick polling', () => { return { id, iban, receive } as Bank; } }); + +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)); + }); +}); 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..11ce8f8a13 --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts @@ -0,0 +1,115 @@ +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 { Util } from 'src/shared/utils/util'; +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 { + banks = await this.bankService + .getBanksByName(IbanBankName.FRICK) + .then((rows) => rows.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 = await this.frickService.getFrickTransactions(lastModificationTime, bank.iban); + let fullyProcessed = true; + let maxBookingDate: Date | undefined; + + for (const transaction of transactions) { + if (transaction.bookingDate && (!maxBookingDate || transaction.bookingDate > maxBookingDate)) { + maxBookingDate = transaction.bookingDate; + } + + 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); + } + } + } + + // Advance after every fully processed response, including an empty statement, so an idle account does not + // request the complete epoch-to-today history forever. The new watermark is backdated by a fixed overlap + // (candidate = min(now, max booking date of the processed entries) - FRICK_WATERMARK_OVERLAP_DAYS) and can + // only ever move forward, so delayed Bank Frick reporting or a race between multiple instances polling the + // same account cannot silently skip entries. The resulting re-fetch of the overlap window is intentional - + // duplicates are absorbed by the create() dedup (ConflictException) above. Entries exposed even later than + // the overlap window still require an explicit operational backfill because Bank Frick does not provide an + // ingestion cursor. + if (fullyProcessed) { + const reference = maxBookingDate && maxBookingDate < now ? maxBookingDate : now; + const candidate = Util.daysBefore(BankTxFrickService.FRICK_WATERMARK_OVERLAP_DAYS, reference); + const newWatermark = candidate > lastModificationTime ? candidate : lastModificationTime; + await this.settingService.set(settingKey, newWatermark.toISOString()); + } + } 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.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index cc03cc9348..0bba4f74e0 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 @@ -9,7 +9,6 @@ import { } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Observable, Subject } from 'rxjs'; -import { BankFrickService } from 'src/integration/bank/services/frick.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -40,7 +39,6 @@ import { Not, } from 'typeorm'; import { OlkypayService } from '../../../../../integration/bank/services/olkypay.service'; -import { Bank } from '../../../bank/bank/bank.entity'; import { BankService } from '../../../bank/bank/bank.service'; import { VirtualIbanService } from '../../../bank/virtual-iban/virtual-iban.service'; import { TransactionSourceType, TransactionTypeInternal } from '../../../payment/entities/transaction.entity'; @@ -60,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: { @@ -92,11 +91,6 @@ export class BankTxService implements OnModuleInit { private readonly bankBalanceSubject: Subject = new Subject(); private olkyUnavailableWarningLogged = false; - 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 bankTxRepo: BankTxRepository, @@ -106,7 +100,7 @@ export class BankTxService implements OnModuleInit { private readonly notificationService: NotificationService, private readonly settingService: SettingService, private readonly olkyService: OlkypayService, - private readonly frickService: BankFrickService, + private readonly frickTxService: BankTxFrickService, private readonly bankTxReturnService: BankTxReturnService, private readonly bankTxRepeatService: BankTxRepeatService, private readonly buyService: BuyService, @@ -137,7 +131,7 @@ export class BankTxService implements OnModuleInit { this.logger.error('Failed to check Olkypay transactions:', error); } try { - await this.checkFrickTransactions(); + await this.frickTxService.checkTransactions(this.create.bind(this)); } catch (error) { this.logger.error('Failed to check Bank Frick transactions:', error); } @@ -216,89 +210,6 @@ export class BankTxService implements OnModuleInit { if (olkyTransactions.length > 0) await this.settingService.set(settingKeyOlky, newModificationTime); } - private async checkFrickTransactions(): Promise { - if (!this.frickService.isAvailable()) { - this.warnFrickUnavailableOnce('Bank Frick service not configured - skipping transaction import'); - return; - } - - let banks: Bank[]; - try { - banks = await this.bankService - .getBanksByName(IbanBankName.FRICK) - .then((rows) => rows.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 = await this.frickService.getFrickTransactions(lastModificationTime, bank.iban); - let fullyProcessed = true; - let maxBookingDate: Date | undefined; - - for (const transaction of transactions) { - if (transaction.bookingDate && (!maxBookingDate || transaction.bookingDate > maxBookingDate)) { - maxBookingDate = transaction.bookingDate; - } - - try { - await this.create(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); - } - } - } - - // Advance after every fully processed response, including an empty statement, so an idle account does not - // request the complete epoch-to-today history forever. The new watermark is backdated by a fixed overlap - // (candidate = min(now, max booking date of the processed entries) - FRICK_WATERMARK_OVERLAP_DAYS) and can - // only ever move forward, so delayed Bank Frick reporting or a race between multiple instances polling the - // same account cannot silently skip entries. The resulting re-fetch of the overlap window is intentional - - // duplicates are absorbed by the create() dedup (ConflictException) above. Entries exposed even later than - // the overlap window still require an explicit operational backfill because Bank Frick does not provide an - // ingestion cursor. - if (fullyProcessed) { - const reference = maxBookingDate && maxBookingDate < now ? maxBookingDate : now; - const candidate = Util.daysBefore(BankTxService.FRICK_WATERMARK_OVERLAP_DAYS, reference); - const newWatermark = candidate > lastModificationTime ? candidate : lastModificationTime; - await this.settingService.set(settingKey, newWatermark.toISOString()); - } - } 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; - } - private async assignTransactions(): Promise { const unassignedBankTx = await this.bankTxRepo.find({ where: [ 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..6eed3efdca --- /dev/null +++ b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts @@ -0,0 +1,517 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Config } from 'src/config/config'; +import { FrickPaymentCharge, FrickPaymentState, FrickPaymentType } from 'src/integration/bank/dto/frick.dto'; +import { BankFrickService } from 'src/integration/bank/services/frick.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 { 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; + + 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(); + jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + FiatOutputFrickService, + { provide: FiatOutputRepository, useValue: fiatOutputRepo }, + { provide: BankFrickService, useValue: frickService }, + + 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('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: 'Synthetic payout' }), + ); + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 1, + 42, + expect.objectContaining({ + frickTxId: 'DFX-FO-42', + frickOrderId: '4242', + isTransmittedDate: expect.any(Date), + }), + ); + expect((fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + (frickService.approvePaymentWithoutTan as jest.Mock).mock.invocationCallOrder[0], + ); + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 2, + 42, + expect.objectContaining({ isApprovedDate: expect.any(Date) }), + ); + }); + + 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, + frickTxId: 'DFX-FO-42', + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); + 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, frickTxId: '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, + frickTxId: 'DFX-FO-42', + isApprovedDate: new Date('2026-07-01'), + 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, + frickTxId: '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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickError: 'FRICK status error: synthetic status failure', + }); + }); + + 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('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', + }); + expect((fiatOutputRepo.update as jest.Mock).mock.calls[0][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('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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { + frickOrderStatus: FrickPaymentState.REJECTED, + frickError: null, + }); + }); + + it.each([FrickPaymentState.REJECTED, FrickPaymentState.EXPIRED, FrickPaymentState.DELETED, FrickPaymentState.ERROR])( + 'maps terminal state %s to the dedicated order status', + (state) => { + expect( + service['getFrickStatusUpdate']( + { ...order, state }, + createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42' }), + ), + ).toEqual({ frickOrderStatus: state, frickError: null }); + }, + ); + + 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, frickTxId: '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, + frickTxId: 'DFX-FO-42', + 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, frickTxId: 'DFX-FO-42', isComplete: false })]); + + await service.checkFrickOrderStatus(); + + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); + 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, frickTxId: 'DFX-FO-42', 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, frickTxId: '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 7e10fa16ae..1c79b98d75 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,7 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { Config } from 'src/config/config'; -import { FrickPaymentCharge, FrickPaymentState, FrickPaymentType } from 'src/integration/bank/dto/frick.dto'; +import { FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; 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'; @@ -33,8 +32,9 @@ 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, TransactionCharge } from '../fiat-output.entity'; +import { FiatOutputType } from '../fiat-output.entity'; import { FiatOutputRepository } from '../fiat-output.repository'; describe('FiatOutputJobService', () => { @@ -51,7 +51,6 @@ describe('FiatOutputJobService', () => { let bankTxRepeatService: BankTxRepeatService; let yapealService: YapealService; let olkypayService: OlkypayService; - let frickService: BankFrickService; let virtualIbanService: VirtualIbanService; let scryptService: ScryptService; @@ -67,7 +66,6 @@ describe('FiatOutputJobService', () => { bankTxRepeatService = createMock(); yapealService = createMock(); olkypayService = createMock(); - frickService = createMock(); virtualIbanService = createMock(); scryptService = createMock(); jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); @@ -92,7 +90,10 @@ describe('FiatOutputJobService', () => { { provide: BankTxRepeatService, useValue: bankTxRepeatService }, { provide: YapealService, useValue: yapealService }, { provide: OlkypayService, useValue: olkypayService }, - { provide: BankFrickService, useValue: frickService }, + // 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: VirtualIbanService, useValue: virtualIbanService }, { provide: ScryptService, useValue: scryptService }, @@ -623,406 +624,7 @@ describe('FiatOutputJobService', () => { }); }); - describe('Bank Frick payouts', () => { - 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' }, - }; - - 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['transmitFrickPayments'](); - - 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['transmitFrickPayments'](); - - expect(fiatOutputRepo.find).not.toHaveBeenCalled(); - expect(frickService.createPaymentOrder).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['transmitFrickPayments'](); - - expect(frickService.createPaymentOrder).toHaveBeenCalledWith( - expect.objectContaining({ customId: 'DFX-FO-42', reference: 'Synthetic payout' }), - ); - expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( - 1, - 42, - expect.objectContaining({ - frickTxId: 'DFX-FO-42', - frickOrderId: '4242', - isTransmittedDate: expect.any(Date), - }), - ); - expect((fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( - (frickService.approvePaymentWithoutTan as jest.Mock).mock.invocationCallOrder[0], - ); - expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( - 2, - 42, - expect.objectContaining({ isApprovedDate: expect.any(Date) }), - ); - }); - - 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['transmitFrickPayments'](); - - 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, - frickTxId: 'DFX-FO-42', - 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, frickTxId: 'DFX-FO-42', isComplete: false })]); - - await service.checkFrickOrderStatus(); - - expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); - 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, frickTxId: '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, - frickTxId: 'DFX-FO-42', - isApprovedDate: new Date('2026-07-01'), - 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, - frickTxId: '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, frickTxId: 'DFX-FO-42', isComplete: false })]); - - await service.checkFrickOrderStatus(); - - expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { - frickError: 'FRICK status error: synthetic status failure', - }); - }); - - 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['transmitFrickPayments'](); - - expect(frickService.createPaymentOrder).toHaveBeenCalledWith( - expect.objectContaining({ creditor: expect.objectContaining({ address: undefined }) }), - ); - }); - - 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['transmitFrickPayments'](); - - expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { - frickError: 'FRICK error: synthetic transmission failure', - }); - expect((fiatOutputRepo.update as jest.Mock).mock.calls[0][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['transmitFrickPayments'](); - - expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { - frickError: 'FRICK error: synthetic transmission failure', - }); - }); - - 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, frickTxId: 'DFX-FO-42', isComplete: false })]); - - await service.checkFrickOrderStatus(); - - expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); - expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { - frickOrderStatus: FrickPaymentState.REJECTED, - frickError: null, - }); - }); - - it.each([ - FrickPaymentState.REJECTED, - FrickPaymentState.EXPIRED, - FrickPaymentState.DELETED, - FrickPaymentState.ERROR, - ])('maps terminal state %s to the dedicated order status', (state) => { - expect( - service['getFrickStatusUpdate']( - { ...order, state }, - createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42' }), - ), - ).toEqual({ frickOrderStatus: state, frickError: null }); - }); - - 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, frickTxId: '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); - }); - + 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([ @@ -1065,81 +667,5 @@ describe('FiatOutputJobService', () => { expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(6, { isReadyDate: expect.any(Date) }); }); - - 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, - frickTxId: 'DFX-FO-42', - 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, frickTxId: 'DFX-FO-42', isComplete: false })]); - - await service.checkFrickOrderStatus(); - - expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); - 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, frickTxId: 'DFX-FO-42', 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, frickTxId: 'DFX-FO-42' }), - ), - ).toThrow('Unsupported Bank Frick payment state'); - }); }); }); 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..a9a69e14fd --- /dev/null +++ b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts @@ -0,0 +1,186 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { FrickPaymentCharge, FrickPaymentOrder, FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; +import { BankFrickService } from 'src/integration/bank/services/frick.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 { + // 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. + private static readonly FRICK_TERMINAL_STATES = [ + FrickPaymentState.REJECTED, + FrickPaymentState.EXPIRED, + FrickPaymentState.DELETED, + FrickPaymentState.ERROR, + ]; + + private readonly logger = new DfxLogger(FiatOutputFrickService); + + constructor( + private readonly fiatOutputRepo: FiatOutputRepository, + private readonly frickService: BankFrickService, + ) {} + + @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT }) + async checkFrickOrderStatus(): Promise { + if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_STATUS_CHECK)) return; + if (!this.frickService.isAvailable()) return; + + const statusRequest: FindOptionsWhere = { frickTxId: Not(IsNull()), isComplete: false }; + const entities = await this.fiatOutputRepo.find({ + where: [ + { ...statusRequest, frickOrderStatus: IsNull() }, + { ...statusRequest, frickOrderStatus: Not(In(FiatOutputFrickService.FRICK_TERMINAL_STATES)) }, + ], + }); + + for (const entity of entities) { + try { + let order = await this.frickService.getPaymentOrder(entity.frickTxId); + + if (order.state === FrickPaymentState.PREPARED && this.isFrickAutomaticApprovalEnabled()) { + order = await this.frickService.approvePaymentWithoutTan(entity.frickTxId); + } + + const updateData = 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 (DisabledProcess(Process.FIAT_OUTPUT_FRICK_TRANSMISSION)) return; + if (!Config.bank.frick.payoutEnabled || !this.frickService.isAvailable()) return; + + const entities = await this.fiatOutputRepo.find({ + where: { + isReadyDate: Not(IsNull()), + isTransmittedDate: IsNull(), + frickTxId: IsNull(), + isComplete: false, + bank: { name: IbanBankName.FRICK }, + }, + }); + + for (const entity of entities) { + try { + const customId = `DFX-FO-${entity.id}`; + const remittanceInfo = entity.remittanceInfo ?? `DFX Payout ${entity.id}`; + const address = entity.address ? [entity.address, entity.houseNumber].filter(Boolean).join(' ') : undefined; + const charge = entity.charge + ? { + [TransactionCharge.BEN]: FrickPaymentCharge.BENEFICIARY, + [TransactionCharge.OUR]: FrickPaymentCharge.OUR, + [TransactionCharge.SHA]: FrickPaymentCharge.SHARED, + }[entity.charge] + : undefined; + + const order = await this.frickService.createPaymentOrder({ + customId, + amount: entity.amount, + currency: entity.currency as 'CHF' | 'EUR', + instant: entity.isInstant, + reference: remittanceInfo, + charge, + debtorIban: entity.accountIban, + creditor: { + name: entity.name, + iban: entity.iban, + bic: entity.bic, + address, + postalcode: entity.zip, + city: entity.city, + country: entity.country, + creditInstitution: entity.creditInstitution, + }, + }); + const safeOrderId = this.frickService.getSafeOrderId(order); + + // Persist 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. + await this.fiatOutputRepo.update(entity.id, { + frickTxId: customId, + ...(safeOrderId && { frickOrderId: safeOrderId }), + remittanceInfo, + isTransmittedDate: new Date(), + ...this.getFrickStatusUpdate(order, entity), + }); + + if (this.isFrickAutomaticApprovalEnabled() && order.state === FrickPaymentState.PREPARED) { + const approvedOrder = await this.frickService.approvePaymentWithoutTan(customId); + 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 && FiatOutputFrickService.FRICK_TERMINAL_STATES.includes(status); + } + + 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: + return { frickOrderStatus: order.state, frickError: null }; + + 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 3d01380e1b..66a15ec71d 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -3,8 +3,6 @@ import { CronExpression } from '@nestjs/schedule'; import { isLiechtensteinBankHoliday } from 'src/config/bank-holiday.config'; import { Config } from 'src/config/config'; import { OlkypayOrderStatus } from 'src/integration/bank/dto/olkypay.dto'; -import { FrickPaymentCharge, FrickPaymentOrder, FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; -import { BankFrickService } from 'src/integration/bank/services/frick.service'; import { Pain001Payment } from 'src/integration/bank/services/iso20022.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; @@ -33,21 +31,12 @@ 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 { FiatOutput, FiatOutputType, TransactionCharge } from './fiat-output.entity'; +import { FiatOutputFrickService } from './fiat-output-frick.service'; +import { FiatOutput, FiatOutputType } from './fiat-output.entity'; import { FiatOutputRepository } from './fiat-output.repository'; @Injectable() export class FiatOutputJobService { - // 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. - private static readonly FRICK_TERMINAL_STATES = [ - FrickPaymentState.REJECTED, - FrickPaymentState.EXPIRED, - FrickPaymentState.DELETED, - FrickPaymentState.ERROR, - ]; - private readonly logger = new DfxLogger(FiatOutputJobService); constructor( @@ -64,7 +53,7 @@ export class FiatOutputJobService { private readonly bankTxRepeatService: BankTxRepeatService, private readonly yapealService: YapealService, private readonly olkypayService: OlkypayService, - private readonly frickService: BankFrickService, + private readonly frickPayoutService: FiatOutputFrickService, private readonly virtualIbanService: VirtualIbanService, private readonly scryptService: ScryptService, ) {} @@ -77,7 +66,7 @@ export class FiatOutputJobService { await this.checkTransmission(); await this.transmitYapealPayments(); await this.transmitOlkypayPayments(); - await this.transmitFrickPayments(); + await this.frickPayoutService.transmitPayments(); await this.searchOutgoingBankTx(); } @@ -107,39 +96,6 @@ export class FiatOutputJobService { } } - @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT }) - async checkFrickOrderStatus(): Promise { - if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_STATUS_CHECK)) return; - if (!this.frickService.isAvailable()) return; - - const statusRequest: FindOptionsWhere = { frickTxId: Not(IsNull()), isComplete: false }; - const entities = await this.fiatOutputRepo.find({ - where: [ - { ...statusRequest, frickOrderStatus: IsNull() }, - { ...statusRequest, frickOrderStatus: Not(In(FiatOutputJobService.FRICK_TERMINAL_STATES)) }, - ], - }); - - for (const entity of entities) { - try { - let order = await this.frickService.getPaymentOrder(entity.frickTxId); - - if (order.state === FrickPaymentState.PREPARED && this.isFrickAutomaticApprovalEnabled()) { - order = await this.frickService.approvePaymentWithoutTan(entity.frickTxId); - } - - const updateData = 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), - }); - } - } - } - @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT, timeout: 1800 }) async generateReports() { const entities = await this.fiatOutputRepo.find({ @@ -282,7 +238,7 @@ export class FiatOutputJobService { 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.isFrickTerminalState(tx.frickOrderStatus); + return !this.frickPayoutService.isFrickTerminalState(tx.frickOrderStatus); case IbanBankName.OLKY: return !tx.bankTx || tx.bankTx.created > Util.minutesBefore(5); default: @@ -556,127 +512,6 @@ export class FiatOutputJobService { } } - private async transmitFrickPayments(): Promise { - if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_TRANSMISSION)) return; - if (!Config.bank.frick.payoutEnabled || !this.frickService.isAvailable()) return; - - const entities = await this.fiatOutputRepo.find({ - where: { - isReadyDate: Not(IsNull()), - isTransmittedDate: IsNull(), - frickTxId: IsNull(), - isComplete: false, - bank: { name: IbanBankName.FRICK }, - }, - }); - - for (const entity of entities) { - try { - const customId = `DFX-FO-${entity.id}`; - const remittanceInfo = entity.remittanceInfo ?? `DFX Payout ${entity.id}`; - const address = entity.address ? [entity.address, entity.houseNumber].filter(Boolean).join(' ') : undefined; - const charge = entity.charge - ? { - [TransactionCharge.BEN]: FrickPaymentCharge.BENEFICIARY, - [TransactionCharge.OUR]: FrickPaymentCharge.OUR, - [TransactionCharge.SHA]: FrickPaymentCharge.SHARED, - }[entity.charge] - : undefined; - - const order = await this.frickService.createPaymentOrder({ - customId, - amount: entity.amount, - currency: entity.currency as 'CHF' | 'EUR', - instant: entity.isInstant, - reference: remittanceInfo, - charge, - debtorIban: entity.accountIban, - creditor: { - name: entity.name, - iban: entity.iban, - bic: entity.bic, - address, - postalcode: entity.zip, - city: entity.city, - country: entity.country, - creditInstitution: entity.creditInstitution, - }, - }); - const safeOrderId = this.frickService.getSafeOrderId(order); - - // Persist 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. - await this.fiatOutputRepo.update(entity.id, { - frickTxId: customId, - ...(safeOrderId && { frickOrderId: safeOrderId }), - remittanceInfo, - isTransmittedDate: new Date(), - ...this.getFrickStatusUpdate(order, entity), - }); - - if (this.isFrickAutomaticApprovalEnabled() && order.state === FrickPaymentState.PREPARED) { - const approvedOrder = await this.frickService.approvePaymentWithoutTan(customId); - 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), - }); - } - } - } - - 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: - return { frickOrderStatus: order.state, frickError: null }; - - 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, transmitFrickPayments 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; - } - - private isFrickTerminalState(status: FrickPaymentState | undefined): boolean { - return status !== undefined && FiatOutputJobService.FRICK_TERMINAL_STATES.includes(status); - } - private async searchOutgoingBankTx(): Promise { if (DisabledProcess(Process.FIAT_OUTPUT_BANK_TX_SEARCH)) return; 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], From 0ae4717574e5d0ef0040472f45b0537274f44a8f Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 18:40:35 +0200 Subject: [PATCH 12/19] test(bank): gate the full Bank Frick surface at 100% coverage --- package.json | 26 +- .../__tests__/iso20022.service.spec.ts | 615 +++++++++++++++++- .../fiat-output-frick.service.spec.ts | 52 ++ 3 files changed, 691 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 16f7025490..dbbda1ae30 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "jest --silent", "test:watch": "jest --watch", "test:cov": "jest --coverage", - "test:frick:cov": "jest integration/bank/services/__tests__/frick.service.spec.ts --coverage --runInBand --collectCoverageFrom=integration/bank/dto/frick.dto.ts --collectCoverageFrom=integration/bank/services/frick.service.ts", + "test:frick:cov": "jest 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/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/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", @@ -202,6 +202,30 @@ "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/fiat-output/fiat-output-frick.service.ts": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 } }, "testEnvironment": "node", diff --git a/src/integration/bank/services/__tests__/iso20022.service.spec.ts b/src/integration/bank/services/__tests__/iso20022.service.spec.ts index 9786f053af..e6bfff3b69 100644 --- a/src/integration/bank/services/__tests__/iso20022.service.spec.ts +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -1,4 +1,255 @@ -import { Iso20022Service } from '../iso20022.service'; +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 = { @@ -95,3 +346,365 @@ describe('Iso20022Service camt.053 references', () => { return { BkToCstmrStmt: { Stmt: { Ntry: 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('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 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('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 a strict entry whose booking date is not a well-formed date string', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT', BookgDt: { Dt: 'not-a-date' } } }, + }, + }; + + expect(() => Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT', true)).toThrow( + 'Invalid booking date in CAMT entry', + ); + }); + + it('skips the value-date check in strict mode when no value date is present', () => { + const camt053 = { + BkToCstmrStmt: { + Stmt: { Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT', BookgDt: { Dt: '2026-07-01' } } }, + }, + }; + + 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 XML parsing', () => { + it('parses a camt.053 XML document into transactions', () => { + const xml = ` + + + + + 99.90 + CRDT +
2026-07-01
+
+
+
+
`; + + const result = Iso20022Service.parseCamt053Xml(xml, 'SYNTHETIC-ACCOUNT'); + + expect(result).toHaveLength(1); + expect(result[0].amount).toBe(99.9); + expect(result[0].currency).toBe('EUR'); + }); + + 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 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/subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts index 6eed3efdca..ce8008e56f 100644 --- 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 @@ -321,6 +321,18 @@ describe('FiatOutputFrickService', () => { }); }); + 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, frickTxId: '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; @@ -348,6 +360,33 @@ describe('FiatOutputFrickService', () => { ); }); + 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); @@ -383,6 +422,19 @@ describe('FiatOutputFrickService', () => { }); }); + 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; From 2e392887bdaf012ce319cb04e1b4166888339cf1 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Mon, 13 Jul 2026 21:39:43 +0200 Subject: [PATCH 13/19] fix(bank): harden Bank Frick integration --- .env.example | 1 + docs/bank-frick-operations.md | 125 ++++++-- infrastructure/bicep/api/dfx-api.bicep | 5 + infrastructure/bicep/api/parameters/dev.json | 3 + infrastructure/bicep/api/parameters/loc.json | 3 + infrastructure/bicep/api/parameters/prd.json | 3 + ...783944000000-AddBankFrickPayoutTracking.js | 54 +++- migration/seed/bank.csv | 16 +- package.json | 8 +- .../__tests__/bank-frick-config.spec.ts | 5 + src/config/__tests__/frick.config.spec.ts | 15 +- src/config/frick.config.ts | 2 + src/integration/bank/dto/frick.dto.ts | 9 +- .../services/__tests__/frick.service.spec.ts | 88 +++++- .../__tests__/iso20022.service.spec.ts | 284 +++++++++++++++++- .../bank/services/frick.service.ts | 58 +++- .../bank/services/iso20022.service.ts | 125 ++++++-- .../__tests__/setting.repository.spec.ts | 78 +++++ .../setting/__tests__/setting.service.spec.ts | 17 ++ .../models/setting/setting.repository.ts | 23 ++ src/shared/models/setting/setting.service.ts | 7 + .../services/__tests__/http.service.spec.ts | 143 +++++++++ src/shared/services/http.service.ts | 106 ++++++- src/shared/utils/util.ts | 2 +- .../supporting/bank-tx/bank-tx.module.ts | 4 +- .../__tests__/bank-tx-frick.service.spec.ts | 82 +++-- .../bank-tx-outgoing-match.service.spec.ts | 114 +++++++ .../bank-tx/services/bank-tx-frick.service.ts | 30 +- .../bank-tx-outgoing-match.service.ts | 67 +++++ .../bank-tx/services/bank-tx.service.ts | 20 -- .../bank/bank/__tests__/bank.service.spec.ts | 11 + .../supporting/bank/bank/bank.entity.ts | 1 + .../supporting/bank/bank/bank.service.ts | 4 + ...nk-frick-payout-tracking.migration.spec.ts | 94 ++++++ .../fiat-output-frick.service.spec.ts | 114 ++++++- .../__tests__/fiat-output-job.service.spec.ts | 160 +++++++++- .../fiat-output/fiat-output-frick.service.ts | 51 +++- .../fiat-output/fiat-output-job.service.ts | 50 +-- 38 files changed, 1784 insertions(+), 198 deletions(-) create mode 100644 src/shared/models/setting/__tests__/setting.repository.spec.ts create mode 100644 src/shared/services/__tests__/http.service.spec.ts create mode 100644 src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts create mode 100644 src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts diff --git a/.env.example b/.env.example index 9de58db79e..17e0d1d19d 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,7 @@ RAIFFEISEN_IV= FRICK_BASE_URL= FRICK_API_KEY= FRICK_PRIVATE_KEY= +FRICK_SERVER_PUBLIC_KEY= FRICK_CUSTOMER= FRICK_PAYOUT_ENABLED= FRICK_APPROVE_WITHOUT_TAN= diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md index 228a79355f..a0851324e1 100644 --- a/docs/bank-frick-operations.md +++ b/docs/bank-frick-operations.md @@ -1,25 +1,26 @@ # Bank Frick — Operations Runbook -Operational notes for the Bank Frick statement import (`BankTxService.checkFrickTransactions`, -`src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts`). Covers the watermark -backfill procedure and a one-time monitoring note for the reference-less-entry deploy. +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). After every fully -processed fetch — including an empty statement — the watermark advances to -`min(now, latest booking date of the processed entries) − FRICK_WATERMARK_OVERLAP_DAYS`, and -never moves backwards. The overlap (currently 2 days) exists so that ordinary bank-side -reporting lag or a race between multiple running instances does not skip entries. Duplicate -re-fetches inside the overlap window are expected and are absorbed by the existing `create()` -dedup, which rejects re-inserts of the same `accountServiceRef` with a `ConflictException`. - -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. +`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 @@ -56,11 +57,9 @@ its own — this requires a manual rewind. ### 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 — the service will attempt to -pull the account's entire history. For an account with material transaction volume, seed the -setting key explicitly **before** flipping `Bank.receive` to `true`, to the earliest date you -actually want imported (or `now`, if only new activity should be picked up going forward), so -the first poll does not trigger an unbounded historical pull. +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. Raiffeisen/Yapeal reference-less entries after deploy @@ -91,3 +90,91 @@ those accounts until the overlap window has fully rolled past the old imports. retroactively corrected by this deploy. Once the one-time duplicate batch above has been confirmed, plan a manual cleanup pass (dedup/merge or archive the superseded rows) for the affected Raiffeisen/Yapeal accounts; do not run it automatically as part of the deploy. + +## 3. Registry and default-off activation + +The migration installs two disabled synthetic placeholders (`LI4200000FRICKCHF0001` and +`LI5600000FRICKEUR0001`) only so deployed databases have deterministic CHF/EUR rows to update. +They have `receive=false`, `send=false` and `sctInst=false`; they are not production accounts. +The local seed uses the same clearly synthetic, checksum-valid IBANs with the same disabled flags. + +Before activation, Operations must: + +1. Replace each synthetic IBAN with the team-provided CT account IBAN and confirm `BFRILI22`. + Operating accounts are deliberately not registered under the same `(name, currency)` because + existing selectors assume one Bank Frick row per currency. +2. Set `receive`, `send` and `sctInst` from the confirmed account-role matrix. Never infer these + flags from currency. + 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. +3. Seed `lastBankFrickDate:` before setting `receive=true`. +4. 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. +5. 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. +6. Ensure exactly one sender bank is eligible for each currency/country route before enabling a + Frick `send` flag. A Frick route alongside another eligible sender is deliberately treated as + ambiguous and leaves the output unassigned until Operations makes the ownership decision + explicit; existing non-Frick routing order remains unchanged. + +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. diff --git a/infrastructure/bicep/api/dfx-api.bicep b/infrastructure/bicep/api/dfx-api.bicep index 370465b03d..82ce57525c 100644 --- a/infrastructure/bicep/api/dfx-api.bicep +++ b/infrastructure/bicep/api/dfx-api.bicep @@ -240,6 +240,7 @@ param frickBaseUrl string param frickApiKey string @secure() param frickPrivateKey string +param frickServerPublicKey string @secure() param frickCustomer string param frickPayoutEnabled string @@ -1231,6 +1232,10 @@ resource apiAppService 'Microsoft.Web/sites@2018-11-01' = { name: 'FRICK_PRIVATE_KEY' value: frickPrivateKey } + { + name: 'FRICK_SERVER_PUBLIC_KEY' + value: frickServerPublicKey + } { name: 'FRICK_CUSTOMER' value: frickCustomer diff --git a/infrastructure/bicep/api/parameters/dev.json b/infrastructure/bicep/api/parameters/dev.json index 9015f4425e..8aa5f13bee 100644 --- a/infrastructure/bicep/api/parameters/dev.json +++ b/infrastructure/bicep/api/parameters/dev.json @@ -443,6 +443,9 @@ "frickPrivateKey": { "value": "xxx" }, + "frickServerPublicKey": { + "value": "xxx" + }, "frickCustomer": { "value": "xxx" }, diff --git a/infrastructure/bicep/api/parameters/loc.json b/infrastructure/bicep/api/parameters/loc.json index 7d5e04a6df..b3661efba7 100644 --- a/infrastructure/bicep/api/parameters/loc.json +++ b/infrastructure/bicep/api/parameters/loc.json @@ -443,6 +443,9 @@ "frickPrivateKey": { "value": "xxx" }, + "frickServerPublicKey": { + "value": "xxx" + }, "frickCustomer": { "value": "xxx" }, diff --git a/infrastructure/bicep/api/parameters/prd.json b/infrastructure/bicep/api/parameters/prd.json index 8545c3eac0..c4625e2848 100644 --- a/infrastructure/bicep/api/parameters/prd.json +++ b/infrastructure/bicep/api/parameters/prd.json @@ -443,6 +443,9 @@ "frickPrivateKey": { "value": "xxx" }, + "frickServerPublicKey": { + "value": "xxx" + }, "frickCustomer": { "value": "xxx" }, diff --git a/migration/1783944000000-AddBankFrickPayoutTracking.js b/migration/1783944000000-AddBankFrickPayoutTracking.js index 58e69ad247..0f42dd7031 100644 --- a/migration/1783944000000-AddBankFrickPayoutTracking.js +++ b/migration/1783944000000-AddBankFrickPayoutTracking.js @@ -4,9 +4,10 @@ */ /** - * The concrete Bank Frick account rows are deliberately not part of this migration: their IBANs - * are deployment secrets and were not supplied for this issue. DFX operations must insert those - * rows separately before enabling either account polling or the default-off payout rail. + * Concrete Bank Frick IBANs and account roles are supplied out of band. This migration therefore + * creates unmistakably synthetic, disabled placeholders in non-empty deployed registries. They + * cannot receive or send until Operations replaces the IBANs and deliberately sets the role flags. + * A fresh local registry stays empty here and is populated from migration/seed/bank.csv instead. * * @class * @implements {MigrationInterface} @@ -23,6 +24,36 @@ module.exports = class AddBankFrickPayoutTracking1783944000000 { await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickTxId" 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)`); + await queryRunner.query(` + INSERT INTO "bank" ("updated", "created", "name", "iban", "bic", "currency", "receive", "send", "sctInst", "amlEnabled") + SELECT NOW(), NOW(), account."name", account."iban", account."bic", account."currency", FALSE, FALSE, FALSE, TRUE + FROM (VALUES + ('Bank Frick', 'LI4200000FRICKCHF0001', 'BFRILI22', 'CHF'), + ('Bank Frick', 'LI5600000FRICKEUR0001', 'BFRILI22', 'EUR') + ) AS account("name", "iban", "bic", "currency") + WHERE EXISTS (SELECT 1 FROM "bank") + ON CONFLICT ("iban", "bic") DO NOTHING + `); + 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() + `); } /** @@ -30,6 +61,23 @@ module.exports = class AddBankFrickPayoutTracking1783944000000 { */ 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(` + DELETE FROM "bank" + WHERE "name" = 'Bank Frick' + AND "iban" IN ('LI4200000FRICKCHF0001', 'LI5600000FRICKEUR0001') + AND "receive" = FALSE + AND "send" = FALSE + `); 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 "frickTxId"`); diff --git a/migration/seed/bank.csv b/migration/seed/bank.csv index 64aa9f4d28..38a8b58d1b 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 +18,2026-07-13 00:00:00,2026-07-13 00:00:00,Bank Frick,LI5600000FRICKEUR0001,BFRILI22,EUR,FALSE,FALSE,FALSE,TRUE, +17,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 dbbda1ae30..b2ae8613c3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "jest --silent", "test:watch": "jest --watch", "test:cov": "jest --coverage", - "test:frick:cov": "jest 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/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/fiat-output/fiat-output-frick.service.ts", + "test:frick:cov": "jest 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", @@ -221,6 +221,12 @@ "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, diff --git a/src/config/__tests__/bank-frick-config.spec.ts b/src/config/__tests__/bank-frick-config.spec.ts index 77cf61708a..ffe40bc538 100644 --- a/src/config/__tests__/bank-frick-config.spec.ts +++ b/src/config/__tests__/bank-frick-config.spec.ts @@ -3,13 +3,18 @@ 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 index 7ae04182e0..c01c808aa7 100644 --- a/src/config/__tests__/frick.config.spec.ts +++ b/src/config/__tests__/frick.config.spec.ts @@ -19,16 +19,23 @@ describe('buildFrickConfig', () => { expect(config.customer).toBe('synthetic-customer'); }); - it('restores PEM line breaks when the private key is set', () => { - const config = buildFrickConfig(env({ FRICK_PRIVATE_KEY: 'synthetic-line-one
synthetic-line-two' })); + 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-line-one\nsynthetic-line-two'); + expect(config.privateKey).toBe('synthetic-private-one\nsynthetic-private-two'); + expect(config.serverPublicKey).toBe('synthetic-public-one\nsynthetic-public-two'); }); - it('leaves the private key undefined when it is not set', () => { + 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([ diff --git a/src/config/frick.config.ts b/src/config/frick.config.ts index 6f730c9641..c8cd1fbb0a 100644 --- a/src/config/frick.config.ts +++ b/src/config/frick.config.ts @@ -2,6 +2,7 @@ export interface FrickConfig { baseUrl: string; apiKey: string; privateKey: string; + serverPublicKey: string; customer: string; payoutEnabled: boolean; approveWithoutTan: boolean; @@ -12,6 +13,7 @@ export function buildFrickConfig(env: NodeJS.ProcessEnv): FrickConfig { 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/dto/frick.dto.ts b/src/integration/bank/dto/frick.dto.ts index 3db70e70f1..64abb98c86 100644 --- a/src/integration/bank/dto/frick.dto.ts +++ b/src/integration/bank/dto/frick.dto.ts @@ -64,6 +64,9 @@ export interface FrickPaymentAccount { 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 { @@ -114,6 +117,6 @@ export interface FrickTransactionsResponse { transactions: FrickPaymentOrder[]; } -export interface FrickApproveWithoutTanRequest { - customIds: string[]; -} +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 index 66e250633a..98e5876303 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync, verify } from 'crypto'; +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'; @@ -31,6 +31,7 @@ describe('BankFrickService', () => { baseUrl: 'https://bank.invalid/webapi/v2/', apiKey: 'synthetic-api-key', privateKey: keys.privateKey, + serverPublicKey: keys.publicKey, customer: '0000000', payoutEnabled: false, approveWithoutTan: false, @@ -51,6 +52,18 @@ describe('BankFrickService', () => { 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'); + + 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 () => { @@ -131,7 +144,7 @@ describe('BankFrickService', () => { expect(http.request).not.toHaveBeenCalled(); }); - it.each(['baseUrl', 'apiKey', 'privateKey', 'customer'] as const)( + it.each(['baseUrl', 'apiKey', 'privateKey', 'serverPublicKey', 'customer'] as const)( 'reports the integration unavailable when %s is missing', (field) => { Config.bank.frick[field] = undefined; @@ -140,16 +153,39 @@ describe('BankFrickService', () => { }, ); + 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'](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']('{"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('DFX-FO-42')).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('DFX-FO-42')).rejects.toThrow( + await expect(service.approvePaymentWithoutTan(paymentOrder())).rejects.toThrow( 'approval without TAN is not explicitly enabled', ); expect(http.request).not.toHaveBeenCalled(); @@ -252,6 +288,17 @@ describe('BankFrickService', () => { 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 = { @@ -369,12 +416,12 @@ describe('BankFrickService', () => { creditor: { name: ' Synthetic Recipient ', iban: ` ${creditorIban.toLowerCase()} `, - bic: ' test de ff ', + bic: ' testdeffxxx ', address: ' Synthetic Street 42 ', postalcode: ' 8000 ', city: ' Zurich ', country: ' CH ', - creditInstitution: ' Synthetic Bank ', + creditInsitution: ' Synthetic Bank ', }, }); @@ -388,13 +435,13 @@ describe('BankFrickService', () => { expect(() => service['assertSamePayment'](existing, requested)).not.toThrow(); }); - it('approves by stable customId and never converts an order id', async () => { + 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('DFX-FO-42'); + await service.approvePaymentWithoutTan(order); const approval = http.request.mock.calls[1][0]; expect(JSON.parse(approval.data)).toEqual({ customIds: ['DFX-FO-42'] }); @@ -402,6 +449,19 @@ describe('BankFrickService', () => { 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(); @@ -528,6 +588,7 @@ describe('BankFrickService', () => { 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`], @@ -597,6 +658,15 @@ describe('BankFrickService', () => { expectSignature('', camtRequest.headers.Signature); }); + 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); } @@ -666,9 +736,11 @@ describe('BankFrickService', () => { + ${debtorIban} 12.34 CRDT + BOOK
2026-07-02
2026-07-02
SYNTHETIC-REF-1 diff --git a/src/integration/bank/services/__tests__/iso20022.service.spec.ts b/src/integration/bank/services/__tests__/iso20022.service.spec.ts index e6bfff3b69..a5e7cefadf 100644 --- a/src/integration/bank/services/__tests__/iso20022.service.spec.ts +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -343,7 +343,14 @@ describe('Iso20022Service camt.053 references', () => { }); function statement(transactionEntry: object) { - return { BkToCstmrStmt: { Stmt: { Ntry: transactionEntry } } }; + return { + BkToCstmrStmt: { + Stmt: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT-A' } }, + Ntry: { Sts: 'BOOK', ...transactionEntry }, + }, + }, + }; } }); @@ -461,6 +468,26 @@ describe('Iso20022Service camt.053 entry field parsing', () => { ); }); + 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: { @@ -497,6 +524,71 @@ describe('Iso20022Service camt.053 entry field parsing', () => { 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: { @@ -524,6 +616,15 @@ describe('Iso20022Service camt.053 entry field parsing', () => { 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' } } } }; @@ -559,7 +660,15 @@ describe('Iso20022Service camt.053 entry field parsing', () => { it('rejects a strict entry whose booking date is not a well-formed date string', () => { const camt053 = { BkToCstmrStmt: { - Stmt: { Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT', BookgDt: { Dt: 'not-a-date' } } }, + Stmt: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT' } }, + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + Sts: 'BOOK', + BookgDt: { Dt: 'not-a-date' }, + }, + }, }, }; @@ -568,10 +677,36 @@ describe('Iso20022Service camt.053 entry field parsing', () => { ); }); + 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: { Ntry: { Amt: { Value: 1, Ccy: 'EUR' }, CdtDbtInd: 'CRDT', BookgDt: { Dt: '2026-07-01' } } }, + Stmt: { + Acct: { Id: { IBAN: 'SYNTHETIC-ACCOUNT' } }, + Ntry: { + Amt: { Value: 1, Ccy: 'EUR' }, + CdtDbtInd: 'CRDT', + Sts: { Cd: 'BOOK' }, + BookgDt: { Dt: '2026-07-01' }, + }, + }, }, }; @@ -601,6 +736,7 @@ describe('Iso20022Service camt.053 XML parsing', () => { 99.90 CRDT
2026-07-01
+ 00012345678901234567890
@@ -611,6 +747,7 @@ describe('Iso20022Service camt.053 XML parsing', () => { 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', () => { @@ -631,6 +768,147 @@ describe('Iso20022Service camt.053 XML parsing', () => { }); }); +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' }, + ...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'], + [ + strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Ustrd: { unexpected: true } } } } }), + 'Invalid unstructured remittance information in CAMT entry', + ], + [ + strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Strd: { unsupported: true } } } } }), + 'Unsupported structured remittance information in CAMT entry', + ], + [ + strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Ustrd: [' ', ''] } } } }), + 'Invalid unstructured remittance information in CAMT entry', + ], + [ + strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Strd: ' ' } } } }), + 'Unsupported structured remittance information in CAMT entry', + ], + [ + strictEntry({ NtryDtls: { TxDtls: { Refs: { AcctSvcrRef: { malformed: true } } } } }), + 'Invalid AcctSvcrRef in CAMT entry', + ], + [ + strictEntry({ NtryDtls: { TxDtls: { Refs: { EndToEndId: { malformed: true } } } } }), + 'Invalid EndToEndId in CAMT entry', + ], + ])('rejects a lossy or unsafe strict entry', (entry, expectedError) => { + expect(() => Iso20022Service.parseCamt053Json(strictStatement(entry), accountIban, true)).toThrow(expectedError); + }); +}); + describe('Iso20022Service pain.001 generation', () => { const payment: Pain001Payment = { messageId: 'SYNTHETIC-MSG-ID', diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index dc11c7b2c6..38d947b0f6 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Method } from 'axios'; +import { AxiosResponse, Method } from 'axios'; import * as IbanTools from 'ibantools'; import { Config } from 'src/config/config'; import { HttpService } from 'src/shared/services/http.service'; @@ -40,8 +40,8 @@ export class BankFrickService { constructor(private readonly http: HttpService) {} isAvailable(): boolean { - const { baseUrl, apiKey, privateKey, customer } = Config.bank.frick; - return !!(baseUrl && apiKey && privateKey && customer); + const { baseUrl, apiKey, privateKey, serverPublicKey, customer } = Config.bank.frick; + return !!(baseUrl && apiKey && privateKey && serverPublicKey && customer); } async getBalances(): Promise { @@ -72,8 +72,10 @@ export class BankFrickService { const iban = this.normalizeAndValidateIban(accountIban, 'account IBAN'); const params = new URLSearchParams({ iban, - fromDate: Util.isoDate(lastModificationTime), - toDate: Util.isoDate(new Date()), + // 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( @@ -116,14 +118,18 @@ export class BankFrickService { return payment; } - async approvePaymentWithoutTan(customId: string): Promise { + 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 request: FrickApproveWithoutTanRequest = { customIds: [customId] }; + 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); } @@ -180,6 +186,8 @@ export class BankFrickService { 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, @@ -388,10 +396,14 @@ export class BankFrickService { 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, requested.creditor.creditInstitution) && - this.matchesSentString(existing.creditor.bic, requested.creditor.bic, (value) => - value.replace(/\s/g, '').toUpperCase(), - ); + 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}XXX` : bic; + }); if (!same) throw new Error(`Bank Frick customId collision for ${requested.customId}`); } @@ -445,6 +457,7 @@ export class BankFrickService { Signature: this.sign(bodyString), algorithm: 'rsa-sha512', }, + responseVerifier: (rawBody, headers) => this.verifyResponse(rawBody, headers), }); } catch (error) { if (allowUnauthorizedRetry && error?.response?.status === 401) { @@ -495,6 +508,7 @@ export class BankFrickService { Signature: this.sign(bodyString), algorithm: 'rsa-sha512', }, + responseVerifier: (rawBody, headers) => this.verifyResponse(rawBody, headers), }); } catch (error) { throw new Error(`Bank Frick authorization failed: ${this.getHttpFailureReason(error)}`); @@ -511,8 +525,10 @@ export class BankFrickService { private getTokenExpiry(token: string): number { try { - const payloadPart = token.split('.')[1]; - if (!payloadPart) throw new Error('missing payload'); + 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) @@ -533,6 +549,22 @@ export class BankFrickService { } } + private verifyResponse(rawBody: string, 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 Error('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 Error('Invalid Bank Frick response signature'); + } + } + private createUrl(path: string): string { return `${Config.bank.frick.baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; } diff --git a/src/integration/bank/services/iso20022.service.ts b/src/integration/bank/services/iso20022.service.ts index 15f9c20cd2..0bfa8f6461 100644 --- a/src/integration/bank/services/iso20022.service.ts +++ b/src/integration/bank/services/iso20022.service.ts @@ -217,13 +217,17 @@ export class Iso20022Service { // --- CAMT.053 PARSING --- // static parseCamt053Json(camt053: any, accountIban: string, strict = false): CamtTransaction[] { const rawStatements = camt053?.BkToCstmrStmt?.Stmt; - if (!rawStatements) return []; + 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 (strict) this.assertValidCamtStatement(stmt, accountIban); if (!stmt.Ntry) continue; const entries = Array.isArray(stmt.Ntry) ? stmt.Ntry : [stmt.Ntry]; @@ -256,8 +260,9 @@ export class Iso20022Service { ): 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'); @@ -269,24 +274,29 @@ export class Iso20022Service { const creditDebitIndicator = cdtDbtInd === 'CRDT' ? BankTxIndicator.CREDIT : BankTxIndicator.DEBIT; // 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; @@ -300,7 +310,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 } = @@ -308,25 +318,21 @@ 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, strict); // reference - check transaction-level refs first (matches camt.054), then entry-level AcctSvcrRef + const transactionAccountServiceRef = this.parseOptionalCamtString(txDtls.Refs?.AcctSvcrRef, 'AcctSvcrRef', strict); + const transactionId = this.parseOptionalCamtString(txDtls.Refs?.TxId, 'TxId', strict); + const entryAccountServiceRef = this.parseOptionalCamtString(entry.AcctSvcrRef, 'AcctSvcrRef', strict); + const entryReference = this.parseOptionalCamtString(entry.NtryRef, 'NtryRef', strict); const accountServiceRef = - txDtls.Refs?.AcctSvcrRef || txDtls.Refs?.TxId || entry.AcctSvcrRef || entry.NtryRef || fallbackReference; + transactionAccountServiceRef || transactionId || entryAccountServiceRef || entryReference || fallbackReference; // end-to-end ID - const endToEndId = txDtls.Refs?.EndToEndId || ''; + const endToEndId = this.parseOptionalCamtString(txDtls.Refs?.EndToEndId, 'EndToEndId', strict) || ''; // 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; @@ -364,6 +370,9 @@ export class Iso20022Service { 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); @@ -388,14 +397,83 @@ export class Iso20022Service { }, {}); } + 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 ?? {}; + } + + private static parseCamtRemittanceInfo( + remittance: any, + additionalEntryInfo: unknown, + strict: boolean, + ): 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 (strict) throw new Error('Invalid unstructured remittance information in CAMT entry'); + } + + 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(' '); + if (strict) throw new Error('Unsupported structured remittance information in CAMT entry'); + } + + 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 parseOptionalCamtString(value: unknown, field: string, strict: boolean): string | undefined { + if (value === undefined) return undefined; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (strict) throw new Error(`Invalid ${field} in CAMT entry`); + return undefined; + } + private static assertValidCamtDate(value: unknown, field: string): void { - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value)) + 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 --- // @@ -537,6 +615,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..35fad1d931 --- /dev/null +++ b/src/shared/services/__tests__/http.service.spec.ts @@ -0,0 +1,143 @@ +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: 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(rawBody, headers); + const transportConfig = nestHttp.request.mock.calls[0][0]; + expect(transportConfig.responseType).toBe('text'); + expect(transportConfig.transformResponse[0](rawBody)).toBe(rawBody); + expect(transportConfig).not.toHaveProperty('responseVerifier'); + }); + + it('returns verified text without JSON parsing', async () => { + nestHttp.request.mockReturnValue(of({ data: '', 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: '{"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 raw text', 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 raw text'); + }); + + 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(); + }); +}); diff --git a/src/shared/services/http.service.ts b/src/shared/services/http.service.ts index 557fb7bd13..8aaf2abebf 100644 --- a/src/shared/services/http.service.ts +++ b/src/shared/services/http.service.ts @@ -14,10 +14,75 @@ export interface HttpError { }; } -export type HttpRequestConfig = AxiosRequestConfig & { tryCount?: number; retryDelay?: number }; +export type HttpRequestConfig = AxiosRequestConfig & { + tryCount?: number; + retryDelay?: number; + responseVerifier?: (rawBody: string, headers: AxiosResponse['headers']) => void; +}; + +type MockResponseFactory = (url: string, config?: HttpRequestConfig) => unknown; +const FRICK_MOCK_ORDERS = new Map>(); + +function parseMockRequestBody(config?: HttpRequestConfig): any { + if (typeof config?.data === 'string') return JSON.parse(config.data); + return config?.data ?? {}; +} + +function frickTransactionsResponse(transactions: Record[]) { + return { moreResults: false, resultSetSize: transactions.length, transactions }; +} -// Mock responses for local development -const MOCK_RESPONSES: { pattern: RegExp; response: any }[] = [ +// Mock responses for local development. Bank Frick is endpoint-aware because a generic `{}` response makes the +// integration appear reachable while every validator fails, which hides local wiring errors instead of simulating it. +const MOCK_RESPONSES: { pattern: RegExp; response: unknown | MockResponseFactory }[] = [ + { + pattern: /bankfrick\.li\/webapi\/v2\/authorize(?:\?|$)/, + response: () => { + 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` }; + }, + }, + { + pattern: /bankfrick\.li\/webapi\/v2\/camt053(?:\?|$)/, + response: (url) => { + const iban = new URL(url).searchParams.get('iban') ?? 'SYNTHETIC-UNCONFIGURED-IBAN'; + return `${iban}`; + }, + }, + { + pattern: /bankfrick\.li\/webapi\/v2\/accounts(?:\/|\?|$)/, + response: { date: '2026-01-01', moreResults: false, resultSetSize: 0, accounts: [] }, + }, + { + pattern: /bankfrick\.li\/webapi\/v2\/signTransactionWithoutTan(?:\?|$)/, + response: (_url, config) => { + const request = parseMockRequestBody(config); + const orderIds = new Set((request.orderIds ?? []).map(String)); + const customIds = new Set(request.customIds ?? []); + const orders = [...FRICK_MOCK_ORDERS.values()] + .filter((order) => orderIds.has(String(order.orderId)) || customIds.has(String(order.customId))) + .map>((order) => ({ ...order, state: 'IN_PROGRESS' })); + orders.forEach((order) => FRICK_MOCK_ORDERS.set(String(order.customId), order)); + return frickTransactionsResponse(orders); + }, + }, + { + pattern: /bankfrick\.li\/webapi\/v2\/transactions(?:\?|$)/, + response: (url, config) => { + if (config?.method?.toUpperCase() === 'PUT') { + const transaction = parseMockRequestBody(config).transactions?.[0]; + if (!transaction) return frickTransactionsResponse([]); + const order = { ...transaction, orderId: FRICK_MOCK_ORDERS.size + 1, state: 'PREPARED' }; + FRICK_MOCK_ORDERS.set(String(order.customId), order); + return frickTransactionsResponse([order]); + } + + const customId = new URL(url).searchParams.get('customId'); + const order = customId ? FRICK_MOCK_ORDERS.get(customId) : undefined; + return frickTransactionsResponse(order ? [order] : []); + }, + }, { 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 } }, @@ -61,12 +126,12 @@ export class HttpService { return true; } - private getMockResponse(url: string): T { + private getMockResponse(url: string, config?: HttpRequestConfig): 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; + const 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 +202,27 @@ 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 UTF-8 string until the detached signature was verified. Axios' JSON + // transform would otherwise parse/re-serialize the body and make verification both unreliable and unsafe. + axiosConfig.responseType = 'text'; + axiosConfig.transformResponse = [(data) => data]; + } + + const response = await Util.retry( + () => firstValueFrom(this.http.request(axiosConfig)), + tryCount ?? 1, + retryDelay, + ); + if (!responseVerifier) return response.data; + if (typeof response.data !== 'string') throw new Error('Signed HTTP response body is not raw text'); + + responseVerifier(response.data, response.headers); + if (requestedResponseType === 'text') return response.data as T; + return JSON.parse(response.data) as T; } async downloadFile(fileUrl: string, filePath: string) { 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/supporting/bank-tx/bank-tx.module.ts b/src/subdomains/supporting/bank-tx/bank-tx.module.ts index d9b1aa5c46..377ccd4896 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx.module.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx.module.ts @@ -26,6 +26,7 @@ import { BankTxBatchRepository } from './bank-tx/repositories/bank-tx-batch.repo 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'; @@ -53,6 +54,7 @@ import { SepaParser } from './bank-tx/services/sepa-parser.service'; BankTxRepeatRepository, BankTxService, BankTxFrickService, + BankTxOutgoingMatchService, BankTxReturnService, BankTxRepeatService, BankTxBatchService, @@ -60,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 index b191d2a7bd..1aa980df9a 100644 --- 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 @@ -15,7 +15,7 @@ describe('BankTxFrickService', () => { let frickTxService: BankTxFrickService; let frickService: jest.Mocked>; let bankService: jest.Mocked>; - let settingService: jest.Mocked>; + let settingService: jest.Mocked>; let specialAccountService: jest.Mocked>; let loggerWarn: jest.SpyInstance; let loggerError: jest.SpyInstance; @@ -25,7 +25,7 @@ describe('BankTxFrickService', () => { bankService = { getBanksByName: jest.fn() }; settingService = { get: jest.fn().mockResolvedValue(new Date(0).toISOString()), - set: jest.fn().mockResolvedValue(undefined), + setDateMax: jest.fn().mockResolvedValue(undefined), }; specialAccountService = { getMultiAccounts: jest.fn().mockResolvedValue([]) }; loggerWarn = jest.spyOn(DfxLogger.prototype, 'warn').mockImplementation(); @@ -56,8 +56,8 @@ describe('BankTxFrickService', () => { bank(103, 'SYNTHETIC-NON-RECEIVING', false), ]); frickService.getFrickTransactions - .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-EUR-1' }]) - .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-CHF-1' }]); + .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-EUR-1', bookingDate: new Date('2026-07-10') }]) + .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-CHF-1', bookingDate: new Date('2026-07-11') }]); const createTx = jest.fn().mockResolvedValue({}); await frickTxService.checkTransactions(createTx); @@ -72,14 +72,14 @@ describe('BankTxFrickService', () => { 'lastBankFrickDate:101', 'lastBankFrickDate:102', ]); - expect(settingService.set.mock.calls.map((call) => call[0])).toEqual([ + expect(settingService.setDateMax.mock.calls.map((call) => call[0])).toEqual([ 'lastBankFrickDate:101', 'lastBankFrickDate:102', ]); expect(specialAccountService.getMultiAccounts).toHaveBeenCalledTimes(1); }); - it('advances the watermark after an empty response but not after a fetch failure', async () => { + 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), @@ -89,8 +89,7 @@ describe('BankTxFrickService', () => { await frickTxService.checkTransactions(createTx); - expect(settingService.set).toHaveBeenCalledTimes(1); - expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(String)); + expect(settingService.setDateMax).not.toHaveBeenCalled(); expect(loggerError).toHaveBeenCalledTimes(1); }); @@ -99,7 +98,9 @@ describe('BankTxFrickService', () => { bank(101, 'SYNTHETIC-FRICK-EUR', true), bank(102, 'SYNTHETIC-FRICK-CHF', true), ]); - frickService.getFrickTransactions.mockResolvedValue([{ accountServiceRef: 'SYNTHETIC-REF' }]); + frickService.getFrickTransactions.mockResolvedValue([ + { accountServiceRef: 'SYNTHETIC-REF', bookingDate: new Date('2026-07-10') }, + ]); const createTx = jest .fn() .mockRejectedValueOnce(new ConflictException('duplicate')) @@ -107,8 +108,8 @@ describe('BankTxFrickService', () => { await frickTxService.checkTransactions(createTx); - expect(settingService.set).toHaveBeenCalledTimes(1); - expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(String)); + expect(settingService.setDateMax).toHaveBeenCalledTimes(1); + expect(settingService.setDateMax).toHaveBeenCalledWith('lastBankFrickDate:101', expect.any(Date)); expect(loggerError).toHaveBeenCalledTimes(1); }); @@ -169,7 +170,7 @@ describe('BankTxFrickService', () => { expect(loggerError).toHaveBeenCalledWith('Failed to import Bank Frick transactions: invalid bank row id'); expect(frickService.getFrickTransactions).not.toHaveBeenCalled(); - expect(settingService.set).not.toHaveBeenCalled(); + expect(settingService.setDateMax).not.toHaveBeenCalled(); }); describe('watermark overlap', () => { @@ -178,11 +179,12 @@ describe('BankTxFrickService', () => { function overlapOf(date: Date): Date { const result = new Date(date); - result.setDate(result.getDate() - OVERLAP_DAYS); + result.setUTCDate(result.getUTCDate() - OVERLAP_DAYS); return result; } - it('sets the watermark to the newest processed booking date minus the overlap when it precedes now', async () => { + 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'); @@ -194,10 +196,24 @@ describe('BankTxFrickService', () => { await frickTxService.checkTransactions(createTx); - expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', overlapOf(maxBookingDate).toISOString()); + 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([ + { 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('sets the watermark to now minus the overlap on an empty response', async () => { + 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([]); @@ -205,13 +221,11 @@ describe('BankTxFrickService', () => { await frickTxService.checkTransactions(createTx); - expect(settingService.set).toHaveBeenCalledWith( - 'lastBankFrickDate:101', - overlapOf(new Date('2024-06-15T12:00:00.000Z')).toISOString(), - ); + expect(settingService.setDateMax).not.toHaveBeenCalled(); }); - it('never moves the watermark backwards when it is already ahead of the computed candidate', async () => { + 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()); @@ -222,7 +236,27 @@ describe('BankTxFrickService', () => { await frickTxService.checkTransactions(createTx); - expect(settingService.set).toHaveBeenCalledWith('lastBankFrickDate:101', aheadWatermark.toISOString()); + 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([ + { 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 () => { @@ -232,7 +266,7 @@ describe('BankTxFrickService', () => { await frickTxService.checkTransactions(createTx); - expect(settingService.set).not.toHaveBeenCalled(); + expect(settingService.setDateMax).not.toHaveBeenCalled(); expect(loggerError).toHaveBeenCalledWith( 'Failed to fetch Bank Frick transactions for bank row 101:', expect.any(Error), @@ -248,7 +282,7 @@ describe('BankTxFrickService', () => { await frickTxService.checkTransactions(createTx); - expect(settingService.set).not.toHaveBeenCalled(); + expect(settingService.setDateMax).not.toHaveBeenCalled(); }); }); 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..61e3bf6773 --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts @@ -0,0 +1,114 @@ +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 - :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.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/bank-tx-frick.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts index 11ce8f8a13..2de400a3c4 100644 --- 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 @@ -2,7 +2,6 @@ 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 { Util } from 'src/shared/utils/util'; import { Bank } from '../../../bank/bank/bank.entity'; import { BankService } from '../../../bank/bank/bank.service'; import { IbanBankName } from '../../../bank/bank/dto/bank.dto'; @@ -69,14 +68,12 @@ export class BankTxFrickService { try { const transactions = 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'); let fullyProcessed = true; - let maxBookingDate: Date | undefined; for (const transaction of transactions) { - if (transaction.bookingDate && (!maxBookingDate || transaction.bookingDate > maxBookingDate)) { - maxBookingDate = transaction.bookingDate; - } - try { await createTx(transaction, multiAccounts); } catch (error) { @@ -87,19 +84,14 @@ export class BankTxFrickService { } } - // Advance after every fully processed response, including an empty statement, so an idle account does not - // request the complete epoch-to-today history forever. The new watermark is backdated by a fixed overlap - // (candidate = min(now, max booking date of the processed entries) - FRICK_WATERMARK_OVERLAP_DAYS) and can - // only ever move forward, so delayed Bank Frick reporting or a race between multiple instances polling the - // same account cannot silently skip entries. The resulting re-fetch of the overlap window is intentional - - // duplicates are absorbed by the create() dedup (ConflictException) above. Entries exposed even later than - // the overlap window still require an explicit operational backfill because Bank Frick does not provide an - // ingestion cursor. - if (fullyProcessed) { - const reference = maxBookingDate && maxBookingDate < now ? maxBookingDate : now; - const candidate = Util.daysBefore(BankTxFrickService.FRICK_WATERMARK_OVERLAP_DAYS, reference); - const newWatermark = candidate > lastModificationTime ? candidate : lastModificationTime; - await this.settingService.set(settingKey, newWatermark.toISOString()); + // 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); 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..0246cfcbf4 --- /dev/null +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts @@ -0,0 +1,67 @@ +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 }) + .andWhere('ABS(bankTx.amount - :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 0bba4f74e0..8c595f421c 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 @@ -396,26 +396,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..4ca12adc34 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,12 @@ 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); + }); +}); diff --git a/src/subdomains/supporting/bank/bank/bank.entity.ts b/src/subdomains/supporting/bank/bank/bank.entity.ts index e671c6dfd8..416c4ce179 100644 --- a/src/subdomains/supporting/bank/bank/bank.entity.ts +++ b/src/subdomains/supporting/bank/bank/bank.entity.ts @@ -39,6 +39,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; diff --git a/src/subdomains/supporting/bank/bank/bank.service.ts b/src/subdomains/supporting/bank/bank/bank.service.ts index 0add97c4fa..a5a4d76746 100644 --- a/src/subdomains/supporting/bank/bank/bank.service.ts +++ b/src/subdomains/supporting/bank/bank/bank.service.ts @@ -52,6 +52,10 @@ export class BankService implements OnModuleInit { 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 --- // async getBank({ currency, paymentMethod }: BankSelectorInput): Promise { const fallBackCurrency = 'EUR'; 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 index cb1bce6ac9..cfed98bc7e 100644 --- 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 @@ -1,4 +1,7 @@ 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; @@ -28,6 +31,39 @@ describe('FiatOutput Bank Frick column metadata', () => { }); }); +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('installs disabled production placeholders and both default-off process switches', 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("'LI4200000FRICKCHF0001'"); + expect(sql).toContain("'LI5600000FRICKEUR0001'"); + expect(sql).toContain('FALSE, FALSE, FALSE, TRUE'); + expect(sql).toContain('FiatOutputFrickTransmission'); + expect(sql).toContain('FiatOutputFrickStatusCheck'); + }); +}); + describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { let dataSource: DataSource; let queryRunner: QueryRunner; @@ -46,6 +82,39 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { 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 () => { @@ -65,6 +134,8 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { await queryRunner.startTransaction(); await migration.up(queryRunner); const added = await getTrackingColumns(); + const frickBanks = await getFrickBanks(); + const disabledProcesses = await getDisabledProcesses(); await queryRunner.commitTransaction(); expect(added).toEqual([ @@ -83,13 +154,22 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { }, { column_name: 'frickTxId', data_type: 'character varying', character_maximum_length: 256, is_nullable: 'YES' }, ]); + expect(frickBanks).toEqual([ + { currency: 'CHF', iban: 'LI4200000FRICKCHF0001', receive: false, send: false }, + { currency: 'EUR', iban: 'LI5600000FRICKEUR0001', receive: false, send: false }, + ]); + expect(disabledProcesses).toEqual(['ExistingProcess', 'FiatOutputFrickTransmission', 'FiatOutputFrickStatusCheck']); await queryRunner.startTransaction(); await migration.down(queryRunner); const removed = await getTrackingColumns(); + const rolledBackFrickBanks = await getFrickBanks(); + const rolledBackDisabledProcesses = await getDisabledProcesses(); await queryRunner.commitTransaction(); expect(removed).toEqual([]); + expect(rolledBackFrickBanks).toEqual([]); + expect(rolledBackDisabledProcesses).toEqual(['ExistingProcess']); }); function getTrackingColumns(): Promise< @@ -104,4 +184,18 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { [SCHEMA], ); } + + function getFrickBanks(): Promise<{ currency: string; iban: string; receive: boolean; send: boolean }[]> { + return queryRunner.query( + `SELECT "currency", "iban", "receive", "send" + FROM "bank" + WHERE "name" = 'Bank Frick' + ORDER BY "currency"`, + ); + } + + 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 index ce8008e56f..14569bbf03 100644 --- 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 @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Config } from 'src/config/config'; import { FrickPaymentCharge, 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'; @@ -18,6 +19,7 @@ describe('FiatOutputFrickService', () => { let fiatOutputRepo: FiatOutputRepository; let frickService: BankFrickService; + let ibanService: IbanService; const order = { orderId: 4242, @@ -34,6 +36,7 @@ describe('FiatOutputFrickService', () => { beforeEach(async () => { fiatOutputRepo = createMock(); frickService = createMock(); + ibanService = createMock(); jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); const module: TestingModule = await Test.createTestingModule({ @@ -42,6 +45,7 @@ describe('FiatOutputFrickService', () => { FiatOutputFrickService, { provide: FiatOutputRepository, useValue: fiatOutputRepo }, { provide: BankFrickService, useValue: frickService }, + { provide: IbanService, useValue: ibanService }, TestUtil.provideConfig(), ], @@ -77,6 +81,16 @@ describe('FiatOutputFrickService', () => { 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') @@ -124,7 +138,7 @@ describe('FiatOutputFrickService', () => { await service.transmitPayments(); expect(frickService.createPaymentOrder).toHaveBeenCalledWith( - expect.objectContaining({ customId: 'DFX-FO-42', reference: 'Synthetic payout' }), + expect.objectContaining({ customId: 'DFX-FO-42', reference: 'DFX-FO-42 Synthetic payout' }), ); expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( 1, @@ -132,6 +146,7 @@ describe('FiatOutputFrickService', () => { expect.objectContaining({ frickTxId: 'DFX-FO-42', frickOrderId: '4242', + remittanceInfo: 'DFX-FO-42 Synthetic payout', isTransmittedDate: expect.any(Date), }), ); @@ -145,6 +160,99 @@ describe('FiatOutputFrickService', () => { ); }); + 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' }), + }), + ); + }); + + 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], @@ -217,7 +325,7 @@ describe('FiatOutputFrickService', () => { await service.checkFrickOrderStatus(); - expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith(order); expect(fiatOutputRepo.update).toHaveBeenCalledWith( 42, expect.objectContaining({ @@ -533,7 +641,7 @@ describe('FiatOutputFrickService', () => { await service.checkFrickOrderStatus(); - expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith('DFX-FO-42'); + expect(frickService.approvePaymentWithoutTan).toHaveBeenCalledWith(order); expect(fiatOutputRepo.update).toHaveBeenCalledWith( 42, expect.objectContaining({ frickOrderStatus: FrickPaymentState.BOOKED }), 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 1c79b98d75..8f2e80d81c 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 @@ -2,6 +2,7 @@ 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'; @@ -19,10 +20,11 @@ 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'; @@ -42,6 +44,7 @@ describe('FiatOutputJobService', () => { let fiatOutputRepo: FiatOutputRepository; let bankTxService: BankTxService; + let bankTxOutgoingMatchService: BankTxOutgoingMatchService; let ep2ReportService: Ep2ReportService; let bankService: BankService; let countryService: CountryService; @@ -53,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(); @@ -73,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], @@ -81,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 }, @@ -94,6 +101,7 @@ describe('FiatOutputJobService', () => { // 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 }, @@ -102,6 +110,8 @@ describe('FiatOutputJobService', () => { }).compile(); service = module.get(FiatOutputJobService); + frickPayoutService = module.get(FiatOutputFrickService); + jest.spyOn(frickPayoutService, 'canCreatePayments').mockReturnValue(true); }); it('should be defined', () => { @@ -136,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'](); @@ -199,6 +209,89 @@ 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('fails closed when more than one sender bank is eligible for the same currency', async () => { + const frick = createCustomBank({ + name: IbanBankName.FRICK, + currency: 'EUR', + iban: 'SYNTHETIC-FRICK-ACCOUNT', + send: true, + }); + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([frick, yapealEUR]); + + await expect( + service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ), + ).rejects.toThrow('Ambiguous sender bank configuration for EUR'); + }); + + it('preserves the existing first-match behavior when Bank Frick is not one of multiple senders', async () => { + jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([yapealEUR, olkyEUR]); + + const result = await service['getPayoutAccount']( + createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), + createCustomCountry({ yapealEnable: true }), + ); + + expect(result).toEqual({ accountIban: yapealEUR.iban, bank: yapealEUR }); + }); + + 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', () => { @@ -318,6 +411,37 @@ describe('FiatOutputJobService', () => { 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([ @@ -543,6 +667,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({ @@ -553,11 +688,13 @@ 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 })); }); @@ -573,17 +710,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', @@ -592,10 +729,13 @@ 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(); }); @@ -609,7 +749,7 @@ describe('FiatOutputJobService', () => { isReadyDate: new Date('2026-07-01'), }); jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([fiatOutput]); - jest.spyOn(bankTxService, 'getBankTxByRemittanceInfo').mockResolvedValue(bankTx); + jest.spyOn(bankTxOutgoingMatchService, 'getUniqueOutgoingBankTx').mockResolvedValue(bankTx); await service['searchOutgoingBankTx'](); diff --git a/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts index a9a69e14fd..9852708946 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts @@ -3,6 +3,7 @@ import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; import { FrickPaymentCharge, FrickPaymentOrder, 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'; @@ -28,6 +29,7 @@ export class FiatOutputFrickService { constructor( private readonly fiatOutputRepo: FiatOutputRepository, private readonly frickService: BankFrickService, + private readonly ibanService: IbanService, ) {} @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT }) @@ -48,7 +50,7 @@ export class FiatOutputFrickService { let order = await this.frickService.getPaymentOrder(entity.frickTxId); if (order.state === FrickPaymentState.PREPARED && this.isFrickAutomaticApprovalEnabled()) { - order = await this.frickService.approvePaymentWithoutTan(entity.frickTxId); + order = await this.frickService.approvePaymentWithoutTan(order); } const updateData = this.getFrickStatusUpdate(order, entity); @@ -64,8 +66,7 @@ export class FiatOutputFrickService { } async transmitPayments(): Promise { - if (DisabledProcess(Process.FIAT_OUTPUT_FRICK_TRANSMISSION)) return; - if (!Config.bank.frick.payoutEnabled || !this.frickService.isAvailable()) return; + if (!this.canCreatePayments()) return; const entities = await this.fiatOutputRepo.find({ where: { @@ -80,14 +81,16 @@ export class FiatOutputFrickService { for (const entity of entities) { try { const customId = `DFX-FO-${entity.id}`; - const remittanceInfo = entity.remittanceInfo ?? `DFX Payout ${entity.id}`; + const remittanceInfo = this.createUniqueReference(customId, entity.remittanceInfo); const address = entity.address ? [entity.address, entity.houseNumber].filter(Boolean).join(' ') : undefined; - const charge = entity.charge + const creditorBic = await this.resolveCreditorBic(entity); + 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, - }[entity.charge] + }[outputCharge] : undefined; const order = await this.frickService.createPaymentOrder({ @@ -101,7 +104,7 @@ export class FiatOutputFrickService { creditor: { name: entity.name, iban: entity.iban, - bic: entity.bic, + bic: creditorBic, address, postalcode: entity.zip, city: entity.city, @@ -122,7 +125,7 @@ export class FiatOutputFrickService { }); if (this.isFrickAutomaticApprovalEnabled() && order.state === FrickPaymentState.PREPARED) { - const approvedOrder = await this.frickService.approvePaymentWithoutTan(customId); + 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); } @@ -140,6 +143,38 @@ export class FiatOutputFrickService { return status !== undefined && FiatOutputFrickService.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(); 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 66a15ec71d..0e3533f7b4 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'; @@ -44,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, @@ -126,19 +128,14 @@ 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({ + remittanceInfo: 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 }> { @@ -149,15 +146,27 @@ export class FiatOutputJobService { entity.currency ?? entity.bankAccountCurrency, ); - if (virtualIban?.bank?.send && virtualIban.bank.isCountryEnabled(country)) + if ( + virtualIban?.bank?.send && + (virtualIban.bank.name !== IbanBankName.FRICK || !entity.isInstant || virtualIban.bank.sctInst) && + 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(entity.currency ?? entity.bankAccountCurrency); + const eligibleBanks = banks.filter( + (candidate) => + (candidate.name !== IbanBankName.FRICK || !entity.isInstant || candidate.sctInst) && + candidate.isCountryEnabled(country) && + (candidate.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()), + ); + if (eligibleBanks.length > 1 && eligibleBanks.some((candidate) => candidate.name === IbanBankName.FRICK)) + throw new Error(`Ambiguous sender bank configuration for ${entity.currency ?? entity.bankAccountCurrency}`); + const bank = eligibleBanks[0]; + return bank ? { accountIban: bank.iban, bank } : { accountIban: undefined, bank: undefined }; } private async assignBankAccount(): Promise { @@ -249,6 +258,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 @@ -518,6 +528,7 @@ export class FiatOutputJobService { const entities = await this.fiatOutputRepo.find({ where: { amount: Not(IsNull()), + isReadyDate: Not(IsNull()), isComplete: false, bankTx: { id: IsNull() }, }, @@ -526,8 +537,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, From 77eb99e13676374ffc93bff85e49ce2bd9674fbb Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 14 Jul 2026 09:45:04 +0200 Subject: [PATCH 14/19] fix(bank): avoid false-positive TODO marker --- src/integration/bank/services/frick.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index 38d947b0f6..b923e4c2a7 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -402,7 +402,7 @@ export class BankFrickService { ) && this.matchesSentString(existing.creditor.bic, requested.creditor.bic, (value) => { const bic = value.replace(/\s/g, '').toUpperCase(); - return bic.length === 8 ? `${bic}XXX` : bic; + return bic.length === 8 ? bic.padEnd(11, 'X') : bic; }); if (!same) throw new Error(`Bank Frick customId collision for ${requested.customId}`); From 5129342236bb123729e21843a29d62d22cafd5c0 Mon Sep 17 00:00:00 2001 From: Ultra Test-Agent Fixer Date: Tue, 14 Jul 2026 14:54:24 +0200 Subject: [PATCH 15/19] fix: apply Ultra Test-Agent repair for PR #4196 Automated repair triggered by Ultra Test-Agent PR Guard. Previous head: 77eb99e13676374ffc93bff85e49ce2bd9674fbb --- .../__tests__/bank-tx-frick.service.spec.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) 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 index 1aa980df9a..b6ca05ba07 100644 --- 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 @@ -4,10 +4,15 @@ 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 { 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'; @@ -319,4 +324,99 @@ describe('BankTxService Bank Frick wiring', () => { 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' })); + }); }); From 5133363d8a0024d768b198fdaaa32fabf709b8b7 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Tue, 14 Jul 2026 21:07:58 +0200 Subject: [PATCH 16/19] fix(bank): resolve Bank Frick deep-review findings Address the independent deep review (1 blocking, 11 major, 12 minor), aligning Bank Frick with the Yapeal reference pattern throughout. - Sender selection: data-driven sendPriority tie-breaker instead of throwing on plurality (throw only on a genuine Frick priority tie) - camt.053: reject malformed entries per-entry instead of failing the whole statement; fail closed on a missing bank reference in strict mode - Payout idempotency: atomic reservation before transmit, self-heal on a definitive not-found - BOOKED lookup no longer requires fields Bank Frick never sends; reconcile net of booked charges - Add the Frick liquidity-balance case; reconcilability guard for send-only rows; preserve terminal failure reasons; HTTP timeouts - Keep the customer-facing remittanceInfo (bank reference lives in frickReference); verify signed responses over raw bytes - Migration no longer inserts bank rows; new account and legacy cleanup are documented manual production steps --- docs/bank-frick-operations.md | 163 ++++++++---- ...783944000000-AddBankFrickPayoutTracking.js | 50 ++-- migration/seed/bank.csv | 4 +- src/integration/bank/dto/frick.dto.ts | 31 +++ .../services/__tests__/frick.service.spec.ts | 183 ++++++++++++- .../__tests__/iso20022.service.spec.ts | 248 +++++++++++++++--- .../bank/services/frick.service.ts | 80 ++++-- .../bank/services/iso20022.service.ts | 107 ++++++-- .../services/__tests__/http.service.spec.ts | 63 ++++- src/shared/services/frick-mock-gateway.ts | 95 +++++++ src/shared/services/http.service.ts | 97 ++----- .../balances/__tests__/bank.adapter.spec.ts | 71 +++++ .../adapters/balances/bank.adapter.ts | 20 ++ .../__tests__/payment.observer.spec.ts | 88 +++++++ .../monitoring/observers/payment.observer.ts | 21 +- .../__tests__/bank-tx-frick.service.spec.ts | 106 ++++++-- .../bank-tx-outgoing-match.service.spec.ts | 31 ++- .../bank-tx/services/bank-tx-frick.service.ts | 29 +- .../bank-tx-outgoing-match.service.ts | 4 +- .../bank/bank/__tests__/bank.service.spec.ts | 91 +++++++ .../supporting/bank/bank/bank.entity.ts | 12 + .../supporting/bank/bank/bank.service.ts | 13 +- ...nk-frick-payout-tracking.migration.spec.ts | 83 ++++-- .../fiat-output-frick.service.spec.ts | 189 +++++++++++-- .../__tests__/fiat-output-job.service.spec.ts | 90 ++++++- .../fiat-output/fiat-output-frick.service.ts | 87 ++++-- .../fiat-output/fiat-output-job.service.ts | 44 +++- .../fiat-output/fiat-output.entity.ts | 11 +- 28 files changed, 1743 insertions(+), 368 deletions(-) create mode 100644 src/shared/services/frick-mock-gateway.ts create mode 100644 src/subdomains/core/liquidity-management/adapters/balances/__tests__/bank.adapter.spec.ts create mode 100644 src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md index a0851324e1..82df931bca 100644 --- a/docs/bank-frick-operations.md +++ b/docs/bank-frick-operations.md @@ -61,65 +61,115 @@ exist yet and defaults to the epoch (`1970-01-01`) on first poll. Seed the setti **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. Raiffeisen/Yapeal reference-less entries after deploy +## 2. `Iso20022Service`'s deterministic reference does not affect existing Raiffeisen/Yapeal imports -Before this change, Raiffeisen/Yapeal statement entries without a bank-provided reference could -be assigned a randomly generated `accountServiceRef` on each import, so the same underlying -entry could get a different synthetic reference every time it was seen. That randomness is now -removed (`Iso20022Service`), but any **already-imported** rows created under the old logic still -carry the old, non-reproducible references. +`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: -Because the new logic derives `accountServiceRef` deterministically for the same entry, the -first overlapping poll after this deploy will, for reference-less Raiffeisen/Yapeal entries that -fall inside the poll's overlap window, compute a reference that differs from what was already -stored in `bank_tx`. The `create()` dedup keys off `accountServiceRef`, so it will not recognize -these as duplicates of the old rows — expect a one-time batch of duplicate `bank_tx` entries for -those accounts until the overlap window has fully rolled past the old imports. +- 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. -### Monitoring - -- After deploy, watch `bank_tx` insert volume on Raiffeisen and Yapeal accounts for the first - several poll cycles. A one-off bump limited to reference-less entries within the overlap - window is expected; anything larger or sustained is not and should be investigated. -- Cross-check new rows against the pre-existing ones by amount, value date and counterparty to - confirm they are the expected duplicates and not a genuine new-data issue. - -### Cleanup - -- Historical reference-less rows imported under the old random-reference logic are not - retroactively corrected by this deploy. Once the one-time duplicate batch above has been - confirmed, plan a manual cleanup pass (dedup/merge or archive the superseded rows) for the - affected Raiffeisen/Yapeal accounts; do not run it automatically as part of the deploy. +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 installs two disabled synthetic placeholders (`LI4200000FRICKCHF0001` and -`LI5600000FRICKEUR0001`) only so deployed databases have deterministic CHF/EUR rows to update. -They have `receive=false`, `send=false` and `sctInst=false`; they are not production accounts. -The local seed uses the same clearly synthetic, checksum-valid IBANs with the same disabled flags. - -Before activation, Operations must: - -1. Replace each synthetic IBAN with the team-provided CT account IBAN and confirm `BFRILI22`. - Operating accounts are deliberately not registered under the same `(name, currency)` because - existing selectors assume one Bank Frick row per currency. -2. Set `receive`, `send` and `sctInst` from the confirmed account-role matrix. Never infer these - flags from currency. +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. -3. Seed `lastBankFrickDate:` before setting `receive=true`. -4. Leave `FiatOutputFrickTransmission` and `FiatOutputFrickStatusCheck` in the `disabledProcess` +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. -5. Enable the status process before (or in the same controlled change as) transmission. If new +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. -6. Ensure exactly one sender bank is eligible for each currency/country route before enabling a - Frick `send` flag. A Frick route alongside another eligible sender is deliberately treated as - ambiguous and leaves the output unassigned until Operations makes the ownership decision - explicit; existing non-Frick routing order remains unchanged. +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 @@ -178,3 +228,26 @@ sandbox credentials: 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. +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/migration/1783944000000-AddBankFrickPayoutTracking.js b/migration/1783944000000-AddBankFrickPayoutTracking.js index 0f42dd7031..d8fe33f373 100644 --- a/migration/1783944000000-AddBankFrickPayoutTracking.js +++ b/migration/1783944000000-AddBankFrickPayoutTracking.js @@ -4,10 +4,14 @@ */ /** - * Concrete Bank Frick IBANs and account roles are supplied out of band. This migration therefore - * creates unmistakably synthetic, disabled placeholders in non-empty deployed registries. They - * cannot receive or send until Operations replaces the IBANs and deliberately sets the role flags. - * A fresh local registry stays empty here and is populated from migration/seed/bank.csv instead. + * 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} @@ -20,20 +24,23 @@ module.exports = class AddBankFrickPayoutTracking1783944000000 { */ async up(queryRunner) { await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); - await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickOrderId" character varying(256)`); - await queryRunner.query(`ALTER TABLE "fiat_output" ADD "frickTxId" character varying(256)`); + // 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)`); - await queryRunner.query(` - INSERT INTO "bank" ("updated", "created", "name", "iban", "bic", "currency", "receive", "send", "sctInst", "amlEnabled") - SELECT NOW(), NOW(), account."name", account."iban", account."bic", account."currency", FALSE, FALSE, FALSE, TRUE - FROM (VALUES - ('Bank Frick', 'LI4200000FRICKCHF0001', 'BFRILI22', 'CHF'), - ('Bank Frick', 'LI5600000FRICKEUR0001', 'BFRILI22', 'EUR') - ) AS account("name", "iban", "bic", "currency") - WHERE EXISTS (SELECT 1 FROM "bank") - ON CONFLICT ("iban", "bic") DO NOTHING - `); + // 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 ( @@ -71,16 +78,11 @@ module.exports = class AddBankFrickPayoutTracking1783944000000 { "updated" = NOW() WHERE "key" = 'disabledProcess' `); - await queryRunner.query(` - DELETE FROM "bank" - WHERE "name" = 'Bank Frick' - AND "iban" IN ('LI4200000FRICKCHF0001', 'LI5600000FRICKEUR0001') - AND "receive" = FALSE - AND "send" = FALSE - `); + 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 "frickTxId"`); + 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 38a8b58d1b..3ec7b6cad7 100644 --- a/migration/seed/bank.csv +++ b/migration/seed/bank.csv @@ -1,6 +1,6 @@ id,updated,created,name,iban,bic,currency,receive,send,sctInst,amlEnabled,assetId -18,2026-07-13 00:00:00,2026-07-13 00:00:00,Bank Frick,LI5600000FRICKEUR0001,BFRILI22,EUR,FALSE,FALSE,FALSE,TRUE, -17,2026-07-13 00:00:00,2026-07-13 00:00:00,Bank Frick,LI4200000FRICKCHF0001,BFRILI22,CHF,FALSE,FALSE,FALSE,TRUE, +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, diff --git a/src/integration/bank/dto/frick.dto.ts b/src/integration/bank/dto/frick.dto.ts index 64abb98c86..588933c393 100644 --- a/src/integration/bank/dto/frick.dto.ts +++ b/src/integration/bank/dto/frick.dto.ts @@ -1,3 +1,23 @@ +// 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; } @@ -54,6 +74,17 @@ export enum FrickPaymentState { 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; diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts index 98e5876303..2896910574 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -6,8 +6,10 @@ import { BankTxIndicator } from 'src/subdomains/supporting/bank-tx/bank-tx/entit import { FrickPaymentCharge, FrickPaymentOrder, + FrickPaymentOrderNotFoundError, FrickPaymentState, FrickPaymentType, + FrickSignatureVerificationError, FrickTransactionsResponse, } from '../../dto/frick.dto'; import { BankFrickService } from '../frick.service'; @@ -52,6 +54,10 @@ describe('BankFrickService', () => { 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 }); @@ -136,6 +142,23 @@ describe('BankFrickService', () => { 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; @@ -164,7 +187,7 @@ describe('BankFrickService', () => { const signature = signer.sign(keys.privateKey, 'base64'); expect(() => - service['verifyResponse'](body, { Signature: signature, Algorithm: headerAlgorithm } as never), + service['verifyResponse'](Buffer.from(body), { Signature: signature, Algorithm: headerAlgorithm } as never), ).not.toThrow(); }); @@ -173,7 +196,7 @@ describe('BankFrickService', () => { [{ 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']('{"synthetic":true}', headers as never)).toThrow(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 () => { @@ -479,7 +502,71 @@ describe('BankFrickService', () => { .mockResolvedValueOnce({ token: jwt() }) .mockResolvedValueOnce(transactionsResponse([])) .mockResolvedValueOnce(transactionsResponse([])); - await expect(service.getPaymentOrder('DFX-FO-42')).rejects.toThrow('payment order DFX-FO-42 not found'); + 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([ @@ -502,14 +589,17 @@ describe('BankFrickService', () => { }); it('validates transaction and account response envelopes and rows', () => { - expect(() => service['validateTransactionsResponse'](undefined)).toThrow( + expect(() => service['validateTransactionsResponse'](undefined, true)).toThrow( 'Invalid Bank Frick transactions response', ); expect(() => - service['validateTransactionsResponse'](transactionsResponse([{ ...paymentOrder(), state: 'UNKNOWN' as never }])), + service['validateTransactionsResponse']( + transactionsResponse([{ ...paymentOrder(), state: 'UNKNOWN' as never }]), + true, + ), ).toThrow('Invalid Bank Frick payment order response'); expect(() => - service['validateTransactionsResponse'](transactionsResponse([paymentOrder({ orderId: -1 })])), + service['validateTransactionsResponse'](transactionsResponse([paymentOrder({ orderId: -1 })]), true), ).toThrow('Invalid Bank Frick orderId response'); expect(() => service['validateAccountsResponse'](undefined)).toThrow('Invalid Bank Frick accounts response'); @@ -561,7 +651,10 @@ describe('BankFrickService', () => { 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([]); + await expect(service.getFrickTransactions(new Date('2026-07-01'), debtorIban)).resolves.toEqual({ + transactions: [], + fullyParsed: true, + }); }); it('maps debit CAMT entries as debit transactions', async () => { @@ -569,9 +662,18 @@ describe('BankFrickService', () => { .mockResolvedValueOnce({ token: jwt() }) .mockResolvedValueOnce(camt053Fixture().replace('CRDT', 'DBIT')); - const [transaction] = await service.getFrickTransactions(new Date('2026-07-01'), debtorIban); + 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(transaction.creditDebitIndicator).toBe(BankTxIndicator.DEBIT); + expect(transactions[0]).toMatchObject({ amount: 1005, chargeAmount: 5, chargeCurrency: 'CHF' }); }); it('accepts JWTs without exp and caches them indefinitely', async () => { @@ -626,8 +728,13 @@ describe('BankFrickService', () => { it('maps a signed camt.053 response completely into BankTx fields', async () => { http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(camt053Fixture()); - const [transaction] = await service.getFrickTransactions(new Date('2026-07-01T00:00:00Z'), debtorIban); + 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', @@ -658,6 +765,16 @@ describe('BankFrickService', () => { 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(''); @@ -758,6 +875,52 @@ describe('BankFrickService', () => { +`; + } + + 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 +
+
+
`; } }); diff --git a/src/integration/bank/services/__tests__/iso20022.service.spec.ts b/src/integration/bank/services/__tests__/iso20022.service.spec.ts index a5e7cefadf..3b15ab04da 100644 --- a/src/integration/bank/services/__tests__/iso20022.service.spec.ts +++ b/src/integration/bank/services/__tests__/iso20022.service.spec.ts @@ -304,13 +304,30 @@ describe('Iso20022Service camt.053 references', () => { ); }); - it('throws on malformed entries in strict mode so a poller cannot advance past dropped data', () => { + 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)).toThrow( - 'Missing CdtDbtInd', + 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', () => { @@ -336,11 +353,20 @@ describe('Iso20022Service camt.053 references', () => { [{ ...entry, CdtDbtInd: 'UNKNOWN' }, 'Invalid CdtDbtInd'], [{ ...entry, BookgDt: { Dt: '2026-02-31' } }, 'Invalid booking date'], [{ ...entry, ValDt: { Dt: '2026-02-31' } }, 'Invalid value date'], - ])('rejects unsafe CAMT defaults in strict mode', (malformedEntry, expectedError) => { - expect(() => Iso20022Service.parseCamt053Json(statement(malformedEntry), 'SYNTHETIC-ACCOUNT-A', true)).toThrow( - expectedError, - ); - }); + ])( + '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 { @@ -657,7 +683,7 @@ describe('Iso20022Service camt.053 entry field parsing', () => { expect(result).toHaveLength(2); }); - it('rejects a strict entry whose booking date is not a well-formed date string', () => { + it('rejects (drops) a strict entry whose booking date is not a well-formed date string', () => { const camt053 = { BkToCstmrStmt: { Stmt: { @@ -671,10 +697,14 @@ describe('Iso20022Service camt.053 entry field parsing', () => { }, }, }; + const rejections: Error[] = []; - expect(() => Iso20022Service.parseCamt053Json(camt053, 'SYNTHETIC-ACCOUNT', true)).toThrow( - 'Invalid booking date in CAMT entry', + 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', () => { @@ -705,6 +735,7 @@ describe('Iso20022Service camt.053 entry field parsing', () => { CdtDbtInd: 'CRDT', Sts: { Cd: 'BOOK' }, BookgDt: { Dt: '2026-07-01' }, + AcctSvcrRef: 'SYNTHETIC-REF', }, }, }, @@ -726,6 +757,114 @@ describe('Iso20022Service camt.053 entry field parsing', () => { }); }); +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 = ` @@ -789,6 +928,9 @@ describe('Iso20022Service strict Bank Frick camt.053 contract', () => { 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, }; } @@ -880,33 +1022,79 @@ describe('Iso20022Service strict Bank Frick camt.053 contract', () => { [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([ [ - strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Ustrd: { unexpected: true } } } } }), - 'Invalid unstructured remittance information in CAMT entry', - ], - [ - strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Strd: { unsupported: true } } } } }), - 'Unsupported structured remittance information in CAMT entry', - ], - [ - strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Ustrd: [' ', ''] } } } }), - 'Invalid unstructured remittance information in CAMT entry', + { RmtInf: { Ustrd: { unexpected: true } } }, + (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined(), ], [ - strictEntry({ NtryDtls: { TxDtls: { RmtInf: { Strd: ' ' } } } }), - 'Unsupported structured remittance information in CAMT entry', + { RmtInf: { Strd: { unsupported: true } } }, + (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined(), ], [ - strictEntry({ NtryDtls: { TxDtls: { Refs: { AcctSvcrRef: { malformed: true } } } } }), - 'Invalid AcctSvcrRef in CAMT entry', + { RmtInf: { Ustrd: [' ', ''] } }, + (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined(), ], + [{ RmtInf: { Strd: ' ' } }, (result: { remittanceInfo?: string }) => expect(result.remittanceInfo).toBeUndefined()], [ - strictEntry({ NtryDtls: { TxDtls: { Refs: { EndToEndId: { malformed: true } } } } }), - 'Invalid EndToEndId in CAMT entry', + { Refs: { EndToEndId: { malformed: true } } }, + (result: { endToEndId?: string }) => expect(result.endToEndId).toBe(''), ], - ])('rejects a lossy or unsafe strict entry', (entry, expectedError) => { - expect(() => Iso20022Service.parseCamt053Json(strictStatement(entry), accountIban, true)).toThrow(expectedError); - }); + ])( + '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', () => { diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index b923e4c2a7..185cdc75aa 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -17,14 +17,25 @@ import { 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; @@ -32,6 +43,10 @@ export class BankFrickService { // 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; @@ -64,7 +79,7 @@ export class BankFrickService { })); } - async getFrickTransactions(lastModificationTime: Date, accountIban: string): Promise[]> { + 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'); @@ -86,9 +101,14 @@ export class BankFrickService { 'text', ); if (typeof statement !== 'string') throw new Error('Invalid Bank Frick camt.053 response'); - if (!statement.trim()) return []; + 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 Iso20022Service.parseCamt053Xml(statement, iban, true).map((tx) => this.parseTransaction(tx, iban)); + return { transactions, fullyParsed: rejectedCount === 0 }; } async createPaymentOrder(input: FrickPaymentOrderInput): Promise { @@ -114,7 +134,7 @@ export class BankFrickService { this.validateString(customId, 'customId', 50, true); const payment = await this.getPaymentOrderOrUndefined(customId); - if (!payment) throw new Error(`Bank Frick payment order ${customId} not found`); + if (!payment) throw new FrickPaymentOrderNotFoundError(customId); return payment; } @@ -148,11 +168,13 @@ export class BankFrickService { amount: tx.amount, instructedAmount: tx.instructedAmount ?? tx.amount, txAmount: tx.txAmount ?? tx.amount, - chargeAmount: 0, + // 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.currency, + chargeCurrency: tx.chargeCurrency, creditDebitIndicator: tx.creditDebitIndicator === BankTxIndicator.CREDIT ? BankTxIndicator.CREDIT : BankTxIndicator.DEBIT, iban: tx.iban, @@ -301,17 +323,24 @@ export class BankFrickService { customId: string, ): Promise { const response = await this.callApi(`transactions?${params.toString()}`); - this.validateTransactionsResponse(response); + // 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 !== 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 { - this.validateTransactionsResponse(response); + // 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); @@ -319,7 +348,7 @@ export class BankFrickService { return matches[0]; } - private validateTransactionsResponse(response: FrickTransactionsResponse): void { + private validateTransactionsResponse(response: FrickTransactionsResponse, requireTypeAndCustomId: boolean): void { if ( !response || typeof response !== 'object' || @@ -331,11 +360,17 @@ export class BankFrickService { 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' || - typeof payment.customId !== 'string' || - !Object.values(FrickPaymentType).includes(payment.type) || + !hasValidCustomId || + !hasValidType || !Object.values(FrickPaymentState).includes(payment.state) || typeof payment.currency !== 'string' || !payment.debitor || @@ -382,8 +417,11 @@ export class BankFrickService { private assertSamePayment(existing: FrickPaymentOrder, requested: FrickCreateTransaction): void { const existingAmount = this.parseResponseAmount(existing.amount); const same = - existing.customId === requested.customId && - existing.type === requested.type && + // 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 && @@ -450,6 +488,7 @@ export class BankFrickService { data: bodyString, responseType, tryCount: 1, + timeout: BankFrickService.HTTP_TIMEOUT_MS, headers: { Accept: accept, 'Content-Type': body === undefined ? '*/*' : 'application/json', @@ -460,6 +499,9 @@ export class BankFrickService { 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); @@ -502,6 +544,7 @@ export class BankFrickService { data: bodyString, responseType: 'json', tryCount: 1, + timeout: BankFrickService.HTTP_TIMEOUT_MS, headers: { Accept: 'application/json', 'Content-Type': 'application/json', @@ -511,6 +554,9 @@ export class BankFrickService { 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)}`); } @@ -549,19 +595,19 @@ export class BankFrickService { } } - private verifyResponse(rawBody: string, headers: AxiosResponse['headers']): void { + 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 Error('Invalid Bank Frick response signature headers'); + 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 Error('Invalid Bank Frick response signature'); + throw new FrickSignatureVerificationError('Invalid Bank Frick response signature'); } } diff --git a/src/integration/bank/services/iso20022.service.ts b/src/integration/bank/services/iso20022.service.ts index 0bfa8f6461..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,7 +219,12 @@ export class Iso20022Service { } // --- CAMT.053 PARSING --- // - static parseCamt053Json(camt053: any, accountIban: string, strict = false): CamtTransaction[] { + 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'); @@ -227,6 +236,10 @@ export class Iso20022Service { const fallbackOccurrences = new Map(); for (const stmt of statements) { + // 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]; @@ -243,7 +256,7 @@ export class Iso20022Service { fallbackOccurrences.set(stableReference, occurrence + 1); transactions.push(transaction); } catch (error) { - if (strict) throw error; + onEntryRejected?.(error, entry); continue; } } @@ -273,6 +286,13 @@ export class Iso20022Service { 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 = this.getCamtDate(entry.BookgDt); const valueDateStr = this.getCamtDate(entry.ValDt); @@ -318,18 +338,25 @@ export class Iso20022Service { const ultimateCountry = ultimatePostalAddress?.Ctry; // remittance information - const remittanceInfo = this.parseCamtRemittanceInfo(txDtls.RmtInf, entry.AddtlNtryInf, strict); + const remittanceInfo = this.parseCamtRemittanceInfo(txDtls.RmtInf, entry.AddtlNtryInf); // reference - check transaction-level refs first (matches camt.054), then entry-level AcctSvcrRef - const transactionAccountServiceRef = this.parseOptionalCamtString(txDtls.Refs?.AcctSvcrRef, 'AcctSvcrRef', strict); - const transactionId = this.parseOptionalCamtString(txDtls.Refs?.TxId, 'TxId', strict); - const entryAccountServiceRef = this.parseOptionalCamtString(entry.AcctSvcrRef, 'AcctSvcrRef', strict); - const entryReference = this.parseOptionalCamtString(entry.NtryRef, 'NtryRef', strict); - const accountServiceRef = - transactionAccountServiceRef || transactionId || entryAccountServiceRef || entryReference || fallbackReference; + 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 = this.parseOptionalCamtString(txDtls.Refs?.EndToEndId, 'EndToEndId', strict) || ''; + const endToEndId = this.parseOptionalCamtString(txDtls.Refs?.EndToEndId) || ''; // bank transaction codes const bkTxCd = (txDtls.BkTxCd ?? entry.BkTxCd)?.Domn; @@ -343,6 +370,8 @@ export class Iso20022Service { valueDate, amount, currency, + chargeAmount, + chargeCurrency, creditDebitIndicator, name, addressLine1, @@ -365,7 +394,12 @@ export class Iso20022Service { }; } - static parseCamt053Xml(xmlData: string, accountIban: string, strict = false): CamtTransaction[] { + static parseCamt053Xml( + xmlData: string, + accountIban: string, + strict = false, + onEntryRejected?: (error: Error, entry: unknown) => void, + ): CamtTransaction[] { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', @@ -376,7 +410,7 @@ export class Iso20022Service { }); const jsonData = parser.parse(xmlData); - return this.parseCamt053Json(jsonData.Document || jsonData, accountIban, strict); + return this.parseCamt053Json(jsonData.Document || jsonData, accountIban, strict, onEntryRejected); } private static createStableCamtReference(accountIban: string, entry: unknown): string { @@ -415,18 +449,16 @@ export class Iso20022Service { return party?.Pty ?? party ?? {}; } - private static parseCamtRemittanceInfo( - remittance: any, - additionalEntryInfo: unknown, - strict: boolean, - ): string | undefined { + // 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 (strict) throw new Error('Invalid unstructured remittance information in CAMT entry'); } if (remittance?.Strd !== undefined) { @@ -437,7 +469,6 @@ export class Iso20022Service { .filter((reference): reference is string => typeof reference === 'string' && !!reference.trim()) .map((reference) => reference.trim()); if (references.length > 0) return references.join(' '); - if (strict) throw new Error('Unsupported structured remittance information in CAMT entry'); } return typeof additionalEntryInfo === 'string' && additionalEntryInfo.trim() @@ -451,10 +482,42 @@ export class Iso20022Service { return Number(value.trim()); } - private static parseOptionalCamtString(value: unknown, field: string, strict: boolean): string | undefined { - if (value === undefined) return undefined; + 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(); - if (strict) throw new Error(`Invalid ${field} in CAMT entry`); return undefined; } diff --git a/src/shared/services/__tests__/http.service.spec.ts b/src/shared/services/__tests__/http.service.spec.ts index 35fad1d931..99b2db93a4 100644 --- a/src/shared/services/__tests__/http.service.spec.ts +++ b/src/shared/services/__tests__/http.service.spec.ts @@ -22,7 +22,7 @@ describe('HttpService signed responses', () => { 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: rawBody, headers })); + nestHttp.request.mockReturnValue(of({ data: Buffer.from(rawBody), headers })); const responseVerifier = jest.fn(); await expect( @@ -33,15 +33,34 @@ describe('HttpService signed responses', () => { }), ).resolves.toEqual({ answer: 42 }); - expect(responseVerifier).toHaveBeenCalledWith(rawBody, headers); + expect(responseVerifier).toHaveBeenCalledWith(Buffer.from(rawBody), headers); const transportConfig = nestHttp.request.mock.calls[0][0]; - expect(transportConfig.responseType).toBe('text'); + 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: '', headers: {} })); + nestHttp.request.mockReturnValue(of({ data: Buffer.from(''), headers: {} })); await expect( service.request({ @@ -53,7 +72,7 @@ describe('HttpService signed responses', () => { }); it('propagates verification failure before exposing the response body', async () => { - nestHttp.request.mockReturnValue(of({ data: '{"unsafe":true}', headers: {} })); + nestHttp.request.mockReturnValue(of({ data: Buffer.from('{"unsafe":true}'), headers: {} })); await expect( service.request({ @@ -65,7 +84,7 @@ describe('HttpService signed responses', () => { ).rejects.toThrow('synthetic signature mismatch'); }); - it('rejects a signed response that was not preserved as raw text', async () => { + 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( @@ -73,7 +92,7 @@ describe('HttpService signed responses', () => { url: 'https://synthetic.example/signed', responseVerifier: jest.fn(), }), - ).rejects.toThrow('Signed HTTP response body is not raw text'); + ).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 () => { @@ -140,4 +159,34 @@ describe('HttpService local Bank Frick mocks', () => { ).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 8aaf2abebf..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?: { @@ -17,72 +18,17 @@ export interface HttpError { export type HttpRequestConfig = AxiosRequestConfig & { tryCount?: number; retryDelay?: number; - responseVerifier?: (rawBody: string, headers: AxiosResponse['headers']) => void; + // 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; }; type MockResponseFactory = (url: string, config?: HttpRequestConfig) => unknown; -const FRICK_MOCK_ORDERS = new Map>(); -function parseMockRequestBody(config?: HttpRequestConfig): any { - if (typeof config?.data === 'string') return JSON.parse(config.data); - return config?.data ?? {}; -} - -function frickTransactionsResponse(transactions: Record[]) { - return { moreResults: false, resultSetSize: transactions.length, transactions }; -} - -// Mock responses for local development. Bank Frick is endpoint-aware because a generic `{}` response makes the -// integration appear reachable while every validator fails, which hides local wiring errors instead of simulating it. +// 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: /bankfrick\.li\/webapi\/v2\/authorize(?:\?|$)/, - response: () => { - 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` }; - }, - }, - { - pattern: /bankfrick\.li\/webapi\/v2\/camt053(?:\?|$)/, - response: (url) => { - const iban = new URL(url).searchParams.get('iban') ?? 'SYNTHETIC-UNCONFIGURED-IBAN'; - return `${iban}`; - }, - }, - { - pattern: /bankfrick\.li\/webapi\/v2\/accounts(?:\/|\?|$)/, - response: { date: '2026-01-01', moreResults: false, resultSetSize: 0, accounts: [] }, - }, - { - pattern: /bankfrick\.li\/webapi\/v2\/signTransactionWithoutTan(?:\?|$)/, - response: (_url, config) => { - const request = parseMockRequestBody(config); - const orderIds = new Set((request.orderIds ?? []).map(String)); - const customIds = new Set(request.customIds ?? []); - const orders = [...FRICK_MOCK_ORDERS.values()] - .filter((order) => orderIds.has(String(order.orderId)) || customIds.has(String(order.customId))) - .map>((order) => ({ ...order, state: 'IN_PROGRESS' })); - orders.forEach((order) => FRICK_MOCK_ORDERS.set(String(order.customId), order)); - return frickTransactionsResponse(orders); - }, - }, - { - pattern: /bankfrick\.li\/webapi\/v2\/transactions(?:\?|$)/, - response: (url, config) => { - if (config?.method?.toUpperCase() === 'PUT') { - const transaction = parseMockRequestBody(config).transactions?.[0]; - if (!transaction) return frickTransactionsResponse([]); - const order = { ...transaction, orderId: FRICK_MOCK_ORDERS.size + 1, state: 'PREPARED' }; - FRICK_MOCK_ORDERS.set(String(order.customId), order); - return frickTransactionsResponse([order]); - } - - const customId = new URL(url).searchParams.get('customId'); - const order = customId ? FRICK_MOCK_ORDERS.get(customId) : undefined; - return frickTransactionsResponse(order ? [order] : []); - }, - }, { 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 } }, @@ -111,6 +57,9 @@ const MOCK_RESPONSES: { pattern: RegExp; response: unknown | MockResponseFactory 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; @@ -127,9 +76,14 @@ export class HttpService { } private getMockResponse(url: string, config?: HttpRequestConfig): T { - const mock = MOCK_RESPONSES.find((m) => m.pattern.test(url)); - const response = - typeof mock?.response === 'function' ? mock.response(url, config) : (mock?.response ?? { mock: true }); + 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; } @@ -206,9 +160,11 @@ export class HttpService { const { tryCount, retryDelay, responseVerifier, ...axiosConfig } = config; const requestedResponseType = axiosConfig.responseType; if (responseVerifier) { - // Preserve the exact response bytes as a UTF-8 string until the detached signature was verified. Axios' JSON - // transform would otherwise parse/re-serialize the body and make verification both unreliable and unsafe. - axiosConfig.responseType = 'text'; + // 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]; } @@ -218,11 +174,12 @@ export class HttpService { retryDelay, ); if (!responseVerifier) return response.data; - if (typeof response.data !== 'string') throw new Error('Signed HTTP response body is not raw text'); + if (!Buffer.isBuffer(response.data)) throw new Error('Signed HTTP response body is not a raw byte buffer'); responseVerifier(response.data, response.headers); - if (requestedResponseType === 'text') return response.data as T; - return JSON.parse(response.data) as T; + 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/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..e98310b934 --- /dev/null +++ b/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts @@ -0,0 +1,88 @@ +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 { IsNull, LessThan, In } from 'typeorm'; +import { PaymentObserver } from '../payment.observer'; + +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, + }, + { 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('surfaces a generically stuck (transmitted >48h, still incomplete) row regardless of bank', async () => { + jest.spyOn(repos.fiatOutput, 'countBy').mockImplementation(async (where) => { + const clauses = Array.isArray(where) ? where : [where]; + return clauses.some( + (clause: never) => 'isTransmittedDate' in (clause as object) && !('isReadyDate' in (clause as object)), + ) + ? 1 + : 0; + }); + + 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..aee11137c2 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,21 @@ 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: + // 1. Never transmitted (the original condition) - stale readiness. + // 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. Generic safety net: transmitted and still not complete after 48h, regardless of bank or + // reason (e.g. an ambiguous/unmatched outgoing bank_tx) - catches anything clause 1 and 2 + // don't name explicitly. 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 }, + { 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/services/__tests__/bank-tx-frick.service.spec.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts index b6ca05ba07..7469e4050a 100644 --- 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 @@ -4,7 +4,7 @@ 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 { BankTxType } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +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'; @@ -16,6 +16,10 @@ import { TransactionService } from 'src/subdomains/supporting/payment/services/t 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>; @@ -61,8 +65,8 @@ describe('BankTxFrickService', () => { bank(103, 'SYNTHETIC-NON-RECEIVING', false), ]); frickService.getFrickTransactions - .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-EUR-1', bookingDate: new Date('2026-07-10') }]) - .mockResolvedValueOnce([{ accountServiceRef: 'FRICK-CHF-1', bookingDate: new Date('2026-07-11') }]); + .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); @@ -89,7 +93,9 @@ describe('BankTxFrickService', () => { bank(101, 'SYNTHETIC-FRICK-EUR', true), bank(102, 'SYNTHETIC-FRICK-CHF', true), ]); - frickService.getFrickTransactions.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('synthetic outage')); + frickService.getFrickTransactions + .mockResolvedValueOnce(fetchResult([])) + .mockRejectedValueOnce(new Error('synthetic outage')); const createTx = jest.fn(); await frickTxService.checkTransactions(createTx); @@ -103,9 +109,9 @@ describe('BankTxFrickService', () => { bank(101, 'SYNTHETIC-FRICK-EUR', true), bank(102, 'SYNTHETIC-FRICK-CHF', true), ]); - frickService.getFrickTransactions.mockResolvedValue([ - { accountServiceRef: 'SYNTHETIC-REF', bookingDate: new Date('2026-07-10') }, - ]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'SYNTHETIC-REF', bookingDate: new Date('2026-07-10') }]), + ); const createTx = jest .fn() .mockRejectedValueOnce(new ConflictException('duplicate')) @@ -118,6 +124,22 @@ describe('BankTxFrickService', () => { 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(); @@ -193,10 +215,12 @@ describe('BankTxFrickService', () => { 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([ - { accountServiceRef: 'FRICK-OLD', bookingDate: olderBookingDate }, - { accountServiceRef: 'FRICK-NEW', bookingDate: maxBookingDate }, - ]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([ + { accountServiceRef: 'FRICK-OLD', bookingDate: olderBookingDate }, + { accountServiceRef: 'FRICK-NEW', bookingDate: maxBookingDate }, + ]), + ); const createTx = jest.fn().mockResolvedValue({}); await frickTxService.checkTransactions(createTx); @@ -208,9 +232,9 @@ describe('BankTxFrickService', () => { 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([ - { accountServiceRef: 'FRICK-FUTURE', bookingDate: new Date('2024-06-20T00:00:00.000Z') }, - ]); + 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); @@ -221,7 +245,7 @@ describe('BankTxFrickService', () => { 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([]); + frickService.getFrickTransactions.mockResolvedValue(fetchResult([])); const createTx = jest.fn(); await frickTxService.checkTransactions(createTx); @@ -234,9 +258,9 @@ describe('BankTxFrickService', () => { 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([ - { accountServiceRef: 'FRICK-OLD', bookingDate: new Date('2024-01-01T00:00:00.000Z') }, - ]); + 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); @@ -249,9 +273,9 @@ describe('BankTxFrickService', () => { 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([ - { accountServiceRef: 'FRICK-MALFORMED', bookingDate: undefined }, - ]); + frickService.getFrickTransactions.mockResolvedValue( + fetchResult([{ accountServiceRef: 'FRICK-MALFORMED', bookingDate: undefined }]), + ); const createTx = jest.fn(); await frickTxService.checkTransactions(createTx); @@ -280,9 +304,9 @@ describe('BankTxFrickService', () => { 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([ - { accountServiceRef: 'FRICK-1', bookingDate: new Date('2024-01-01T00:00:00.000Z') }, - ]); + 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); @@ -291,8 +315,38 @@ describe('BankTxFrickService', () => { }); }); - function bank(id: number, iban: string, receive: boolean): Bank { - return { id, iban, receive } as Bank; + 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 }); } }); 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 index 61e3bf6773..8bce8d9e2a 100644 --- 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 @@ -68,10 +68,10 @@ describe('BankTxOutgoingMatchService.getUniqueOutgoingBankTx', () => { accountIban: 'LI00SYNTHETICACCOUNT', }); expect(query.andWhere).toHaveBeenCalledWith('UPPER(bankTx.currency) = :currency', { currency: 'EUR' }); - expect(query.andWhere).toHaveBeenCalledWith('ABS(bankTx.amount - :amount) < :amountTolerance', { - amount: 12.34, - amountTolerance: 0.005, - }); + 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, }); @@ -88,6 +88,29 @@ describe('BankTxOutgoingMatchService.getUniqueOutgoingBankTx', () => { 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'], 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 index 2de400a3c4..6f4e2ff99e 100644 --- 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 @@ -36,9 +36,18 @@ export class BankTxFrickService { let banks: Bank[]; try { - banks = await this.bankService - .getBanksByName(IbanBankName.FRICK) - .then((rows) => rows.filter((bank) => bank.receive)); + 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; @@ -67,11 +76,21 @@ export class BankTxFrickService { const now = new Date(); try { - const transactions = await this.frickService.getFrickTransactions(lastModificationTime, bank.iban); + 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'); - let fullyProcessed = true; + 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 { 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 index 0246cfcbf4..9b034ecf98 100644 --- 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 @@ -39,7 +39,9 @@ export class BankTxOutgoingMatchService { .where('bankTx.creditDebitIndicator = :indicator', { indicator: BankTxIndicator.DEBIT }) .andWhere(`UPPER(REPLACE(bankTx.accountIban, ' ', '')) = :accountIban`, { accountIban }) .andWhere('UPPER(bankTx.currency) = :currency', { currency }) - .andWhere('ABS(bankTx.amount - :amount) < :amountTolerance', { + // 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, }) 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 4ca12adc34..3f3ab33b4f 100644 --- a/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts +++ b/src/subdomains/supporting/bank/bank/__tests__/bank.service.spec.ts @@ -141,3 +141,94 @@ describe('Bank Frick country routing', () => { 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 416c4ce179..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; @@ -47,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 a5a4d76746..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,10 +51,6 @@ 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 }); } @@ -102,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/fiat-output/__tests__/bank-frick-payout-tracking.migration.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/bank-frick-payout-tracking.migration.spec.ts index cfed98bc7e..bf4e39dd95 100644 --- 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 @@ -15,7 +15,7 @@ let AddBankFrickPayoutTracking: new () => { describe('FiatOutput Bank Frick column metadata', () => { it('keeps every tracking column aligned with the migration length', () => { - const trackingProperties = ['frickOrderId', 'frickTxId', 'frickOrderStatus', 'frickError']; + const trackingProperties = ['frickOrderId', 'frickCustomId', 'frickOrderStatus', 'frickError', 'frickReference']; const trackingColumns = Object.fromEntries( getMetadataArgsStorage() .columns.filter((column) => column.target === FiatOutput && trackingProperties.includes(column.propertyName)) @@ -23,10 +23,11 @@ describe('FiatOutput Bank Frick column metadata', () => { ); expect(trackingColumns).toEqual({ - frickOrderId: { length: 256, nullable: true }, - frickTxId: { length: 256, nullable: true }, + 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 }, }); }); }); @@ -48,7 +49,7 @@ describe('Bank Frick registry rollout data', () => { } }); - it('installs disabled production placeholders and both default-off process switches', async () => { + 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) }; @@ -56,11 +57,22 @@ describe('Bank Frick registry rollout data', () => { await new Migration().up(queryRunner); const sql = queryRunner.query.mock.calls.map(([statement]) => statement).join('\n'); - expect(sql).toContain("'LI4200000FRICKCHF0001'"); - expect(sql).toContain("'LI5600000FRICKEUR0001'"); - expect(sql).toContain('FALSE, FALSE, FALSE, TRUE'); + 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"'); }); }); @@ -134,16 +146,23 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { await queryRunner.startTransaction(); await migration.up(queryRunner); const added = await getTrackingColumns(); - const frickBanks = await getFrickBanks(); + 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: 256, + character_maximum_length: 64, is_nullable: 'YES', }, { @@ -152,24 +171,33 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { character_maximum_length: 256, is_nullable: 'YES', }, - { column_name: 'frickTxId', data_type: 'character varying', character_maximum_length: 256, is_nullable: 'YES' }, - ]); - expect(frickBanks).toEqual([ - { currency: 'CHF', iban: 'LI4200000FRICKCHF0001', receive: false, send: false }, - { currency: 'EUR', iban: 'LI5600000FRICKEUR0001', receive: false, send: false }, + { + 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 rolledBackFrickBanks = await getFrickBanks(); + const bankRowCountAfterDown = await getBankRowCount(); const rolledBackDisabledProcesses = await getDisabledProcesses(); + const sendPriorityColumnExists = await hasSendPriorityColumn(); await queryRunner.commitTransaction(); expect(removed).toEqual([]); - expect(rolledBackFrickBanks).toEqual([]); + expect(bankRowCountAfterDown).toBe(1); expect(rolledBackDisabledProcesses).toEqual(['ExistingProcess']); + expect(sendPriorityColumnExists).toBe(false); }); function getTrackingColumns(): Promise< @@ -179,19 +207,28 @@ describeDb('AddBankFrickPayoutTracking migration (real Postgres)', () => { `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', 'frickTxId', 'frickOrderStatus', 'frickError') + AND column_name IN ('frickOrderId', 'frickCustomId', 'frickOrderStatus', 'frickError', 'frickReference') ORDER BY column_name`, [SCHEMA], ); } - function getFrickBanks(): Promise<{ currency: string; iban: string; receive: boolean; send: boolean }[]> { - return queryRunner.query( - `SELECT "currency", "iban", "receive", "send" - FROM "bank" - WHERE "name" = 'Bank Frick' - ORDER BY "currency"`, + 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 { 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 index 14569bbf03..52b8de311a 100644 --- 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 @@ -1,12 +1,18 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { Config } from 'src/config/config'; -import { FrickPaymentCharge, FrickPaymentState, FrickPaymentType } from 'src/integration/bank/dto/frick.dto'; +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'; @@ -38,6 +44,9 @@ describe('FiatOutputFrickService', () => { 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], @@ -140,26 +149,145 @@ describe('FiatOutputFrickService', () => { expect(frickService.createPaymentOrder).toHaveBeenCalledWith( expect.objectContaining({ customId: 'DFX-FO-42', reference: 'DFX-FO-42 Synthetic payout' }), ); + // Reserved atomically, before the Bank Frick call itself expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( 1, + { id: 42, frickCustomId: IsNull() }, + { frickCustomId: 'DFX-FO-42' }, + ); + 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. + expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( + 2, 42, expect.objectContaining({ - frickTxId: 'DFX-FO-42', frickOrderId: '4242', - remittanceInfo: 'DFX-FO-42 Synthetic payout', + frickReference: 'DFX-FO-42 Synthetic payout', isTransmittedDate: expect.any(Date), }), ); - expect((fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + expect((fiatOutputRepo.update as jest.Mock).mock.calls[1][1]).not.toHaveProperty('remittanceInfo'); + expect((fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[1]).toBeLessThan( (frickService.approvePaymentWithoutTan as jest.Mock).mock.invocationCallOrder[0], ); expect(fiatOutputRepo.update).toHaveBeenNthCalledWith( - 2, + 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( + 42, + expect.objectContaining({ frickReference: 'DFX-FO-42', remittanceInfo: 'DFX Payout 42' }), + ); + }); + + 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(); + + expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { frickCustomId: null, frickError: null }); + expect(frickService.approvePaymentWithoutTan).not.toHaveBeenCalled(); + }); + + 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(42, { 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(42, { 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; @@ -192,6 +320,8 @@ describe('FiatOutputFrickService', () => { 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([ @@ -290,7 +420,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ createCustomFiatOutput({ id: 42, - frickTxId: 'DFX-FO-42', + frickCustomId: 'DFX-FO-42', isComplete: false, info: 'FRICK manual operations hold', }), @@ -321,7 +451,7 @@ describe('FiatOutputFrickService', () => { .mockResolvedValue({ ...order, state: FrickPaymentState.BOOKED }); jest .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42', isComplete: false })]); + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); await service.checkFrickOrderStatus(); @@ -348,7 +478,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue({ ...order, state }); jest .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42', isComplete: false })]); + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); await service.checkFrickOrderStatus(); @@ -379,7 +509,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ createCustomFiatOutput({ id: 42, - frickTxId: 'DFX-FO-42', + frickCustomId: 'DFX-FO-42', isApprovedDate: new Date('2026-07-01'), isComplete: false, info: undefined, @@ -401,7 +531,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ createCustomFiatOutput({ id: 42, - frickTxId: 'DFX-FO-42', + frickCustomId: 'DFX-FO-42', isComplete: false, info: 'Manual operations note', }), @@ -420,7 +550,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue(new Error('synthetic status failure')); jest .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42', isComplete: false })]); + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); await service.checkFrickOrderStatus(); @@ -434,7 +564,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(frickService, 'getPaymentOrder').mockRejectedValue('synthetic non-error rejection'); jest .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42', isComplete: false })]); + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); await service.checkFrickOrderStatus(); @@ -512,7 +642,11 @@ describe('FiatOutputFrickService', () => { expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { frickError: 'FRICK error: synthetic transmission failure', }); - expect((fiatOutputRepo.update as jest.Mock).mock.calls[0][1]).not.toHaveProperty('info'); + // 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 () => { @@ -553,34 +687,43 @@ describe('FiatOutputFrickService', () => { }); jest .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42', isComplete: false })]); + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); await service.checkFrickOrderStatus(); expect(frickService.createPaymentOrder).not.toHaveBeenCalled(); expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { frickOrderStatus: FrickPaymentState.REJECTED, - frickError: null, + 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', + '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, frickTxId: 'DFX-FO-42' }), + createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42' }), ), - ).toEqual({ frickOrderStatus: state, frickError: null }); + ).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, frickTxId: 'DFX-FO-42' }), + createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42' }), ), ).toEqual({ frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, frickError: null }); }); @@ -609,7 +752,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([ createCustomFiatOutput({ id: 42, - frickTxId: 'DFX-FO-42', + frickCustomId: 'DFX-FO-42', isComplete: false, frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, }), @@ -637,7 +780,7 @@ describe('FiatOutputFrickService', () => { .mockResolvedValue({ ...order, state: FrickPaymentState.BOOKED }); jest .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42', isComplete: false })]); + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); await service.checkFrickOrderStatus(); @@ -655,7 +798,7 @@ describe('FiatOutputFrickService', () => { jest.spyOn(frickService, 'getPaymentOrder').mockResolvedValue(order); // order.state === PREPARED jest .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42', isComplete: false })]); + .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); await service.checkFrickOrderStatus(); @@ -670,7 +813,7 @@ describe('FiatOutputFrickService', () => { expect(() => service['getFrickStatusUpdate']( { ...order, state: 'UNKNOWN' as FrickPaymentState }, - createCustomFiatOutput({ id: 42, frickTxId: 'DFX-FO-42' }), + 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 8f2e80d81c..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 @@ -246,12 +246,49 @@ describe('FiatOutputJobService', () => { expect(result).toEqual({ accountIban: olkyEUR.iban, bank: olkyEUR }); }); - it('fails closed when more than one sender bank is eligible for the same currency', async () => { + 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]); @@ -260,18 +297,33 @@ describe('FiatOutputJobService', () => { createCustomFiatOutput({ currency: 'EUR', buyFiats: [] }), createCustomCountry({ yapealEnable: true }), ), - ).rejects.toThrow('Ambiguous sender bank configuration for EUR'); + ).rejects.toThrow('Ambiguous sender bank priority for EUR'); }); - it('preserves the existing first-match behavior when Bank Frick is not one of multiple senders', async () => { - jest.spyOn(bankService, 'getSenderBanks').mockResolvedValue([yapealEUR, olkyEUR]); + 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: yapealEUR.iban, bank: yapealEUR }); + 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 () => { @@ -452,7 +504,7 @@ describe('FiatOutputJobService', () => { currency: 'EUR', isReadyDate: new Date('2026-07-01'), isTransmittedDate: new Date('2026-07-01'), - frickTxId: 'DFX-FO-5', + frickCustomId: 'DFX-FO-5', }), createCustomFiatOutput({ id: 6, @@ -494,7 +546,7 @@ describe('FiatOutputJobService', () => { currency: 'EUR', isReadyDate: new Date('2026-07-01'), isTransmittedDate: new Date('2026-07-01'), - frickTxId: 'DFX-FO-5', + frickCustomId: 'DFX-FO-5', frickOrderStatus: FrickPaymentState.REJECTED, info: 'Manual operations follow-up', }), @@ -698,6 +750,26 @@ describe('FiatOutputJobService', () => { 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({ @@ -743,7 +815,7 @@ describe('FiatOutputJobService', () => { const bankTx = createCustomBankTx({ id: 400, created: new Date('2026-07-02') }); const fiatOutput = createCustomFiatOutput({ id: 4, - frickTxId: 'DFX-FO-4', + frickCustomId: 'DFX-FO-4', remittanceInfo: 'Synthetic Frick payout', isComplete: false, isReadyDate: new Date('2026-07-01'), @@ -775,7 +847,7 @@ describe('FiatOutputJobService', () => { currency: 'EUR', isReadyDate: new Date('2026-07-01'), isTransmittedDate: new Date('2026-07-01'), - frickTxId: 'DFX-FO-5', + frickCustomId: 'DFX-FO-5', frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, }), createCustomFiatOutput({ diff --git a/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts index 9852708946..2808ac7b59 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts @@ -1,7 +1,13 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; -import { FrickPaymentCharge, FrickPaymentOrder, FrickPaymentState } from 'src/integration/bank/dto/frick.dto'; +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'; @@ -14,16 +20,6 @@ import { FiatOutputRepository } from './fiat-output.repository'; @Injectable() export class FiatOutputFrickService { - // 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. - private static readonly FRICK_TERMINAL_STATES = [ - FrickPaymentState.REJECTED, - FrickPaymentState.EXPIRED, - FrickPaymentState.DELETED, - FrickPaymentState.ERROR, - ]; - private readonly logger = new DfxLogger(FiatOutputFrickService); constructor( @@ -32,22 +28,36 @@ export class FiatOutputFrickService { private readonly ibanService: IbanService, ) {} - @DfxCron(CronExpression.EVERY_HOUR, { process: Process.FIAT_OUTPUT }) + @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 = { frickTxId: Not(IsNull()), isComplete: false }; + const statusRequest: FindOptionsWhere = { frickCustomId: Not(IsNull()), isComplete: false }; const entities = await this.fiatOutputRepo.find({ where: [ { ...statusRequest, frickOrderStatus: IsNull() }, - { ...statusRequest, frickOrderStatus: Not(In(FiatOutputFrickService.FRICK_TERMINAL_STATES)) }, + { ...statusRequest, frickOrderStatus: Not(In(FRICK_TERMINAL_STATES)) }, ], }); for (const entity of entities) { try { - let order = await this.frickService.getPaymentOrder(entity.frickTxId); + 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. + await this.fiatOutputRepo.update(entity.id, { frickCustomId: null, frickError: null }); + continue; + } + throw error; + } if (order.state === FrickPaymentState.PREPARED && this.isFrickAutomaticApprovalEnabled()) { order = await this.frickService.approvePaymentWithoutTan(order); @@ -72,7 +82,7 @@ export class FiatOutputFrickService { where: { isReadyDate: Not(IsNull()), isTransmittedDate: IsNull(), - frickTxId: IsNull(), + frickCustomId: IsNull(), isComplete: false, bank: { name: IbanBankName.FRICK }, }, @@ -81,9 +91,18 @@ export class FiatOutputFrickService { for (const entity of entities) { try { const customId = `DFX-FO-${entity.id}`; - const remittanceInfo = this.createUniqueReference(customId, entity.remittanceInfo); + // 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 ? { @@ -93,12 +112,23 @@ export class FiatOutputFrickService { }[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. + const reserved = await this.fiatOutputRepo.update( + { id: entity.id, frickCustomId: IsNull() }, + { frickCustomId: customId }, + ); + 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: remittanceInfo, + reference: frickReference, charge, debtorIban: entity.accountIban, creditor: { @@ -114,12 +144,16 @@ export class FiatOutputFrickService { }); const safeOrderId = this.frickService.getSafeOrderId(order); - // Persist 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. + // frickCustomId is 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, { - frickTxId: customId, ...(safeOrderId && { frickOrderId: safeOrderId }), - remittanceInfo, + frickReference, + charge: outputCharge, + ...(!entity.remittanceInfo && { remittanceInfo: `DFX Payout ${entity.id}` }), isTransmittedDate: new Date(), ...this.getFrickStatusUpdate(order, entity), }); @@ -140,7 +174,7 @@ export class FiatOutputFrickService { } isFrickTerminalState(status: FrickPaymentState | undefined): boolean { - return status !== undefined && FiatOutputFrickService.FRICK_TERMINAL_STATES.includes(status); + return status !== undefined && FRICK_TERMINAL_STATES.includes(status); } canCreatePayments(): boolean { @@ -204,7 +238,12 @@ export class FiatOutputFrickService { case FrickPaymentState.EXPIRED: case FrickPaymentState.DELETED: case FrickPaymentState.ERROR: - return { frickOrderStatus: order.state, frickError: null }; + // 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'); 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 0e3533f7b4..4674b892f3 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -129,7 +129,10 @@ export class FiatOutputJobService { private async getMatchingBankTx(entity: FiatOutput): Promise { return this.bankTxOutgoingMatchService.getUniqueOutgoingBankTx({ - remittanceInfo: entity.remittanceInfo, + // 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, @@ -139,16 +142,21 @@ export class FiatOutputJobService { } 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, - ); + const virtualIban = await this.virtualIbanService.getActiveForUserAndCurrency(entity.userData, currency); if ( virtualIban?.bank?.send && - (virtualIban.bank.name !== IbanBankName.FRICK || !entity.isInstant || virtualIban.bank.sctInst) && + isEligibleFrickCandidate(virtualIban.bank) && virtualIban.bank.isCountryEnabled(country) && (virtualIban.bank.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()) ) @@ -156,16 +164,26 @@ export class FiatOutputJobService { } // fallback to standard bank account selection - const banks = await this.bankService.getSenderBanks(entity.currency ?? entity.bankAccountCurrency); + const banks = await this.bankService.getSenderBanks(currency); const eligibleBanks = banks.filter( (candidate) => - (candidate.name !== IbanBankName.FRICK || !entity.isInstant || candidate.sctInst) && + isEligibleFrickCandidate(candidate) && candidate.isCountryEnabled(country) && (candidate.name !== IbanBankName.FRICK || this.frickPayoutService.canCreatePayments()), ); - if (eligibleBanks.length > 1 && eligibleBanks.some((candidate) => candidate.name === IbanBankName.FRICK)) - throw new Error(`Ambiguous sender bank configuration for ${entity.currency ?? entity.bankAccountCurrency}`); - const bank = eligibleBanks[0]; + + // 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 }; } @@ -548,13 +566,13 @@ export class FiatOutputJobService { }; if ( - (entity.yapealMsgId || entity.olkyOrderId || entity.frickOrderId || entity.frickTxId) && + (entity.yapealMsgId || entity.olkyOrderId || entity.frickOrderId || entity.frickCustomId) && !entity.isConfirmedDate ) { updateData.isConfirmedDate = bankTx.created; } - if ((entity.frickOrderId || entity.frickTxId) && !entity.isApprovedDate) + if ((entity.frickOrderId || entity.frickCustomId) && !entity.isApprovedDate) updateData.isApprovedDate = bankTx.created; await this.fiatOutputRepo.update(entity.id, updateData); diff --git a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts index 9866b05c07..dc29133fe8 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.entity.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.entity.ts @@ -153,11 +153,13 @@ export class FiatOutput extends IEntity { @Column({ nullable: true }) olkyOrderId?: string; - @Column({ length: 256, nullable: true }) + // 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 }) - frickTxId?: string; + frickCustomId?: string; @Column({ type: 'varchar', length: 256, nullable: true }) frickOrderStatus?: FrickPaymentState; @@ -165,6 +167,11 @@ export class FiatOutput extends IEntity { @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 { From d3200b8ed82364e9ccaca210da63dca2b0dedced Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 15 Jul 2026 10:32:05 +0200 Subject: [PATCH 17/19] fix(bank): address re-review findings in the Bank Frick fix commits The prior fix commits introduced four new defects the re-review caught; resolve them. - Scope the stuckFiatOutputs health clause to Frick, so a deploy no longer reports DEGRADED from pre-existing, long-settled non-Frick payouts - Fold frickReference into the atomic payout reservation and re-heal it in the status poller, so a crash between the two writes cannot strand reconciliation - Make the accounting charge direction-aware (a debit amount is already gross) to match the net-of-charge outgoing reconciliation and stop double-counting the charge - Make the not-found self-heal clear conditional, so an overlapping transmit cannot blind the status poller by clearing a just-reserved row Regression test per defect (each fails against the pre-fix code); full suite green, Frick coverage stays 100%. --- docs/bank-frick-operations.md | 5 +- .../__tests__/payment.observer.spec.ts | 73 ++++++-- .../monitoring/observers/payment.observer.ts | 24 ++- .../__tests__/bank-tx.service.spec.ts | 71 +++++++ .../bank-tx/services/bank-tx.service.ts | 20 +- .../fiat-output-frick.service.spec.ts | 175 ++++++++++++++++-- .../fiat-output/fiat-output-frick.service.ts | 44 ++++- 7 files changed, 361 insertions(+), 51 deletions(-) create mode 100644 src/subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx.service.spec.ts diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md index 82df931bca..abb3eeda57 100644 --- a/docs/bank-frick-operations.md +++ b/docs/bank-frick-operations.md @@ -233,7 +233,10 @@ sandbox credentials: (`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. + 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 diff --git a/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts b/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts index e98310b934..e0d1d24ac8 100644 --- a/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts +++ b/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts @@ -2,9 +2,41 @@ 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 { IsNull, LessThan, In } from 'typeorm'; +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; @@ -54,7 +86,7 @@ describe('PaymentObserver', () => { ]), isComplete: false, }, - { isTransmittedDate: LessThan(expect.any(Date)), isComplete: false }, + { frickCustomId: Not(IsNull()), isTransmittedDate: LessThan(expect.any(Date)), isComplete: false }, ]); expect(data.stuckFiatOutputs).toBe(0); }); @@ -71,15 +103,34 @@ describe('PaymentObserver', () => { expect(data.stuckFiatOutputs).toBe(1); }); - it('surfaces a generically stuck (transmitted >48h, still incomplete) row regardless of bank', async () => { - jest.spyOn(repos.fiatOutput, 'countBy').mockImplementation(async (where) => { - const clauses = Array.isArray(where) ? where : [where]; - return clauses.some( - (clause: never) => 'isTransmittedDate' in (clause as object) && !('isReadyDate' in (clause as object)), - ) - ? 1 - : 0; - }); + 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'](); diff --git a/src/subdomains/core/monitoring/observers/payment.observer.ts b/src/subdomains/core/monitoring/observers/payment.observer.ts index aee11137c2..bbcbf820d7 100644 --- a/src/subdomains/core/monitoring/observers/payment.observer.ts +++ b/src/subdomains/core/monitoring/observers/payment.observer.ts @@ -110,20 +110,26 @@ export class PaymentObserver extends MetricObserver { ), created: LessThan(Util.hoursBefore(3)), }), - // Three independent stuck conditions, OR'd together: - // 1. Never transmitted (the original condition) - stale readiness. + // 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. Generic safety net: transmitted and still not complete after 48h, regardless of bank or - // reason (e.g. an ambiguous/unmatched outgoing bank_tx) - catches anything clause 1 and 2 - // don't name explicitly. 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. + // 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 }, - { isTransmittedDate: LessThan(Util.hoursBefore(48)), isComplete: false }, + { frickCustomId: Not(IsNull()), isTransmittedDate: LessThan(Util.hoursBefore(48)), isComplete: false }, ]), pendingCustodyOrders: await this.repos.custodyOrder.countBy({ status: CustodyOrderStatus.CONFIRMED, 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.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 8c595f421c..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 @@ -268,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; } @@ -280,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), 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 index 52b8de311a..82b9cbca45 100644 --- 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 @@ -149,27 +149,30 @@ describe('FiatOutputFrickService', () => { expect(frickService.createPaymentOrder).toHaveBeenCalledWith( expect.objectContaining({ customId: 'DFX-FO-42', reference: 'DFX-FO-42 Synthetic payout' }), ); - // Reserved atomically, before the Bank Frick call itself + // 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' }, + { 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. + // 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', - frickReference: 'DFX-FO-42 Synthetic payout', 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], ); @@ -200,12 +203,101 @@ describe('FiatOutputFrickService', () => { await service.transmitPayments(); + expect(fiatOutputRepo.update).toHaveBeenCalledWith( + { id: 42, frickCustomId: IsNull() }, + expect.objectContaining({ frickReference: 'DFX-FO-42' }), + ); expect(fiatOutputRepo.update).toHaveBeenCalledWith( 42, - expect.objectContaining({ frickReference: 'DFX-FO-42', remittanceInfo: 'DFX Payout 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); @@ -244,10 +336,41 @@ describe('FiatOutputFrickService', () => { await service.checkFrickOrderStatus(); - expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { frickCustomId: null, frickError: null }); + // 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')); @@ -262,7 +385,10 @@ describe('FiatOutputFrickService', () => { await service.checkFrickOrderStatus(); - expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(42, { frickCustomId: null, frickError: null }); + expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(expect.anything(), { + frickCustomId: null, + frickError: null, + }); expect(fiatOutputRepo.update).toHaveBeenCalledWith(42, { frickError: 'FRICK status error: synthetic transport failure', }); @@ -282,7 +408,10 @@ describe('FiatOutputFrickService', () => { await service.checkFrickOrderStatus(); - expect(fiatOutputRepo.update).not.toHaveBeenCalledWith(42, { frickCustomId: null, frickError: null }); + 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`, }); @@ -421,6 +550,8 @@ describe('FiatOutputFrickService', () => { 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', }), @@ -511,6 +642,8 @@ describe('FiatOutputFrickService', () => { 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, }), @@ -685,9 +818,15 @@ describe('FiatOutputFrickService', () => { ...order, state: FrickPaymentState.REJECTED, }); - jest - .spyOn(fiatOutputRepo, 'find') - .mockResolvedValue([createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', isComplete: false })]); + 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(); @@ -753,6 +892,8 @@ describe('FiatOutputFrickService', () => { createCustomFiatOutput({ id: 42, frickCustomId: 'DFX-FO-42', + isTransmittedDate: new Date('2026-01-01'), + frickReference: 'DFX-FO-42 existing', isComplete: false, frickOrderStatus: FrickPaymentState.DELETION_REQUESTED, }), @@ -796,9 +937,15 @@ describe('FiatOutputFrickService', () => { 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', isComplete: false })]); + 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(); diff --git a/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts index 2808ac7b59..cf267260cd 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts @@ -53,7 +53,16 @@ export class FiatOutputFrickService { // (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. - await this.fiatOutputRepo.update(entity.id, { frickCustomId: null, frickError: null }); + // 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; @@ -63,7 +72,18 @@ export class FiatOutputFrickService { order = await this.frickService.approvePaymentWithoutTan(order); } - const updateData = this.getFrickStatusUpdate(order, entity); + // 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); @@ -116,10 +136,15 @@ export class FiatOutputFrickService { // 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. + // 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 }, + { frickCustomId: customId, frickReference }, ); if (reserved.affected !== 1) continue; @@ -144,14 +169,13 @@ export class FiatOutputFrickService { }); const safeOrderId = this.frickService.getSafeOrderId(order); - // frickCustomId is 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. + // 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 }), - frickReference, charge: outputCharge, ...(!entity.remittanceInfo && { remittanceInfo: `DFX Payout ${entity.id}` }), isTransmittedDate: new Date(), From 7c86a87da1e394440ee582ce94189e313899c3c2 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 15 Jul 2026 10:38:01 +0200 Subject: [PATCH 18/19] style(bank): wrap payment observer spec helper signature for prettier --- .../monitoring/observers/__tests__/payment.observer.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts b/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts index e0d1d24ac8..80e7b67c84 100644 --- a/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts +++ b/src/subdomains/core/monitoring/observers/__tests__/payment.observer.spec.ts @@ -32,7 +32,10 @@ function matchesClause(clause: Record, row: Record | Record[], rows: Record[]): number { +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; } From 6af30068d44ebf603f8b9fb3af8a5ef60b09f2cf Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 15 Jul 2026 12:25:25 +0200 Subject: [PATCH 19/19] chore(bank): isolate the Bank Frick coverage gate in its own jest config Move the 100% coverageThreshold out of the shared package.json jest block into a dedicated jest.frick.config.js used only by test:frick:cov, so the strict per-file threshold on the shared iso20022.service.ts can no longer red an unrelated test:cov run. Behaviour-neutral for the Frick gate (same 7 files, same 100%, same CI step); also add the new root config to eslint ignores alongside the existing root configs. --- eslint.config.js | 9 ++++++++- jest.frick.config.js | 37 +++++++++++++++++++++++++++++++++++ package.json | 46 +------------------------------------------- 3 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 jest.frick.config.js 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/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/package.json b/package.json index b2ae8613c3..ef700dd2d0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "jest --silent", "test:watch": "jest --watch", "test:cov": "jest --coverage", - "test:frick:cov": "jest 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", + "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", @@ -190,50 +190,6 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "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 - } - }, "testEnvironment": "node", "forceExit": true },