From 29d1ad4b1fb01af8f3a19749a1d3fb4289f8d9af Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:10:57 +0200 Subject: [PATCH 01/10] Automate Bank Frick vIBAN recovery --- docs/bank-frick-operations.md | 134 ++-- jest.frick.config.js | 2 +- src/integration/bank/dto/frick-vban.dto.ts | 8 + .../services/__tests__/frick.service.spec.ts | 30 + .../bank/services/frick.service.ts | 38 +- .../__tests__/bank-frick-operations.spec.ts | 9 +- ...ck-issuance-reconciliation.service.spec.ts | 573 ++++++++++-------- .../__tests__/virtual-iban.service.spec.ts | 237 +++++++- .../__tests__/frick-viban.provider.spec.ts | 143 +++++ .../providers/frick-viban.provider.ts | 87 ++- ...n-frick-issuance-reconciliation.service.ts | 371 +++++------- ...irtual-iban-issuance-intent-status.enum.ts | 1 + .../bank/virtual-iban/virtual-iban.service.ts | 80 ++- 13 files changed, 1135 insertions(+), 578 deletions(-) diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md index bec8366685..8db7a5cc2a 100644 --- a/docs/bank-frick-operations.md +++ b/docs/bank-frick-operations.md @@ -318,14 +318,15 @@ Yapeal still has the pre-existing weakness that an external create cannot be und reference-based reconciliation protocol. This change intentionally does not introduce a second recovery design for that provider; restoring pre-feature customer behavior takes precedence. -### Request path is fail-closed (no self-heal) +### Request path is fail-closed On the customer request path, an empty Frick recovery listing is **not** treated as proof of non-existence (a concurrent create may still be mid-flight at Bank Frick). `VirtualIbanService` therefore **never** resets the intent, rotates `requestReference`, or re-enters issuance after an empty listing: it fails the call (`ServiceUnavailableException`) and leaves the intent row exactly as the create attempt left it (`InFlight` / `Failed`). The hourly reconciliation job also -leaves it non-retryable; a human must reconcile ambiguous outcomes. +never repeats the create. The customer-facing buy flow immediately falls back to the referenced +collection account while the hourly job resolves the intent automatically. Automatic retry still exists where the evidence is conclusive. A failed preflight occurs before the create call and may reset the intent to `Pending`; a classified `VibanNotCreatedError` means the @@ -337,32 +338,30 @@ listing miss are ambiguous and never arm an automatic retry. ### Two-phase hourly job `VirtualIbanFrickIssuanceReconciliationService.reconcileRetiredIssuanceReferences` -(`@DfxCron` process `VirtualIbanFrickIssuanceReconciliation`) is an alert-only check for stuck -intents and retired references. A complete, fully validated, reference-account-scoped listing -across **all lifecycle states** is useful positive evidence when it contains the exact -`description`, but absence is **not authoritative proof of non-creation** and causes no mutation: +(`@DfxCron` process `VirtualIbanFrickIssuanceReconciliation`) automatically recovers stuck intents +and cleans up retired references. A listing match is positive evidence; listing absence remains +non-authoritative and therefore never enables a second create: - **Schedule:** every hour (`CronExpression.EVERY_HOUR`) - **Rail guard:** silent no-op when `FrickVibanProvider.isAvailable()` is false (vIBAN rail not configured) - **`timeout: 1800` (resumption, not abort):** LockClass (`src/shared/utils/lock.ts`) treats 1800s as a _resumption threshold_, not a hard abort of a still-running previous tick. A run older than - 1800s no longer blocks a new hour-tick, so two overlapping invocations are possible. Both phases - are read-only with respect to issuance intents; overlap can duplicate an alert but cannot arm a - retry. + 1800s no longer blocks a new hour-tick, so two overlapping invocations are possible. Intent + transitions are locked and idempotent, external cleanup always targets an exact vIBAN, and no + path arms another create. - **Shared listing cache:** both phases list Frick vIBANs for each immutable reference-account snapshot (not a single hardcoded EUR account). Successful results share a per-run `referenceAccountIban → listing` cache across both phases, so different bank IDs with the same snapshotted IBAN reuse that result. Failed listing calls are not cached and can be attempted again. -- **On unhandled phase failure:** the job attempts a fail-closed `ERROR_MONITORING` alert that the - check itself could not run; alert-delivery failure is logged. Absence of a match alert is **not** - evidence of a clean state. Phases are independent try/catch blocks so a Phase-1 failure still - allows Phase 2 to run (and vice versa). +- **Monitoring:** ambiguous Bank Frick outcomes and every failed automatic action are written at + `ERROR`. The job sends no additional monitoring mail. Phases are independent try/catch blocks so + a Phase-1 failure still allows Phase 2 to run (and vice versa). Kill-switch: disable process `VirtualIbanFrickIssuanceReconciliation` via the standard disabled- processes setting. -#### Phase 1 — inspect stuck InFlight/Failed intents (alert-only) +#### Phase 1 — automatically recover stuck InFlight/Failed intents 1. Load Bank Frick issuance intents (`provider = 'Bank Frick'`) with status `InFlight` or `Failed`, then **exclude** permanently merge-superseded intents (`error` contains @@ -370,31 +369,31 @@ processes setting. 2. Group remaining intents by the immutable `[provider, referenceAccountIban, referenceAccountReceive]` snapshot and list Frick vIBANs for that snapshotted reference-account IBAN (`FrickVibanProvider.listByReferenceAccount`). -3. For each eligible intent, compare the listing's `description` set to the intent's current +3. For each eligible intent, compare the listing's `description` to the intent's current `requestReference`: - - **Listing match** (object already exists under the current reference) → collect for an - `ERROR_MONITORING` alert; **change nothing** on the intent. Manual operator follow-through - required (no auto-cleanup at Bank Frick). - - **Not found, fully validated, and older than the safety threshold** → keep the intent - `InFlight`/`Failed`, keep the same reference, and send - `Frick vIBAN reconciliation Phase 1: listing does not prove create absence`. Manual - reconciliation is required; no automatic retry is enabled, even when the listing started - after the locally bounded HTTP window. + - **One PREPARED/ACTIVE match** → approve activation if required and finalize the existing vIBAN + locally under the issuance lock. + - **Several PREPARED/ACTIVE matches** → keep the earliest deterministic match, automatically + deactivate and approve every duplicate, then finalize the winner. A cleanup failure leaves the + intent recoverable for the next hourly run. + - **Not found and older than 30 minutes** → keep the intent non-retryable and emit `ERROR` because + Bank Frick's result remains inconclusive. + - **Not found and older than 24 hours** → transition the intent to terminal `Fallback`, retire its + request reference for Phase 2, and continue serving the collection account. This is a business + cutoff, not proof that Bank Frick created nothing; it never enables another create. - **Not found but still fresh** (`intent.updated` younger than the threshold) → skip until a later run. - **Listing not fully validated** (per-entry validation drops) → treat as **inconclusive** for that snapshot group: still surface any positive matches (they are evidence), leave every - unmatched intent non-retryable, and send a separate incomplete-listing alert. Once an - unmatched intent in such a group passes the 30-minute safety threshold, also send the chronic - incomplete-listing alert on each run where the intent is still eligible and that snapshot - group's listing is again incomplete. Absence of a match alert is not a clean state. + unmatched intent non-retryable, and emit `ERROR`. The 24-hour fallback remains safe because it + retires the reference without retrying create; Phase 2 continues scanning it indefinitely. The listing result carries `listingStartedAt` (captured immediately before page 0 is dispatched) and `listingCompletedAt` (captured after the final page validates). In Phase 1, invalid/reversed timestamps fail that snapshot group for the run. For each intent, the code computes `latestPossibleCreateProcessedAt = intent.updated + FRICK_CREATE_MAX_PROCESSING_MS`. The absence -alert includes `listingStartedAt` and `latestPossibleCreateProcessedAt`. `listingCompletedAt` is -checked only for a valid `Date` and for not preceding `listingStartedAt`; it is not compared with +log context includes the count of inconclusive intents. `listingCompletedAt` is checked for a valid +`Date` and for not preceding `listingStartedAt`; it is not compared with `latestPossibleCreateProcessedAt` and establishes no temporal coverage of the create window. These times are not an automatic-retry precondition: even a correctly ordered listing miss remains non-authoritative because Bank Frick provides no authoritative “this create did not happen” @@ -410,14 +409,16 @@ operation. - **120s is not an upper bound on Bank Frick processing or on when its create side effect can occur.** Bank Frick may queue or finish work after the local HTTP attempt has ended. -The separate 30-minute `FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS` remains as a conservative delay -before escalating a listing miss to Operations. It does not change the intent. +The 30-minute `FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS` controls when an inconclusive miss becomes +an `ERROR`; the 24-hour `FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS` bounds recovery before terminal +fallback. -#### Phase 2 — retired-reference orphan scan (alert-only) +#### Phase 2 — automatic retired-reference cleanup Phase 2 scans only event rows whose durable provider snapshot is `Bank Frick`, then checks their -previously **retired** references and alerts when Bank Frick still shows an object under one. It is -**alert-only**: it never mutates intents or Bank Frick state. +previously **retired** references. A PREPARED, ACTIVE or DEACTIVATION_REQUESTED vIBAN found under a +retired reference is automatically deactivated and the deactivation is approved. An already +DEACTIVATED object is a completed cleanup and causes no further mutation. **Where retired references come from** (writers of the durable markers in `nextError`): @@ -435,6 +436,9 @@ previously **retired** references and alerts when Bank Frick still shows an obje the (currency, bank) pair. Embeds both `MERGE_SUPERSEDED_MARKER` (so finalize and Phase 1 refuse revival) and `CREATE_PATH_REFERENCE_MARKER` (so Phase 2 still scans the retired reference): `Superseded by account merge of userData into ; merge-superseded; previousRequestReference=` +5. **Automatic collection-account fallback** — after 24 hours without a recoverable match, Phase 1 + transitions the intent to `Fallback` and writes + `automatic-collection-account-fallback; previousRequestReference=`. Markers (shared constants on `VirtualIbanService`): @@ -445,21 +449,17 @@ Markers (shared constants on `VirtualIbanService`): - `MERGE_SUPERSEDED_MARKER` = `merge-superseded` — permanent retirement marker written only by the merge-fail path; `finalizeFrickIssuance` and Phase 1 refuse to complete/reopen over it -The **request path never retires references** and never writes these markers. +The request path never retires references; only locked state transitions do. **No rolling lookback window:** every matching issuance-event transition is loaded and kept under -scan indefinitely. A still-unresolved abandoned reference must keep being checked and (on match) -alerted; silent-forgetting after a day count is intentionally not used. These events are expected +scan indefinitely. A still-unresolved abandoned reference must keep being checked and cleaned up; +silent-forgetting after a day count is intentionally not used. These events are expected to be rare recovery / deactivation / merge artifacts. Per immutable snapshot group (using Phase 1's reference-account-IBAN cache): if the listing is fully validated, any abandoned reference whose `description` appears is a match; if the listing is not -fully validated, positive matches are still alerted but unmatched abandoned references are **not** -treated as clean (incomplete-listing alert). - -On a match: one `ERROR_MONITORING` alert listing each hit with `abandonedReference` / `eventId` / -`intentId` / `userDataId` / `currencyId` / `bankId` / event `created`. **No auto-cleanup** of Bank -Frick or local state — **manual operator follow-through is required**. +fully validated, positive matches are still cleaned up but unmatched abandoned references are not +treated as clean. Failed cleanup is logged at `ERROR` and retried by the next hourly run. ### What the job looks for (Phase 2 SQL) @@ -485,14 +485,7 @@ ORDER BY created DESC; Extract each abandoned reference from the `nextError` text (`previousRequestReference=…` or `under requestReference=…`, value ends at the next `;` or end of string). A row that matches the LIKE clause but has an empty value after the marker (e.g. `previousRequestReference=;…`) cannot be -parsed — that candidate is alerted via `sendUnresolvedAbandonedReferenceAlert` (see below), not -treated as a Frick listing match. When Phase 2 finds a listing match, `sendMatchAlert` already puts -the exact extracted `abandonedReference` into the alert email body -(`abandonedReference=…; eventId=…`) — operators do **not** need DB access for the normal orphan -alert path. Direct DB access to `nextError` / `previousError` (or an equivalent privileged tool) is -the fallback for **ad-hoc** investigation and for unextractable-marker rows (where the free-form -`nextError` text itself must be inspected). `/gs/debug` deliberately does **not** allowlist those -free-form columns, so the debug endpoint is not that fallback. +parsed and is logged at `ERROR`; it is not treated as a Frick listing match. ### What it reconciles against @@ -505,9 +498,8 @@ edited `Bank.iban`. Comparison is exact equality of listed `description` to the intent's current `requestReference` (Phase 1) or the extracted abandoned reference (Phase 2). A missing IBAN, a receive-disabled snapshot or a non-Frick provider snapshot throws inside that snapshot group's processing and is -caught by the group-level `try/catch` in both phases. That group is skipped and contributes to the -aggregate `sendPerBankFailureAlert`; every other snapshot group in the same run continues normally. -Absence of a match alert for a skipped group is **not** evidence of a clean state. +caught by the group-level `try/catch` in both phases. That group is logged at `ERROR` and skipped; +every other snapshot group in the same run continues normally. Before changing a Frick reference-account IBAN or disabling its receive state, Operations must: @@ -522,27 +514,10 @@ Finalization reloads the Bank row and refuses to expose a newly finalized vIBAN or receive-enabled state differs from the intent snapshot. The old snapshot remains the reconciliation authority even after such a refusal. -### What to do on a match (operator follow-up) - -Applies to **Phase 1 listing matches** (stuck intent already present at Frick under the current -reference) and **Phase 2 orphan matches** (Frick still holds a vIBAN under a retired reference). -In both cases the job does **not** deactivate, delete, or auto-bind anything at Bank Frick — -and Phase 1 never reopens the local intent. - -1. From the alert (or the same event/intent row), note `userDataId`, `currencyId`, `bankId` (and - `intentId`) to identify which customer / currency / bank the reference belongs to. -2. Decide the manual reconciliation path (same fail-closed, hands-on style as section 1 - watermark rewinds and section 5 multi-match payouts): - - If the customer already has a correct local `virtual_iban` for that currency/bank, - ask Bank Frick to deactivate the stray object through an approved portal/API procedure and - verify its resulting lifecycle state. If deactivation is unavailable or does not prevent - receipt, keep the snapshotted reference account monitored and escalate; the application has no - automatic revocation path. - - If local state is incomplete and the Frick-side vIBAN is the only live receiving - account, use an approved, audited repair to bind it to the correct local record (or complete - issuance under support supervision) rather than leaving an unmonitored IBAN live. -3. Do not invent a second automated recovery path here; treat every match as an ops incident - until local and Frick state agree, then record what was done for audit. +### Match handling + +Phase 1 automatically binds the selected live match; Phase 2 automatically deactivates retired +matches. Operations only investigates when one of these automatic actions itself logs an `ERROR`. ### Residual risk: committed account merge can lose a post-commit effect @@ -586,7 +561,8 @@ returns success because replaying the merge itself is impossible once the slave The product-approved automatic retry remains, but only for conclusive evidence: preflight failure before any create call, or a classified definite create rejection or definitely non-dispatched create outcome. An ambiguous create or activation outcome stays non-retryable. Phase 1 listing -matches are alert-only. -Phase 1 listing misses are alert-only; keep the existing `requestReference`. Require the manual -reconciliation procedure above. This prevents a non-authoritative listing miss from causing a second -irreversible Bank Frick account. +matches recover the existing external object; they never issue a second create. Phase 1 listing +misses keep the existing `requestReference` during recovery and transition to terminal `Fallback` +after 24 hours. Phase 2 then scans that retired reference indefinitely and automatically deactivates +any delayed object. This prevents a non-authoritative listing miss from causing a second irreversible +Bank Frick account without requiring routine manual reconciliation. diff --git a/jest.frick.config.js b/jest.frick.config.js index bdece73fde..791c015554 100644 --- a/jest.frick.config.js +++ b/jest.frick.config.js @@ -40,7 +40,7 @@ module.exports = { }, // Frick vIBAN money path: claim/reserve/finalize/reset/merge-dissolution live in virtual-iban.service.ts // and frick-viban.provider.ts (no longer excluded — those files hold the issuance logic, not just - // supporting glue). The alert-only reconciliation job remains gated for stuck-intent and orphan scans. + // supporting glue). Automatic reconciliation remains gated for stuck-intent recovery and orphan cleanup. 'src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts': { branches: 100, functions: 100, diff --git a/src/integration/bank/dto/frick-vban.dto.ts b/src/integration/bank/dto/frick-vban.dto.ts index 2dd0ae07c6..72a876d1b3 100644 --- a/src/integration/bank/dto/frick-vban.dto.ts +++ b/src/integration/bank/dto/frick-vban.dto.ts @@ -49,3 +49,11 @@ export interface FrickVirtualIbansResponse { export interface FrickApproveVirtualIbanActivationRequest { vban: string; } + +export interface FrickDeactivateVirtualIbanRequest { + vban: string; +} + +export interface FrickApproveVirtualIbanDeactivationRequest { + vban: string; +} diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts index 0cebf783ca..29b04c768a 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -1257,6 +1257,36 @@ describe('BankFrickService', () => { expect(http.request).not.toHaveBeenCalled(); }); + it('requests and approves virtual IBAN deactivation with signed PUT requests', async () => { + const requested = virtualIbanResponse({ state: FrickVirtualIbanState.DEACTIVATION_REQUESTED }); + const deactivated = virtualIbanResponse({ state: FrickVirtualIbanState.DEACTIVATED }); + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce(requested) + .mockResolvedValueOnce(deactivated); + + await expect(service.deactivateViban(requested.vban)).resolves.toEqual(requested); + await expect(service.approveVibanDeactivation(requested.vban)).resolves.toEqual(deactivated); + + const deactivateRequest = http.request.mock.calls[1][0]; + expect(deactivateRequest.url).toBe('https://vban.bank.invalid/vban/virtual-ibans/deactivations'); + expect(deactivateRequest.method).toBe('PUT'); + expect(deactivateRequest.data).toBe(JSON.stringify({ vban: requested.vban })); + expectSignature(deactivateRequest.data, deactivateRequest.headers.Signature); + + const approveRequest = http.request.mock.calls[2][0]; + expect(approveRequest.url).toBe('https://vban.bank.invalid/vban/virtual-ibans/deactivations/approvals'); + expect(approveRequest.method).toBe('PUT'); + expect(approveRequest.data).toBe(JSON.stringify({ vban: requested.vban })); + expectSignature(approveRequest.data, approveRequest.headers.Signature); + }); + + it('rejects an empty vban before either deactivation HTTP call', async () => { + await expect(service.deactivateViban('')).rejects.toThrow('Invalid Bank Frick vban'); + await expect(service.approveVibanDeactivation('')).rejects.toThrow('Invalid Bank Frick vban'); + expect(http.request).not.toHaveBeenCalled(); + }); + it('gets a virtual IBAN with encodeURIComponent applied to the path segment', async () => { // Path segment may contain reserved characters; response vban must still be a valid IBAN. const vbanWithSlash = 'LI/TEST VBAN'; diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index 9838527b55..2eb1e9e9ec 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -7,8 +7,10 @@ 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 { + FrickApproveVirtualIbanDeactivationRequest, FrickApproveVirtualIbanActivationRequest, FrickCreateVirtualIbanRequest, + FrickDeactivateVirtualIbanRequest, FrickVirtualIban, FrickVirtualIbanState, FrickVirtualIbansResponse, @@ -61,7 +63,7 @@ export interface FrickVirtualIbansFetchResult { virtualIbans: FrickVirtualIban[]; // False when at least one list entry failed per-entry validation and was dropped. Callers that // inspect listing misses must treat fullyValidated=false as an incomplete check, never as proof of - // absence. Reconciliation is alert-only; well-formed entries may still prove positive matches. + // absence. Reconciliation only acts on positive matches; a miss never enables another create. fullyValidated: boolean; /** Local instant immediately before the first page request was dispatched. */ listingStartedAt: Date; @@ -254,6 +256,40 @@ export class BankFrickService { return response; } + async deactivateViban(vban: string): Promise { + this.assertVibanAvailable(); + this.validateString(vban, 'vban', 34, true); + const request: FrickDeactivateVirtualIbanRequest = { vban }; + const response = await this.callVbanApi( + 'virtual-ibans/deactivations', + 'PUT', + request, + 'application/json', + 'json', + true, + false, + ); + this.validateVirtualIbanResponse(response); + return response; + } + + async approveVibanDeactivation(vban: string): Promise { + this.assertVibanAvailable(); + this.validateString(vban, 'vban', 34, true); + const request: FrickApproveVirtualIbanDeactivationRequest = { vban }; + const response = await this.callVbanApi( + 'virtual-ibans/deactivations/approvals', + 'PUT', + request, + 'application/json', + 'json', + true, + false, + ); + this.validateVirtualIbanResponse(response); + return response; + } + async getViban(vban: string): Promise { this.assertVibanAvailable(); this.validateString(vban, 'vban', 34, true); diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts index 305c36d600..1340bbfb6a 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts @@ -36,24 +36,25 @@ describe('Bank Frick operations runbook', () => { }); it('documents that non-authoritative listing misses never arm an automatic retry', () => { - expect(compactRunbook).toContain('listing misses are alert-only'); + expect(compactRunbook).toContain('listing absence remains non-authoritative'); + expect(compactRunbook).toContain('never enables a second create'); expect(compactRunbook).toContain('keep the existing `requestReference`'); expect(compactRunbook).toContain('preflight failure before any create call'); expect(compactRunbook).toContain('classified definite create rejection'); expect(runbook).not.toContain('non-authoritative listing miss will arm automatic retry'); }); - it('keeps code comments aligned with alert-only reconciliation', () => { + it('keeps code comments aligned with automatic fail-closed reconciliation', () => { expect(serviceSource).not.toContain('Reconciliation is the only'); expect(serviceSource).not.toContain('reconciliation would reopen'); expect(frickServiceSource).not.toContain('reconciliation empty-listing resets'); expect(frickCoverageConfig).not.toContain('stuck-intent reopen'); - expect(frickServiceSource).toContain('Reconciliation is alert-only'); + expect(frickServiceSource).toContain('Reconciliation only acts on positive matches'); }); it('states exactly what listingCompletedAt validation establishes', () => { expect(compactRunbook).toContain( - '`listingCompletedAt` is checked only for a valid `Date` and for not preceding `listingStartedAt`', + '`listingCompletedAt` is checked for a valid `Date` and for not preceding `listingStartedAt`', ); expect(compactRunbook).toContain( 'it is not compared with `latestPossibleCreateProcessedAt` and establishes no temporal coverage', diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts index 2a008ce481..b13d5ac164 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts @@ -3,7 +3,6 @@ import { CronExpression } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; import { Process } from 'src/shared/services/process.service'; import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; -import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { DataSource, FindOperator, FindOptionsWhere, Repository } from 'typeorm'; import { IbanBankName } from '../../bank/dto/bank.dto'; @@ -20,7 +19,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { let intentRepo: { find: jest.Mock }; let frickVibanProvider: FrickVibanProvider; let notificationService: NotificationService; - let legacyReopener: { resetStuckFrickIntentForReconciliationOnly: jest.Mock }; + let legacyReopener: { + resetStuckFrickIntentForReconciliationOnly: jest.Mock; + recoverFrickIntentForReconciliation: jest.Mock; + moveFrickIntentToFallbackForReconciliation: jest.Mock; + }; const referenceAccountIban = 'LI32088110105923K000C'; const bankId = 50; @@ -93,7 +96,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { intentRepo = { find: jest.fn().mockResolvedValue([]) }; frickVibanProvider = createMock(); notificationService = createMock(); - legacyReopener = { resetStuckFrickIntentForReconciliationOnly: jest.fn().mockResolvedValue(true) }; + legacyReopener = { + resetStuckFrickIntentForReconciliationOnly: jest.fn().mockResolvedValue(true), + recoverFrickIntentForReconciliation: jest.fn().mockResolvedValue(true), + moveFrickIntentToFallbackForReconciliation: jest.fn().mockResolvedValue(true), + }; jest.spyOn(frickVibanProvider, 'isAvailable').mockReturnValue(true); jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined as any); @@ -202,16 +209,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: expect.objectContaining({ - subject: 'Frick vIBAN reconciliation Phase 1: processing failed for one or more banks', - }), - }); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('alerts and does not reset when the Frick listing already contains the intent requestReference', async () => { + it('automatically recovers when the Frick listing contains the intent requestReference', async () => { intentRepo.find.mockResolvedValue([ intent({ id: 101, @@ -227,18 +228,161 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: stuck intent(s) already exist at Bank Frick', - errors: [expect.stringContaining(`requestReference=${stuckRequestReference}`)], - }, - }); - const mailArg = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - expect(mailArg.input.errors[0]).toContain('intentId=101'); - expect(mailArg.input.errors[0]).toContain(`bankId=${bankId}`); + expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( + 101, + expect.objectContaining({ description: stuckRequestReference }), + ); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + }); + + it('ignores malformed listing descriptions while recovering a valid exact match', async () => { + intentRepo.find.mockResolvedValue([intent({ id: 117, requestReference: stuckRequestReference })]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue( + listingResult( + [ + { ...listingEntry(stuckRequestReference), vban: 'LI11MALFORMED00000001', description: undefined as any }, + listingEntry(stuckRequestReference), + ], + true, + ), + ); + + await service.reconcileRetiredIssuanceReferences(); + + expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( + 117, + expect.objectContaining({ description: stuckRequestReference }), + ); + }); + + it('logs ERROR and continues when automatic recovery fails', async () => { + intentRepo.find.mockResolvedValue([intent({ id: 115, requestReference: stuckRequestReference })]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([listingEntry(stuckRequestReference)], true)); + legacyReopener.recoverFrickIntentForReconciliation.mockRejectedValue(new Error('local finalization failed')); + const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 1: automatic recovery failed for intentId=115', + expect.any(Error), + ); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + }); + + it('does not count a raced automatic recovery as completed', async () => { + intentRepo.find.mockResolvedValue([intent({ id: 116, requestReference: stuckRequestReference })]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([listingEntry(stuckRequestReference)], true)); + legacyReopener.recoverFrickIntentForReconciliation.mockResolvedValue(false); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(116, expect.anything()); + }); + + it('deactivates duplicate matches before recovering the deterministic winner', async () => { + intentRepo.find.mockResolvedValue([intent({ id: 130, requestReference: stuckRequestReference })]); + const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; + const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + newer, + referenceAccountIban, + stuckRequestReference, + ); + expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(130, older); + }); + + it('uses the vIBAN as deterministic tie-breaker when duplicate timestamps are equal', async () => { + intentRepo.find.mockResolvedValue([intent({ id: 134, requestReference: stuckRequestReference })]); + const higher = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002' }; + const lower = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001' }; + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([higher, lower], true)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + higher, + referenceAccountIban, + stuckRequestReference, + ); + expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(134, lower); + }); + + it('moves a day-old listing miss to the collection-account fallback', async () => { + intentRepo.find.mockResolvedValue([ + intent({ + id: 131, + requestReference: stuckRequestReference, + updated: new Date( + Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS - 1_000, + ), + }), + ]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(legacyReopener.moveFrickIntentToFallbackForReconciliation).toHaveBeenCalledWith( + 131, + stuckRequestReference, + ); + expect(legacyReopener.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + }); + + it('logs ERROR and continues when the automatic fallback transition fails', async () => { + intentRepo.find.mockResolvedValue([ + intent({ + id: 132, + requestReference: stuckRequestReference, + updated: new Date( + Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS - 1_000, + ), + }), + ]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); + legacyReopener.moveFrickIntentToFallbackForReconciliation.mockRejectedValue( + new Error('fallback transaction failed'), + ); + const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 1: automatic fallback transition failed for intentId=132', + expect.any(Error), + ); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + }); + + it('accepts a raced fallback transition without treating it as newly completed', async () => { + intentRepo.find.mockResolvedValue([ + intent({ + id: 133, + requestReference: stuckRequestReference, + updated: new Date( + Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS - 1_000, + ), + }), + ]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); + legacyReopener.moveFrickIntentToFallbackForReconciliation.mockResolvedValue(false); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(legacyReopener.moveFrickIntentToFallbackForReconciliation).toHaveBeenCalledWith( + 133, + stuckRequestReference, + ); }); it('skips intents younger than the safety threshold', async () => { @@ -256,7 +400,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('leaves an old intent non-retryable and alerts on an empty fully validated listing', async () => { + it('logs an old inconclusive intent without sending an alert', async () => { const pastThresholdUpdated = new Date( Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS - 5_000, ); @@ -271,20 +415,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: listing does not prove create absence', - errors: [expect.stringContaining('intentId=103')], - }, - }); - const alert = (notificationService.sendMail as jest.Mock).mock.calls[0][0].input.errors[0] as string; - expect(alert).not.toContain(stuckRequestReference); + expect(notificationService.sendMail).not.toHaveBeenCalled(); expect(legacyReopener.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); }); - it('leaves an old intent non-retryable and alerts when the listing began before create processing could end', async () => { + it('keeps an old intent inconclusive when the listing began before create processing could end', async () => { const updated = new Date( Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS - 5_000, ); @@ -307,20 +442,8 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: listing does not prove create absence', - errors: [expect.stringContaining('intentId=113')], - }, - }); - const mail = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - expect(mail.input.errors[0]).toContain(`listingStartedAt=${listingStartedAt.toISOString()}`); - expect(mail.input.errors[0]).toContain( - `latestPossibleCreateProcessedAt=${latestPossibleCreateProcessedAt.toISOString()}`, - ); - expect(mail.input.errors[0]).not.toContain(currentReference); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(legacyReopener.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); it('rejects invalid listing timestamps without reopening an intent', async () => { @@ -333,7 +456,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); }); - it('does not reset when listing is not fully validated, and alerts incomplete check', async () => { + it('does not mutate a recent intent when listing is not fully validated', async () => { intentRepo.find.mockResolvedValue([ intent({ id: 104, @@ -344,23 +467,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).toContain('Frick vIBAN reconciliation Phase 1: listing not fully validated'); - // Default fixture intent is past the safety threshold → escalated chronic signal too. - expect(subjects).toContain( - 'Frick vIBAN reconciliation Phase 1: listing chronically incomplete — stuck intent(s) cannot be resolved', - ); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: listing not fully validated', - errors: [expect.stringContaining('incomplete for those banks')], - }, - }); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(legacyReopener.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); }); - it('still alerts positive matches when listing is not fully validated but does not reset others', async () => { + it('still recovers a positive match when listing is not fully validated', async () => { intentRepo.find.mockResolvedValue([ intent({ id: 105, requestReference: stuckRequestReference }), intent({ id: 106, requestReference: 'dfx-viban-other-stuck-ref-00000001' }), @@ -371,16 +482,15 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).toContain('Frick vIBAN reconciliation Phase 1: stuck intent(s) already exist at Bank Frick'); - expect(subjects).toContain('Frick vIBAN reconciliation Phase 1: listing not fully validated'); - // Intent 106 is unmatched and past threshold → chronic escalated alert. - expect(subjects).toContain( - 'Frick vIBAN reconciliation Phase 1: listing chronically incomplete — stuck intent(s) cannot be resolved', + expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( + 105, + expect.objectContaining({ description: stuckRequestReference }), ); + expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledTimes(1); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('sends chronic incomplete listing alert with bankId only when unmatched intent is past threshold', async () => { + it('does not send a separate alert for a chronically incomplete listing', async () => { intentRepo.find.mockResolvedValue([ intent({ id: 107, @@ -392,25 +502,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: - 'Frick vIBAN reconciliation Phase 1: listing chronically incomplete — stuck intent(s) cannot be resolved', - errors: [expect.stringContaining(`bankId(s) ${bankId}`)], - }, - }); - const chronicCall = (notificationService.sendMail as jest.Mock).mock.calls.find( - (c) => - c[0].input.subject === - 'Frick vIBAN reconciliation Phase 1: listing chronically incomplete — stuck intent(s) cannot be resolved', - ); - expect(chronicCall[0].input.errors[0]).not.toContain(stuckRequestReference); - expect(chronicCall[0].input.errors[0]).not.toContain('LI'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('does not send chronic incomplete alert when unmatched intents are still within the safety threshold', async () => { + it('does not send an alert when incomplete-listing intents are still within the safety threshold', async () => { intentRepo.find.mockResolvedValue([ intent({ id: 108, @@ -422,14 +517,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).toContain('Frick vIBAN reconciliation Phase 1: listing not fully validated'); - expect(subjects).not.toContain( - 'Frick vIBAN reconciliation Phase 1: listing chronically incomplete — stuck intent(s) cannot be resolved', - ); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('continues Phase 1 for healthy banks when one bank throws, and sends per-bank failure alert', async () => { + it('continues Phase 1 for healthy banks when one bank throws without sending an alert', async () => { const healthyBankId = 51; const healthyRef = 'dfx-viban-healthybankintent0000000001'; const healthyReferenceAccountIban = 'LI00HEALTHY00000000000C'; @@ -447,16 +538,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(legacyReopener.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: processing failed for one or more banks', - errors: [expect.stringContaining('Processing threw for 1 bank(s)')], - }, - }); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).not.toContain('Frick vIBAN retired-reference reconciliation: check could not run'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('filters merge-superseded FAILED intents and leaves other listing misses non-retryable', async () => { @@ -483,13 +565,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(legacyReopener.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); - expect(notificationService.sendMail).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - subject: 'Frick vIBAN reconciliation Phase 1: listing does not prove create absence', - }), - }), - ); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('skips Phase 1 entirely when every loaded InFlight/Failed intent is merge-superseded', async () => { @@ -540,7 +616,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('sends exactly one match alert when at least one abandoned reference appears in the Frick listing', async () => { + it('automatically deactivates an abandoned reference found in the Frick listing', async () => { eventRepo.find.mockResolvedValue([ event({ id: 11, @@ -571,20 +647,70 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected', - errors: [expect.stringContaining(`abandonedReference=${abandonedCreate}`)], - }, - }); - const mailArg = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - expect(mailArg.input.errors).toHaveLength(1); - expect(mailArg.input.errors[0]).toContain('intentId=21'); - expect(mailArg.input.errors[0]).toContain('userDataId=30'); - expect(mailArg.input.errors[0]).not.toContain(abandonedRecovery); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + expect.objectContaining({ description: abandonedCreate }), + referenceAccountIban, + abandonedCreate, + ); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + }); + + it('ignores malformed Phase 2 descriptions and cleans up the valid orphan', async () => { + eventRepo.find.mockResolvedValue([ + event({ id: 14, nextError: `${CREATE_PATH_REFERENCE_MARKER}${abandonedCreate}` }), + ]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue( + listingResult( + [ + { ...listingEntry(abandonedCreate), vban: 'LI11MALFORMED00000002', description: undefined as any }, + listingEntry(abandonedCreate), + ], + true, + ), + ); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + }); + + it('de-duplicates the same orphan vIBAN before cleanup', async () => { + eventRepo.find.mockResolvedValue([ + event({ id: 15, nextError: `${CREATE_PATH_REFERENCE_MARKER}${abandonedCreate}` }), + ]); + const duplicate = listingEntry(abandonedCreate); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([duplicate, { ...duplicate }], true)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + }); + + it('logs ERROR and continues when automatic orphan cleanup fails', async () => { + eventRepo.find.mockResolvedValue([ + event({ + id: 13, + nextError: `${CREATE_PATH_REFERENCE_MARKER}${abandonedCreate}`, + }), + ]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([listingEntry(abandonedCreate)], true)); + jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockRejectedValue(new Error('deactivation ambiguous')); + const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 2: automatic orphan cleanup failed for eventId=13', + expect.any(Error), + ); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('does not treat unmatched abandoned refs as clean when listing is not fully validated', async () => { @@ -599,19 +725,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 2: listing not fully validated', - errors: [expect.stringContaining('NOT evidence of a clean state')], - }, - }); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).not.toContain('Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected'); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('still alerts orphan matches when listing is incomplete', async () => { + it('still deactivates a positive orphan match when listing is incomplete', async () => { eventRepo.find.mockResolvedValue([ event({ nextError: `create failed; recovery listing found no match; previousRequestReference=${abandonedCreate}; newRequestReference=dfx-viban-new`, @@ -623,12 +741,26 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).toContain('Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected'); - expect(subjects).toContain('Frick vIBAN reconciliation Phase 2: listing not fully validated'); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + }); + + it('does not call Bank Frick again for an orphan that is already deactivated', async () => { + eventRepo.find.mockResolvedValue([ + event({ + nextError: `${CREATE_PATH_REFERENCE_MARKER}${abandonedCreate}`, + }), + ]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([{ ...listingEntry(abandonedCreate), state: 'DEACTIVATED' as any }], true)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); }); - it('sends a per-bank failure alert when the Frick listing fetch throws and does not rethrow', async () => { + it('logs when the Frick listing fetch throws and does not rethrow', async () => { eventRepo.find.mockResolvedValue([ event({ nextError: `create failed; recovery listing found no match; previousRequestReference=${abandonedCreate}; newRequestReference=dfx-viban-new`, @@ -640,21 +772,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 2: processing failed for one or more banks', - errors: [expect.stringContaining('Processing threw for 1 bank(s)')], - }, - }); - const mailArg = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - // Alert must not embed the raw upstream failure text (PII / free-form provider content). - expect(mailArg.input.errors[0]).not.toContain('upstream listing unavailable'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('continues Phase 2 for healthy banks when one bank throws, and sends per-bank failure alert', async () => { + it('continues Phase 2 cleanup for healthy banks when one bank throws', async () => { const healthyBankId = 52; const healthyReferenceAccountIban = 'LI00HEALTHY00000000000C'; eventRepo.find.mockResolvedValue([ @@ -677,15 +798,12 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).toContain('Frick vIBAN reconciliation Phase 2: processing failed for one or more banks'); - expect(subjects).toContain('Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected'); - expect(subjects).not.toContain('Frick vIBAN retired-reference reconciliation: check could not run'); - const matchCall = (notificationService.sendMail as jest.Mock).mock.calls.find( - (c) => c[0].input.subject === 'Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected', + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + expect.objectContaining({ description: abandonedRecovery }), + healthyReferenceAccountIban, + abandonedRecovery, ); - expect(matchCall[0].input.errors[0]).toContain(`abandonedReference=${abandonedRecovery}`); - expect(matchCall[0].input.errors[0]).not.toContain(abandonedCreate); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('queries abandoned references without a rolling time window', async () => { @@ -820,36 +938,34 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { jest .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockResolvedValue( - listingResult([listingEntry(mergeRetiredReference), listingEntry(phase1ReopenReference)], true), + listingResult( + [ + listingEntry(mergeRetiredReference), + { ...listingEntry(phase1ReopenReference), vban: 'LI11ACTIVE00000000002' }, + ], + true, + ), ); await service.reconcileRetiredIssuanceReferences(); - // Unrelated event must not contribute an abandoned reference → no listing lookup under its ref. - // (Listing is still called once for the bank; the orphan alert must not mention unrelated.) - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected', - errors: expect.arrayContaining([ - expect.stringContaining(`abandonedReference=${mergeRetiredReference}`), - expect.stringContaining(`abandonedReference=${phase1ReopenReference}`), - ]), - }, - }); - const matchCall = (notificationService.sendMail as jest.Mock).mock.calls.find( - (c) => c[0].input.subject === 'Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected', + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(2); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + expect.objectContaining({ description: mergeRetiredReference }), + referenceAccountIban, + mergeRetiredReference, + ); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + expect.objectContaining({ description: phase1ReopenReference }), + referenceAccountIban, + phase1ReopenReference, ); - expect(matchCall[0].input.errors).toHaveLength(2); - expect(matchCall[0].input.errors.join('\n')).not.toContain(unrelatedReference); - expect(matchCall[0].input.errors.join('\n')).toContain('eventId=31'); - expect(matchCall[0].input.errors.join('\n')).toContain('eventId=32'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); }); describe('edge cases for full coverage', () => { - it('alerts and continues when Phase 2 event nextError has a marker but empty reference value', async () => { + it('logs and continues when Phase 2 event nextError has a marker but empty reference value', async () => { // Productive-path reachable fixture: nextError contains CREATE_PATH_REFERENCE_MARKER so the // real WHERE nextError LIKE '%previousRequestReference=%' clause would return this row, but // the value after the marker is empty (marker immediately followed by ';') so extractAbandonedReference @@ -866,95 +982,49 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { // Zero resolvable abandoned references → Phase 2 must never reach Frick listing. expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - // One alert for the single unextractable candidate. - expect(notificationService.sendMail).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 2: abandoned-reference candidate(s) could not be resolved', - errors: expect.arrayContaining([expect.stringContaining('eventId=99')]), - }, - }); - const mailArg = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - expect(mailArg.input.errors).toHaveLength(1); - expect(mailArg.input.errors.join('\n')).toContain('reason=reference_unextractable'); - // No free-text nextError content in the alert (PII-safe identifiers only). - expect(mailArg.input.errors.join('\n')).not.toContain(unextractableNextError); - expect(mailArg.input.errors.join('\n')).not.toContain('newRequestReference='); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('sends per-bank failure alert when bank has no IBAN configured and does not abort the whole run', async () => { + it('logs a bank with no IBAN configured and does not abort the whole run', async () => { intentRepo.find.mockResolvedValue([ intent({ id: 200, requestReference: stuckRequestReference, referenceAccountIban: '' }), ]); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: processing failed for one or more banks', - errors: [expect.stringContaining('Processing threw for 1 bank(s)')], - }, - }); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).not.toContain('Frick vIBAN retired-reference reconciliation: check could not run'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('sends outer Phase 1 failure alert when the intent query itself throws', async () => { + it('logs an outer Phase 1 failure when the intent query itself throws', async () => { intentRepo.find.mockRejectedValue(new Error('intent repository unavailable')); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN retired-reference reconciliation: check could not run', - errors: [expect.stringContaining('NOT evidence of a clean state')], - }, - }); - const mailArg = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - expect(mailArg.input.errors[0]).not.toContain('intent repository unavailable'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('still runs Phase 2 when delivery of the Phase 1 failure alert rejects', async () => { + it('still runs Phase 2 after a Phase 1 failure', async () => { intentRepo.find.mockRejectedValue(new Error('intent repository unavailable')); - jest - .spyOn(notificationService, 'sendMail') - .mockRejectedValueOnce(new Error('monitoring mail unavailable')) - .mockResolvedValue(undefined as any); const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); expect(eventRepo.find).toHaveBeenCalledTimes(1); expect(loggerError).toHaveBeenCalledWith( - 'Failed to deliver Frick vIBAN reconciliation Phase 1 failure alert:', + 'Frick vIBAN reconciliation Phase 1 (stuck intents) failed:', expect.any(Error), ); }); - it('sends outer Phase 2 failure alert when the abandoned-event query itself throws', async () => { + it('logs an outer Phase 2 failure when the abandoned-event query itself throws', async () => { eventRepo.find.mockRejectedValue(new Error('event repository unavailable')); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN retired-reference reconciliation: check could not run', - errors: [expect.stringContaining('NOT evidence of a clean state')], - }, - }); - const mailArg = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - expect(mailArg.input.errors[0]).not.toContain('event repository unavailable'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('sends outer Phase 2 failure alert when a LIKE-matched event has nextError=null (SQL invariant fail-closed)', async () => { + it('logs an outer Phase 2 failure when a LIKE-matched event has nextError=null', async () => { // Productively unreachable via the Postgres LIKE query path (NULL LIKE pattern is never // true — see the invariant comment in loadAbandonedReferences ~line 422). This test only // covers the fail-closed reaction to a hypothetical future dialect/driver violation of that @@ -970,28 +1040,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { // Fail-closed throw aborts loadAbandonedReferences before any Frick listing work. expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - // Outer Phase-2 catch → sendFailureAlert — not sendUnresolvedAbandonedReferenceAlert - // (that path only runs after the null check, for unextractable non-null nextError). - expect(notificationService.sendMail).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN retired-reference reconciliation: check could not run', - errors: [ - 'The reconciliation check itself failed; absence of a match alert is NOT evidence of a clean state. See server logs for the classified failure reason.', - ], - }, - }); - const subjects = (notificationService.sendMail as jest.Mock).mock.calls.map((c) => c[0].input.subject); - expect(subjects).not.toContain( - 'Frick vIBAN reconciliation Phase 2: abandoned-reference candidate(s) could not be resolved', - ); - // Fixed alert text only — no free-form throw message / nextError / event PII. - const mailArg = (notificationService.sendMail as jest.Mock).mock.calls[0][0]; - expect(mailArg.input.errors[0]).not.toContain('SQL invariant violated'); - expect(mailArg.input.errors[0]).not.toContain('event 426'); - expect(mailArg.input.errors[0]).not.toContain('nextError'); + expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('reuses the Phase-1 listing cache in Phase 2 for the same reference-account snapshot', async () => { diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts index d28ad60c1d..0f6004de2d 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { BadRequestException, ConflictException, ServiceUnavailableException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { DataType, newDb } from 'pg-mem'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; @@ -1034,7 +1035,7 @@ describe('VirtualIbanService', () => { QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED, ); - // Neither the request path nor alert-only reconciliation may reopen or rotate this intent. + // Neither the request path nor automatic reconciliation may reopen or rotate this intent. expect(currentIntent.requestReference).toBe(oldReference); expect(currentIntent.status).toBe(VirtualIbanIssuanceIntentStatus.IN_FLIGHT); expect(frickVibanProvider.reserveViban).not.toHaveBeenCalled(); @@ -1424,6 +1425,220 @@ describe('VirtualIbanService', () => { expect(frickVibanProvider.findRecoverableByDescription).not.toHaveBeenCalled(); expect(frickVibanProvider.reserveViban).not.toHaveBeenCalled(); }); + + it('logs ERROR and keeps returning the collection-account fallback for a terminal fallback intent', async () => { + currentIntent = Object.assign(new VirtualIbanIssuanceIntent(), { + id: 301, + requestReference: 'dfx-viban-terminal-fallback-reference', + userDataId: userData.id, + currencyId: eur.id, + bankId: frickBank.id, + status: VirtualIbanIssuanceIntentStatus.FALLBACK, + externalIban: null, + error: 'automatic-collection-account-fallback', + }); + const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + + await expect(service.getOrCreateFrickForUser(userData, 'EUR')).rejects.toThrow( + QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED, + ); + + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('using the collection-account fallback')); + expect(frickVibanProvider.findRecoverableByDescription).not.toHaveBeenCalled(); + expect(frickVibanProvider.reserveViban).not.toHaveBeenCalled(); + }); + }); + + describe('automatic Frick reconciliation transitions', () => { + const eur = { id: 4, name: 'EUR' } as Fiat; + const frickBank = { + id: 19, + iban: 'LI32088110105923K000C', + receive: true, + name: IbanBankName.FRICK, + } as Bank; + const match = { + vban: 'LI75088110105923K000E', + referenceAccountIban: frickBank.iban, + description: 'dfx-viban-reconciliation-reference', + state: 'ACTIVE', + } as any; + + const reconciliationIntent = (partial: Partial = {}): VirtualIbanIssuanceIntent => + Object.assign(new VirtualIbanIssuanceIntent(), { + id: 301, + requestReference: match.description, + userDataId: userData.id, + currencyId: eur.id, + bankId: frickBank.id, + provider: IbanBankName.FRICK, + referenceAccountIban: frickBank.iban, + referenceAccountReceive: true, + status: VirtualIbanIssuanceIntentStatus.IN_FLIGHT, + externalIban: null, + error: null, + ...partial, + }); + + function mockReconciliationRepositories( + currentIntent: VirtualIbanIssuanceIntent | null, + currentUserData: UserData | null = userData, + currentBank: Bank | null = frickBank, + currentCurrency: Fiat | null = eur, + ): void { + (dataSource.getRepository as jest.Mock).mockImplementation((entity) => ({ + findOne: jest + .fn() + .mockResolvedValue( + entity === VirtualIbanIssuanceIntent + ? currentIntent + : entity === UserData + ? currentUserData + : entity === Bank + ? currentBank + : entity === Fiat + ? currentCurrency + : null, + ), + })); + } + + it('adopts and finalizes the exact external match without a second create', async () => { + const currentIntent = reconciliationIntent(); + mockReconciliationRepositories(currentIntent); + jest.spyOn(frickVibanProvider, 'adoptAndActivate').mockResolvedValue({ + iban: match.vban, + providerAccountRef: match.vban, + }); + const finalize = jest.spyOn(service as any, 'finalizeFrickIssuance').mockResolvedValue({ id: 501 }); + + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toBe(true); + + expect(frickVibanProvider.adoptAndActivate).toHaveBeenCalledWith( + match, + currentIntent.referenceAccountIban, + currentIntent.requestReference, + ); + expect(finalize).toHaveBeenCalledWith( + currentIntent.id, + currentIntent.requestReference, + userData, + frickBank, + eur, + expect.objectContaining({ iban: match.vban }), + ); + expect(frickVibanProvider.reserveViban).not.toHaveBeenCalled(); + }); + + it('fails closed when the reconciliation intent no longer exists', async () => { + mockReconciliationRepositories(null); + + await expect(service.recoverFrickIntentForReconciliation(301, match)).rejects.toThrow( + 'Bank Frick reconciliation intent not found', + ); + expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); + }); + + it.each([ + VirtualIbanIssuanceIntentStatus.PENDING, + VirtualIbanIssuanceIntentStatus.COMPLETED, + VirtualIbanIssuanceIntentStatus.FALLBACK, + ])('ignores reconciliation after intent reached %s', async (status) => { + const currentIntent = reconciliationIntent({ status }); + mockReconciliationRepositories(currentIntent); + + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toBe(false); + expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); + }); + + it('ignores reconciliation for a merge-superseded failure', async () => { + const currentIntent = reconciliationIntent({ + status: VirtualIbanIssuanceIntentStatus.FAILED, + error: `terminated; ${MERGE_SUPERSEDED_MARKER}`, + }); + mockReconciliationRepositories(currentIntent); + + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toBe(false); + expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); + }); + + it.each([ + ['user', null, frickBank, eur], + ['bank', userData, null, eur], + ['currency', userData, frickBank, null], + ])('fails closed when the %s reconciliation context is missing', async (_label, owner, bank, currency) => { + const currentIntent = reconciliationIntent(); + mockReconciliationRepositories( + currentIntent, + owner as UserData | null, + bank as Bank | null, + currency as Fiat | null, + ); + + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).rejects.toThrow( + 'Bank Frick reconciliation context missing', + ); + expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); + }); + + it('moves an old ambiguous intent to terminal fallback with an auditable retired reference', async () => { + const currentIntent = reconciliationIntent({ status: VirtualIbanIssuanceIntentStatus.FAILED }); + manager.findOne.mockImplementation(async (entity) => + entity === VirtualIbanIssuanceIntent ? currentIntent : null, + ); + + await expect( + service.moveFrickIntentToFallbackForReconciliation(currentIntent.id, currentIntent.requestReference), + ).resolves.toBe(true); + + expect(currentIntent).toMatchObject({ + status: VirtualIbanIssuanceIntentStatus.FALLBACK, + externalIban: null, + error: expect.stringContaining(`previousRequestReference=${match.description}`), + }); + expect(manager.save).toHaveBeenCalledWith( + expect.objectContaining({ + previousStatus: VirtualIbanIssuanceIntentStatus.FAILED, + nextStatus: VirtualIbanIssuanceIntentStatus.FALLBACK, + }), + ); + }); + + it('refuses fallback when the request reference changed under lock', async () => { + const currentIntent = reconciliationIntent(); + manager.findOne.mockResolvedValue(currentIntent); + + await expect( + service.moveFrickIntentToFallbackForReconciliation(currentIntent.id, 'dfx-viban-stale-reference'), + ).rejects.toThrow('requestReference changed'); + expect(manager.save).not.toHaveBeenCalled(); + }); + + it.each([VirtualIbanIssuanceIntentStatus.PENDING, VirtualIbanIssuanceIntentStatus.COMPLETED])( + 'does not overwrite an intent already in %s during fallback', + async (status) => { + const currentIntent = reconciliationIntent({ status }); + manager.findOne.mockResolvedValue(currentIntent); + + await expect( + service.moveFrickIntentToFallbackForReconciliation(currentIntent.id, currentIntent.requestReference), + ).resolves.toBe(false); + expect(manager.save).not.toHaveBeenCalled(); + }, + ); + + it('does not move a merge-superseded failure to fallback', async () => { + const currentIntent = reconciliationIntent({ + status: VirtualIbanIssuanceIntentStatus.FAILED, + error: `terminated; ${MERGE_SUPERSEDED_MARKER}`, + }); + manager.findOne.mockResolvedValue(currentIntent); + + await expect( + service.moveFrickIntentToFallbackForReconciliation(currentIntent.id, currentIntent.requestReference), + ).resolves.toBe(false); + expect(manager.save).not.toHaveBeenCalled(); + }); }); describe('read helpers', () => { @@ -3368,6 +3583,26 @@ describe('VirtualIbanService', () => { manager.save.mockImplementation(async (value) => value); }); + it('refuses finalization after reconciliation already committed terminal fallback', async () => { + manager.findOne.mockImplementation(async (entity) => + entity === VirtualIbanIssuanceIntent + ? Object.assign(new VirtualIbanIssuanceIntent(), { + id: 301, + requestReference: callerReference, + userDataId: userData.id, + currencyId: eur.id, + bankId: frickBank.id, + status: VirtualIbanIssuanceIntentStatus.FALLBACK, + externalIban: null, + error: 'automatic-collection-account-fallback', + }) + : null, + ); + + await expect(finalize(301, callerReference)).rejects.toThrow(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); + expect(manager.save).not.toHaveBeenCalled(); + }); + it('refuses finalize and alerts when requestReference changed under lock', async () => { manager.findOne.mockResolvedValue( Object.assign(new VirtualIbanIssuanceIntent(), { diff --git a/src/subdomains/supporting/bank/virtual-iban/providers/__tests__/frick-viban.provider.spec.ts b/src/subdomains/supporting/bank/virtual-iban/providers/__tests__/frick-viban.provider.spec.ts index c5e0625f42..0cb8f9eddc 100644 --- a/src/subdomains/supporting/bank/virtual-iban/providers/__tests__/frick-viban.provider.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/providers/__tests__/frick-viban.provider.spec.ts @@ -33,6 +33,9 @@ describe('FrickVibanProvider', () => { prepareVibanCreate: jest.Mock; createViban: jest.Mock; approveVibanActivation: jest.Mock; + deactivateViban: jest.Mock; + approveVibanDeactivation: jest.Mock; + getViban: jest.Mock; listAllVibans: jest.Mock; }; let provider: FrickVibanProvider; @@ -44,6 +47,9 @@ describe('FrickVibanProvider', () => { prepareVibanCreate: jest.fn(), createViban: jest.fn(), approveVibanActivation: jest.fn(), + deactivateViban: jest.fn(), + approveVibanDeactivation: jest.fn(), + getViban: jest.fn(), listAllVibans: jest.fn(), }; provider = new FrickVibanProvider(bankFrickService as unknown as BankFrickService); @@ -236,6 +242,9 @@ describe('FrickVibanProvider', () => { } expect(message).toMatch(/Bank Frick virtual IBAN could not be activated \(state: PREPARED, vbanLength=\d+\)/); expect(message).not.toContain('LI33STUCK00000000001'); + expect(loggerError).toHaveBeenCalledWith( + expect.stringMatching(/Bank Frick virtual IBAN activation returned an unexpected state/), + ); }); it('fails closed when activation returns a different vban without embedding either IBAN', async () => { @@ -413,6 +422,140 @@ describe('FrickVibanProvider', () => { expect(bankFrickService.approveVibanActivation).toHaveBeenCalledWith('LI44ADOPT00000000001'); }); + it('deactivateAndApprove requests and approves deactivation', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const active = virtualIban({ state: FrickVirtualIbanState.ACTIVE }); + bankFrickService.deactivateViban.mockResolvedValue( + virtualIban({ state: FrickVirtualIbanState.DEACTIVATION_REQUESTED }), + ); + bankFrickService.approveVibanDeactivation.mockResolvedValue( + virtualIban({ state: FrickVirtualIbanState.DEACTIVATED }), + ); + + await expect( + provider.deactivateAndApprove(active, active.referenceAccountIban, active.description), + ).resolves.toBeUndefined(); + expect(bankFrickService.deactivateViban).toHaveBeenCalledWith(active.vban); + expect(bankFrickService.approveVibanDeactivation).toHaveBeenCalledWith(active.vban); + }); + + it('deactivateAndApprove refuses to call Bank Frick when the vIBAN rail is unavailable', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(false); + const active = virtualIban({ state: FrickVirtualIbanState.ACTIVE }); + + await expect( + provider.deactivateAndApprove(active, active.referenceAccountIban, active.description), + ).rejects.toThrow(new ServiceUnavailableException('Bank Frick virtual IBAN service is not available')); + expect(bankFrickService.deactivateViban).not.toHaveBeenCalled(); + }); + + it('deactivateAndApprove treats an already deactivated vIBAN as complete', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const deactivated = virtualIban({ state: FrickVirtualIbanState.DEACTIVATED }); + + await expect( + provider.deactivateAndApprove(deactivated, deactivated.referenceAccountIban, deactivated.description), + ).resolves.toBeUndefined(); + expect(bankFrickService.deactivateViban).not.toHaveBeenCalled(); + expect(bankFrickService.approveVibanDeactivation).not.toHaveBeenCalled(); + }); + + it('deactivateAndApprove recovers an ambiguous deactivation through exact lookup', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const active = virtualIban({ state: FrickVirtualIbanState.ACTIVE }); + bankFrickService.deactivateViban.mockRejectedValue(new Error('timeout')); + bankFrickService.getViban.mockResolvedValue(virtualIban({ state: FrickVirtualIbanState.DEACTIVATION_REQUESTED })); + bankFrickService.approveVibanDeactivation.mockResolvedValue( + virtualIban({ state: FrickVirtualIbanState.DEACTIVATED }), + ); + + await expect( + provider.deactivateAndApprove(active, active.referenceAccountIban, active.description), + ).resolves.toBeUndefined(); + expect(loggerError).toHaveBeenCalledWith( + 'Bank Frick virtual IBAN deactivation returned an ambiguous result', + expect.any(Error), + ); + expect(bankFrickService.getViban).toHaveBeenCalledWith(active.vban); + }); + + it('deactivateAndApprove logs a non-Error ambiguous deactivation and verifies it through exact lookup', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const active = virtualIban({ state: FrickVirtualIbanState.ACTIVE }); + bankFrickService.deactivateViban.mockRejectedValue('timeout'); + bankFrickService.getViban.mockResolvedValue(virtualIban({ state: FrickVirtualIbanState.DEACTIVATED })); + + await expect( + provider.deactivateAndApprove(active, active.referenceAccountIban, active.description), + ).resolves.toBeUndefined(); + expect(loggerError).toHaveBeenCalledWith( + 'Bank Frick virtual IBAN deactivation returned an ambiguous result', + undefined, + ); + }); + + it('deactivateAndApprove recovers an ambiguous approval through exact lookup', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const requested = virtualIban({ state: FrickVirtualIbanState.DEACTIVATION_REQUESTED }); + bankFrickService.approveVibanDeactivation.mockRejectedValue(new Error('timeout')); + bankFrickService.getViban.mockResolvedValue(virtualIban({ state: FrickVirtualIbanState.DEACTIVATED })); + + await expect( + provider.deactivateAndApprove(requested, requested.referenceAccountIban, requested.description), + ).resolves.toBeUndefined(); + expect(loggerError).toHaveBeenCalledWith( + 'Bank Frick virtual IBAN deactivation approval returned an ambiguous result', + expect.any(Error), + ); + }); + + it('deactivateAndApprove logs a non-Error ambiguous approval and verifies it through exact lookup', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const requested = virtualIban({ state: FrickVirtualIbanState.DEACTIVATION_REQUESTED }); + bankFrickService.approveVibanDeactivation.mockRejectedValue('timeout'); + bankFrickService.getViban.mockResolvedValue(virtualIban({ state: FrickVirtualIbanState.DEACTIVATED })); + + await expect( + provider.deactivateAndApprove(requested, requested.referenceAccountIban, requested.description), + ).resolves.toBeUndefined(); + expect(loggerError).toHaveBeenCalledWith( + 'Bank Frick virtual IBAN deactivation approval returned an ambiguous result', + undefined, + ); + }); + + it('deactivateAndApprove rejects mismatched or non-progressing responses', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const active = virtualIban({ state: FrickVirtualIbanState.ACTIVE }); + bankFrickService.deactivateViban.mockResolvedValueOnce( + virtualIban({ vban: 'LI44MISMATCH000000001', state: FrickVirtualIbanState.DEACTIVATION_REQUESTED }), + ); + await expect( + provider.deactivateAndApprove(active, active.referenceAccountIban, active.description), + ).rejects.toThrow('Bank Frick virtual IBAN deactivation identity mismatch'); + + bankFrickService.deactivateViban.mockResolvedValueOnce(virtualIban({ state: FrickVirtualIbanState.ACTIVE })); + await expect( + provider.deactivateAndApprove(active, active.referenceAccountIban, active.description), + ).rejects.toThrow('Bank Frick virtual IBAN deactivation did not reach an approvable state'); + expect(loggerError).toHaveBeenCalledWith( + 'Bank Frick virtual IBAN deactivation returned an unexpected state (state: ACTIVE)', + ); + }); + + it('deactivateAndApprove fails closed and logs ERROR when approval does not complete', async () => { + bankFrickService.isVibanAvailable.mockReturnValue(true); + const requested = virtualIban({ state: FrickVirtualIbanState.DEACTIVATION_REQUESTED }); + bankFrickService.approveVibanDeactivation.mockResolvedValue(requested); + + await expect( + provider.deactivateAndApprove(requested, requested.referenceAccountIban, requested.description), + ).rejects.toThrow('Bank Frick virtual IBAN could not be deactivated'); + expect(loggerError).toHaveBeenCalledWith( + 'Bank Frick virtual IBAN deactivation approval returned an unexpected state (state: DEACTIVATION_REQUESTED)', + ); + }); + it('throws ServiceUnavailableException from prepare when not available', async () => { bankFrickService.isVibanAvailable.mockReturnValue(false); diff --git a/src/subdomains/supporting/bank/virtual-iban/providers/frick-viban.provider.ts b/src/subdomains/supporting/bank/virtual-iban/providers/frick-viban.provider.ts index 421af5f8f0..ffc86fba07 100644 --- a/src/subdomains/supporting/bank/virtual-iban/providers/frick-viban.provider.ts +++ b/src/subdomains/supporting/bank/virtual-iban/providers/frick-viban.provider.ts @@ -127,6 +127,70 @@ export class FrickVibanProvider implements VibanProvider { return this.ensureActive(viban, expectedReferenceAccountIban, expectedDescription); } + async deactivateAndApprove( + viban: FrickVirtualIban, + expectedReferenceAccountIban: string, + expectedDescription: string, + ): Promise { + if (!this.isAvailable()) throw new ServiceUnavailableException('Bank Frick virtual IBAN service is not available'); + + const normalizedReferenceAccountIban = expectedReferenceAccountIban.replace(/\s/g, '').toUpperCase(); + this.assertResponseBinding(viban, normalizedReferenceAccountIban, expectedDescription, 'deactivation'); + let current = viban; + + if ([FrickVirtualIbanState.PREPARED, FrickVirtualIbanState.ACTIVE].includes(current.state)) { + try { + current = await this.bankFrickService.deactivateViban(current.vban); + } catch (error) { + this.logger.error( + 'Bank Frick virtual IBAN deactivation returned an ambiguous result', + error instanceof Error ? error : undefined, + ); + current = await this.bankFrickService.getViban(current.vban); + } + this.assertSameVibanAndBinding( + current, + viban, + normalizedReferenceAccountIban, + expectedDescription, + 'deactivation', + ); + } + + if (current.state === FrickVirtualIbanState.DEACTIVATED) return; + if (current.state !== FrickVirtualIbanState.DEACTIVATION_REQUESTED) { + this.logger.error(`Bank Frick virtual IBAN deactivation returned an unexpected state (state: ${current.state})`); + throw new ServiceUnavailableException( + `Bank Frick virtual IBAN deactivation did not reach an approvable state (state: ${current.state})`, + ); + } + + try { + current = await this.bankFrickService.approveVibanDeactivation(current.vban); + } catch (error) { + this.logger.error( + 'Bank Frick virtual IBAN deactivation approval returned an ambiguous result', + error instanceof Error ? error : undefined, + ); + current = await this.bankFrickService.getViban(current.vban); + } + this.assertSameVibanAndBinding( + current, + viban, + normalizedReferenceAccountIban, + expectedDescription, + 'deactivation approval', + ); + if (current.state !== FrickVirtualIbanState.DEACTIVATED) { + this.logger.error( + `Bank Frick virtual IBAN deactivation approval returned an unexpected state (state: ${current.state})`, + ); + throw new ServiceUnavailableException( + `Bank Frick virtual IBAN could not be deactivated (state: ${current.state})`, + ); + } + } + private async ensureActive( created: FrickVirtualIban, expectedReferenceAccountIban: string, @@ -156,10 +220,15 @@ export class FrickVibanProvider implements VibanProvider { } this.assertResponseBinding(activated, normalizedReferenceAccountIban, expectedDescription, 'activation'); - if (activated.state !== FrickVirtualIbanState.ACTIVE) + if (activated.state !== FrickVirtualIbanState.ACTIVE) { + this.logger.error( + `Bank Frick virtual IBAN activation returned an unexpected state ` + + `(state: ${activated.state}, vbanLength=${created.vban.length})`, + ); throw new ServiceUnavailableException( `Bank Frick virtual IBAN could not be activated (state: ${activated.state}, vbanLength=${created.vban.length})`, ); + } return { iban: activated.vban, providerAccountRef: activated.vban }; } @@ -168,7 +237,7 @@ export class FrickVibanProvider implements VibanProvider { response: FrickVirtualIban, expectedReferenceAccountIban: string, expectedDescription: string, - phase: 'create' | 'activation', + phase: 'create' | 'activation' | 'deactivation' | 'deactivation approval', ): void { if ( response.referenceAccountIban === expectedReferenceAccountIban && @@ -180,4 +249,18 @@ export class FrickVibanProvider implements VibanProvider { this.logger.error(`Bank Frick virtual IBAN ${phase} response binding mismatch`); throw new ServiceUnavailableException(`Bank Frick virtual IBAN ${phase} response binding mismatch`); } + + private assertSameVibanAndBinding( + response: FrickVirtualIban, + expected: FrickVirtualIban, + expectedReferenceAccountIban: string, + expectedDescription: string, + phase: 'deactivation' | 'deactivation approval', + ): void { + if (response.vban !== expected.vban) { + this.logger.error(`Bank Frick virtual IBAN ${phase} identity mismatch`); + throw new ServiceUnavailableException(`Bank Frick virtual IBAN ${phase} identity mismatch`); + } + this.assertResponseBinding(response, expectedReferenceAccountIban, expectedDescription, phase); + } } diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts index ea5937b211..93e128a6d8 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; +import { FrickVirtualIban, FrickVirtualIbanState } from 'src/integration/bank/dto/frick-vban.dto'; import { FrickVirtualIbansFetchResult } from 'src/integration/bank/services/frick.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Process } from 'src/shared/services/process.service'; @@ -9,8 +10,6 @@ import { FrickVibanProvider } from 'src/subdomains/supporting/bank/virtual-iban/ import { VirtualIbanIssuanceEvent } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-event.entity'; import { VirtualIbanIssuanceIntentStatus } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent-status.enum'; import { VirtualIbanIssuanceIntent } from 'src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent.entity'; -import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; -import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { DataSource, In, Like } from 'typeorm'; import { CREATE_PATH_REFERENCE_MARKER, @@ -58,6 +57,11 @@ interface StuckIntentListingMatch { bankId: number; status: VirtualIbanIssuanceIntentStatus; updated: Date; + virtualIbans: FrickVirtualIban[]; +} + +interface AbandonedReferenceMatch extends AbandonedReferenceHit { + virtualIban: FrickVirtualIban; } interface UnprovenAbsenceIntent { @@ -73,9 +77,9 @@ interface UnprovenAbsenceIntent { /** * Periodic Frick issuance reconciliation: - * - Phase 1: alert on stuck InFlight/Failed intents. Positive matches prove that a create occurred; - * listing misses are not authoritative and therefore never reopen an intent automatically. - * - Phase 2: alert-only check for delayed Frick vIBANs under retired issuance references. + * - Phase 1: automatically adopt a uniquely identified delayed create. After a bounded recovery + * window, move listing misses to the safe collection-account fallback without issuing a second POST. + * - Phase 2: automatically deactivate delayed vIBANs found under retired issuance references. */ @Injectable() export class VirtualIbanFrickIssuanceReconciliationService { @@ -93,19 +97,20 @@ export class VirtualIbanFrickIssuanceReconciliationService { * delay of more than eight times that window (the job itself runs hourly). */ static readonly FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS = 1_800_000; + static readonly FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS = 86_400_000; static readonly FRICK_CREATE_MAX_PROCESSING_MS = VirtualIbanService.FRICK_CREATE_MAX_PROCESSING_MS; constructor( private readonly dataSource: DataSource, private readonly frickVibanProvider: FrickVibanProvider, - private readonly notificationService: NotificationService, + private readonly virtualIbanService: VirtualIbanService, ) {} /** * `timeout: 1800` is a LockClass resumption threshold (src/shared/utils/lock.ts), not a hard abort * of a still-running previous tick: a run older than 1800s no longer blocks a new hour-tick from - * starting, so two overlapping invocations are possible. Both reconciliation phases are read-only - * with respect to issuance intents, so overlapping runs can only duplicate monitoring alerts. + * starting, so two overlapping invocations are possible. Intent transitions use row/advisory locks, + * and external cleanup targets exact vIBAN identities, making repeated work fail closed or idempotent. */ @DfxCron(CronExpression.EVERY_HOUR, { process: Process.VIRTUAL_IBAN_FRICK_ISSUANCE_RECONCILIATION, @@ -122,26 +127,20 @@ export class VirtualIbanFrickIssuanceReconciliationService { try { await this.runPhase1StuckIntents(listingCache); } catch (error) { - // Fail-closed: any unhandled Phase 1 failure must surface as an operator alert. this.logger.error('Frick vIBAN reconciliation Phase 1 (stuck intents) failed:', error); - await this.trySendFailureAlert(error, 'Phase 1'); } try { await this.runPhase2RetiredReferences(listingCache); } catch (error) { - // Fail-closed: any unhandled Phase 2 failure must surface as an operator alert. this.logger.error('Frick vIBAN reconciliation Phase 2 (retired references) failed:', error); - await this.trySendFailureAlert(error, 'Phase 2'); } } /** - * Phase 1: for each InFlight/Failed intent, alert on a positive technical-description match. - * An all-state, fully validated listing miss is the strongest evidence this bank exposes but is - * not authoritative proof of non-creation. It therefore alerts and leaves the intent non-retryable - * for manual reconciliation. Automatic retries remain limited to conclusive request-path evidence - * such as a rejected create or a failed preflight before any create call. + * Phase 1: recover delayed creates by their exact technical description. Listing misses never arm + * a second create. After the bounded recovery window they become a terminal collection-account + * fallback, while Phase 2 keeps watching the retired reference for a delayed external object. * * Merge-superseded intents (`error` contains {@link MERGE_SUPERSEDED_MARKER}) are permanently * retired and must never be reopened — exclude them before any listing work. @@ -205,14 +204,15 @@ export class VirtualIbanFrickIssuanceReconciliationService { ) { throw new Error(`Frick vIBAN listing timestamps invalid for bankId=${bankId}`); } - const descriptions = new Set( - listingResult.virtualIbans - .map((viban) => viban.description) - .filter((description): description is string => typeof description === 'string'), - ); + const byDescription = new Map(); + for (const viban of listingResult.virtualIbans) { + if (typeof viban.description !== 'string') continue; + const entries = byDescription.get(viban.description) ?? []; + entries.push(viban); + byDescription.set(viban.description, entries); + } - // Incomplete listing: still surface positive matches (they are evidence), but absence is - // inconclusive and requires manual reconciliation. + // Incomplete listing: positive matches remain safe evidence, but absence is inconclusive. // "not listed" is not proof of absence when validation dropped entries. if (!listingResult.fullyValidated) { incompleteListingBankCount += 1; @@ -221,7 +221,10 @@ export class VirtualIbanFrickIssuanceReconciliationService { `check incomplete this run; intents for this bank remain non-retryable`, ); for (const intent of group) { - if (descriptions.has(intent.requestReference)) { + const virtualIbans = (byDescription.get(intent.requestReference) ?? []).filter((viban) => + [FrickVirtualIbanState.PREPARED, FrickVirtualIbanState.ACTIVE].includes(viban.state), + ); + if (virtualIbans.length > 0) { listingMatches.push({ intentId: intent.id, requestReference: intent.requestReference, @@ -230,10 +233,11 @@ export class VirtualIbanFrickIssuanceReconciliationService { bankId: intent.bankId, status: intent.status, updated: intent.updated, + virtualIbans, }); } else { skippedIncompleteCount += 1; - // Old enough for operator escalation, but blocked by incomplete listing. + // Old enough for ERROR logging, but blocked from automatic recovery by incomplete evidence. if ( Date.now() - intent.updated.getTime() >= VirtualIbanFrickIssuanceReconciliationService.FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS @@ -246,7 +250,10 @@ export class VirtualIbanFrickIssuanceReconciliationService { } for (const intent of group) { - if (descriptions.has(intent.requestReference)) { + const virtualIbans = (byDescription.get(intent.requestReference) ?? []).filter((viban) => + [FrickVirtualIbanState.PREPARED, FrickVirtualIbanState.ACTIVE].includes(viban.state), + ); + if (virtualIbans.length > 0) { listingMatches.push({ intentId: intent.id, requestReference: intent.requestReference, @@ -255,6 +262,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { bankId: intent.bankId, status: intent.status, updated: intent.updated, + virtualIbans, }); continue; } @@ -289,48 +297,89 @@ export class VirtualIbanFrickIssuanceReconciliationService { } } - if (listingMatches.length > 0) { - await this.sendStuckIntentMatchAlert(listingMatches); + let recoveredCount = 0; + let recoveryFailureCount = 0; + let duplicateCleanupCount = 0; + const matchedIntentIds = new Set(listingMatches.map((match) => match.intentId)); + for (const match of listingMatches) { + const candidates = [...match.virtualIbans].sort( + (a, b) => a.createdAt.localeCompare(b.createdAt) || a.vban.localeCompare(b.vban), + ); + const winner = candidates[0]; + try { + for (const duplicate of candidates.slice(1)) { + await this.frickVibanProvider.deactivateAndApprove( + duplicate, + duplicate.referenceAccountIban, + match.requestReference, + ); + duplicateCleanupCount += 1; + } + if (await this.virtualIbanService.recoverFrickIntentForReconciliation(match.intentId, winner)) { + recoveredCount += 1; + this.logger.info(`Frick vIBAN reconciliation Phase 1: automatically recovered intent ${match.intentId}`); + } + } catch (error) { + recoveryFailureCount += 1; + this.logger.error( + `Frick vIBAN reconciliation Phase 1: automatic recovery failed for intentId=${match.intentId}`, + error, + ); + } } if (unprovenAbsences.length > 0) { - await this.sendUnprovenAbsenceAlert(unprovenAbsences); - } - - if (incompleteListingBankCount > 0) { - await this.sendIncompleteListingAlert(incompleteListingBankCount, 'Phase 1'); + this.logUnprovenAbsences(unprovenAbsences); } - if (chronicIncompleteBankIds.size > 0) { - await this.sendChronicIncompleteListingAlert([...chronicIncompleteBankIds]); + let fallbackCount = 0; + for (const intent of intents) { + if ( + matchedIntentIds.has(intent.id) || + Date.now() - intent.updated.getTime() < + VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS + ) + continue; + try { + if ( + await this.virtualIbanService.moveFrickIntentToFallbackForReconciliation(intent.id, intent.requestReference) + ) + fallbackCount += 1; + } catch (error) { + this.logger.error( + `Frick vIBAN reconciliation Phase 1: automatic fallback transition failed for intentId=${intent.id}`, + error, + ); + } } - if (failedBankCount > 0) { - await this.sendPerBankFailureAlert(failedBankCount, 'Phase 1'); - } + if (chronicIncompleteBankIds.size > 0) + this.logger.error( + `Frick vIBAN reconciliation Phase 1: listing chronically incomplete for bankId(s) ${[ + ...chronicIncompleteBankIds, + ].join(', ')}`, + ); this.logger.info( `Frick vIBAN reconciliation Phase 1: checked ${intents.length} intent(s) across ` + `${byReferenceAccountSnapshot.size} reference-account snapshot(s); ` + `${listingMatches.length} listing match(es), ${skippedFreshCount} skipped (too fresh), ` + - `${unprovenAbsences.length} left non-retryable (listing cannot prove absence), ` + + `${recoveredCount} recovered, ${recoveryFailureCount} recovery failure(s), ` + + `${duplicateCleanupCount} duplicate(s) deactivated, ${fallbackCount} moved to fallback, ` + + `${unprovenAbsences.length} inconclusive absence(s), ${failedBankCount} bank failure(s), ` + `${skippedIncompleteCount} skipped (incomplete listing across ${incompleteListingBankCount} bank(s))` + (skippedMergeSupersededCount > 0 ? `, ${skippedMergeSupersededCount} skipped (merge-superseded)` : ''), ); } - /** - * Phase 2: alert when Bank Frick still lists a vIBAN under a retired (abandoned) requestReference. - * Incomplete listings never count as "clean" for unmatched abandoned references. - */ + /** Phase 2: deactivate vIBANs that appear under a retired requestReference. */ private async runPhase2RetiredReferences(listingCache: Map): Promise { const { hits: abandoned, unresolved } = await this.loadAbandonedReferences(); - // Unresolved candidates (query match but no parseable abandoned reference) always alert — - // they are the last-line-of-defense failure mode, even when zero hits are resolvable. - if (unresolved.length > 0) { - await this.sendUnresolvedAbandonedReferenceAlert(unresolved); - } + if (unresolved.length > 0) + this.logger.error( + `Frick vIBAN reconciliation Phase 2: ${unresolved.length} abandoned reference(s) could not be extracted`, + ); if (abandoned.length === 0) { if (unresolved.length === 0) { @@ -347,7 +396,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { byReferenceAccountSnapshot.set(snapshotKey, group); } - const matches: AbandonedReferenceHit[] = []; + const matches: AbandonedReferenceMatch[] = []; let incompleteListingBankCount = 0; let incompleteUnresolvedCount = 0; let failedBankCount = 0; @@ -362,11 +411,13 @@ export class VirtualIbanFrickIssuanceReconciliationService { snapshot.provider, listingCache, ); - const descriptions = new Set( - listingResult.virtualIbans - .map((viban) => viban.description) - .filter((description): description is string => typeof description === 'string'), - ); + const byDescription = new Map(); + for (const viban of listingResult.virtualIbans) { + if (typeof viban.description !== 'string') continue; + const entries = byDescription.get(viban.description) ?? []; + entries.push(viban); + byDescription.set(viban.description, entries); + } if (!listingResult.fullyValidated) { incompleteListingBankCount += 1; @@ -375,8 +426,9 @@ export class VirtualIbanFrickIssuanceReconciliationService { `check incomplete this run; unmatched abandoned references are NOT treated as clean`, ); for (const hit of group) { - if (descriptions.has(hit.abandonedReference)) { - matches.push(hit); + const externalMatches = byDescription.get(hit.abandonedReference) ?? []; + if (externalMatches.length > 0) { + matches.push(...externalMatches.map((virtualIban) => ({ ...hit, virtualIban }))); } else { incompleteUnresolvedCount += 1; } @@ -385,7 +437,8 @@ export class VirtualIbanFrickIssuanceReconciliationService { } for (const hit of group) { - if (descriptions.has(hit.abandonedReference)) matches.push(hit); + const externalMatches = byDescription.get(hit.abandonedReference) ?? []; + matches.push(...externalMatches.map((virtualIban) => ({ ...hit, virtualIban }))); } } catch (error) { // Isolate per-bank failures so one misconfigured/unreachable bank cannot abort every other bank. @@ -397,16 +450,30 @@ export class VirtualIbanFrickIssuanceReconciliationService { } } - if (matches.length > 0) { - await this.sendMatchAlert(matches); - } - - if (incompleteListingBankCount > 0) { - await this.sendIncompleteListingAlert(incompleteListingBankCount, 'Phase 2'); - } - - if (failedBankCount > 0) { - await this.sendPerBankFailureAlert(failedBankCount, 'Phase 2'); + let deactivatedCount = 0; + let cleanupFailureCount = 0; + const processedVibans = new Set(); + for (const match of matches) { + if (processedVibans.has(match.virtualIban.vban)) continue; + processedVibans.add(match.virtualIban.vban); + if (match.virtualIban.state === FrickVirtualIbanState.DEACTIVATED) continue; + try { + await this.frickVibanProvider.deactivateAndApprove( + match.virtualIban, + match.referenceAccountIban, + match.abandonedReference, + ); + deactivatedCount += 1; + this.logger.info( + `Frick vIBAN reconciliation Phase 2: automatically deactivated orphan for eventId=${match.eventId}`, + ); + } catch (error) { + cleanupFailureCount += 1; + this.logger.error( + `Frick vIBAN reconciliation Phase 2: automatic orphan cleanup failed for eventId=${match.eventId}`, + error, + ); + } } if (matches.length === 0 && incompleteListingBankCount === 0) { @@ -422,6 +489,13 @@ export class VirtualIbanFrickIssuanceReconciliationService { `no matches but ${incompleteUnresolvedCount} unresolved under incomplete listing(s)`, ); } + + if (matches.length > 0) + this.logger.info( + `Frick vIBAN reconciliation Phase 2: ${matches.length} orphan match(es), ` + + `${deactivatedCount} deactivated, ${cleanupFailureCount} cleanup failure(s), ` + + `${failedBankCount} bank failure(s)`, + ); } private async getListingForReferenceAccount( @@ -487,7 +561,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { for (const event of events) { // Invariant of the LIKE query above: NULL LIKE pattern is never true in SQL, so a matched // row cannot have nextError = null. Fail closed (throws into runPhase2RetiredReferences → - // sendFailureAlert) if a future dialect/driver ever violates that. + // the outer Phase-2 ERROR log) if a future dialect/driver ever violates that. if (event.nextError == null) { throw new Error( `Frick vIBAN reconciliation Phase 2: event ${event.id} matched the abandoned-reference ` + @@ -554,163 +628,10 @@ export class VirtualIbanFrickIssuanceReconciliationService { return (end >= 0 ? rest.slice(0, end) : rest).trim(); } - private async sendStuckIntentMatchAlert(matches: StuckIntentListingMatch[]): Promise { - const errors = matches.map( - (m) => - `requestReference=${m.requestReference}; intentId=${m.intentId}; ` + - `userDataId=${m.userDataId}; currencyId=${m.currencyId}; bankId=${m.bankId}; ` + - `status=${m.status}; updated=${m.updated.toISOString()}`, - ); - - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: stuck intent(s) already exist at Bank Frick', - errors, - }, - }); - + private logUnprovenAbsences(intents: UnprovenAbsenceIntent[]): void { this.logger.error( - `Frick vIBAN reconciliation Phase 1: ${matches.length} listing match(es) on stuck intent(s) — operator follow-up required`, + `Frick vIBAN reconciliation Phase 1: ${intents.length} intent(s) remain inconclusive because ` + + `listing absence is not authoritative; automatic recovery will continue`, ); } - - private async sendUnprovenAbsenceAlert(intents: UnprovenAbsenceIntent[]): Promise { - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 1: listing does not prove create absence', - errors: intents.map( - (intent) => - `intentId=${intent.intentId}; userDataId=${intent.userDataId}; currencyId=${intent.currencyId}; ` + - `bankId=${intent.bankId}; status=${intent.status}; updated=${intent.updated.toISOString()}; ` + - `listingStartedAt=${intent.listingStartedAt.toISOString()}; ` + - `latestPossibleCreateProcessedAt=${intent.latestPossibleCreateProcessedAt.toISOString()}`, - ), - }, - }); - this.logger.error( - `Frick vIBAN reconciliation Phase 1: ${intents.length} intent(s) left non-retryable because ` + - `listing absence is not authoritative; manual reconciliation required`, - ); - } - - private async sendMatchAlert(matches: AbandonedReferenceHit[]): Promise { - const errors = matches.map( - (m) => - `abandonedReference=${m.abandonedReference}; eventId=${m.eventId}; intentId=${m.intentId}; ` + - `userDataId=${m.userDataId}; currencyId=${m.currencyId}; bankId=${m.bankId}; ` + - `created=${m.created.toISOString()}`, - ); - - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN retired-reference reconciliation: orphan external vIBAN(s) detected', - errors, - }, - }); - - this.logger.error( - `Frick vIBAN reconciliation Phase 2: ${matches.length} orphan match(es) — operator follow-up required`, - ); - } - - private async sendUnresolvedAbandonedReferenceAlert( - candidates: UnresolvedAbandonedReferenceCandidate[], - ): Promise { - const errors = candidates.map( - (c) => - `eventId=${c.eventId}; intentId=${c.intentId}; userDataId=${c.userDataId}; ` + - `currencyId=${c.currencyId}; bankId=${c.bankId}; created=${c.created.toISOString()}; ` + - `reason=${c.reason}`, - ); - - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN reconciliation Phase 2: abandoned-reference candidate(s) could not be resolved', - errors, - }, - }); - - this.logger.error( - `Frick vIBAN reconciliation Phase 2: ${candidates.length} unresolved abandoned-reference candidate(s) — operator follow-up required`, - ); - } - - private async sendIncompleteListingAlert(bankCount: number, phase: 'Phase 1' | 'Phase 2'): Promise { - // Fixed text only — never bank IBANs or entry content. Distinct from a clean check. - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: `Frick vIBAN reconciliation ${phase}: listing not fully validated`, - errors: [ - `Listing validation dropped one or more entries for ${bankCount} bank(s); ` + - 'this run is incomplete for those banks (no empty-listing reset / no clean Phase-2 conclusion). ' + - 'See server logs for the drop count; absence of a match alert is NOT evidence of a clean state.', - ], - }, - }); - } - - private async sendPerBankFailureAlert(bankCount: number, phase: 'Phase 1' | 'Phase 2'): Promise { - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: `Frick vIBAN reconciliation ${phase}: processing failed for one or more banks`, - errors: [ - `Processing threw for ${bankCount} bank(s) this run; those bank(s) were skipped and the ` + - 'rest of the run continued for every other bank. See server logs for the classified ' + - 'failure reason per bank; absence of a match alert for a skipped bank is NOT evidence of a clean state.', - ], - }, - }); - } - - private async sendChronicIncompleteListingAlert(bankIds: number[]): Promise { - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: - 'Frick vIBAN reconciliation Phase 1: listing chronically incomplete — stuck intent(s) cannot be resolved', - errors: [ - `bankId(s) ${bankIds.join(', ')}: listing validation has dropped entries for long enough that ` + - 'at least one InFlight/Failed intent has passed the safety threshold with no clean check possible. ' + - 'This will keep repeating every run until the underlying listing-validation failure is fixed ' + - '(see server logs for the drop reason) — treat as an operator incident, not a transient blip.', - ], - }, - }); - } - - private async sendFailureAlert(_error: unknown): Promise { - // Fixed text only — never interpolate error.message (may carry path/query PII if a provider - // layer regresses). The calling catch already logs the Error object for operators. - await this.notificationService.sendMail({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN retired-reference reconciliation: check could not run', - errors: [ - 'The reconciliation check itself failed; absence of a match alert is NOT evidence of a clean state. See server logs for the classified failure reason.', - ], - }, - }); - } - - private async trySendFailureAlert(error: unknown, phase: 'Phase 1' | 'Phase 2'): Promise { - try { - await this.sendFailureAlert(error); - } catch (alertError) { - this.logger.error(`Failed to deliver Frick vIBAN reconciliation ${phase} failure alert:`, alertError); - } - } } diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent-status.enum.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent-status.enum.ts index 5fdef4d414..9423b56890 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent-status.enum.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-issuance-intent-status.enum.ts @@ -3,4 +3,5 @@ export enum VirtualIbanIssuanceIntentStatus { IN_FLIGHT = 'InFlight', COMPLETED = 'Completed', FAILED = 'Failed', + FALLBACK = 'Fallback', } diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts index 63683390d4..5cb1679337 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, ConflictException, Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { FrickVirtualIban } from 'src/integration/bank/dto/frick-vban.dto'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -75,6 +76,7 @@ export const RECOVERY_PATH_REFERENCE_MARKER = 'recovery listing found no match u * orphan scan. */ export const MERGE_SUPERSEDED_MARKER = 'merge-superseded'; +export const AUTOMATIC_FALLBACK_MARKER = 'automatic-collection-account-fallback'; @Injectable() export class VirtualIbanService { @@ -553,6 +555,14 @@ export class VirtualIbanService { providerAccountRef: intent.externalIban, }); + if (intent.status === VirtualIbanIssuanceIntentStatus.FALLBACK) { + this.logger.error( + `Bank Frick personal IBAN issuance remains inconclusive; using the collection-account fallback ` + + `(intentId=${intent.id}, userDataId=${userData.id}, currencyId=${currency.id}, bankId=${bank.id})`, + ); + throw new ServiceUnavailableException(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); + } + if ( intent.status !== VirtualIbanIssuanceIntentStatus.IN_FLIGHT && intent.status !== VirtualIbanIssuanceIntentStatus.FAILED @@ -610,6 +620,66 @@ export class VirtualIbanService { return this.finalizeFrickIssuance(intent.id, intent.requestReference, userData, bank, currency, reserved); } + async recoverFrickIntentForReconciliation(intentId: number, match: FrickVirtualIban): Promise { + const intent = await this.dataSource.getRepository(VirtualIbanIssuanceIntent).findOne({ + where: { id: intentId, provider: IbanBankName.FRICK }, + }); + if (!intent) throw new Error(`Bank Frick reconciliation intent not found (intentId=${intentId})`); + if ( + intent.status !== VirtualIbanIssuanceIntentStatus.IN_FLIGHT && + intent.status !== VirtualIbanIssuanceIntentStatus.FAILED + ) + return false; + if (intent.error?.includes(MERGE_SUPERSEDED_MARKER)) return false; + + const [userData, bank, currency] = await Promise.all([ + this.dataSource.getRepository(UserData).findOne({ where: { id: intent.userDataId } }), + this.dataSource.getRepository(Bank).findOne({ where: { id: intent.bankId } }), + this.dataSource.getRepository(Fiat).findOne({ where: { id: intent.currencyId } }), + ]); + if (!userData || !bank || !currency) { + throw new Error( + `Bank Frick reconciliation context missing (intentId=${intent.id}, userDataFound=${Boolean(userData)}, ` + + `bankFound=${Boolean(bank)}, currencyFound=${Boolean(currency)})`, + ); + } + + const reserved = await this.frickVibanProvider.adoptAndActivate( + match, + intent.referenceAccountIban, + intent.requestReference, + ); + await this.finalizeFrickIssuance(intent.id, intent.requestReference, userData, bank, currency, reserved); + return true; + } + + async moveFrickIntentToFallbackForReconciliation( + intentId: number, + expectedRequestReference: string, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const intent = await this.getFrickIntentByIdForUpdate(manager, intentId); + if (intent.requestReference !== expectedRequestReference) { + throw new Error(`Bank Frick fallback refused because requestReference changed (intentId=${intent.id})`); + } + if ( + intent.status !== VirtualIbanIssuanceIntentStatus.IN_FLIGHT && + intent.status !== VirtualIbanIssuanceIntentStatus.FAILED + ) + return false; + if (intent.error?.includes(MERGE_SUPERSEDED_MARKER)) return false; + + await this.transitionFrickIntent( + manager, + intent, + VirtualIbanIssuanceIntentStatus.FALLBACK, + null, + `${AUTOMATIC_FALLBACK_MARKER}; ${CREATE_PATH_REFERENCE_MARKER}${intent.requestReference}`, + ); + return true; + }); + } + private async finalizeFrickIssuance( intentId: number, expectedRequestReference: string, @@ -697,6 +767,10 @@ export class VirtualIbanService { ); } + if (intent.status === VirtualIbanIssuanceIntentStatus.FALLBACK) { + throw new ServiceUnavailableException(QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED); + } + if (intent.externalIban && intent.externalIban !== reserved.iban) { this.logger.error( `Bank Frick issuance intent conflicts with the recovered personal IBAN ` + @@ -1429,7 +1503,7 @@ export class VirtualIbanService { * - No master intent for the same (currencyId, bankId): reassign the slave row to master. * - Master already has a row: unique index blocks reassignment — permanently merge-fail every * non-COMPLETED slave intent (Pending/InFlight/Failed) via the event-logged transition path so - * alert-only reconciliation never treats a pre-merge failure under the retired slave id as + * automatic reconciliation never treats a pre-merge failure under the retired slave id as * eligible work. COMPLETED is left alone (runPhase1StuckIntents never loads Completed rows). * * Pairs already reconciled by {@link resolveMergedVirtualIbanPairLocked} are naturally safe to @@ -1464,7 +1538,7 @@ export class VirtualIbanService { } // COMPLETED is safe to leave alone: runPhase1StuckIntents only selects IN_FLIGHT/FAILED. - // FAILED must still be merge-marked so alert-only reconciliation excludes the retired + // FAILED must still be merge-marked so automatic reconciliation excludes the retired // userDataId from listing and absence alerts after the merge. if (slaveIntent.status === VirtualIbanIssuanceIntentStatus.COMPLETED) { continue; @@ -1568,7 +1642,7 @@ export class VirtualIbanService { if (winnerIntent != null && intent.id === winnerIntent.id) continue; // PENDING / IN_FLIGHT / FAILED: permanently mark merge-superseded so runPhase1StuckIntents - // excludes the retired userDataId from its alert-only checks. COMPLETED non-winner historical + // excludes the retired userDataId from automatic recovery. COMPLETED non-winner historical // rows are left untouched (reconciliation never loads Completed). if ( intent.status === VirtualIbanIssuanceIntentStatus.PENDING || From 3a59114eb125101ae0981a6e101a9cbbbb370031 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:07:41 +0200 Subject: [PATCH 02/10] Harden Bank Frick vIBAN reconciliation --- ...ck-issuance-reconciliation.service.spec.ts | 629 ++++++++++++++++-- .../__tests__/virtual-iban.service.spec.ts | 502 ++++++++++---- ...n-frick-issuance-reconciliation.service.ts | 388 ++++++++++- .../bank/virtual-iban/virtual-iban.service.ts | 61 +- 4 files changed, 1378 insertions(+), 202 deletions(-) diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts index b13d5ac164..232d0e4801 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts @@ -1,6 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; import { CronExpression } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; +import { FrickVirtualIban, FrickVirtualIbanState } from 'src/integration/bank/dto/frick-vban.dto'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Process } from 'src/shared/services/process.service'; import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; @@ -19,10 +21,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { let intentRepo: { find: jest.Mock }; let frickVibanProvider: FrickVibanProvider; let notificationService: NotificationService; - let legacyReopener: { + let virtualIbanService: { resetStuckFrickIntentForReconciliationOnly: jest.Mock; recoverFrickIntentForReconciliation: jest.Mock; moveFrickIntentToFallbackForReconciliation: jest.Mock; + isIbanProtectedFromReconciliationDeactivation: jest.Mock; }; const referenceAccountIban = 'LI32088110105923K000C'; @@ -73,37 +76,55 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); } - function listingEntry(description: string) { + function listingEntry(description: string, overrides: Partial = {}): FrickVirtualIban { return { vban: 'LI11ACTIVE00000000001', referenceAccountIban, - state: 'ACTIVE' as any, + state: FrickVirtualIbanState.ACTIVE, createdAt: '2026-07-01T00:00:00Z', createdBy: 'synthetic', activationApprovals: [], deactivationApprovals: [], description, + ...overrides, }; } - function listingResult(virtualIbans: ReturnType[], fullyValidated: boolean) { + function listingResult(virtualIbans: FrickVirtualIban[], fullyValidated: boolean) { const listingStartedAt = new Date(); return { virtualIbans, fullyValidated, listingStartedAt, listingCompletedAt: new Date() }; } + /** Routes Phase-1 (InFlight/Failed) vs completed-intent cleanup (COMPLETED) find calls. */ + function mockIntentFinds( + phase1Intents: VirtualIbanIssuanceIntent[], + completedIntents: VirtualIbanIssuanceIntent[] = [], + ): void { + intentRepo.find.mockImplementation(async (options?: { where?: FindOptionsWhere }) => { + if (options?.where?.status === VirtualIbanIssuanceIntentStatus.COMPLETED) return completedIntents; + return phase1Intents; + }); + } + beforeEach(async () => { eventRepo = { find: jest.fn().mockResolvedValue([]) }; intentRepo = { find: jest.fn().mockResolvedValue([]) }; frickVibanProvider = createMock(); notificationService = createMock(); - legacyReopener = { + virtualIbanService = { resetStuckFrickIntentForReconciliationOnly: jest.fn().mockResolvedValue(true), - recoverFrickIntentForReconciliation: jest.fn().mockResolvedValue(true), + recoverFrickIntentForReconciliation: jest + .fn() + .mockImplementation(async (_intentId: number, match: { vban: string }) => ({ + kind: 'finalized' as const, + canonicalIban: match.vban, + })), moveFrickIntentToFallbackForReconciliation: jest.fn().mockResolvedValue(true), + isIbanProtectedFromReconciliationDeactivation: jest.fn().mockResolvedValue(false), }; jest.spyOn(frickVibanProvider, 'isAvailable').mockReturnValue(true); - jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined as any); + jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); const dataSource = { getRepository: jest.fn((entity) => { @@ -119,11 +140,12 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { { provide: DataSource, useValue: dataSource }, { provide: FrickVibanProvider, useValue: frickVibanProvider }, { provide: NotificationService, useValue: notificationService }, - { provide: VirtualIbanService, useValue: legacyReopener }, + { provide: VirtualIbanService, useValue: virtualIbanService }, ], }).compile(); service = module.get(VirtualIbanFrickIssuanceReconciliationService); + mockIntentFinds([]); }); afterEach(() => { @@ -228,7 +250,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( 101, expect.objectContaining({ description: stuckRequestReference }), ); @@ -242,7 +264,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { .mockResolvedValue( listingResult( [ - { ...listingEntry(stuckRequestReference), vban: 'LI11MALFORMED00000001', description: undefined as any }, + listingEntry(stuckRequestReference, { vban: 'LI11MALFORMED00000001', description: undefined }), listingEntry(stuckRequestReference), ], true, @@ -251,7 +273,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( 117, expect.objectContaining({ description: stuckRequestReference }), ); @@ -262,8 +284,8 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { jest .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockResolvedValue(listingResult([listingEntry(stuckRequestReference)], true)); - legacyReopener.recoverFrickIntentForReconciliation.mockRejectedValue(new Error('local finalization failed')); - const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + virtualIbanService.recoverFrickIntentForReconciliation.mockRejectedValue(new Error('local finalization failed')); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); @@ -271,35 +293,58 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { 'Frick vIBAN reconciliation Phase 1: automatic recovery failed for intentId=115', expect.any(Error), ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('does not count a raced automatic recovery as completed', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 116, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 116, requestReference: stuckRequestReference })]); jest .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockResolvedValue(listingResult([listingEntry(stuckRequestReference)], true)); - legacyReopener.recoverFrickIntentForReconciliation.mockResolvedValue(false); + virtualIbanService.recoverFrickIntentForReconciliation.mockResolvedValue({ kind: 'not_eligible' }); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(116, expect.anything()); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(116, expect.anything()); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('recovery not eligible after positive match for intentId=116'), + ); + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('duplicates left untouched')); }); - it('deactivates duplicate matches before recovering the deterministic winner', async () => { + it('finalizes the deterministic winner first, then deactivates duplicates with the intent snapshot', async () => { intentRepo.find.mockResolvedValue([intent({ id: 130, requestReference: stuckRequestReference })]); const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); + const callOrder: string[] = []; + virtualIbanService.recoverFrickIntentForReconciliation.mockImplementation( + async (_id: number, match: { vban: string }) => { + callOrder.push('recover'); + return { kind: 'finalized' as const, canonicalIban: match.vban }; + }, + ); + jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockImplementation(async () => { + callOrder.push('deactivate'); + }); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); + expect(callOrder).toEqual(['recover', 'deactivate']); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(130, older); expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( newer, referenceAccountIban, stuckRequestReference, ); - expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(130, older); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('positive listing match for InFlight/Failed intent intentId=130'), + ); + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('candidateCount=2')); }); it('uses the vIBAN as deterministic tie-breaker when duplicate timestamps are equal', async () => { @@ -310,12 +355,204 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(134, lower); expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( higher, referenceAccountIban, stuckRequestReference, ); - expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(134, lower); + }); + + it('never deactivates when a race already finalized a different canonical IBAN', async () => { + mockIntentFinds([intent({ id: 135, requestReference: stuckRequestReference })]); + const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; + const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); + virtualIbanService.recoverFrickIntentForReconciliation.mockResolvedValue({ + kind: 'already_finalized', + canonicalIban: 'LI99OTHER00000000001', + }); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(135, older); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('raced different canonical for intentId=135')); + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('candidateCount=2')); + }); + + it('still deactivates duplicates when recovery reports already_finalized with the winner canonical', async () => { + mockIntentFinds([intent({ id: 142, requestReference: stuckRequestReference })]); + const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; + const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); + virtualIbanService.recoverFrickIntentForReconciliation.mockResolvedValue({ + kind: 'already_finalized', + canonicalIban: older.vban, + }); + + await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(142, older); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + newer, + referenceAccountIban, + stuckRequestReference, + ); + }); + + it('logs ERROR and performs no external action on cross-account listing mismatch', async () => { + intentRepo.find.mockResolvedValue([intent({ id: 136, requestReference: stuckRequestReference })]); + const foreign = { + ...listingEntry(stuckRequestReference), + referenceAccountIban: 'LI99FOREIGN0000000000C', + }; + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([foreign], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('cross-account listing mismatch for intentId=136'), + ); + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('no external action')); + }); + + it('returns early from recoverPhase1ListingMatch when virtualIbans is empty', async () => { + // Productively unreachable via runPhase1StuckIntents (only pushes matches with length > 0). + // Direct private-helper call covers the empty-candidates guard at line 897. + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + const onRecovered = jest.fn(); + const onRecoveryFailure = jest.fn(); + const onDuplicateDeactivated = jest.fn(); + + await service['recoverPhase1ListingMatch']( + { + intentId: 150, + requestReference: stuckRequestReference, + referenceAccountIban, + userDataId: 30, + currencyId: 40, + bankId, + status: VirtualIbanIssuanceIntentStatus.IN_FLIGHT, + updated: new Date('2026-07-01T12:00:00.000Z'), + virtualIbans: [], + }, + { + onRecovered, + onRecoveryFailure, + onDuplicateDeactivated, + }, + ); + + expect(loggerError).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(onRecovered).not.toHaveBeenCalled(); + expect(onRecoveryFailure).not.toHaveBeenCalled(); + expect(onDuplicateDeactivated).not.toHaveBeenCalled(); + }); + + it('logs PII-safe ERROR for a single Phase-1 positive match before recovery', async () => { + intentRepo.find.mockResolvedValue([intent({ id: 137, requestReference: stuckRequestReference })]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([listingEntry(stuckRequestReference)], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 1: positive listing match for InFlight/Failed intent ' + + `intentId=137 bankId=${bankId} candidateCount=1`, + ); + expect(loggerError.mock.calls.flat().join(' ')).not.toMatch(/LI\d{2}/); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalled(); + }); + + it('does not fall back after failed listing even when the intent is day-old', async () => { + intentRepo.find.mockResolvedValue([ + intent({ + id: 138, + requestReference: stuckRequestReference, + updated: new Date( + Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS - 1_000, + ), + }), + ]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockRejectedValue(new Error('upstream listing unavailable')); + + await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); + }); + + it('does not fall back after invalid listing timestamps even when the intent is day-old', async () => { + intentRepo.find.mockResolvedValue([ + intent({ + id: 139, + requestReference: stuckRequestReference, + updated: new Date( + Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS - 1_000, + ), + }), + ]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue({ + ...listingResult([], true), + listingStartedAt: new Date('invalid'), + }); + + await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); + }); + + it('does not fall back after incomplete listing even when the intent is day-old', async () => { + intentRepo.find.mockResolvedValue([ + intent({ + id: 140, + requestReference: stuckRequestReference, + updated: new Date( + Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS - 1_000, + ), + }), + ]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], false)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + }); + + it('leaves remaining duplicates untouched and ERROR-logs when cleanup fails after canonical finalize', async () => { + mockIntentFinds([intent({ id: 141, requestReference: stuckRequestReference })]); + const mid = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; + const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + const newest = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000003', createdAt: '2026-07-03' }; + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([newest, mid, older], true)); + jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockRejectedValueOnce(new Error('deactivation ambiguous')); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(141, older); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('duplicate cleanup failed for intentId=141'), + expect.any(Error), + ); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('remaining duplicates left for completed-intent cleanup retry'), + expect.any(Error), + ); }); it('moves a day-old listing miss to the collection-account fallback', async () => { @@ -332,11 +569,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(legacyReopener.moveFrickIntentToFallbackForReconciliation).toHaveBeenCalledWith( + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).toHaveBeenCalledWith( 131, stuckRequestReference, ); - expect(legacyReopener.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); }); it('logs ERROR and continues when the automatic fallback transition fails', async () => { @@ -350,10 +587,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }), ]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); - legacyReopener.moveFrickIntentToFallbackForReconciliation.mockRejectedValue( + virtualIbanService.moveFrickIntentToFallbackForReconciliation.mockRejectedValue( new Error('fallback transaction failed'), ); - const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); @@ -375,11 +612,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }), ]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); - legacyReopener.moveFrickIntentToFallbackForReconciliation.mockResolvedValue(false); + virtualIbanService.moveFrickIntentToFallbackForReconciliation.mockResolvedValue(false); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(legacyReopener.moveFrickIntentToFallbackForReconciliation).toHaveBeenCalledWith( + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).toHaveBeenCalledWith( 133, stuckRequestReference, ); @@ -416,7 +653,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(notificationService.sendMail).not.toHaveBeenCalled(); - expect(legacyReopener.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); + expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); }); it('keeps an old intent inconclusive when the listing began before create processing could end', async () => { @@ -443,7 +680,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(notificationService.sendMail).not.toHaveBeenCalled(); - expect(legacyReopener.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); it('rejects invalid listing timestamps without reopening an intent', async () => { @@ -468,7 +705,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(notificationService.sendMail).not.toHaveBeenCalled(); - expect(legacyReopener.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); }); it('still recovers a positive match when listing is not fully validated', async () => { @@ -482,11 +719,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith( 105, expect.objectContaining({ description: stuckRequestReference }), ); - expect(legacyReopener.recoverFrickIntentForReconciliation).toHaveBeenCalledTimes(1); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledTimes(1); expect(notificationService.sendMail).not.toHaveBeenCalled(); }); @@ -537,7 +774,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(legacyReopener.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); + expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); expect(notificationService.sendMail).not.toHaveBeenCalled(); }); @@ -564,13 +801,14 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(legacyReopener.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); + expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('skips Phase 1 entirely when every loaded InFlight/Failed intent is merge-superseded', async () => { const mergeRetiredReference = 'dfx-viban-mergesupersededonly000000001'; - intentRepo.find.mockResolvedValue([ + // Route by status so the Completed-intent pass does not receive Phase-1 rows. + mockIntentFinds([ intent({ id: 122, requestReference: mergeRetiredReference, @@ -585,11 +823,289 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { // Filtered out before bank grouping — no listing or reopening work. expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(legacyReopener.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); + expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); expect(notificationService.sendMail).not.toHaveBeenCalled(); }); }); + describe('Completed-intent duplicate cleanup', () => { + const canonicalVban = 'LI11ACTIVE00000000001'; + const duplicateVban = 'LI11ACTIVE00000000002'; + const completedRef = 'dfx-viban-completedcleanup00000000001'; + + function completedIntent( + partial: Partial & { requestReference?: string } = {}, + ): VirtualIbanIssuanceIntent { + return intent({ + id: 500, + requestReference: completedRef, + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + externalIban: canonicalVban, + ...partial, + }); + } + + it('exits early when there are no COMPLETED Frick intents', async () => { + mockIntentFinds([]); + const loggerInfo = jest.spyOn(DfxLogger.prototype, 'info').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerInfo).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation completed-intent cleanup: no COMPLETED Frick intents', + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('ERROR-logs and skips when a COMPLETED intent has no externalIban', async () => { + mockIntentFinds([], [completedIntent({ externalIban: null })]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('missing externalIban for intentId=500')); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('ERROR-logs and skips on cross-account listing mismatch', async () => { + mockIntentFinds([], [completedIntent()]); + const foreign = { + ...listingEntry(completedRef, { vban: canonicalVban }), + referenceAccountIban: 'LI99FOREIGN0000000000C', + }; + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([foreign], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('cross-account listing mismatch for intentId=500'), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('ERROR-logs and skips when the canonical IBAN is absent from a fully validated listing', async () => { + mockIntentFinds([], [completedIntent()]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([listingEntry(completedRef, { vban: duplicateVban })], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('canonical IBAN not present in fully validated listing for intentId=500'), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('treats a missing requestReference group as an empty listing and performs no external write', async () => { + // byDescription.get(intent.requestReference) is undefined → production `?? []` at cleanup entry. + mockIntentFinds([], [completedIntent()]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue( + listingResult([listingEntry('dfx-viban-unrelated-completedcleanup-ref-01', { vban: canonicalVban })], true), + ); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('canonical IBAN not present in fully validated listing for intentId=500'), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); + expect(virtualIbanService.isIbanProtectedFromReconciliationDeactivation).not.toHaveBeenCalled(); + }); + + it('deactivates only non-canonical cleanup-target duplicates after a PII-safe ERROR', async () => { + mockIntentFinds([], [completedIntent()]); + const canonical = listingEntry(completedRef, { vban: canonicalVban, state: FrickVirtualIbanState.ACTIVE }); + const duplicate = listingEntry(completedRef, { + vban: duplicateVban, + state: FrickVirtualIbanState.PREPARED, + createdAt: '2026-07-02T00:00:00Z', + }); + const deactivatedOther = listingEntry(completedRef, { + vban: 'LI11ACTIVE00000000003', + state: FrickVirtualIbanState.DEACTIVATED, + createdAt: '2026-07-03T00:00:00Z', + }); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([canonical, duplicate, deactivatedOther], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation completed-intent cleanup: duplicate match for COMPLETED intent ' + + `intentId=500 bankId=${bankId} candidateCount=2`, + ); + expect(loggerError.mock.calls.flat().join(' ')).not.toMatch(/LI\d{2}/); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + duplicate, + referenceAccountIban, + completedRef, + ); + }); + + it('skips protected duplicates without deactivating them', async () => { + mockIntentFinds([], [completedIntent()]); + const canonical = listingEntry(completedRef, { vban: canonicalVban }); + const protectedDuplicate = listingEntry(completedRef, { + vban: duplicateVban, + createdAt: '2026-07-02T00:00:00Z', + }); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([canonical, protectedDuplicate], true)); + virtualIbanService.isIbanProtectedFromReconciliationDeactivation.mockResolvedValue(true); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('protected IBAN refused deactivation for intentId=500'), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('ERROR-logs and continues when duplicate deactivation fails', async () => { + mockIntentFinds([], [completedIntent()]); + const canonical = listingEntry(completedRef, { vban: canonicalVban }); + const duplicate = listingEntry(completedRef, { + vban: duplicateVban, + createdAt: '2026-07-02T00:00:00Z', + }); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([canonical, duplicate], true)); + jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockRejectedValue(new Error('deactivation ambiguous')); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('duplicate cleanup failed for intentId=500'), + expect.any(Error), + ); + }); + + it('refuses deactivation when the listing is not fully validated', async () => { + mockIntentFinds([], [completedIntent()]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue( + listingResult( + [listingEntry(completedRef, { vban: canonicalVban }), listingEntry(completedRef, { vban: duplicateVban })], + false, + ), + ); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`listing for bankId=${bankId} not fully validated — no deactivation this run`), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('skips the bank when listing timestamps are invalid', async () => { + mockIntentFinds([], [completedIntent()]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue({ + ...listingResult( + [listingEntry(completedRef, { vban: canonicalVban }), listingEntry(completedRef, { vban: duplicateVban })], + true, + ), + listingStartedAt: new Date('invalid'), + }); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`processing failed for bankId=${bankId}`), + expect.any(Error), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('skips the bank when the listing fetch throws', async () => { + mockIntentFinds([], [completedIntent()]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockRejectedValue(new Error('upstream listing unavailable')); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`processing failed for bankId=${bankId}`), + expect.any(Error), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + + it('logs an outer completed-intent cleanup failure when the COMPLETED query itself throws', async () => { + intentRepo.find.mockImplementation(async (options?: { where?: FindOptionsWhere }) => { + if (options?.where?.status === VirtualIbanIssuanceIntentStatus.COMPLETED) { + throw new Error('completed intent repository unavailable'); + } + return []; + }); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); + + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation completed-intent cleanup failed:', + expect.any(Error), + ); + // Phase 2 still runs after the outer catch. + expect(eventRepo.find).toHaveBeenCalled(); + }); + + it('deactivates a DEACTIVATION_REQUESTED non-canonical duplicate', async () => { + mockIntentFinds([], [completedIntent()]); + const canonical = listingEntry(completedRef, { vban: canonicalVban }); + const deactivationRequested = listingEntry(completedRef, { + vban: duplicateVban, + state: FrickVirtualIbanState.DEACTIVATION_REQUESTED, + createdAt: '2026-07-02T00:00:00Z', + }); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([canonical, deactivationRequested], true)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + deactivationRequested, + referenceAccountIban, + completedRef, + ); + }); + + it('leaves a COMPLETED intent with only the canonical listing entry untouched', async () => { + mockIntentFinds([], [completedIntent()]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([listingEntry(completedRef, { vban: canonicalVban })], true)); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + }); + }); + describe('Phase 2 — retired references', () => { it('sends no alert when no abandoned references are found', async () => { eventRepo.find.mockResolvedValue([]); @@ -634,12 +1150,11 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { listingResult( [ listingEntry(abandonedCreate), - { - ...listingEntry('dfx-viban-other'), + listingEntry('dfx-viban-other', { vban: 'LI11ACTIVE00000000002', - state: 'PREPARED' as any, + state: FrickVirtualIbanState.PREPARED, createdAt: '2026-07-02T00:00:00Z', - }, + }), ], true, ), @@ -665,7 +1180,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { .mockResolvedValue( listingResult( [ - { ...listingEntry(abandonedCreate), vban: 'LI11MALFORMED00000002', description: undefined as any }, + listingEntry(abandonedCreate, { vban: 'LI11MALFORMED00000002', description: undefined }), listingEntry(abandonedCreate), ], true, @@ -702,7 +1217,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockResolvedValue(listingResult([listingEntry(abandonedCreate)], true)); jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockRejectedValue(new Error('deactivation ambiguous')); - const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); @@ -713,6 +1228,34 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(notificationService.sendMail).not.toHaveBeenCalled(); }); + it('skips deactivation when the matched orphan IBAN is protected locally', async () => { + eventRepo.find.mockResolvedValue([ + event({ + id: 16, + intentId: 26, + nextError: `${CREATE_PATH_REFERENCE_MARKER}${abandonedCreate}`, + }), + ]); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([listingEntry(abandonedCreate)], true)); + virtualIbanService.isIbanProtectedFromReconciliationDeactivation.mockResolvedValue(true); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining('Frick vIBAN reconciliation Phase 2: retired-reference match eventId=16 intentId=26'), + ); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 2: protected IBAN refused deactivation for ' + + `eventId=16 intentId=26 bankId=${bankId}`, + ); + expect(loggerError.mock.calls.flat().join(' ')).not.toMatch(/LI\d{2}/); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + }); + it('does not treat unmatched abandoned refs as clean when listing is not fully validated', async () => { eventRepo.find.mockResolvedValue([ event({ @@ -753,7 +1296,9 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { ]); jest .spyOn(frickVibanProvider, 'listByReferenceAccount') - .mockResolvedValue(listingResult([{ ...listingEntry(abandonedCreate), state: 'DEACTIVATED' as any }], true)); + .mockResolvedValue( + listingResult([listingEntry(abandonedCreate, { state: FrickVirtualIbanState.DEACTIVATED })], true), + ); await service.reconcileRetiredIssuanceReferences(); @@ -1005,7 +1550,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('still runs Phase 2 after a Phase 1 failure', async () => { intentRepo.find.mockRejectedValue(new Error('intent repository unavailable')); - const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts index 0f6004de2d..aedd19cdec 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts @@ -2,8 +2,10 @@ import { createMock } from '@golevelup/ts-jest'; import { BadRequestException, ConflictException, ServiceUnavailableException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { DataType, newDb } from 'pg-mem'; +import { FrickVirtualIban, FrickVirtualIbanState } from 'src/integration/bank/dto/frick-vban.dto'; import { Fiat } from 'src/shared/models/fiat/fiat.entity'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { TestSharedModule } from 'src/shared/utils/test.shared.module'; import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; @@ -21,13 +23,14 @@ import { IsNull, ManyToOne, PrimaryGeneratedColumn, + SelectQueryBuilder, } from 'typeorm'; import { Bank } from '../../bank/bank.entity'; import { BankService } from '../../bank/bank.service'; import { IbanBankName } from '../../bank/dto/bank.dto'; import { FrickVibanProvider } from '../providers/frick-viban.provider'; import { VibanAccountHolder } from '../providers/viban-account-holder.enum'; -import { VibanNotCreatedError } from '../providers/viban-provider.interface'; +import { ReservedViban, VibanNotCreatedError } from '../providers/viban-provider.interface'; import { YapealVibanProvider } from '../providers/yapeal-viban.provider'; import { VirtualIban, VirtualIbanStatus } from '../virtual-iban.entity'; import { VirtualIbanIssuanceEvent } from '../virtual-iban-issuance-event.entity'; @@ -37,6 +40,86 @@ import { VirtualIbanLifecycleEvent } from '../virtual-iban-lifecycle-event.entit import { VirtualIbanRepository } from '../virtual-iban.repository'; import { CREATE_PATH_REFERENCE_MARKER, MERGE_SUPERSEDED_MARKER, VirtualIbanService } from '../virtual-iban.service'; +/** Narrow private surface for unit tests — always cast via unknown, never any. */ +interface VirtualIbanServicePrivateAccess { + finalizeFrickIssuance( + intentId: number, + expectedRequestReference: string, + userData: UserData, + bank: Bank, + currency: Fiat, + reserved: ReservedViban, + ): Promise; + resetFrickIntentToPending(intentId: number, expectedRequestReference: string, message: string): Promise; + resetFrickIntentToPendingLocked( + manager: EntityManager, + intent: VirtualIbanIssuanceIntent, + allowedSourceStatuses: ReadonlyArray, + message: string, + rotateReference: boolean, + nextRequestReference: string | null, + ): Promise; + failFrickIntent(intentId: number, message: string): Promise; + failFrickIntentLocked(manager: EntityManager, intentId: number, message: string): Promise; + resolveVirtualIbanId( + manager: EntityManager, + iban: string | null | undefined, + intentIds: { intentId: number; userDataId: number; currencyId: number; bankId: number }, + ): Promise; + reassignFrickIntentLocked( + manager: EntityManager, + intent: VirtualIbanIssuanceIntent, + nextUserDataId: number, + ): Promise; + recordVirtualIbanLifecycleEventLocked( + manager: EntityManager, + virtualIban: VirtualIban, + next: { + userDataId: number; + active: boolean; + status: VirtualIbanStatus | null | undefined; + deactivatedAt: Date | null | undefined; + }, + reason: string, + ): Promise; + transitionFrickIntent( + manager: EntityManager, + intent: VirtualIbanIssuanceIntent, + nextStatus: VirtualIbanIssuanceIntentStatus, + nextExternalIban: string | null, + nextError: string | null, + nextRequestReference?: string, + ): Promise; + deactivateVirtualIbanLocked(manager: EntityManager, virtualIban: VirtualIban, reason: string): Promise; + resolveIssuanceIntentsForMergeLocked(manager: EntityManager, masterId: number, slaveId: number): Promise; + resolveMergedVirtualIbanPairLocked( + manager: EntityManager, + masterId: number, + slaveId: number, + currencyId: number, + bankId: number, + ): Promise; +} + +function privateService(service: VirtualIbanService): VirtualIbanServicePrivateAccess { + return service as unknown as VirtualIbanServicePrivateAccess; +} + +function frickVirtualIban(overrides: Partial & Pick): FrickVirtualIban { + return { + vban: overrides.vban, + referenceAccountIban: overrides.referenceAccountIban ?? 'LI32088110105923K000C', + state: overrides.state ?? FrickVirtualIbanState.ACTIVE, + createdAt: overrides.createdAt ?? '2026-07-01T00:00:00Z', + createdBy: overrides.createdBy ?? 'synthetic', + activationApprovals: overrides.activationApprovals ?? [], + deactivationApprovals: overrides.deactivationApprovals ?? [], + description: overrides.description, + name: overrides.name, + address: overrides.address, + }; +} + // These shadow entities mirror only the columns the receiving lookup actually queries. The production // VirtualIban entity is too relation-heavy to synchronize into pg-mem, but the point of these tests is // that a real SQL engine evaluates the WHERE clause and the ORDER BY - not that the full schema exists. @@ -141,7 +224,7 @@ describe('VirtualIbanService', () => { accountHolder: VibanAccountHolder.DFX, }); notificationService = createMock(); - jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined as any); + jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); issuanceUserDataFindOne = jest.fn().mockResolvedValue(userData); manager = { exists: jest.fn(), @@ -299,10 +382,10 @@ describe('VirtualIbanService', () => { describe('createForUser', () => { beforeEach(() => { jest.spyOn(virtualIbanRepo, 'findOne').mockResolvedValue(null); - jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(currency as any); + jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(currency as Fiat); jest.spyOn(yapealVibanProvider, 'isAvailable').mockReturnValue(true); jest.spyOn(frickVibanProvider, 'isAvailable').mockReturnValue(false); - jest.spyOn(bankService, 'getBankInternal').mockResolvedValue(bank as any); + jest.spyOn(bankService, 'getBankInternal').mockResolvedValue(bank as Bank); jest.spyOn(yapealVibanProvider, 'reserveViban').mockImplementation(async () => { expect(transactionActive).toBe(false); return { @@ -355,7 +438,7 @@ describe('VirtualIbanService', () => { }); it('throws BadRequestException when bank has no receive account', async () => { - jest.spyOn(bankService, 'getBankInternal').mockResolvedValue({ ...bank, receive: false } as any); + jest.spyOn(bankService, 'getBankInternal').mockResolvedValue({ ...bank, receive: false } as Bank); await expect(service.createForUser(userData, 'CHF')).rejects.toThrow( new BadRequestException('No bank available for this currency'), @@ -435,10 +518,10 @@ describe('VirtualIbanService', () => { it('conflict-checks buy+currency and persists buy + label from asset name', async () => { const buy = { id: 55, asset: { name: 'BTC' } } as Buy; jest.spyOn(virtualIbanRepo, 'findOne').mockResolvedValue(null); - jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(currency as any); + jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(currency as Fiat); jest.spyOn(yapealVibanProvider, 'isAvailable').mockReturnValue(true); jest.spyOn(frickVibanProvider, 'isAvailable').mockReturnValue(false); - jest.spyOn(bankService, 'getBankInternal').mockResolvedValue(bank as any); + jest.spyOn(bankService, 'getBankInternal').mockResolvedValue(bank as Bank); jest.spyOn(yapealVibanProvider, 'reserveViban').mockImplementation(async () => { expect(transactionActive).toBe(false); return { @@ -524,8 +607,8 @@ describe('VirtualIbanService', () => { beforeEach(() => { jest.spyOn(frickVibanProvider, 'isAvailable').mockReturnValue(true); - jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(eur as any); - jest.spyOn(bankService, 'getBankInternal').mockResolvedValue(frickBank as any); + jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(eur as Fiat); + jest.spyOn(bankService, 'getBankInternal').mockResolvedValue(frickBank as Bank); jest.spyOn(virtualIbanRepo, 'invalidateCache').mockImplementation(() => undefined); jest.spyOn(frickVibanProvider, 'prepareVibanReservation').mockResolvedValue(undefined); currentIntent = null; @@ -737,7 +820,7 @@ describe('VirtualIbanService', () => { expect(transactionActive).toBe(false); enterRecovery(); await recoveryRelease; - return { vban: reserved.iban } as any; + return frickVirtualIban({ vban: reserved.iban }); }); jest.spyOn(frickVibanProvider, 'adoptAndActivate').mockResolvedValue(reserved); jest.spyOn(frickVibanProvider, 'reserveViban'); @@ -985,7 +1068,7 @@ describe('VirtualIbanService', () => { }); jest.spyOn(frickVibanProvider, 'findRecoverableByDescription').mockImplementation(async () => { expect(transactionActive).toBe(false); - return { vban: reserved.iban } as any; + return frickVirtualIban({ vban: reserved.iban }); }); jest.spyOn(frickVibanProvider, 'adoptAndActivate').mockImplementation(async () => { expect(transactionActive).toBe(false); @@ -1086,9 +1169,9 @@ describe('VirtualIbanService', () => { it('recovers and finalizes when create fails but recovery listing finds the match', async () => { jest.spyOn(frickVibanProvider, 'reserveViban').mockRejectedValue(new Error('socket closed')); - jest.spyOn(frickVibanProvider, 'findRecoverableByDescription').mockResolvedValue({ - vban: reserved.iban, - } as any); + jest + .spyOn(frickVibanProvider, 'findRecoverableByDescription') + .mockResolvedValue(frickVirtualIban({ vban: reserved.iban })); jest.spyOn(frickVibanProvider, 'adoptAndActivate').mockResolvedValue(reserved); await expect(service.getOrCreateFrickForUser(userData, 'EUR')).resolves.toMatchObject({ @@ -1356,7 +1439,7 @@ describe('VirtualIbanService', () => { }); it('rejects when Frick receive bank is missing before any Frick I/O', async () => { - jest.spyOn(bankService, 'getBankInternal').mockResolvedValue({ ...frickBank, receive: false } as any); + jest.spyOn(bankService, 'getBankInternal').mockResolvedValue({ ...frickBank, receive: false } as Bank); await expect(service.getOrCreateFrickForUser(userData, 'EUR')).rejects.toThrow( QuoteError.NO_BANK_AVAILABLE_FOR_THIS_CURRENCY, @@ -1437,7 +1520,7 @@ describe('VirtualIbanService', () => { externalIban: null, error: 'automatic-collection-account-fallback', }); - const loggerError = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.getOrCreateFrickForUser(userData, 'EUR')).rejects.toThrow( QuoteError.PERSONAL_IBAN_ISSUANCE_FAILED, @@ -1457,12 +1540,12 @@ describe('VirtualIbanService', () => { receive: true, name: IbanBankName.FRICK, } as Bank; - const match = { + const match = frickVirtualIban({ vban: 'LI75088110105923K000E', referenceAccountIban: frickBank.iban, description: 'dfx-viban-reconciliation-reference', - state: 'ACTIVE', - } as any; + state: FrickVirtualIbanState.ACTIVE, + }); const reconciliationIntent = (partial: Partial = {}): VirtualIbanIssuanceIntent => Object.assign(new VirtualIbanIssuanceIntent(), { @@ -1510,9 +1593,14 @@ describe('VirtualIbanService', () => { iban: match.vban, providerAccountRef: match.vban, }); - const finalize = jest.spyOn(service as any, 'finalizeFrickIssuance').mockResolvedValue({ id: 501 }); + const finalize = jest + .spyOn(privateService(service), 'finalizeFrickIssuance') + .mockResolvedValue({ id: 501 } as VirtualIban); - await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toBe(true); + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toEqual({ + kind: 'finalized', + canonicalIban: match.vban, + }); expect(frickVibanProvider.adoptAndActivate).toHaveBeenCalledWith( match, @@ -1539,15 +1627,43 @@ describe('VirtualIbanService', () => { expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); }); - it.each([ - VirtualIbanIssuanceIntentStatus.PENDING, - VirtualIbanIssuanceIntentStatus.COMPLETED, - VirtualIbanIssuanceIntentStatus.FALLBACK, - ])('ignores reconciliation after intent reached %s', async (status) => { - const currentIntent = reconciliationIntent({ status }); + it.each([VirtualIbanIssuanceIntentStatus.PENDING, VirtualIbanIssuanceIntentStatus.FALLBACK])( + 'ignores reconciliation after intent reached %s', + async (status) => { + const currentIntent = reconciliationIntent({ status }); + mockReconciliationRepositories(currentIntent); + + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toEqual({ + kind: 'not_eligible', + }); + expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); + }, + ); + + it('surfaces the already-persisted canonical IBAN when the intent is already completed', async () => { + const currentIntent = reconciliationIntent({ + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + externalIban: 'LI88ALREADY0000000001', + }); mockReconciliationRepositories(currentIntent); - await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toBe(false); + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toEqual({ + kind: 'already_finalized', + canonicalIban: 'LI88ALREADY0000000001', + }); + expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); + }); + + it('treats a COMPLETED intent without externalIban as not eligible for reconciliation recovery', async () => { + const currentIntent = reconciliationIntent({ + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + externalIban: null, + }); + mockReconciliationRepositories(currentIntent); + + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toEqual({ + kind: 'not_eligible', + }); expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); }); @@ -1558,7 +1674,9 @@ describe('VirtualIbanService', () => { }); mockReconciliationRepositories(currentIntent); - await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toBe(false); + await expect(service.recoverFrickIntentForReconciliation(currentIntent.id, match)).resolves.toEqual({ + kind: 'not_eligible', + }); expect(frickVibanProvider.adoptAndActivate).not.toHaveBeenCalled(); }); @@ -1825,7 +1943,9 @@ describe('VirtualIbanService', () => { where: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue({ id: 1 } as VirtualIban), }; - jest.spyOn(virtualIbanRepo, 'createQueryBuilder').mockReturnValue(qb as any); + jest + .spyOn(virtualIbanRepo, 'createQueryBuilder') + .mockReturnValue(qb as unknown as SelectQueryBuilder); await expect(service.getVirtualIbanByKey('iban', 'CH4400762011623852958')).resolves.toEqual({ id: 1 }); expect(virtualIbanRepo.createQueryBuilder).toHaveBeenCalledWith('virtualIban'); @@ -1839,7 +1959,9 @@ describe('VirtualIbanService', () => { where: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(null), }; - jest.spyOn(virtualIbanRepo, 'createQueryBuilder').mockReturnValue(qb as any); + jest + .spyOn(virtualIbanRepo, 'createQueryBuilder') + .mockReturnValue(qb as unknown as SelectQueryBuilder); await service.getVirtualIbanByKey('userData.id', 7); expect(qb.where).toHaveBeenCalledWith('userData.id = :param', { param: 7 }); @@ -1967,7 +2089,7 @@ describe('VirtualIbanService', () => { const eur = { id: 1, name: 'EUR' }; const deactivateLocked = (viban: VirtualIban): Promise => - dataSource.transaction((m) => (service as any).deactivateVirtualIbanLocked(m, viban, 'test deactivation')); + dataSource.transaction((m) => privateService(service).deactivateVirtualIbanLocked(m, viban, 'test deactivation')); beforeEach(() => { manager.findOne.mockResolvedValue(null); @@ -2229,7 +2351,7 @@ describe('VirtualIbanService', () => { ).slice(0, 2000); const resolveLocked = (): Promise => - dataSource.transaction((m) => (service as any).resolveIssuanceIntentsForMergeLocked(m, masterId, slaveId)); + dataSource.transaction((m) => privateService(service).resolveIssuanceIntentsForMergeLocked(m, masterId, slaveId)); beforeEach(() => { manager.create.mockImplementation((entity, value) => Object.assign(new entity(), value)); @@ -2390,7 +2512,7 @@ describe('VirtualIbanService', () => { bank: { id: 11, name: IbanBankName.YAPEAL }, deactivatedAt: null, }); - manager.find.mockImplementation(async (entity, options: any) => { + manager.find.mockImplementation(async (entity, options?: { where?: { bank?: { name?: IbanBankName } } }) => { if (entity === VirtualIban) { // Simulate TypeORM's bank predicate: the fixed Frick-only query cannot return Yapeal. return options?.where?.bank?.name === IbanBankName.FRICK ? [] : [slaveViban]; @@ -2421,7 +2543,7 @@ describe('VirtualIbanService', () => { currency: chf, bank: { id: 11, name: IbanBankName.YAPEAL }, }); - const deactivateSpy = jest.spyOn(service as any, 'deactivateVirtualIbanLocked'); + const deactivateSpy = jest.spyOn(privateService(service), 'deactivateVirtualIbanLocked'); await service.mergeUserLevelVirtualIbans( masterId, @@ -2448,10 +2570,10 @@ describe('VirtualIbanService', () => { deactivatedAt: null, }); const deactivateSpy = jest - .spyOn(service as any, 'deactivateVirtualIbanLocked') + .spyOn(privateService(service), 'deactivateVirtualIbanLocked') .mockResolvedValue(Object.assign(loser, { active: false, status: VirtualIbanStatus.DEACTIVATED })); const resolvePairSpy = jest - .spyOn(service as any, 'resolveMergedVirtualIbanPairLocked') + .spyOn(privateService(service), 'resolveMergedVirtualIbanPairLocked') .mockResolvedValue(undefined); manager.find.mockImplementation(async (entity) => { if (entity === VirtualIban) return [loser]; @@ -2972,9 +3094,9 @@ describe('VirtualIbanService', () => { const owned = Object.assign(new VirtualIban(), loser, { currency: eur, bank: frickBank }); manager.findOne.mockResolvedValue(owned); manager.find.mockResolvedValue([]); - const deactivateSpy = jest.spyOn(service as any, 'deactivateVirtualIbanLocked').mockResolvedValue(owned); + const deactivateSpy = jest.spyOn(privateService(service), 'deactivateVirtualIbanLocked').mockResolvedValue(owned); const resolvePairSpy = jest - .spyOn(service as any, 'resolveMergedVirtualIbanPairLocked') + .spyOn(privateService(service), 'resolveMergedVirtualIbanPairLocked') .mockResolvedValue(undefined); try { @@ -3545,14 +3667,14 @@ describe('VirtualIbanService', () => { }); describe('finalizeFrickIssuance / resetFrickIntentToPending reference integrity (B2)', () => { - const eur = { id: 4, name: 'EUR' } as any; + const eur = { id: 4, name: 'EUR' } as Fiat; const frickBank = { id: 19, iban: 'LI32088110105923K000C', receive: true, name: IbanBankName.FRICK, - } as any; - const reserved = { + } as Bank; + const reserved: ReservedViban = { iban: 'LI75088110105923K000E', providerAccountRef: 'LI75088110105923K000E', }; @@ -3561,10 +3683,17 @@ describe('VirtualIbanService', () => { // Private methods are exercised directly — practical given the multi-transaction claim path // and matching the task's allowance for direct testing when more practical. const finalize = (intentId: number, expectedRequestReference: string): Promise => - (service as any).finalizeFrickIssuance(intentId, expectedRequestReference, userData, frickBank, eur, reserved); + privateService(service).finalizeFrickIssuance( + intentId, + expectedRequestReference, + userData, + frickBank, + eur, + reserved, + ); const reset = (intentId: number, expectedRequestReference: string, message: string): Promise => - (service as any).resetFrickIntentToPending(intentId, expectedRequestReference, message); + privateService(service).resetFrickIntentToPending(intentId, expectedRequestReference, message); const followMergedOwner = (master: UserData | null): void => { issuanceUserDataFindOne .mockResolvedValueOnce( @@ -4096,7 +4225,7 @@ describe('VirtualIbanService', () => { }); await expect( - (service as any).finalizeFrickIssuance(301, callerReference, userData, frickBank, eur, { + privateService(service).finalizeFrickIssuance(301, callerReference, userData, frickBank, eur, { ...reserved, bban: 'new-bban', }), @@ -4185,8 +4314,8 @@ describe('VirtualIbanService', () => { reason: string, nextStatus: VirtualIbanStatus | undefined, ): Promise => - (service as any).recordVirtualIbanLifecycleEventLocked( - manager, + privateService(service).recordVirtualIbanLifecycleEventLocked( + manager as unknown as EntityManager, virtualIban, { userDataId: 1000, @@ -4228,7 +4357,7 @@ describe('VirtualIbanService', () => { userDataId: 1000, }); - await (service as any).reassignFrickIntentLocked(manager, intent, 1000); + await privateService(service).reassignFrickIntentLocked(manager as unknown as EntityManager, intent, 1000); expect(manager.create).not.toHaveBeenCalled(); expect(manager.update).not.toHaveBeenCalled(); @@ -4245,8 +4374,8 @@ describe('VirtualIbanService', () => { rotateReference: boolean, nextRequestReference: string | null, ): Promise => - (service as any).resetFrickIntentToPendingLocked( - manager, + privateService(service).resetFrickIntentToPendingLocked( + manager as unknown as EntityManager, intent, [VirtualIbanIssuanceIntentStatus.IN_FLIGHT], 'guard test', @@ -4296,6 +4425,11 @@ describe('VirtualIbanService', () => { manager.save.mockImplementation(async (value) => value); }); + afterEach(() => { + // Prototype spies on DfxLogger must not leak calls across tests in this describe. + jest.restoreAllMocks(); + }); + it('does not overwrite a COMPLETED intent when failFrickIntentLocked races with completion', async () => { const intent = Object.assign(new VirtualIbanIssuanceIntent(), { id: 301, @@ -4309,7 +4443,11 @@ describe('VirtualIbanService', () => { }); manager.findOne.mockResolvedValue(intent); - await (service as any).failFrickIntentLocked(manager, 301, 'should be ignored'); + await privateService(service).failFrickIntentLocked( + manager as unknown as EntityManager, + 301, + 'should be ignored', + ); expect(intent.status).toBe(VirtualIbanIssuanceIntentStatus.COMPLETED); expect(intent.error).toBeNull(); @@ -4331,10 +4469,9 @@ describe('VirtualIbanService', () => { manager.findOne.mockImplementation(async (entity) => (entity === VirtualIbanIssuanceIntent ? intent : null)); jest.spyOn(notificationService, 'sendMail').mockImplementation(async () => { expect(transactionActive).toBe(false); - return undefined as any; }); - await expect((service as any).failFrickIntent(intent.id, 'classified failure')).rejects.toThrow( + await expect(privateService(service).failFrickIntent(intent.id, 'classified failure')).rejects.toThrow( `Cannot transition Frick issuance intent ${intent.id}: stored external IBAN has no VirtualIban row`, ); expect(notificationService.sendMail).toHaveBeenCalled(); @@ -4344,78 +4481,100 @@ describe('VirtualIbanService', () => { const databaseError = new Error('intent lookup failed'); manager.findOne.mockRejectedValue(databaseError); - await expect((service as any).failFrickIntent(301, 'classified failure')).rejects.toBe(databaseError); + await expect(privateService(service).failFrickIntent(301, 'classified failure')).rejects.toBe(databaseError); expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('resolveVirtualIbanId returns the VirtualIban id when a row matches', async () => { manager.findOne.mockResolvedValue({ id: 99 }); const intentIds = { intentId: 301, userDataId: userData.id, currencyId: 4, bankId: 19 }; - const loggerErrorSpy = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const loggerErrorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + loggerErrorSpy.mockClear(); - await expect((service as any).resolveVirtualIbanId(manager, 'LI75088110105923K000E', intentIds)).resolves.toBe( - 99, - ); - expect(manager.findOne).toHaveBeenCalledWith(VirtualIban, { - where: { iban: 'LI75088110105923K000E' }, - select: ['id'], - }); - expect(notificationService.sendMail).not.toHaveBeenCalled(); - expect(loggerErrorSpy).not.toHaveBeenCalled(); + try { + await expect( + privateService(service).resolveVirtualIbanId( + manager as unknown as EntityManager, + 'LI75088110105923K000E', + intentIds, + ), + ).resolves.toBe(99); + expect(manager.findOne).toHaveBeenCalledWith(VirtualIban, { + where: { iban: 'LI75088110105923K000E' }, + select: ['id'], + }); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerErrorSpy).not.toHaveBeenCalled(); + } finally { + loggerErrorSpy.mockRestore(); + } }); it('defers the integrity alert until after the locked helper returns', async () => { manager.findOne.mockResolvedValue(null); const intentIds = { intentId: 301, userDataId: userData.id, currencyId: 4, bankId: 19 }; const orphanIban = 'LI75088110105923K000E'; - const loggerErrorSpy = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const loggerErrorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + loggerErrorSpy.mockClear(); - let integrityError: unknown; try { - await (service as any).resolveVirtualIbanId(manager, orphanIban, intentIds); - } catch (error) { - integrityError = error; - } - expect(integrityError).toBeInstanceOf(Error); - expect((integrityError as Error).message).toContain( - 'Cannot transition Frick issuance intent 301: stored external IBAN has no VirtualIban row', - ); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + let integrityError: unknown; + try { + await privateService(service).resolveVirtualIbanId( + manager as unknown as EntityManager, + orphanIban, + intentIds, + ); + } catch (error) { + integrityError = error; + } + expect(integrityError).toBeInstanceOf(Error); + expect((integrityError as Error).message).toContain( + 'Cannot transition Frick issuance intent 301: stored external IBAN has no VirtualIban row', + ); + expect(notificationService.sendMail).not.toHaveBeenCalled(); - await service.reportIntegrityError(integrityError); - expect(manager.findOne).toHaveBeenCalledWith(VirtualIban, { - where: { iban: orphanIban }, - select: ['id'], - }); - expect(loggerErrorSpy).toHaveBeenCalledWith( - expect.stringMatching( - /resolveVirtualIbanId: genuine miss.*intentId=301.*userDataId=7.*currencyId=4.*bankId=19/, - ), - ); - expect(notificationService.sendMail).toHaveBeenCalledWith({ - type: MailType.ERROR_MONITORING, - context: MailContext.MONITORING, - input: { - subject: 'Frick vIBAN issuance: requestReference integrity check failed under lock', - errors: [ - expect.stringContaining('reason=resolveVirtualIbanId: genuine miss — no VirtualIban row for stored IBAN'), - ], - }, - }); - const mailErrors = (notificationService.sendMail as jest.Mock).mock.calls[0][0].input.errors[0] as string; - expect(mailErrors).toContain('intentId=301'); - expect(mailErrors).toContain(`userDataId=${userData.id}`); - expect(mailErrors).toContain('currencyId=4'); - expect(mailErrors).toContain('bankId=19'); - expect(mailErrors).not.toContain(orphanIban); - expect(loggerErrorSpy.mock.calls.flat().join(' ')).not.toContain(orphanIban); + await service.reportIntegrityError(integrityError); + expect(manager.findOne).toHaveBeenCalledWith(VirtualIban, { + where: { iban: orphanIban }, + select: ['id'], + }); + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.stringMatching( + /resolveVirtualIbanId: genuine miss.*intentId=301.*userDataId=7.*currencyId=4.*bankId=19/, + ), + ); + expect(notificationService.sendMail).toHaveBeenCalledWith({ + type: MailType.ERROR_MONITORING, + context: MailContext.MONITORING, + input: { + subject: 'Frick vIBAN issuance: requestReference integrity check failed under lock', + errors: [ + expect.stringContaining('reason=resolveVirtualIbanId: genuine miss — no VirtualIban row for stored IBAN'), + ], + }, + }); + const mailErrors = (notificationService.sendMail as jest.Mock).mock.calls[0][0].input.errors[0] as string; + expect(mailErrors).toContain('intentId=301'); + expect(mailErrors).toContain(`userDataId=${userData.id}`); + expect(mailErrors).toContain('currencyId=4'); + expect(mailErrors).toContain('bankId=19'); + expect(mailErrors).not.toContain(orphanIban); + expect(loggerErrorSpy.mock.calls.flat().join(' ')).not.toContain(orphanIban); + } finally { + loggerErrorSpy.mockRestore(); + } }); it('resolveVirtualIbanId returns null for null/undefined IBAN without querying', async () => { const intentIds = { intentId: 301, userDataId: userData.id, currencyId: 4, bankId: 19 }; - await expect((service as any).resolveVirtualIbanId(manager, null, intentIds)).resolves.toBeNull(); - await expect((service as any).resolveVirtualIbanId(manager, undefined, intentIds)).resolves.toBeNull(); + await expect( + privateService(service).resolveVirtualIbanId(manager as unknown as EntityManager, null, intentIds), + ).resolves.toBeNull(); + await expect( + privateService(service).resolveVirtualIbanId(manager as unknown as EntityManager, undefined, intentIds), + ).resolves.toBeNull(); expect(manager.findOne).not.toHaveBeenCalled(); expect(notificationService.sendMail).not.toHaveBeenCalled(); }); @@ -4437,33 +4596,110 @@ describe('VirtualIbanService', () => { // VirtualIban lookup for resolveVirtualIbanId — miss return null; }); - const loggerErrorSpy = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const loggerErrorSpy = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + loggerErrorSpy.mockClear(); - let integrityError: unknown; try { - await (service as any).failFrickIntentLocked(manager, 301, 'classified failure'); - } catch (error) { - integrityError = error; + let integrityError: unknown; + try { + await privateService(service).failFrickIntentLocked( + manager as unknown as EntityManager, + 301, + 'classified failure', + ); + } catch (error) { + integrityError = error; + } + expect((integrityError as Error).message).toContain( + 'Cannot transition Frick issuance intent 301: stored external IBAN has no VirtualIban row', + ); + + expect(intent.status).toBe(VirtualIbanIssuanceIntentStatus.IN_FLIGHT); + expect(intent.externalIban).toBe(orphanIban); + expect(intent.error).toBeNull(); + expect(manager.create).not.toHaveBeenCalled(); + expect(manager.save).not.toHaveBeenCalled(); + expect(notificationService.sendMail).not.toHaveBeenCalled(); + await service.reportIntegrityError(integrityError); + expect(notificationService.sendMail).toHaveBeenCalled(); + const mailErrors = (notificationService.sendMail as jest.Mock).mock.calls + .map((call) => call[0].input.errors[0] as string) + .join(' '); + expect(mailErrors).toContain('intentId=301'); + expect(mailErrors).toContain(`userDataId=${userData.id}`); + expect(mailErrors).not.toContain(orphanIban); + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('genuine miss')); + } finally { + loggerErrorSpy.mockRestore(); } - expect((integrityError as Error).message).toContain( - 'Cannot transition Frick issuance intent 301: stored external IBAN has no VirtualIban row', - ); + }); + }); - expect(intent.status).toBe(VirtualIbanIssuanceIntentStatus.IN_FLIGHT); - expect(intent.externalIban).toBe(orphanIban); - expect(intent.error).toBeNull(); - expect(manager.create).not.toHaveBeenCalled(); - expect(manager.save).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); - await service.reportIntegrityError(integrityError); - expect(notificationService.sendMail).toHaveBeenCalled(); - const mailErrors = (notificationService.sendMail as jest.Mock).mock.calls - .map((call) => call[0].input.errors[0] as string) - .join(' '); - expect(mailErrors).toContain('intentId=301'); - expect(mailErrors).toContain(`userDataId=${userData.id}`); - expect(mailErrors).not.toContain(orphanIban); - expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('genuine miss')); + describe('isIbanProtectedFromReconciliationDeactivation', () => { + const protectedIban = 'LI75088110105923K000E'; + + it('returns true when a locally active VirtualIban row exists', async () => { + jest.spyOn(virtualIbanRepo, 'findOne').mockResolvedValue({ id: 42 } as VirtualIban); + const intentFindOne = jest.fn(); + (dataSource.getRepository as jest.Mock).mockImplementation((entity) => { + if (entity === VirtualIbanIssuanceIntent) return { findOne: intentFindOne }; + throw new Error(`Unexpected repository ${String(entity)}`); + }); + + await expect(service.isIbanProtectedFromReconciliationDeactivation(protectedIban)).resolves.toBe(true); + + expect(virtualIbanRepo.findOne).toHaveBeenCalledWith({ + where: { + iban: protectedIban, + active: true, + status: VirtualIbanStatus.ACTIVE, + }, + select: ['id'], + }); + // Active local row is sufficient — intent lookup must not run. + expect(intentFindOne).not.toHaveBeenCalled(); + }); + + it('returns true when the IBAN is the externalIban of a COMPLETED Frick intent', async () => { + jest.spyOn(virtualIbanRepo, 'findOne').mockResolvedValue(null); + const intentFindOne = jest.fn().mockResolvedValue({ id: 88 }); + (dataSource.getRepository as jest.Mock).mockImplementation((entity) => { + if (entity === VirtualIbanIssuanceIntent) return { findOne: intentFindOne }; + throw new Error(`Unexpected repository ${String(entity)}`); + }); + + await expect(service.isIbanProtectedFromReconciliationDeactivation(protectedIban)).resolves.toBe(true); + + expect(virtualIbanRepo.findOne).toHaveBeenCalledWith({ + where: { + iban: protectedIban, + active: true, + status: VirtualIbanStatus.ACTIVE, + }, + select: ['id'], + }); + expect(intentFindOne).toHaveBeenCalledWith({ + where: { + provider: IbanBankName.FRICK, + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + externalIban: protectedIban, + }, + select: ['id'], + }); + }); + + it('returns false when neither an active local row nor a COMPLETED canonical intent exists', async () => { + jest.spyOn(virtualIbanRepo, 'findOne').mockResolvedValue(null); + const intentFindOne = jest.fn().mockResolvedValue(null); + (dataSource.getRepository as jest.Mock).mockImplementation((entity) => { + if (entity === VirtualIbanIssuanceIntent) return { findOne: intentFindOne }; + throw new Error(`Unexpected repository ${String(entity)}`); + }); + + await expect(service.isIbanProtectedFromReconciliationDeactivation(protectedIban)).resolves.toBe(false); + + expect(virtualIbanRepo.findOne).toHaveBeenCalled(); + expect(intentFindOne).toHaveBeenCalled(); }); }); @@ -4534,7 +4770,7 @@ describe('VirtualIbanService', () => { ); await expect( - (service as any).transitionFrickIntent( + privateService(service).transitionFrickIntent( pgDataSource.manager, intent, VirtualIbanIssuanceIntentStatus.IN_FLIGHT, @@ -4563,7 +4799,7 @@ describe('VirtualIbanService', () => { deactivatedAt: undefined, }); - await (service as any).recordVirtualIbanLifecycleEventLocked( + await privateService(service).recordVirtualIbanLifecycleEventLocked( pgDataSource.manager, virtualIban, { @@ -4685,7 +4921,13 @@ describe('VirtualIbanService', () => { // Call the REAL production method — do not reimplement park-swap in the test. await expect( - (service as any).resolveMergedVirtualIbanPairLocked(proxyManager, masterId, slaveId, currencyId, bankId), + privateService(service).resolveMergedVirtualIbanPairLocked( + proxyManager as unknown as EntityManager, + masterId, + slaveId, + currencyId, + bankId, + ), ).resolves.toBeUndefined(); // Assert end-state via real SQL against pg-mem (not in-memory object references). diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts index 93e128a6d8..3bfa07d4a4 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts @@ -13,6 +13,7 @@ import { VirtualIbanIssuanceIntent } from 'src/subdomains/supporting/bank/virtua import { DataSource, In, Like } from 'typeorm'; import { CREATE_PATH_REFERENCE_MARKER, + FrickReconciliationRecoveryResult, MERGE_SUPERSEDED_MARKER, RECOVERY_PATH_REFERENCE_MARKER, VirtualIbanService, @@ -52,6 +53,8 @@ interface UnresolvedAbandonedReferenceCandidate { interface StuckIntentListingMatch { intentId: number; requestReference: string; + /** Intent-captured reference-account IBAN snapshot — never the listing response value. */ + referenceAccountIban: string; userDataId: number; currencyId: number; bankId: number; @@ -79,7 +82,10 @@ interface UnprovenAbsenceIntent { * Periodic Frick issuance reconciliation: * - Phase 1: automatically adopt a uniquely identified delayed create. After a bounded recovery * window, move listing misses to the safe collection-account fallback without issuing a second POST. - * - Phase 2: automatically deactivate delayed vIBANs found under retired issuance references. + * - Completed-intent cleanup: permanently retryable duplicate deactivation for COMPLETED Frick intents + * (same technical description; canonical = persisted externalIban). No free-text log as state. + * - Phase 2: automatically deactivate delayed vIBANs found under retired issuance references, with a + * local active/canonical protection check before every external action. */ @Injectable() export class VirtualIbanFrickIssuanceReconciliationService { @@ -100,6 +106,12 @@ export class VirtualIbanFrickIssuanceReconciliationService { static readonly FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS = 86_400_000; static readonly FRICK_CREATE_MAX_PROCESSING_MS = VirtualIbanService.FRICK_CREATE_MAX_PROCESSING_MS; + private static readonly CLEANUP_TARGET_STATES: ReadonlySet = new Set([ + FrickVirtualIbanState.PREPARED, + FrickVirtualIbanState.ACTIVE, + FrickVirtualIbanState.DEACTIVATION_REQUESTED, + ]); + constructor( private readonly dataSource: DataSource, private readonly frickVibanProvider: FrickVibanProvider, @@ -130,6 +142,12 @@ export class VirtualIbanFrickIssuanceReconciliationService { this.logger.error('Frick vIBAN reconciliation Phase 1 (stuck intents) failed:', error); } + try { + await this.runCompletedIntentDuplicateCleanup(listingCache); + } catch (error) { + this.logger.error('Frick vIBAN reconciliation completed-intent cleanup failed:', error); + } + try { await this.runPhase2RetiredReferences(listingCache); } catch (error) { @@ -179,6 +197,12 @@ export class VirtualIbanFrickIssuanceReconciliationService { const listingMatches: StuckIntentListingMatch[] = []; const unprovenAbsences: UnprovenAbsenceIntent[] = []; + /** + * Fallback is only armed for intents whose listing in this run succeeded with valid timestamps + * and fullyValidated === true. Listing failures, invalid timestamps, and incomplete listings + * exclude fallback even when the intent is older than 24h. + */ + const fallbackEligibleIntentIds = new Set(); let skippedFreshCount = 0; let incompleteListingBankCount = 0; let skippedIncompleteCount = 0; @@ -213,7 +237,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { } // Incomplete listing: positive matches remain safe evidence, but absence is inconclusive. - // "not listed" is not proof of absence when validation dropped entries. + // "not listed" is not proof of absence when validation dropped entries. Fallback is excluded. if (!listingResult.fullyValidated) { incompleteListingBankCount += 1; this.logger.error( @@ -228,6 +252,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { listingMatches.push({ intentId: intent.id, requestReference: intent.requestReference, + referenceAccountIban: intent.referenceAccountIban, userDataId: intent.userDataId, currencyId: intent.currencyId, bankId: intent.bankId, @@ -250,6 +275,10 @@ export class VirtualIbanFrickIssuanceReconciliationService { } for (const intent of group) { + // Successful fully-validated listing with valid timestamps — eligible for terminal fallback + // on a miss older than 24h (positive matches still skip fallback via matchedIntentIds). + fallbackEligibleIntentIds.add(intent.id); + const virtualIbans = (byDescription.get(intent.requestReference) ?? []).filter((viban) => [FrickVirtualIbanState.PREPARED, FrickVirtualIbanState.ACTIVE].includes(viban.state), ); @@ -257,6 +286,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { listingMatches.push({ intentId: intent.id, requestReference: intent.requestReference, + referenceAccountIban: intent.referenceAccountIban, userDataId: intent.userDataId, currencyId: intent.currencyId, bankId: intent.bankId, @@ -289,6 +319,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { } } catch (error) { // Isolate per-bank failures so one misconfigured/unreachable bank cannot abort every other bank. + // Listing failures / invalid timestamps do not arm fallback for any intent in this group. failedBankCount += 1; this.logger.error( `Frick vIBAN reconciliation Phase 1: processing failed for bankId=${bankId} — bank skipped this run:`, @@ -302,30 +333,17 @@ export class VirtualIbanFrickIssuanceReconciliationService { let duplicateCleanupCount = 0; const matchedIntentIds = new Set(listingMatches.map((match) => match.intentId)); for (const match of listingMatches) { - const candidates = [...match.virtualIbans].sort( - (a, b) => a.createdAt.localeCompare(b.createdAt) || a.vban.localeCompare(b.vban), - ); - const winner = candidates[0]; - try { - for (const duplicate of candidates.slice(1)) { - await this.frickVibanProvider.deactivateAndApprove( - duplicate, - duplicate.referenceAccountIban, - match.requestReference, - ); - duplicateCleanupCount += 1; - } - if (await this.virtualIbanService.recoverFrickIntentForReconciliation(match.intentId, winner)) { + await this.recoverPhase1ListingMatch(match, { + onRecovered: () => { recoveredCount += 1; - this.logger.info(`Frick vIBAN reconciliation Phase 1: automatically recovered intent ${match.intentId}`); - } - } catch (error) { - recoveryFailureCount += 1; - this.logger.error( - `Frick vIBAN reconciliation Phase 1: automatic recovery failed for intentId=${match.intentId}`, - error, - ); - } + }, + onRecoveryFailure: () => { + recoveryFailureCount += 1; + }, + onDuplicateDeactivated: () => { + duplicateCleanupCount += 1; + }, + }); } if (unprovenAbsences.length > 0) { @@ -335,6 +353,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { let fallbackCount = 0; for (const intent of intents) { if ( + !fallbackEligibleIntentIds.has(intent.id) || matchedIntentIds.has(intent.id) || Date.now() - intent.updated.getTime() < VirtualIbanFrickIssuanceReconciliationService.FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS @@ -372,6 +391,197 @@ export class VirtualIbanFrickIssuanceReconciliationService { ); } + /** + * Permanently retryable cleanup for COMPLETED Frick intents: list the exact technical description, + * treat persisted `externalIban` as the sole canonical, and deactivate only other + * PREPARED/ACTIVE/DEACTIVATION_REQUESTED entries. Failures leave the COMPLETED intent untouched so + * the next hourly run retries — no free-text log is used as durable state. + * + * Fail-closed: incomplete/failed/invalid listing, cross-account mismatch, missing externalIban, + * or canonical absent from a fully validated list → ERROR and no deactivation. + */ + private async runCompletedIntentDuplicateCleanup( + listingCache: Map, + ): Promise { + const intents = await this.dataSource.getRepository(VirtualIbanIssuanceIntent).find({ + where: { + provider: IbanBankName.FRICK, + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + }, + }); + + if (intents.length === 0) { + this.logger.info('Frick vIBAN reconciliation completed-intent cleanup: no COMPLETED Frick intents'); + return; + } + + const byReferenceAccountSnapshot = new Map(); + for (const intent of intents) { + const snapshotKey = this.referenceAccountSnapshotKey(intent); + const group = byReferenceAccountSnapshot.get(snapshotKey) ?? []; + group.push(intent); + byReferenceAccountSnapshot.set(snapshotKey, group); + } + + let duplicateCleanupCount = 0; + let cleanupFailureCount = 0; + let protectedSkipCount = 0; + let failedBankCount = 0; + let incompleteListingBankCount = 0; + + for (const group of byReferenceAccountSnapshot.values()) { + const snapshot = group.at(0)!; + const bankId = snapshot.bankId; + try { + const listingResult = await this.getListingForReferenceAccount( + snapshot.referenceAccountIban, + snapshot.referenceAccountReceive, + snapshot.provider, + listingCache, + ); + if ( + !(listingResult.listingStartedAt instanceof Date) || + !Number.isFinite(listingResult.listingStartedAt.getTime()) || + !(listingResult.listingCompletedAt instanceof Date) || + !Number.isFinite(listingResult.listingCompletedAt.getTime()) || + listingResult.listingCompletedAt.getTime() < listingResult.listingStartedAt.getTime() + ) { + throw new Error(`Frick vIBAN listing timestamps invalid for bankId=${bankId}`); + } + + if (!listingResult.fullyValidated) { + incompleteListingBankCount += 1; + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: listing for bankId=${bankId} not fully ` + + `validated — no deactivation this run`, + ); + continue; + } + + const byDescription = new Map(); + for (const viban of listingResult.virtualIbans) { + if (typeof viban.description !== 'string') continue; + const entries = byDescription.get(viban.description) ?? []; + entries.push(viban); + byDescription.set(viban.description, entries); + } + + for (const intent of group) { + const result = await this.cleanupCompletedIntentDuplicates(intent, byDescription); + duplicateCleanupCount += result.deactivated; + cleanupFailureCount += result.failures; + protectedSkipCount += result.protectedSkips; + } + } catch (error) { + failedBankCount += 1; + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: processing failed for bankId=${bankId} — ` + + `bank skipped this run:`, + error, + ); + } + } + + this.logger.info( + `Frick vIBAN reconciliation completed-intent cleanup: checked ${intents.length} COMPLETED intent(s) ` + + `across ${byReferenceAccountSnapshot.size} reference-account snapshot(s); ` + + `${duplicateCleanupCount} duplicate(s) deactivated, ${cleanupFailureCount} cleanup failure(s), ` + + `${protectedSkipCount} protected skip(s), ${failedBankCount} bank failure(s), ` + + `${incompleteListingBankCount} incomplete listing bank(s)`, + ); + } + + /** + * Fail-closed duplicate cleanup for one COMPLETED intent against a fully validated listing group. + * Never deactivates the persisted canonical; never issues a second create. + */ + private async cleanupCompletedIntentDuplicates( + intent: VirtualIbanIssuanceIntent, + byDescription: Map, + ): Promise<{ deactivated: number; failures: number; protectedSkips: number }> { + const empty = { deactivated: 0, failures: 0, protectedSkips: 0 }; + + if (intent.externalIban == null) { + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: missing externalIban for ` + + `intentId=${intent.id} bankId=${intent.bankId} — no deactivation`, + ); + return empty; + } + + const listed = byDescription.get(intent.requestReference) ?? []; + const expectedReferenceAccountIban = this.normalizeReferenceAccountIban(intent.referenceAccountIban); + const crossAccountCandidates = listed.filter( + (viban) => this.normalizeReferenceAccountIban(viban.referenceAccountIban) !== expectedReferenceAccountIban, + ); + if (crossAccountCandidates.length > 0) { + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: cross-account listing mismatch for ` + + `intentId=${intent.id} bankId=${intent.bankId} candidateCount=${listed.length} — no deactivation`, + ); + return empty; + } + + const canonicalIban = intent.externalIban; + if (!listed.some((viban) => viban.vban === canonicalIban)) { + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: canonical IBAN not present in fully ` + + `validated listing for intentId=${intent.id} bankId=${intent.bankId} — no deactivation`, + ); + return empty; + } + + const duplicates = listed.filter( + (viban) => + viban.vban !== canonicalIban && + VirtualIbanFrickIssuanceReconciliationService.CLEANUP_TARGET_STATES.has(viban.state), + ); + if (duplicates.length === 0) return empty; + + // PII-safe ERROR before any cleanup (intentId, bankId, candidateCount — never IBAN/description). + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: duplicate match for COMPLETED intent ` + + `intentId=${intent.id} bankId=${intent.bankId} candidateCount=${duplicates.length + 1}`, + ); + + let deactivated = 0; + let failures = 0; + let protectedSkips = 0; + for (const duplicate of duplicates) { + if (await this.virtualIbanService.isIbanProtectedFromReconciliationDeactivation(duplicate.vban)) { + protectedSkips += 1; + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: protected IBAN refused deactivation for ` + + `intentId=${intent.id} bankId=${intent.bankId}`, + ); + continue; + } + + try { + await this.frickVibanProvider.deactivateAndApprove( + duplicate, + intent.referenceAccountIban, + intent.requestReference, + ); + deactivated += 1; + this.logger.info( + `Frick vIBAN reconciliation completed-intent cleanup: deactivated duplicate for ` + + `intentId=${intent.id} bankId=${intent.bankId}`, + ); + } catch (error) { + failures += 1; + this.logger.error( + `Frick vIBAN reconciliation completed-intent cleanup: duplicate cleanup failed for ` + + `intentId=${intent.id} bankId=${intent.bankId}`, + error, + ); + // Remaining duplicates stay for the next hourly retry of this COMPLETED intent. + } + } + + return { deactivated, failures, protectedSkips }; + } + /** Phase 2: deactivate vIBANs that appear under a retired requestReference. */ private async runPhase2RetiredReferences(listingCache: Map): Promise { const { hits: abandoned, unresolved } = await this.loadAbandonedReferences(); @@ -452,11 +662,31 @@ export class VirtualIbanFrickIssuanceReconciliationService { let deactivatedCount = 0; let cleanupFailureCount = 0; + let protectedSkipCount = 0; const processedVibans = new Set(); for (const match of matches) { if (processedVibans.has(match.virtualIban.vban)) continue; processedVibans.add(match.virtualIban.vban); + + // Every distinct action-relevant retired-reference match: PII-safe ERROR before skip/cleanup. + // A historical retirement marker alone never authorizes deactivation of a protected IBAN. + this.logger.error( + `Frick vIBAN reconciliation Phase 2: retired-reference match ` + + `eventId=${match.eventId} intentId=${match.intentId} bankId=${match.bankId} ` + + `state=${match.virtualIban.state}`, + ); + if (match.virtualIban.state === FrickVirtualIbanState.DEACTIVATED) continue; + + if (await this.virtualIbanService.isIbanProtectedFromReconciliationDeactivation(match.virtualIban.vban)) { + protectedSkipCount += 1; + this.logger.error( + `Frick vIBAN reconciliation Phase 2: protected IBAN refused deactivation for ` + + `eventId=${match.eventId} intentId=${match.intentId} bankId=${match.bankId}`, + ); + continue; + } + try { await this.frickVibanProvider.deactivateAndApprove( match.virtualIban, @@ -494,7 +724,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { this.logger.info( `Frick vIBAN reconciliation Phase 2: ${matches.length} orphan match(es), ` + `${deactivatedCount} deactivated, ${cleanupFailureCount} cleanup failure(s), ` + - `${failedBankCount} bank failure(s)`, + `${protectedSkipCount} protected skip(s), ${failedBankCount} bank failure(s)`, ); } @@ -634,4 +864,110 @@ export class VirtualIbanFrickIssuanceReconciliationService { `listing absence is not authoritative; automatic recovery will continue`, ); } + + /** + * Phase-1 positive-match handling: validate candidates against the intent reference-account + * snapshot, log a PII-safe ERROR, finalize the deterministic winner first, and only then + * deactivate remaining duplicates when the persisted canonical IBAN is that winner. + * Never deactivates cleanup-first; never deactivates a raced different canonical. + */ + private async recoverPhase1ListingMatch( + match: StuckIntentListingMatch, + counters: { + onRecovered: () => void; + onRecoveryFailure: () => void; + onDuplicateDeactivated: () => void; + }, + ): Promise { + const expectedReferenceAccountIban = this.normalizeReferenceAccountIban(match.referenceAccountIban); + const crossAccountCandidates = match.virtualIbans.filter( + (viban) => this.normalizeReferenceAccountIban(viban.referenceAccountIban) !== expectedReferenceAccountIban, + ); + if (crossAccountCandidates.length > 0) { + this.logger.error( + `Frick vIBAN reconciliation Phase 1: cross-account listing mismatch for intentId=${match.intentId} ` + + `bankId=${match.bankId} candidateCount=${match.virtualIbans.length} — no external action`, + ); + return; + } + + const candidates = [...match.virtualIbans].sort( + (a, b) => a.createdAt.localeCompare(b.createdAt) || a.vban.localeCompare(b.vban), + ); + if (candidates.length === 0) return; + + // Every positive Phase-1 match of an InFlight/Failed intent: PII-safe ERROR before recovery. + this.logger.error( + `Frick vIBAN reconciliation Phase 1: positive listing match for InFlight/Failed intent ` + + `intentId=${match.intentId} bankId=${match.bankId} candidateCount=${candidates.length}`, + ); + + const winner = candidates[0]; + let recoveryResult: FrickReconciliationRecoveryResult; + try { + recoveryResult = await this.virtualIbanService.recoverFrickIntentForReconciliation(match.intentId, winner); + } catch (error) { + counters.onRecoveryFailure(); + this.logger.error( + `Frick vIBAN reconciliation Phase 1: automatic recovery failed for intentId=${match.intentId}`, + error, + ); + return; + } + + if (recoveryResult.kind === 'not_eligible') { + // No durable canonical known to this caller — leave duplicates untouched (fail-closed). + this.logger.error( + `Frick vIBAN reconciliation Phase 1: recovery not eligible after positive match for ` + + `intentId=${match.intentId} bankId=${match.bankId} candidateCount=${candidates.length} — ` + + `duplicates left untouched`, + ); + return; + } + + if (recoveryResult.canonicalIban !== winner.vban) { + // Race already finalized a different canonical — never deactivate that IBAN (or any other). + this.logger.error( + `Frick vIBAN reconciliation Phase 1: raced different canonical for intentId=${match.intentId} ` + + `bankId=${match.bankId} candidateCount=${candidates.length} — no deactivation`, + ); + return; + } + + if (recoveryResult.kind === 'finalized') { + counters.onRecovered(); + this.logger.info(`Frick vIBAN reconciliation Phase 1: automatically recovered intent ${match.intentId}`); + } + + // Only after successful canonical establishment of the winner may other candidates be deactivated. + // Pass the intent snapshot as expected reference account — never the listing-response value. + // Cleanup failures here are okay to leave pending: the COMPLETED intent remains the durable + // retry source for the dedicated completed-intent cleanup pass (same run and every hourly run). + const duplicates = candidates.slice(1); + for (let i = 0; i < duplicates.length; i++) { + const duplicate = duplicates[i]; + try { + await this.frickVibanProvider.deactivateAndApprove( + duplicate, + match.referenceAccountIban, + match.requestReference, + ); + counters.onDuplicateDeactivated(); + } catch (error) { + // Leave remaining duplicates untouched fail-closed — never cleanup-first. The completed-intent + // cleanup pass will retry against the now-COMPLETED intent on subsequent hourly runs. + this.logger.error( + `Frick vIBAN reconciliation Phase 1: duplicate cleanup failed for intentId=${match.intentId} ` + + `bankId=${match.bankId} remainingDuplicates=${duplicates.length - i} — ` + + `remaining duplicates left for completed-intent cleanup retry`, + error, + ); + return; + } + } + } + + private normalizeReferenceAccountIban(iban: string): string { + return iban.replace(/\s/g, '').toUpperCase(); + } } diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts index 5cb1679337..255f5e1634 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts @@ -78,6 +78,16 @@ export const RECOVERY_PATH_REFERENCE_MARKER = 'recovery listing found no match u export const MERGE_SUPERSEDED_MARKER = 'merge-superseded'; export const AUTOMATIC_FALLBACK_MARKER = 'automatic-collection-account-fallback'; +/** + * Result of {@link VirtualIbanService.recoverFrickIntentForReconciliation}. + * Surfaces the locally persisted canonical external IBAN so reconciliation can decide + * whether duplicate cleanup is safe — never log the IBAN value itself. + */ +export type FrickReconciliationRecoveryResult = + | { kind: 'finalized'; canonicalIban: string } + | { kind: 'already_finalized'; canonicalIban: string } + | { kind: 'not_eligible' }; + @Injectable() export class VirtualIbanService { private readonly logger = new DfxLogger(VirtualIbanService); @@ -620,17 +630,31 @@ export class VirtualIbanService { return this.finalizeFrickIssuance(intent.id, intent.requestReference, userData, bank, currency, reserved); } - async recoverFrickIntentForReconciliation(intentId: number, match: FrickVirtualIban): Promise { + /** + * Recovers a stuck InFlight/Failed Frick intent from a listing match and finalizes it locally. + * Returns the actually persisted canonical external IBAN so callers can fail closed on a raced + * different canonical before any external deactivation. Never logs IBAN values. + */ + async recoverFrickIntentForReconciliation( + intentId: number, + match: FrickVirtualIban, + ): Promise { const intent = await this.dataSource.getRepository(VirtualIbanIssuanceIntent).findOne({ where: { id: intentId, provider: IbanBankName.FRICK }, }); if (!intent) throw new Error(`Bank Frick reconciliation intent not found (intentId=${intentId})`); + if (intent.error?.includes(MERGE_SUPERSEDED_MARKER)) return { kind: 'not_eligible' }; + + if (intent.status === VirtualIbanIssuanceIntentStatus.COMPLETED) { + if (intent.externalIban == null) return { kind: 'not_eligible' }; + return { kind: 'already_finalized', canonicalIban: intent.externalIban }; + } + if ( intent.status !== VirtualIbanIssuanceIntentStatus.IN_FLIGHT && intent.status !== VirtualIbanIssuanceIntentStatus.FAILED ) - return false; - if (intent.error?.includes(MERGE_SUPERSEDED_MARKER)) return false; + return { kind: 'not_eligible' }; const [userData, bank, currency] = await Promise.all([ this.dataSource.getRepository(UserData).findOne({ where: { id: intent.userDataId } }), @@ -650,7 +674,7 @@ export class VirtualIbanService { intent.requestReference, ); await this.finalizeFrickIssuance(intent.id, intent.requestReference, userData, bank, currency, reserved); - return true; + return { kind: 'finalized', canonicalIban: reserved.iban }; } async moveFrickIntentToFallbackForReconciliation( @@ -680,6 +704,35 @@ export class VirtualIbanService { }); } + /** + * Read-only protection check for reconciliation deactivation. + * Returns true when the exact IBAN must not be deactivated: it is a locally active + * {@link VirtualIban} (`active` + {@link VirtualIbanStatus.ACTIVE}) and/or the persisted + * `externalIban` of any COMPLETED Frick issuance intent. + * Never logs the IBAN value. Performs no database mutations. + */ + async isIbanProtectedFromReconciliationDeactivation(iban: string): Promise { + const activeVirtualIban = await this.virtualIbanRepo.findOne({ + where: { + iban, + active: true, + status: VirtualIbanStatus.ACTIVE, + }, + select: ['id'], + }); + if (activeVirtualIban != null) return true; + + const completedCanonicalIntent = await this.dataSource.getRepository(VirtualIbanIssuanceIntent).findOne({ + where: { + provider: IbanBankName.FRICK, + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + externalIban: iban, + }, + select: ['id'], + }); + return completedCanonicalIntent != null; + } + private async finalizeFrickIssuance( intentId: number, expectedRequestReference: string, From 98a7570a635530d663fad4c31ae8db912f3ba4bd Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:31:04 +0200 Subject: [PATCH 03/10] Protect Bank Frick duplicate cleanup --- docs/bank-frick-operations.md | 107 +++++++----- ...ck-issuance-reconciliation.service.spec.ts | 153 +++++++++++++----- ...n-frick-issuance-reconciliation.service.ts | 68 +++----- 3 files changed, 213 insertions(+), 115 deletions(-) diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md index 8db7a5cc2a..9493483d5f 100644 --- a/docs/bank-frick-operations.md +++ b/docs/bank-frick-operations.md @@ -335,12 +335,13 @@ pre-connect transport failures, or a non-408 HTTP 4xx) and may also reset the sa `Pending`. Timeouts, connection resets, activation failures, recovery-listing failures, and every listing miss are ambiguous and never arm an automatic retry. -### Two-phase hourly job +### Hourly reconciliation job (Phase 1 → completed-intent cleanup → Phase 2) `VirtualIbanFrickIssuanceReconciliationService.reconcileRetiredIssuanceReferences` -(`@DfxCron` process `VirtualIbanFrickIssuanceReconciliation`) automatically recovers stuck intents -and cleans up retired references. A listing match is positive evidence; listing absence remains -non-authoritative and therefore never enables a second create: +(`@DfxCron` process `VirtualIbanFrickIssuanceReconciliation`) automatically recovers stuck intents, +cleans up duplicates under completed Frick intents, and cleans up retired references. A listing +match is positive evidence; listing absence remains non-authoritative and therefore never enables a +second create: - **Schedule:** every hour (`CronExpression.EVERY_HOUR`) - **Rail guard:** silent no-op when `FrickVibanProvider.isAvailable()` is false (vIBAN rail not @@ -350,13 +351,14 @@ non-authoritative and therefore never enables a second create: 1800s no longer blocks a new hour-tick, so two overlapping invocations are possible. Intent transitions are locked and idempotent, external cleanup always targets an exact vIBAN, and no path arms another create. -- **Shared listing cache:** both phases list Frick vIBANs for each immutable reference-account - snapshot (not a single hardcoded EUR account). Successful results share a per-run - `referenceAccountIban → listing` cache across both phases, so different bank IDs with the same - snapshotted IBAN reuse that result. Failed listing calls are not cached and can be attempted again. +- **Shared listing cache:** Phase 1, completed-intent cleanup, and Phase 2 list Frick vIBANs for each + immutable reference-account snapshot (not a single hardcoded EUR account). Successful results share + a per-run `referenceAccountIban → listing` cache across all three stages, so different bank IDs with + the same snapshotted IBAN reuse that result. Failed listing calls are not cached and can be + attempted again. - **Monitoring:** ambiguous Bank Frick outcomes and every failed automatic action are written at - `ERROR`. The job sends no additional monitoring mail. Phases are independent try/catch blocks so - a Phase-1 failure still allows Phase 2 to run (and vice versa). + `ERROR`. The job sends no additional monitoring mail. Stages are independent try/catch blocks so + a Phase-1 failure still allows completed-intent cleanup and Phase 2 to run (and vice versa). Kill-switch: disable process `VirtualIbanFrickIssuanceReconciliation` via the standard disabled- processes setting. @@ -373,24 +375,29 @@ processes setting. `requestReference`: - **One PREPARED/ACTIVE match** → approve activation if required and finalize the existing vIBAN locally under the issuance lock. - - **Several PREPARED/ACTIVE matches** → keep the earliest deterministic match, automatically - deactivate and approve every duplicate, then finalize the winner. A cleanup failure leaves the - intent recoverable for the next hourly run. + - **Several PREPARED/ACTIVE matches** → finalize (or confirm) exclusively the deterministic + canonical winner (earliest `createdAt`, then `vban`). Phase 1 **never** deactivates duplicates. + Duplicates are handled only by the subsequent completed-intent cleanup once the intent is + `COMPLETED` with persisted `externalIban` as canonical. - **Not found and older than 30 minutes** → keep the intent non-retryable and emit `ERROR` because Bank Frick's result remains inconclusive. - - **Not found and older than 24 hours** → transition the intent to terminal `Fallback`, retire its - request reference for Phase 2, and continue serving the collection account. This is a business - cutoff, not proof that Bank Frick created nothing; it never enables another create. + - **Not found and older than 24 hours** → only when this run's listing for the intent's snapshot + group was successful, time-valid, and `fullyValidated`: transition the intent to terminal + `Fallback`, retire its request reference for Phase 2, and continue serving the collection + account. This is a business cutoff, not proof that Bank Frick created nothing; it never enables + another create. Incomplete, failed, or time-invalid listings **exclude** the 24-hour fallback + for every intent in that group for this run. - **Not found but still fresh** (`intent.updated` younger than the threshold) → skip until a later run. - **Listing not fully validated** (per-entry validation drops) → treat as **inconclusive** for that snapshot group: still surface any positive matches (they are evidence), leave every - unmatched intent non-retryable, and emit `ERROR`. The 24-hour fallback remains safe because it - retires the reference without retrying create; Phase 2 continues scanning it indefinitely. + unmatched intent non-retryable, emit `ERROR`, and **do not arm** the 24-hour fallback for this + run. Phase 2 continues scanning any previously retired references indefinitely. The listing result carries `listingStartedAt` (captured immediately before page 0 is dispatched) and `listingCompletedAt` (captured after the final page validates). In Phase 1, invalid/reversed -timestamps fail that snapshot group for the run. For each intent, the code computes +timestamps fail that snapshot group for the run and likewise exclude fallback for that group. +For each intent, the code computes `latestPossibleCreateProcessedAt = intent.updated + FRICK_CREATE_MAX_PROCESSING_MS`. The absence log context includes the count of inconclusive intents. `listingCompletedAt` is checked for a valid `Date` and for not preceding `listingStartedAt`; it is not compared with @@ -411,7 +418,30 @@ operation. The 30-minute `FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS` controls when an inconclusive miss becomes an `ERROR`; the 24-hour `FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS` bounds recovery before terminal -fallback. +fallback (only after a successful, time-valid, fully validated listing in that run). + +#### Completed-intent duplicate cleanup + +Runs immediately after Phase 1 in the same hourly tick. Sole permanently retryable path that +deactivates non-canonical Frick vIBANs under a completed issuance: + +1. Load Bank Frick intents with status `Completed`. +2. Group by the same immutable reference-account snapshot and reuse the shared listing cache. +3. Require a successful, time-valid, fully validated listing; otherwise emit `ERROR` and skip + deactivation for that bank snapshot this run. +4. For each intent, treat persisted `externalIban` as the sole canonical. Fail closed (no + deactivation) when `externalIban` is missing, the listing has a cross-account mismatch, or the + canonical IBAN is absent from a fully validated list. +5. Deactivate only other PREPARED / ACTIVE / DEACTIVATION_REQUESTED entries under the same technical + description. Before every external deactivation, call + `isIbanProtectedFromReconciliationDeactivation`; protected IBANs are refused with a PII-safe + `ERROR` and left untouched. +6. Each distinct duplicate IBAN is acted on at most once per run. Failures leave the `Completed` + intent untouched so the next hourly run retries — no free-text log is used as durable state. + +This stage is the only duplicate cleanup after multi-match Phase-1 recovery: Phase 1 finalizes the +winner; completed-intent cleanup then deactivates unprotected non-canonicals against the persisted +canonical. #### Phase 2 — automatic retired-reference cleanup @@ -489,16 +519,16 @@ parsed and is logged at `ERROR`; it is not treated as a Frick listing match. ### What it reconciles against -Both phases list Bank Frick virtual IBANs **per immutable reference-account snapshot and across -every lifecycle state**, not against one hardcoded EUR reference account. Every intent captures -`referenceAccountIban` and `referenceAccountReceive` when it is created; every issuance event copies -the same values. Preflight, create, request recovery, finalization checks, Phase 1, and Phase 2 all -use that snapshot. Reconciliation never switches an in-flight or historical issuance to a newly -edited `Bank.iban`. +Phase 1, completed-intent cleanup, and Phase 2 list Bank Frick virtual IBANs **per immutable +reference-account snapshot and across every lifecycle state**, not against one hardcoded EUR +reference account. Every intent captures `referenceAccountIban` and `referenceAccountReceive` when +it is created; every issuance event copies the same values. Preflight, create, request recovery, +finalization checks, Phase 1, completed-intent cleanup, and Phase 2 all use that snapshot. +Reconciliation never switches an in-flight or historical issuance to a newly edited `Bank.iban`. Comparison is exact equality of listed `description` to the intent's current `requestReference` -(Phase 1) or the extracted abandoned reference (Phase 2). A missing IBAN, a receive-disabled -snapshot or a non-Frick provider snapshot throws inside that snapshot group's processing and is -caught by the group-level `try/catch` in both phases. That group is logged at `ERROR` and skipped; +(Phase 1 / completed-intent cleanup) or the extracted abandoned reference (Phase 2). A missing IBAN, +a receive-disabled snapshot or a non-Frick provider snapshot throws inside that snapshot group's +processing and is caught by the group-level `try/catch`. That group is logged at `ERROR` and skipped; every other snapshot group in the same run continues normally. Before changing a Frick reference-account IBAN or disabling its receive state, Operations must: @@ -516,8 +546,11 @@ reconciliation authority even after such a refusal. ### Match handling -Phase 1 automatically binds the selected live match; Phase 2 automatically deactivates retired -matches. Operations only investigates when one of these automatic actions itself logs an `ERROR`. +Phase 1 automatically binds the selected live match (deterministic canonical winner only; never +deactivates duplicates). Completed-intent cleanup deactivates unprotected non-canonical duplicates +under `Completed` intents (canonical = persisted `externalIban`). Phase 2 automatically deactivates +retired-reference matches. Operations only investigates when one of these automatic actions itself +logs an `ERROR`. ### Residual risk: committed account merge can lose a post-commit effect @@ -561,8 +594,10 @@ returns success because replaying the merge itself is impossible once the slave The product-approved automatic retry remains, but only for conclusive evidence: preflight failure before any create call, or a classified definite create rejection or definitely non-dispatched create outcome. An ambiguous create or activation outcome stays non-retryable. Phase 1 listing -matches recover the existing external object; they never issue a second create. Phase 1 listing -misses keep the existing `requestReference` during recovery and transition to terminal `Fallback` -after 24 hours. Phase 2 then scans that retired reference indefinitely and automatically deactivates -any delayed object. This prevents a non-authoritative listing miss from causing a second irreversible -Bank Frick account without requiring routine manual reconciliation. +matches recover the existing external object (canonical winner only); they never issue a second +create and never deactivate duplicates. Duplicate cleanup runs only via completed-intent cleanup +against persisted `externalIban`. Phase 1 listing misses keep the existing `requestReference` during +recovery and transition to terminal `Fallback` after 24 hours only after a successful, time-valid, +fully validated listing in that run. Phase 2 then scans that retired reference indefinitely and +automatically deactivates any delayed object. This prevents a non-authoritative listing miss from +causing a second irreversible Bank Frick account without requiring routine manual reconciliation. diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts index 232d0e4801..bc491702eb 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts @@ -315,8 +315,9 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('duplicates left untouched')); }); - it('finalizes the deterministic winner first, then deactivates duplicates with the intent snapshot', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 130, requestReference: stuckRequestReference })]); + it('finalizes only the deterministic winner on multi-match and never deactivates in Phase 1', async () => { + // COMPLETED query empty → isolates Phase-1 side effects (no completed-intent cleanup). + mockIntentFinds([intent({ id: 130, requestReference: stuckRequestReference })]); const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); @@ -334,13 +335,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(callOrder).toEqual(['recover', 'deactivate']); + expect(callOrder).toEqual(['recover']); expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(130, older); - expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( - newer, - referenceAccountIban, - stuckRequestReference, - ); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledTimes(1); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); expect(loggerError).toHaveBeenCalledWith( expect.stringContaining('positive listing match for InFlight/Failed intent intentId=130'), ); @@ -348,7 +346,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('uses the vIBAN as deterministic tie-breaker when duplicate timestamps are equal', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 134, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 134, requestReference: stuckRequestReference })]); const higher = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002' }; const lower = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001' }; jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([higher, lower], true)); @@ -356,11 +354,8 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(134, lower); - expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( - higher, - referenceAccountIban, - stuckRequestReference, - ); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledTimes(1); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); }); it('never deactivates when a race already finalized a different canonical IBAN', async () => { @@ -382,7 +377,8 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('candidateCount=2')); }); - it('still deactivates duplicates when recovery reports already_finalized with the winner canonical', async () => { + it('does not deactivate in Phase 1 when recovery reports already_finalized with the winner canonical', async () => { + // Phase 1 confirms only; COMPLETED empty so completed-intent cleanup does not run for this intent. mockIntentFinds([intent({ id: 142, requestReference: stuckRequestReference })]); const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; @@ -395,11 +391,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(142, older); - expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( - newer, - referenceAccountIban, - stuckRequestReference, - ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); }); it('logs ERROR and performs no external action on cross-account listing mismatch', async () => { @@ -423,11 +415,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('returns early from recoverPhase1ListingMatch when virtualIbans is empty', async () => { // Productively unreachable via runPhase1StuckIntents (only pushes matches with length > 0). - // Direct private-helper call covers the empty-candidates guard at line 897. + // Direct private-helper call covers the empty-candidates guard. const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); const onRecovered = jest.fn(); const onRecoveryFailure = jest.fn(); - const onDuplicateDeactivated = jest.fn(); await service['recoverPhase1ListingMatch']( { @@ -444,7 +435,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { { onRecovered, onRecoveryFailure, - onDuplicateDeactivated, }, ); @@ -453,7 +443,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); expect(onRecovered).not.toHaveBeenCalled(); expect(onRecoveryFailure).not.toHaveBeenCalled(); - expect(onDuplicateDeactivated).not.toHaveBeenCalled(); }); it('logs PII-safe ERROR for a single Phase-1 positive match before recovery', async () => { @@ -530,29 +519,83 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); }); - it('leaves remaining duplicates untouched and ERROR-logs when cleanup fails after canonical finalize', async () => { - mockIntentFinds([intent({ id: 141, requestReference: stuckRequestReference })]); - const mid = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; + it('same run: after Phase-1 finalize, completed-intent cleanup deactivates an unprotected duplicate once', async () => { + const phase1Intent = intent({ id: 141, requestReference: stuckRequestReference }); + const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; - const newest = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000003', createdAt: '2026-07-03' }; - jest - .spyOn(frickVibanProvider, 'listByReferenceAccount') - .mockResolvedValue(listingResult([newest, mid, older], true)); - jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockRejectedValueOnce(new Error('deactivation ambiguous')); + // COMPLETED query sees the post-recovery persisted state (externalIban = winner). + const completedAfterRecovery = intent({ + id: 141, + requestReference: stuckRequestReference, + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + externalIban: older.vban, + }); + mockIntentFinds([phase1Intent], [completedAfterRecovery]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); + const callOrder: string[] = []; + virtualIbanService.recoverFrickIntentForReconciliation.mockImplementation( + async (_id: number, match: { vban: string }) => { + callOrder.push('recover'); + return { kind: 'finalized' as const, canonicalIban: match.vban }; + }, + ); + jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockImplementation(async () => { + callOrder.push('deactivate'); + }); const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); + expect(callOrder).toEqual(['recover', 'deactivate']); expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(141, older); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledTimes(1); expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + newer, + referenceAccountIban, + stuckRequestReference, + ); expect(loggerError).toHaveBeenCalledWith( - expect.stringContaining('duplicate cleanup failed for intentId=141'), - expect.any(Error), + 'Frick vIBAN reconciliation completed-intent cleanup: duplicate match for COMPLETED intent ' + + `intentId=141 bankId=${bankId} candidateCount=2`, + ); + expect(loggerError.mock.calls.flat().join(' ')).not.toMatch(/LI\d{2}/); + }); + + it('same run: protected duplicate after Phase-1 finalize is not deactivated and ERROR-logs PII-safe', async () => { + const phase1Intent = intent({ id: 143, requestReference: stuckRequestReference }); + const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; + const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + const completedAfterRecovery = intent({ + id: 143, + requestReference: stuckRequestReference, + status: VirtualIbanIssuanceIntentStatus.COMPLETED, + externalIban: older.vban, + }); + mockIntentFinds([phase1Intent], [completedAfterRecovery]); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); + const callOrder: string[] = []; + virtualIbanService.recoverFrickIntentForReconciliation.mockImplementation( + async (_id: number, match: { vban: string }) => { + callOrder.push('recover'); + return { kind: 'finalized' as const, canonicalIban: match.vban }; + }, ); + jest.spyOn(frickVibanProvider, 'deactivateAndApprove').mockImplementation(async () => { + callOrder.push('deactivate'); + }); + virtualIbanService.isIbanProtectedFromReconciliationDeactivation.mockResolvedValue(true); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(callOrder).toEqual(['recover']); + expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledWith(143, older); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); expect(loggerError).toHaveBeenCalledWith( - expect.stringContaining('remaining duplicates left for completed-intent cleanup retry'), - expect.any(Error), + expect.stringContaining('protected IBAN refused deactivation for intentId=143'), ); + expect(loggerError.mock.calls.flat().join(' ')).not.toMatch(/LI\d{2}/); }); it('moves a day-old listing miss to the collection-account fallback', async () => { @@ -956,6 +999,44 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { ); }); + it('deactivates a shared unprotected duplicate only once when two COMPLETED intents map to it in the same run', async () => { + // Covers processedDuplicateVibans.has → continue (line 553): second intent sees the same + // technical description, same persisted canonical, and same listed unprotected duplicate. + const first = completedIntent({ id: 500 }); + const second = completedIntent({ id: 501 }); + mockIntentFinds([], [first, second]); + const canonical = listingEntry(completedRef, { vban: canonicalVban }); + const duplicate = listingEntry(completedRef, { + vban: duplicateVban, + createdAt: '2026-07-02T00:00:00Z', + }); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([canonical, duplicate], true)); + // beforeEach already resolves isIbanProtectedFromReconciliationDeactivation to false. + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + // Both intents enter cleanup (duplicate-match ERROR each), but per-run set skips the second act. + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation completed-intent cleanup: duplicate match for COMPLETED intent ' + + `intentId=500 bankId=${bankId} candidateCount=2`, + ); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation completed-intent cleanup: duplicate match for COMPLETED intent ' + + `intentId=501 bankId=${bankId} candidateCount=2`, + ); + expect(virtualIbanService.isIbanProtectedFromReconciliationDeactivation).toHaveBeenCalledTimes(1); + expect(virtualIbanService.isIbanProtectedFromReconciliationDeactivation).toHaveBeenCalledWith(duplicateVban); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); + expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledWith( + duplicate, + referenceAccountIban, + completedRef, + ); + }); + it('skips protected duplicates without deactivating them', async () => { mockIntentFinds([], [completedIntent()]); const canonical = listingEntry(completedRef, { vban: canonicalVban }); diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts index 3bfa07d4a4..16c4d97f80 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts @@ -80,10 +80,11 @@ interface UnprovenAbsenceIntent { /** * Periodic Frick issuance reconciliation: - * - Phase 1: automatically adopt a uniquely identified delayed create. After a bounded recovery - * window, move listing misses to the safe collection-account fallback without issuing a second POST. - * - Completed-intent cleanup: permanently retryable duplicate deactivation for COMPLETED Frick intents - * (same technical description; canonical = persisted externalIban). No free-text log as state. + * - Phase 1: automatically adopt a uniquely identified delayed create (canonical winner only). + * After a bounded recovery window, move listing misses to the safe collection-account fallback + * without issuing a second POST. Phase 1 never deactivates duplicates. + * - Completed-intent cleanup: sole permanently retryable duplicate deactivation for COMPLETED Frick + * intents (same technical description; canonical = persisted externalIban). No free-text log as state. * - Phase 2: automatically deactivate delayed vIBANs found under retired issuance references, with a * local active/canonical protection check before every external action. */ @@ -330,7 +331,6 @@ export class VirtualIbanFrickIssuanceReconciliationService { let recoveredCount = 0; let recoveryFailureCount = 0; - let duplicateCleanupCount = 0; const matchedIntentIds = new Set(listingMatches.map((match) => match.intentId)); for (const match of listingMatches) { await this.recoverPhase1ListingMatch(match, { @@ -340,9 +340,6 @@ export class VirtualIbanFrickIssuanceReconciliationService { onRecoveryFailure: () => { recoveryFailureCount += 1; }, - onDuplicateDeactivated: () => { - duplicateCleanupCount += 1; - }, }); } @@ -384,8 +381,8 @@ export class VirtualIbanFrickIssuanceReconciliationService { `${byReferenceAccountSnapshot.size} reference-account snapshot(s); ` + `${listingMatches.length} listing match(es), ${skippedFreshCount} skipped (too fresh), ` + `${recoveredCount} recovered, ${recoveryFailureCount} recovery failure(s), ` + - `${duplicateCleanupCount} duplicate(s) deactivated, ${fallbackCount} moved to fallback, ` + - `${unprovenAbsences.length} inconclusive absence(s), ${failedBankCount} bank failure(s), ` + + `${fallbackCount} moved to fallback, ${unprovenAbsences.length} inconclusive absence(s), ` + + `${failedBankCount} bank failure(s), ` + `${skippedIncompleteCount} skipped (incomplete listing across ${incompleteListingBankCount} bank(s))` + (skippedMergeSupersededCount > 0 ? `, ${skippedMergeSupersededCount} skipped (merge-superseded)` : ''), ); @@ -428,6 +425,8 @@ export class VirtualIbanFrickIssuanceReconciliationService { let protectedSkipCount = 0; let failedBankCount = 0; let incompleteListingBankCount = 0; + // Each distinct duplicate IBAN is acted on at most once per cleanup run. + const processedDuplicateVibans = new Set(); for (const group of byReferenceAccountSnapshot.values()) { const snapshot = group.at(0)!; @@ -467,7 +466,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { } for (const intent of group) { - const result = await this.cleanupCompletedIntentDuplicates(intent, byDescription); + const result = await this.cleanupCompletedIntentDuplicates(intent, byDescription, processedDuplicateVibans); duplicateCleanupCount += result.deactivated; cleanupFailureCount += result.failures; protectedSkipCount += result.protectedSkips; @@ -494,10 +493,12 @@ export class VirtualIbanFrickIssuanceReconciliationService { /** * Fail-closed duplicate cleanup for one COMPLETED intent against a fully validated listing group. * Never deactivates the persisted canonical; never issues a second create. + * Each distinct duplicate IBAN is acted on at most once per run via {@link processedDuplicateVibans}. */ private async cleanupCompletedIntentDuplicates( intent: VirtualIbanIssuanceIntent, byDescription: Map, + processedDuplicateVibans: Set, ): Promise<{ deactivated: number; failures: number; protectedSkips: number }> { const empty = { deactivated: 0, failures: 0, protectedSkips: 0 }; @@ -548,6 +549,10 @@ export class VirtualIbanFrickIssuanceReconciliationService { let failures = 0; let protectedSkips = 0; for (const duplicate of duplicates) { + // Each distinct duplicate IBAN is acted on at most once per cleanup run. + if (processedDuplicateVibans.has(duplicate.vban)) continue; + processedDuplicateVibans.add(duplicate.vban); + if (await this.virtualIbanService.isIbanProtectedFromReconciliationDeactivation(duplicate.vban)) { protectedSkips += 1; this.logger.error( @@ -867,16 +872,16 @@ export class VirtualIbanFrickIssuanceReconciliationService { /** * Phase-1 positive-match handling: validate candidates against the intent reference-account - * snapshot, log a PII-safe ERROR, finalize the deterministic winner first, and only then - * deactivate remaining duplicates when the persisted canonical IBAN is that winner. - * Never deactivates cleanup-first; never deactivates a raced different canonical. + * snapshot, log a PII-safe ERROR, and finalize or confirm exclusively the deterministic canonical + * winner. Never deactivates duplicates here — sole duplicate cleanup is + * {@link runCompletedIntentDuplicateCleanup} against the persisted externalIban on COMPLETED. + * Never deactivates a raced different canonical; never issues a second create. */ private async recoverPhase1ListingMatch( match: StuckIntentListingMatch, counters: { onRecovered: () => void; onRecoveryFailure: () => void; - onDuplicateDeactivated: () => void; }, ): Promise { const expectedReferenceAccountIban = this.normalizeReferenceAccountIban(match.referenceAccountIban); @@ -916,7 +921,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { } if (recoveryResult.kind === 'not_eligible') { - // No durable canonical known to this caller — leave duplicates untouched (fail-closed). + // No durable canonical known to this caller — leave listing untouched (fail-closed). this.logger.error( `Frick vIBAN reconciliation Phase 1: recovery not eligible after positive match for ` + `intentId=${match.intentId} bankId=${match.bankId} candidateCount=${candidates.length} — ` + @@ -926,7 +931,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { } if (recoveryResult.canonicalIban !== winner.vban) { - // Race already finalized a different canonical — never deactivate that IBAN (or any other). + // Race already finalized a different canonical — never act on listing duplicates here. this.logger.error( `Frick vIBAN reconciliation Phase 1: raced different canonical for intentId=${match.intentId} ` + `bankId=${match.bankId} candidateCount=${candidates.length} — no deactivation`, @@ -939,32 +944,9 @@ export class VirtualIbanFrickIssuanceReconciliationService { this.logger.info(`Frick vIBAN reconciliation Phase 1: automatically recovered intent ${match.intentId}`); } - // Only after successful canonical establishment of the winner may other candidates be deactivated. - // Pass the intent snapshot as expected reference account — never the listing-response value. - // Cleanup failures here are okay to leave pending: the COMPLETED intent remains the durable - // retry source for the dedicated completed-intent cleanup pass (same run and every hourly run). - const duplicates = candidates.slice(1); - for (let i = 0; i < duplicates.length; i++) { - const duplicate = duplicates[i]; - try { - await this.frickVibanProvider.deactivateAndApprove( - duplicate, - match.referenceAccountIban, - match.requestReference, - ); - counters.onDuplicateDeactivated(); - } catch (error) { - // Leave remaining duplicates untouched fail-closed — never cleanup-first. The completed-intent - // cleanup pass will retry against the now-COMPLETED intent on subsequent hourly runs. - this.logger.error( - `Frick vIBAN reconciliation Phase 1: duplicate cleanup failed for intentId=${match.intentId} ` + - `bankId=${match.bankId} remainingDuplicates=${duplicates.length - i} — ` + - `remaining duplicates left for completed-intent cleanup retry`, - error, - ); - return; - } - } + // Duplicates are not deactivated in Phase 1. After the intent is COMPLETED with + // externalIban = winner, runCompletedIntentDuplicateCleanup (same hourly run and every later + // run) is the sole, protected, permanently retryable cleanup path. } private normalizeReferenceAccountIban(iban: string): string { From 6354617022b204f3c49c57cc0e71fc6f2363f820 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:47:40 +0200 Subject: [PATCH 04/10] Validate Bank Frick vIBAN timestamps --- .../services/__tests__/frick.service.spec.ts | 52 +++++++++++++- .../bank/services/frick.service.ts | 8 ++- ...ck-issuance-reconciliation.service.spec.ts | 68 ++++++++++++++++--- ...n-frick-issuance-reconciliation.service.ts | 4 +- 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/integration/bank/services/__tests__/frick.service.spec.ts b/src/integration/bank/services/__tests__/frick.service.spec.ts index 29b04c768a..ae167f8dd7 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -1220,7 +1220,7 @@ describe('BankFrickService', () => { expect(http.request).toHaveBeenCalledTimes(5); }); - it('rejects virtual IBAN responses with missing or wrong-typed createdAt/createdBy', async () => { + it('rejects virtual IBAN responses with missing, wrong-typed or non-RFC-3339 createdAt/createdBy', async () => { http.request .mockResolvedValueOnce({ token: jwt() }) .mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: undefined }); @@ -1229,6 +1229,21 @@ describe('BankFrickService', () => { http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: 123 }); await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-07-01' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-07-01T00:00:00' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-07-01 00:00:00Z' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-02-30T00:00:00Z' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdBy: undefined }); await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); @@ -1236,6 +1251,41 @@ describe('BankFrickService', () => { await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); }); + it('accepts virtual IBAN responses with explicit Z or ±HH:MM createdAt offsets', async () => { + const withZ = virtualIbanResponse({ state: FrickVirtualIbanState.ACTIVE }); + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(withZ); + await expect(service.createViban(debtorIban)).resolves.toEqual(withZ); + + const withOffset = { ...virtualIbanResponse(), createdAt: '2026-07-01T02:00:00+02:00' }; + http.request.mockResolvedValueOnce(withOffset); + await expect(service.createViban(debtorIban)).resolves.toEqual(withOffset); + }); + + it('drops list entries with empty, date-only or timezone-less createdAt and reports fullyValidated=false', async () => { + const valid = virtualIbanResponse({ state: FrickVirtualIbanState.ACTIVE }); + const emptyCreatedAt = { ...valid, createdAt: '' }; + const dateOnly = { ...valid, createdAt: '2026-07-01' }; + const noTimezone = { ...valid, createdAt: '2026-07-01T00:00:00' }; + const otherValidWithOffset = { + ...virtualIbanResponse({ + state: FrickVirtualIbanState.PREPARED, + vban: createSyntheticIban('LI', '00000VBANACCOUNT2'), + }), + createdAt: '2026-07-01T12:00:00+02:00', + }; + http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce({ + pagination: { hasMore: false, pageIndex: 0, pageSize: 50, totalCount: 5 }, + virtualIbans: [valid, emptyCreatedAt, dateOnly, noTimezone, otherValidWithOffset], + }); + + await expect(service.listAllVibans(undefined, undefined, 50)).resolves.toEqual({ + virtualIbans: [valid, otherValidWithOffset], + fullyValidated: false, + listingStartedAt: expect.any(Date), + listingCompletedAt: expect.any(Date), + }); + }); + it('approves a virtual IBAN activation with a signed PUT', async () => { const response = virtualIbanResponse({ state: FrickVirtualIbanState.ACTIVE }); http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(response); diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index 2eb1e9e9ec..55da542152 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { AxiosError, AxiosResponse, Method } from 'axios'; +import { isISO8601 } from 'class-validator'; import * as IbanTools from 'ibantools'; import { Config } from 'src/config/config'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -7,8 +8,8 @@ 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 { - FrickApproveVirtualIbanDeactivationRequest, FrickApproveVirtualIbanActivationRequest, + FrickApproveVirtualIbanDeactivationRequest, FrickCreateVirtualIbanRequest, FrickDeactivateVirtualIbanRequest, FrickVirtualIban, @@ -1185,6 +1186,11 @@ export class BankFrickService { ) throw new Error('Invalid Bank Frick virtual IBAN response'); + // Require a full RFC-3339/ISO-8601 instant: calendar-valid date, T separator, and explicit + // timezone (Z or ±HH:MM). isISO8601 alone still accepts date-only / timezone-less forms. + if (!isISO8601(r.createdAt, { strict: true, strictSeparator: true }) || !/(?:Z|[+-]\d{2}:\d{2})$/.test(r.createdAt)) + throw new Error('Invalid Bank Frick virtual IBAN response'); + r.vban = this.normalizeAndValidateIban(r.vban, 'virtual IBAN'); r.referenceAccountIban = this.normalizeAndValidateIban(r.referenceAccountIban, 'reference account IBAN'); // JSON null means "no description" (same as omitted). Only validate when a real value is present. diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts index bc491702eb..7f55b9aa60 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts @@ -2,6 +2,7 @@ import { createMock } from '@golevelup/ts-jest'; import { CronExpression } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; import { FrickVirtualIban, FrickVirtualIbanState } from 'src/integration/bank/dto/frick-vban.dto'; +import { FrickVirtualIbansFetchResult } from 'src/integration/bank/services/frick.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Process } from 'src/shared/services/process.service'; import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; @@ -90,7 +91,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }; } - function listingResult(virtualIbans: FrickVirtualIban[], fullyValidated: boolean) { + function listingResult(virtualIbans: FrickVirtualIban[], fullyValidated: boolean): FrickVirtualIbansFetchResult { const listingStartedAt = new Date(); return { virtualIbans, fullyValidated, listingStartedAt, listingCompletedAt: new Date() }; } @@ -317,9 +318,22 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('finalizes only the deterministic winner on multi-match and never deactivates in Phase 1', async () => { // COMPLETED query empty → isolates Phase-1 side effects (no completed-intent cleanup). + // Offsets chosen so lexical string order differs from true UTC chronology: + // '2026-07-01T20:00:00Z' < '2026-07-01T23:00:00+05:00' lexicographically, + // but +05:00 is 18:00 UTC (older) and Z is 20:00 UTC (newer). mockIntentFinds([intent({ id: 130, requestReference: stuckRequestReference })]); - const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; - const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + const newer = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000002', + createdAt: '2026-07-01T20:00:00Z', + }; + const older = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000001', + createdAt: '2026-07-01T23:00:00+05:00', + }; + expect(newer.createdAt.localeCompare(older.createdAt)).toBeLessThan(0); + expect(Date.parse(older.createdAt)).toBeLessThan(Date.parse(newer.createdAt)); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); const callOrder: string[] = []; virtualIbanService.recoverFrickIntentForReconciliation.mockImplementation( @@ -360,8 +374,16 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('never deactivates when a race already finalized a different canonical IBAN', async () => { mockIntentFinds([intent({ id: 135, requestReference: stuckRequestReference })]); - const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; - const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + const newer = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000002', + createdAt: '2026-07-02T00:00:00Z', + }; + const older = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000001', + createdAt: '2026-07-01T00:00:00Z', + }; jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); virtualIbanService.recoverFrickIntentForReconciliation.mockResolvedValue({ kind: 'already_finalized', @@ -380,8 +402,16 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('does not deactivate in Phase 1 when recovery reports already_finalized with the winner canonical', async () => { // Phase 1 confirms only; COMPLETED empty so completed-intent cleanup does not run for this intent. mockIntentFinds([intent({ id: 142, requestReference: stuckRequestReference })]); - const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; - const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + const newer = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000002', + createdAt: '2026-07-02T00:00:00Z', + }; + const older = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000001', + createdAt: '2026-07-01T00:00:00Z', + }; jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([newer, older], true)); virtualIbanService.recoverFrickIntentForReconciliation.mockResolvedValue({ kind: 'already_finalized', @@ -521,8 +551,16 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('same run: after Phase-1 finalize, completed-intent cleanup deactivates an unprotected duplicate once', async () => { const phase1Intent = intent({ id: 141, requestReference: stuckRequestReference }); - const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; - const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + const newer = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000002', + createdAt: '2026-07-02T00:00:00Z', + }; + const older = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000001', + createdAt: '2026-07-01T00:00:00Z', + }; // COMPLETED query sees the post-recovery persisted state (externalIban = winner). const completedAfterRecovery = intent({ id: 141, @@ -564,8 +602,16 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('same run: protected duplicate after Phase-1 finalize is not deactivated and ERROR-logs PII-safe', async () => { const phase1Intent = intent({ id: 143, requestReference: stuckRequestReference }); - const newer = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000002', createdAt: '2026-07-02' }; - const older = { ...listingEntry(stuckRequestReference), vban: 'LI11ACTIVE00000000001', createdAt: '2026-07-01' }; + const newer = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000002', + createdAt: '2026-07-02T00:00:00Z', + }; + const older = { + ...listingEntry(stuckRequestReference), + vban: 'LI11ACTIVE00000000001', + createdAt: '2026-07-01T00:00:00Z', + }; const completedAfterRecovery = intent({ id: 143, requestReference: stuckRequestReference, diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts index 16c4d97f80..3e8c1938d2 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts @@ -896,8 +896,10 @@ export class VirtualIbanFrickIssuanceReconciliationService { return; } + // Chronological by epoch ms (Frick validation guarantees a finite RFC-3339 instant). vban is + // only the deterministic tie-breaker — never localeCompare of the original createdAt string. const candidates = [...match.virtualIbans].sort( - (a, b) => a.createdAt.localeCompare(b.createdAt) || a.vban.localeCompare(b.vban), + (a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || a.vban.localeCompare(b.vban), ); if (candidates.length === 0) return; From 4a2666c1108220516b52d81c995a3622810a46f4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:04:12 +0200 Subject: [PATCH 05/10] Require RFC 3339 vIBAN timestamps --- .../services/__tests__/frick.service.spec.ts | 30 +++++++++++++++++++ .../bank/services/frick.service.ts | 11 +++++-- 2 files changed, 38 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 ae167f8dd7..2df27a95b0 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -1241,9 +1241,20 @@ describe('BankFrickService', () => { http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-07-01 00:00:00Z' }); await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + // Invalid calendar day: passes the RFC-3339 calendar regex but fails isISO8601 (strict). http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-02-30T00:00:00Z' }); await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + // Ordinal / week / basic forms can pass isISO8601 yet fail the anchored RFC-3339 calendar regex. + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-182T00:00:00Z' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-W27-2T00:00:00Z' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '20260701T000000Z' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdBy: undefined }); await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); @@ -1251,6 +1262,25 @@ describe('BankFrickService', () => { await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); }); + it('rejects a virtual IBAN response when Date.parse does not yield a finite epoch for a valid RFC-3339 createdAt', async () => { + const validCreatedAt = '2026-07-01T00:00:00Z'; + const realParse = Date.parse; + const parseSpy = jest.spyOn(Date, 'parse').mockImplementation((value: string) => { + if (value === validCreatedAt) return Number.NaN; + return realParse(value); + }); + + try { + http.request + .mockResolvedValueOnce({ token: jwt() }) + .mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: validCreatedAt }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + expect(parseSpy).toHaveBeenCalledWith(validCreatedAt); + } finally { + parseSpy.mockRestore(); + } + }); + it('accepts virtual IBAN responses with explicit Z or ±HH:MM createdAt offsets', async () => { const withZ = virtualIbanResponse({ state: FrickVirtualIbanState.ACTIVE }); http.request.mockResolvedValueOnce({ token: jwt() }).mockResolvedValueOnce(withZ); diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index 55da542152..1fbbcf0272 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -1186,9 +1186,14 @@ export class BankFrickService { ) throw new Error('Invalid Bank Frick virtual IBAN response'); - // Require a full RFC-3339/ISO-8601 instant: calendar-valid date, T separator, and explicit - // timezone (Z or ±HH:MM). isISO8601 alone still accepts date-only / timezone-less forms. - if (!isISO8601(r.createdAt, { strict: true, strictSeparator: true }) || !/(?:Z|[+-]\d{2}:\d{2})$/.test(r.createdAt)) + // Require a full RFC-3339 calendar instant: YYYY-MM-DDTHH:mm:ss[.fraction]Z or ±HH:MM. + // Anchored format regex excludes ordinal/week/basic dates; isISO8601 checks calendar validity; + // Date.parse must yield a finite epoch so downstream epoch comparisons never see NaN. + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(r.createdAt) || + !isISO8601(r.createdAt, { strict: true, strictSeparator: true }) || + !Number.isFinite(Date.parse(r.createdAt)) + ) throw new Error('Invalid Bank Frick virtual IBAN response'); r.vban = this.normalizeAndValidateIban(r.vban, 'virtual IBAN'); From d288ad6c3edffd9ead0aa04b43ad223993eb4679 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:19:34 +0200 Subject: [PATCH 06/10] Reject RFC 3339 end-of-day timestamps --- .../bank/services/__tests__/frick.service.spec.ts | 4 ++++ src/integration/bank/services/frick.service.ts | 9 ++++++--- 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 2df27a95b0..523fb4fe28 100644 --- a/src/integration/bank/services/__tests__/frick.service.spec.ts +++ b/src/integration/bank/services/__tests__/frick.service.spec.ts @@ -1241,6 +1241,10 @@ describe('BankFrickService', () => { http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-07-01 00:00:00Z' }); await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + // ISO-8601 allows 24:00:00 as end-of-day; RFC 3339 / this validator require hours 00-23. + http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-07-01T24:00:00Z' }); + await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); + // Invalid calendar day: passes the RFC-3339 calendar regex but fails isISO8601 (strict). http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdAt: '2026-02-30T00:00:00Z' }); await expect(service.createViban(debtorIban)).rejects.toThrow('Invalid Bank Frick virtual IBAN response'); diff --git a/src/integration/bank/services/frick.service.ts b/src/integration/bank/services/frick.service.ts index 1fbbcf0272..e680ad702e 100644 --- a/src/integration/bank/services/frick.service.ts +++ b/src/integration/bank/services/frick.service.ts @@ -1187,10 +1187,13 @@ export class BankFrickService { throw new Error('Invalid Bank Frick virtual IBAN response'); // Require a full RFC-3339 calendar instant: YYYY-MM-DDTHH:mm:ss[.fraction]Z or ±HH:MM. - // Anchored format regex excludes ordinal/week/basic dates; isISO8601 checks calendar validity; - // Date.parse must yield a finite epoch so downstream epoch comparisons never see NaN. + // Hours 00-23, minutes/seconds 00-59 (rejects ISO-8601 end-of-day 24:00:00); offset hours/minutes + // likewise bounded. Anchored format regex excludes ordinal/week/basic dates; isISO8601 checks + // calendar validity; Date.parse must yield a finite epoch so downstream epoch comparisons never see NaN. if ( - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(r.createdAt) || + !/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/.test( + r.createdAt, + ) || !isISO8601(r.createdAt, { strict: true, strictSeparator: true }) || !Number.isFinite(Date.parse(r.createdAt)) ) From 05903a277a824d2bb59b983665a400ea8b95f711 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:01:09 +0200 Subject: [PATCH 07/10] Clean up Frick reconciliation diagnostics --- ...ck-issuance-reconciliation.service.spec.ts | 253 +++++++++++------- ...n-frick-issuance-reconciliation.service.ts | 80 ++---- 2 files changed, 171 insertions(+), 162 deletions(-) diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts index 7f55b9aa60..1c2a7a91dc 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts @@ -6,7 +6,6 @@ import { FrickVirtualIbansFetchResult } from 'src/integration/bank/services/fric import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Process } from 'src/shared/services/process.service'; import { DFX_CRONJOB_PARAMS, DfxCronParams } from 'src/shared/utils/cron'; -import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { DataSource, FindOperator, FindOptionsWhere, Repository } from 'typeorm'; import { IbanBankName } from '../../bank/dto/bank.dto'; import { FrickVibanProvider } from '../providers/frick-viban.provider'; @@ -21,13 +20,14 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { let eventRepo: { find: jest.Mock }; let intentRepo: { find: jest.Mock }; let frickVibanProvider: FrickVibanProvider; - let notificationService: NotificationService; - let virtualIbanService: { - resetStuckFrickIntentForReconciliationOnly: jest.Mock; - recoverFrickIntentForReconciliation: jest.Mock; - moveFrickIntentToFallbackForReconciliation: jest.Mock; - isIbanProtectedFromReconciliationDeactivation: jest.Mock; - }; + let virtualIbanService: jest.Mocked< + Pick< + VirtualIbanService, + | 'recoverFrickIntentForReconciliation' + | 'moveFrickIntentToFallbackForReconciliation' + | 'isIbanProtectedFromReconciliationDeactivation' + > + >; const referenceAccountIban = 'LI32088110105923K000C'; const bankId = 50; @@ -111,9 +111,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { eventRepo = { find: jest.fn().mockResolvedValue([]) }; intentRepo = { find: jest.fn().mockResolvedValue([]) }; frickVibanProvider = createMock(); - notificationService = createMock(); virtualIbanService = { - resetStuckFrickIntentForReconciliationOnly: jest.fn().mockResolvedValue(true), recoverFrickIntentForReconciliation: jest .fn() .mockImplementation(async (_intentId: number, match: { vban: string }) => ({ @@ -125,7 +123,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }; jest.spyOn(frickVibanProvider, 'isAvailable').mockReturnValue(true); - jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); const dataSource = { getRepository: jest.fn((entity) => { @@ -140,7 +137,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { VirtualIbanFrickIssuanceReconciliationService, { provide: DataSource, useValue: dataSource }, { provide: FrickVibanProvider, useValue: frickVibanProvider }, - { provide: NotificationService, useValue: notificationService }, { provide: VirtualIbanService, useValue: virtualIbanService }, ], }).compile(); @@ -171,7 +167,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(intentRepo.find).not.toHaveBeenCalled(); expect(eventRepo.find).not.toHaveBeenCalled(); expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('extracts abandoned references from both exact nextError shapes', () => { @@ -213,9 +208,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('lists the intent reference-account snapshot even when the current Bank IBAN moved', async () => { const snapshottedIban = 'LI00SNAPSHOT0000000000C'; const currentIban = 'LI00CURRENT00000000000C'; - intentRepo.find.mockResolvedValue([ - intent({ requestReference: stuckRequestReference, referenceAccountIban: snapshottedIban }), - ]); + mockIntentFinds([intent({ requestReference: stuckRequestReference, referenceAccountIban: snapshottedIban })]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); await service.reconcileRetiredIssuanceReferences(); @@ -225,18 +218,17 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('refuses to send a non-Frick provider snapshot to Bank Frick', async () => { - intentRepo.find.mockResolvedValue([ - intent({ requestReference: stuckRequestReference, provider: IbanBankName.YAPEAL }), - ]); + mockIntentFinds([intent({ requestReference: stuckRequestReference, provider: IbanBankName.YAPEAL })]); await service.reconcileRetiredIssuanceReferences(); expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); it('automatically recovers when the Frick listing contains the intent requestReference', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 101, requestReference: stuckRequestReference, @@ -255,11 +247,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { 101, expect.objectContaining({ description: stuckRequestReference }), ); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('ignores malformed listing descriptions while recovering a valid exact match', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 117, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 117, requestReference: stuckRequestReference })]); jest .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockResolvedValue( @@ -281,7 +272,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('logs ERROR and continues when automatic recovery fails', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 115, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 115, requestReference: stuckRequestReference })]); jest .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockResolvedValue(listingResult([listingEntry(stuckRequestReference)], true)); @@ -295,7 +286,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect.any(Error), ); expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('does not count a raced automatic recovery as completed', async () => { @@ -425,7 +415,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('logs ERROR and performs no external action on cross-account listing mismatch', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 136, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 136, requestReference: stuckRequestReference })]); const foreign = { ...listingEntry(stuckRequestReference), referenceAccountIban: 'LI99FOREIGN0000000000C', @@ -476,7 +466,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('logs PII-safe ERROR for a single Phase-1 positive match before recovery', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 137, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 137, requestReference: stuckRequestReference })]); jest .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockResolvedValue(listingResult([listingEntry(stuckRequestReference)], true)); @@ -493,7 +483,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('does not fall back after failed listing even when the intent is day-old', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 138, requestReference: stuckRequestReference, @@ -512,7 +502,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('does not fall back after invalid listing timestamps even when the intent is day-old', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 139, requestReference: stuckRequestReference, @@ -532,7 +522,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('does not fall back after incomplete listing even when the intent is day-old', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 140, requestReference: stuckRequestReference, @@ -645,7 +635,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('moves a day-old listing miss to the collection-account fallback', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 131, requestReference: stuckRequestReference, @@ -666,7 +656,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('logs ERROR and continues when the automatic fallback transition fails', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 132, requestReference: stuckRequestReference, @@ -687,11 +677,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { 'Frick vIBAN reconciliation Phase 1: automatic fallback transition failed for intentId=132', expect.any(Error), ); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('accepts a raced fallback transition without treating it as newly completed', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 133, requestReference: stuckRequestReference, @@ -712,7 +701,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('skips intents younger than the safety threshold', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 102, requestReference: stuckRequestReference, @@ -723,14 +712,15 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); - it('logs an old inconclusive intent without sending an alert', async () => { + it('logs an old inconclusive listing miss without recovery or fallback', async () => { const pastThresholdUpdated = new Date( Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS - 5_000, ); - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 103, requestReference: stuckRequestReference, @@ -738,52 +728,58 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }), ]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); - expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 1: 1 intent(s) remain inconclusive because ' + + 'listing absence is not authoritative; automatic recovery will continue', + ); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); - it('keeps an old intent inconclusive when the listing began before create processing could end', async () => { + it('keeps an old listing miss inconclusive without automatic fallback before the day threshold', async () => { const updated = new Date( Date.now() - VirtualIbanFrickIssuanceReconciliationService.FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS - 5_000, ); const currentReference = 'dfx-viban-listing-too-early-00000001'; - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 113, requestReference: currentReference, updated, }), ]); - expect(VirtualIbanFrickIssuanceReconciliationService.FRICK_CREATE_MAX_PROCESSING_MS).toBe(120_000); - const latestPossibleCreateProcessedAt = new Date(updated.getTime() + 120_000); - const listingStartedAt = new Date(latestPossibleCreateProcessedAt.getTime() - 1); - jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue({ - ...listingResult([], true), - listingStartedAt, - listingCompletedAt: new Date(listingStartedAt.getTime() + 1_000), - }); + jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 1: 1 intent(s) remain inconclusive because ' + + 'listing absence is not authoritative; automatic recovery will continue', + ); expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); }); it('rejects invalid listing timestamps without reopening an intent', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 114, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 114, requestReference: stuckRequestReference })]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue({ ...listingResult([], true), listingStartedAt: new Date('invalid'), }); await service.reconcileRetiredIssuanceReferences(); + + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); it('does not mutate a recent intent when listing is not fully validated', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 104, requestReference: stuckRequestReference, @@ -793,12 +789,12 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); it('still recovers a positive match when listing is not fully validated', async () => { - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 105, requestReference: stuckRequestReference }), intent({ id: 106, requestReference: 'dfx-viban-other-stuck-ref-00000001' }), ]); @@ -813,11 +809,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect.objectContaining({ description: stuckRequestReference }), ); expect(virtualIbanService.recoverFrickIntentForReconciliation).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); - it('does not send a separate alert for a chronically incomplete listing', async () => { - intentRepo.find.mockResolvedValue([ + it('ERROR-logs a chronically incomplete listing without recovery or fallback', async () => { + mockIntentFinds([ intent({ id: 107, requestReference: stuckRequestReference, @@ -825,14 +820,22 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }), ]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], false)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`listing for bankId=${bankId} not fully validated`), + ); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`listing chronically incomplete for bankId(s) ${bankId}`), + ); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); - it('does not send an alert when incomplete-listing intents are still within the safety threshold', async () => { - intentRepo.find.mockResolvedValue([ + it('does not treat incomplete-listing intents within the safety threshold as chronic', async () => { + mockIntentFinds([ intent({ id: 108, requestReference: stuckRequestReference, @@ -840,17 +843,23 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }), ]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], false)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`listing for bankId=${bankId} not fully validated`), + ); + expect(loggerError).not.toHaveBeenCalledWith(expect.stringContaining('listing chronically incomplete')); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); - it('continues Phase 1 for healthy banks when one bank throws without sending an alert', async () => { + it('continues Phase 1 for healthy banks when one bank throws', async () => { const healthyBankId = 51; const healthyRef = 'dfx-viban-healthybankintent0000000001'; const healthyReferenceAccountIban = 'LI00HEALTHY00000000000C'; - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 110, requestReference: stuckRequestReference, bankId, referenceAccountReceive: false }), intent({ id: 111, @@ -860,17 +869,23 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }), ]); jest.spyOn(frickVibanProvider, 'listByReferenceAccount').mockResolvedValue(listingResult([], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); - expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`processing failed for bankId=${bankId}`), + expect.any(Error), + ); + expect(frickVibanProvider.listByReferenceAccount).toHaveBeenCalledWith(healthyReferenceAccountIban); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); it('filters merge-superseded FAILED intents and leaves other listing misses non-retryable', async () => { const mergeRetiredReference = 'dfx-viban-mergesuperseded000000000001'; const eligibleReference = 'dfx-viban-eligibleforreset000000000001'; - intentRepo.find.mockResolvedValue([ + mockIntentFinds([ intent({ id: 120, requestReference: mergeRetiredReference, @@ -890,8 +905,10 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); - expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + // Only the eligible FAILED intent is listed; merge-superseded is filtered before bank work. + expect(frickVibanProvider.listByReferenceAccount).toHaveBeenCalledTimes(1); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); it('skips Phase 1 entirely when every loaded InFlight/Failed intent is merge-superseded', async () => { @@ -912,8 +929,8 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { // Filtered out before bank grouping — no listing or reopening work. expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); + expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); }); }); @@ -1005,10 +1022,8 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect.stringContaining('canonical IBAN not present in fully validated listing for intentId=500'), ); expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); expect(virtualIbanService.moveFrickIntentToFallbackForReconciliation).not.toHaveBeenCalled(); - expect(virtualIbanService.resetStuckFrickIntentForReconciliationOnly).not.toHaveBeenCalled(); expect(virtualIbanService.isIbanProtectedFromReconciliationDeactivation).not.toHaveBeenCalled(); }); @@ -1046,7 +1061,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { }); it('deactivates a shared unprotected duplicate only once when two COMPLETED intents map to it in the same run', async () => { - // Covers processedDuplicateVibans.has → continue (line 553): second intent sees the same + // Covers processedDuplicateVibans.has → continue: second intent sees the same // technical description, same persisted canonical, and same listed unprotected duplicate. const first = completedIntent({ id: 500 }); const second = completedIntent({ id: 501 }); @@ -1231,19 +1246,43 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); }); + + it('ignores listing entries without a string description and keeps canonical presence valid', async () => { + // Covers `typeof viban.description !== 'string' continue` while building byDescription: + // the description-less entry must not become a cleanup target or block canonical presence. + mockIntentFinds([], [completedIntent()]); + const canonical = listingEntry(completedRef, { vban: canonicalVban }); + const withoutDescription = listingEntry(completedRef, { + vban: duplicateVban, + description: undefined, + }); + jest + .spyOn(frickVibanProvider, 'listByReferenceAccount') + .mockResolvedValue(listingResult([canonical, withoutDescription], true)); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); + + await service.reconcileRetiredIssuanceReferences(); + + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); + expect(virtualIbanService.isIbanProtectedFromReconciliationDeactivation).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalledWith( + expect.stringContaining('canonical IBAN not present in fully validated listing for intentId=500'), + ); + expect(loggerError).not.toHaveBeenCalledWith(expect.stringContaining('duplicate match for COMPLETED intent')); + }); }); describe('Phase 2 — retired references', () => { - it('sends no alert when no abandoned references are found', async () => { + it('performs no Frick work when no abandoned references are found', async () => { eventRepo.find.mockResolvedValue([]); await service.reconcileRetiredIssuanceReferences(); expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); }); - it('sends no alert when abandoned references exist but none match the fully-validated Frick listing', async () => { + it('performs no deactivation when abandoned references exist but none match the fully-validated Frick listing', async () => { eventRepo.find.mockResolvedValue([ event({ nextError: `create failed; recovery listing found no match; previousRequestReference=${abandonedCreate}; newRequestReference=dfx-viban-new`, @@ -1256,7 +1295,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(frickVibanProvider.listByReferenceAccount).toHaveBeenCalledWith(referenceAccountIban); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); }); it('automatically deactivates an abandoned reference found in the Frick listing', async () => { @@ -1295,7 +1334,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { abandonedCreate, ); expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('ignores malformed Phase 2 descriptions and cleans up the valid orphan', async () => { @@ -1352,7 +1390,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { 'Frick vIBAN reconciliation Phase 2: automatic orphan cleanup failed for eventId=13', expect.any(Error), ); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('skips deactivation when the matched orphan IBAN is protected locally', async () => { @@ -1380,7 +1417,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { ); expect(loggerError.mock.calls.flat().join(' ')).not.toMatch(/LI\d{2}/); expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('does not treat unmatched abandoned refs as clean when listing is not fully validated', async () => { @@ -1396,7 +1432,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('still deactivates a positive orphan match when listing is incomplete', async () => { @@ -1412,7 +1447,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service.reconcileRetiredIssuanceReferences(); expect(frickVibanProvider.deactivateAndApprove).toHaveBeenCalledTimes(1); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('does not call Bank Frick again for an orphan that is already deactivated', async () => { @@ -1441,10 +1475,15 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { jest .spyOn(frickVibanProvider, 'listByReferenceAccount') .mockRejectedValue(new Error('upstream listing unavailable')); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`processing failed for bankId=${bankId}`), + expect.any(Error), + ); + expect(frickVibanProvider.deactivateAndApprove).not.toHaveBeenCalled(); }); it('continues Phase 2 cleanup for healthy banks when one bank throws', async () => { @@ -1475,7 +1514,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { healthyReferenceAccountIban, abandonedRecovery, ); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); it('queries abandoned references without a rolling time window', async () => { @@ -1632,7 +1670,6 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { referenceAccountIban, phase1ReopenReference, ); - expect(notificationService.sendMail).not.toHaveBeenCalled(); }); }); @@ -1641,7 +1678,7 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { // Productive-path reachable fixture: nextError contains CREATE_PATH_REFERENCE_MARKER so the // real WHERE nextError LIKE '%previousRequestReference=%' clause would return this row, but // the value after the marker is empty (marker immediately followed by ';') so extractAbandonedReference - // cannot parse a reference → reason=reference_unextractable. + // cannot parse a reference → unresolvedCount increments. const unextractableNextError = `${CREATE_PATH_REFERENCE_MARKER};newRequestReference=dfx-viban-irrelevant`; eventRepo.find.mockResolvedValue([ event({ @@ -1649,30 +1686,44 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { nextError: unextractableNextError, }), ]); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await service.reconcileRetiredIssuanceReferences(); // Zero resolvable abandoned references → Phase 2 must never reach Frick listing. expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 2: could not extract abandoned reference from event 99', + ); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 2: 1 abandoned reference(s) could not be extracted', + ); }); it('logs a bank with no IBAN configured and does not abort the whole run', async () => { - intentRepo.find.mockResolvedValue([ - intent({ id: 200, requestReference: stuckRequestReference, referenceAccountIban: '' }), - ]); + mockIntentFinds([intent({ id: 200, requestReference: stuckRequestReference, referenceAccountIban: '' })]); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + expect.stringContaining(`processing failed for bankId=${bankId}`), + expect.any(Error), + ); + expect(virtualIbanService.recoverFrickIntentForReconciliation).not.toHaveBeenCalled(); }); it('logs an outer Phase 1 failure when the intent query itself throws', async () => { + // Direct rejected mock for repository error path (not status-routed). intentRepo.find.mockRejectedValue(new Error('intent repository unavailable')); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 1 (stuck intents) failed:', + expect.any(Error), + ); }); it('still runs Phase 2 after a Phase 1 failure', async () => { @@ -1690,15 +1741,19 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { it('logs an outer Phase 2 failure when the abandoned-event query itself throws', async () => { eventRepo.find.mockRejectedValue(new Error('event repository unavailable')); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 2 (retired references) failed:', + expect.any(Error), + ); }); it('logs an outer Phase 2 failure when a LIKE-matched event has nextError=null', async () => { // Productively unreachable via the Postgres LIKE query path (NULL LIKE pattern is never - // true — see the invariant comment in loadAbandonedReferences ~line 422). This test only + // true — see the invariant comment in loadAbandonedReferences). This test only // covers the fail-closed reaction to a hypothetical future dialect/driver violation of that // SQL invariant: coverage completeness + the throw path itself. The mock bypasses SQL // filtering and injects a row that production Postgres cannot return from that query. @@ -1706,17 +1761,21 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { nextError: null, }); eventRepo.find.mockResolvedValue([invariantBreakingEvent]); + const loggerError = jest.spyOn(DfxLogger.prototype, 'error').mockImplementation(() => undefined); // Outer try/catch in reconcileRetiredIssuanceReferences absorbs the throw — must not rethrow. await expect(service.reconcileRetiredIssuanceReferences()).resolves.toBeUndefined(); // Fail-closed throw aborts loadAbandonedReferences before any Frick listing work. expect(frickVibanProvider.listByReferenceAccount).not.toHaveBeenCalled(); - expect(notificationService.sendMail).not.toHaveBeenCalled(); + expect(loggerError).toHaveBeenCalledWith( + 'Frick vIBAN reconciliation Phase 2 (retired references) failed:', + expect.any(Error), + ); }); it('reuses the Phase-1 listing cache in Phase 2 for the same reference-account snapshot', async () => { - intentRepo.find.mockResolvedValue([intent({ id: 202, requestReference: stuckRequestReference })]); + mockIntentFinds([intent({ id: 202, requestReference: stuckRequestReference })]); eventRepo.find.mockResolvedValue([ event({ nextError: `create failed; recovery listing found no match; previousRequestReference=${abandonedCreate}; newRequestReference=dfx-viban-new`, diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts index 3e8c1938d2..5637b109cb 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts @@ -32,24 +32,6 @@ interface AbandonedReferenceHit { created: Date; } -/** - * Fixed-vocabulary reason only — never free-text from nextError. - * Sole remaining unresolved path: query matched a marker substring, but the value after the marker - * is empty (unextractable). `nextError IS NULL` cannot appear here — SQL `NULL LIKE pattern` is - * never true, so the productive find() never returns null nextError rows. - */ -type UnresolvedAbandonedReferenceReason = 'reference_unextractable'; - -interface UnresolvedAbandonedReferenceCandidate { - eventId: number; - intentId: number; - userDataId: number; - currencyId: number; - bankId: number; - created: Date; - reason: UnresolvedAbandonedReferenceReason; -} - interface StuckIntentListingMatch { intentId: number; requestReference: string; @@ -67,17 +49,6 @@ interface AbandonedReferenceMatch extends AbandonedReferenceHit { virtualIban: FrickVirtualIban; } -interface UnprovenAbsenceIntent { - intentId: number; - userDataId: number; - currencyId: number; - bankId: number; - status: VirtualIbanIssuanceIntentStatus; - updated: Date; - listingStartedAt: Date; - latestPossibleCreateProcessedAt: Date; -} - /** * Periodic Frick issuance reconciliation: * - Phase 1: automatically adopt a uniquely identified delayed create (canonical winner only). @@ -105,7 +76,6 @@ export class VirtualIbanFrickIssuanceReconciliationService { */ static readonly FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS = 1_800_000; static readonly FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS = 86_400_000; - static readonly FRICK_CREATE_MAX_PROCESSING_MS = VirtualIbanService.FRICK_CREATE_MAX_PROCESSING_MS; private static readonly CLEANUP_TARGET_STATES: ReadonlySet = new Set([ FrickVirtualIbanState.PREPARED, @@ -197,7 +167,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { } const listingMatches: StuckIntentListingMatch[] = []; - const unprovenAbsences: UnprovenAbsenceIntent[] = []; + let unprovenAbsenceCount = 0; /** * Fallback is only armed for intents whose listing in this run succeeded with valid timestamps * and fullyValidated === true. Listing failures, invalid timestamps, and incomplete listings @@ -304,19 +274,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { continue; } - const latestPossibleCreateProcessedAt = new Date( - intent.updated.getTime() + VirtualIbanFrickIssuanceReconciliationService.FRICK_CREATE_MAX_PROCESSING_MS, - ); - unprovenAbsences.push({ - intentId: intent.id, - userDataId: intent.userDataId, - currencyId: intent.currencyId, - bankId: intent.bankId, - status: intent.status, - updated: intent.updated, - listingStartedAt: listingResult.listingStartedAt, - latestPossibleCreateProcessedAt, - }); + unprovenAbsenceCount += 1; } } catch (error) { // Isolate per-bank failures so one misconfigured/unreachable bank cannot abort every other bank. @@ -343,8 +301,8 @@ export class VirtualIbanFrickIssuanceReconciliationService { }); } - if (unprovenAbsences.length > 0) { - this.logUnprovenAbsences(unprovenAbsences); + if (unprovenAbsenceCount > 0) { + this.logUnprovenAbsences(unprovenAbsenceCount); } let fallbackCount = 0; @@ -381,7 +339,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { `${byReferenceAccountSnapshot.size} reference-account snapshot(s); ` + `${listingMatches.length} listing match(es), ${skippedFreshCount} skipped (too fresh), ` + `${recoveredCount} recovered, ${recoveryFailureCount} recovery failure(s), ` + - `${fallbackCount} moved to fallback, ${unprovenAbsences.length} inconclusive absence(s), ` + + `${fallbackCount} moved to fallback, ${unprovenAbsenceCount} inconclusive absence(s), ` + `${failedBankCount} bank failure(s), ` + `${skippedIncompleteCount} skipped (incomplete listing across ${incompleteListingBankCount} bank(s))` + (skippedMergeSupersededCount > 0 ? `, ${skippedMergeSupersededCount} skipped (merge-superseded)` : ''), @@ -589,15 +547,15 @@ export class VirtualIbanFrickIssuanceReconciliationService { /** Phase 2: deactivate vIBANs that appear under a retired requestReference. */ private async runPhase2RetiredReferences(listingCache: Map): Promise { - const { hits: abandoned, unresolved } = await this.loadAbandonedReferences(); + const { hits: abandoned, unresolvedCount } = await this.loadAbandonedReferences(); - if (unresolved.length > 0) + if (unresolvedCount > 0) this.logger.error( - `Frick vIBAN reconciliation Phase 2: ${unresolved.length} abandoned reference(s) could not be extracted`, + `Frick vIBAN reconciliation Phase 2: ${unresolvedCount} abandoned reference(s) could not be extracted`, ); if (abandoned.length === 0) { - if (unresolved.length === 0) { + if (unresolvedCount === 0) { this.logger.info('Frick vIBAN reconciliation Phase 2: no abandoned references pending check'); } return; @@ -775,7 +733,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { */ private async loadAbandonedReferences(): Promise<{ hits: AbandonedReferenceHit[]; - unresolved: UnresolvedAbandonedReferenceCandidate[]; + unresolvedCount: number; }> { const events = await this.dataSource.getRepository(VirtualIbanIssuanceEvent).find({ where: [ @@ -792,7 +750,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { }); const hits: AbandonedReferenceHit[] = []; - const unresolved: UnresolvedAbandonedReferenceCandidate[] = []; + let unresolvedCount = 0; for (const event of events) { // Invariant of the LIKE query above: NULL LIKE pattern is never true in SQL, so a matched // row cannot have nextError = null. Fail closed (throws into runPhase2RetiredReferences → @@ -810,15 +768,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { this.logger.error( `Frick vIBAN reconciliation Phase 2: could not extract abandoned reference from event ${event.id}`, ); - unresolved.push({ - eventId: event.id, - intentId: event.intentId, - userDataId: event.userDataId, - currencyId: event.currencyId, - bankId: event.bankId, - created: event.created, - reason: 'reference_unextractable', - }); + unresolvedCount += 1; continue; } hits.push({ @@ -834,7 +784,7 @@ export class VirtualIbanFrickIssuanceReconciliationService { created: event.created, }); } - return { hits, unresolved }; + return { hits, unresolvedCount }; } /** @@ -863,9 +813,9 @@ export class VirtualIbanFrickIssuanceReconciliationService { return (end >= 0 ? rest.slice(0, end) : rest).trim(); } - private logUnprovenAbsences(intents: UnprovenAbsenceIntent[]): void { + private logUnprovenAbsences(count: number): void { this.logger.error( - `Frick vIBAN reconciliation Phase 1: ${intents.length} intent(s) remain inconclusive because ` + + `Frick vIBAN reconciliation Phase 1: ${count} intent(s) remain inconclusive because ` + `listing absence is not authoritative; automatic recovery will continue`, ); } From 7863b5f7f6052341413faa5f3a691a38b701b6c0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:16:41 +0200 Subject: [PATCH 08/10] Align Frick reconciliation documentation --- docs/bank-frick-operations.md | 28 +++++++++---------- .../__tests__/bank-frick-operations.spec.ts | 13 +++++---- ...ck-issuance-reconciliation.service.spec.ts | 5 ---- ...n-frick-issuance-reconciliation.service.ts | 21 -------------- .../bank/virtual-iban/virtual-iban.service.ts | 20 ++++++------- 5 files changed, 30 insertions(+), 57 deletions(-) diff --git a/docs/bank-frick-operations.md b/docs/bank-frick-operations.md index 9493483d5f..b55cdb4339 100644 --- a/docs/bank-frick-operations.md +++ b/docs/bank-frick-operations.md @@ -397,24 +397,23 @@ processes setting. The listing result carries `listingStartedAt` (captured immediately before page 0 is dispatched) and `listingCompletedAt` (captured after the final page validates). In Phase 1, invalid/reversed timestamps fail that snapshot group for the run and likewise exclude fallback for that group. -For each intent, the code computes -`latestPossibleCreateProcessedAt = intent.updated + FRICK_CREATE_MAX_PROCESSING_MS`. The absence -log context includes the count of inconclusive intents. `listingCompletedAt` is checked for a valid -`Date` and for not preceding `listingStartedAt`; it is not compared with -`latestPossibleCreateProcessedAt` and establishes no temporal coverage of the create window. These -times are not an automatic-retry precondition: even a correctly ordered listing miss remains -non-authoritative because Bank Frick provides no authoritative “this create did not happen” -operation. - -`FRICK_CREATE_MAX_PROCESSING_MS = 120_000` is derived from -`BankFrickService.HTTP_TIMEOUT_MS = 30_000`: +The absence log context includes the count of inconclusive intents. `listingCompletedAt` is +checked for a valid `Date` and for not preceding `listingStartedAt`; it establishes no temporal +coverage of the create window. These times are not an automatic-retry precondition: even a +correctly ordered listing miss remains non-authoritative because Bank Frick provides no +authoritative “this create did not happen” operation. + +A conservative **local** upper-bound estimate for the create attempt is **120 seconds**, derived +from `BankFrickService.HTTP_TIMEOUT_MS = 30_000`: - authorization preflight before the create call can consume 30s; - the locally bounded create HTTP attempt then lasts at most 90s: original request (30s) + `/authorize` re-auth after 401 (30s) + one-shot retried request (30s). `requestSigned` has no further internal retry beyond that. -- **120s is not an upper bound on Bank Frick processing or on when its create side effect can - occur.** Bank Frick may queue or finish work after the local HTTP attempt has ended. +- **120s is not a Bank Frick SLA or processing deadline**, not an upper bound on when Bank Frick’s + create side effect can occur, and not a retry or automatic-fallback precondition. Bank Frick may + queue or finish work after the local HTTP attempt has ended. The estimate is for operator + planning only (e.g. draining the local create window before changing a reference account). The 30-minute `FRICK_STUCK_INTENT_SAFETY_THRESHOLD_MS` controls when an inconclusive miss becomes an `ERROR`; the 24-hour `FRICK_AUTOMATIC_FALLBACK_THRESHOLD_MS` bounds recovery before terminal @@ -473,7 +472,8 @@ DEACTIVATED object is a completed cleanup and causes no further mutation. Markers (shared constants on `VirtualIbanService`): - `CREATE_PATH_REFERENCE_MARKER` = `previousRequestReference=` — current writer format - (deactivation-reopen + merge supersede) and historical Phase-1 reset format + (deactivation-reopen + merge supersede + automatic collection-account fallback) and historical + Phase-1 reset format - `RECOVERY_PATH_REFERENCE_MARKER` = `recovery listing found no match under requestReference=` — retained so Phase 2 still parses any historical events from older builds - `MERGE_SUPERSEDED_MARKER` = `merge-superseded` — permanent retirement marker written only by the diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts index 1340bbfb6a..9ddcbfe24c 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts @@ -14,11 +14,13 @@ describe('Bank Frick operations runbook', () => { const compactRunbook = runbook.replace(/\s+/g, ' '); it('includes preflight in the 120-second local window without treating it as a Frick deadline', () => { - expect(runbook).toContain('FRICK_CREATE_MAX_PROCESSING_MS = 120_000'); + expect(runbook).toContain('conservative **local** upper-bound estimate for the create attempt is **120 seconds**'); expect(runbook).toContain('authorization preflight before the create call can consume 30s'); - expect(runbook).toContain('120s is not an upper bound on Bank Frick processing'); + expect(runbook).toContain('120s is not a Bank Frick SLA or processing deadline'); + expect(runbook).toContain('not a retry or automatic-fallback precondition'); expect(runbook).toContain('Bank Frick may queue or finish work after the local HTTP attempt has ended'); - expect(runbook).not.toContain('FRICK_CREATE_MAX_PROCESSING_MS = 90_000'); + expect(runbook).not.toContain('FRICK_CREATE_MAX_PROCESSING_MS'); + expect(runbook).not.toContain('latestPossibleCreateProcessedAt'); }); it('documents durable per-effect completion and target verification before manual replay', () => { @@ -56,9 +58,8 @@ describe('Bank Frick operations runbook', () => { expect(compactRunbook).toContain( '`listingCompletedAt` is checked for a valid `Date` and for not preceding `listingStartedAt`', ); - expect(compactRunbook).toContain( - 'it is not compared with `latestPossibleCreateProcessedAt` and establishes no temporal coverage', - ); + expect(compactRunbook).toContain('it establishes no temporal coverage of the create window'); + expect(compactRunbook).not.toContain('latestPossibleCreateProcessedAt'); expect(compactRunbook).not.toContain('validated when deciding whether a miss is fully covered'); }); diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts index 1c2a7a91dc..25b5a495d1 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts @@ -443,13 +443,8 @@ describe('VirtualIbanFrickIssuanceReconciliationService', () => { await service['recoverPhase1ListingMatch']( { intentId: 150, - requestReference: stuckRequestReference, referenceAccountIban, - userDataId: 30, - currencyId: 40, bankId, - status: VirtualIbanIssuanceIntentStatus.IN_FLIGHT, - updated: new Date('2026-07-01T12:00:00.000Z'), virtualIbans: [], }, { diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts index 5637b109cb..84ee88b35e 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts @@ -23,25 +23,17 @@ interface AbandonedReferenceHit { abandonedReference: string; eventId: number; intentId: number; - userDataId: number; - currencyId: number; bankId: number; provider: IbanBankName; referenceAccountIban: string; referenceAccountReceive: boolean; - created: Date; } interface StuckIntentListingMatch { intentId: number; - requestReference: string; /** Intent-captured reference-account IBAN snapshot — never the listing response value. */ referenceAccountIban: string; - userDataId: number; - currencyId: number; bankId: number; - status: VirtualIbanIssuanceIntentStatus; - updated: Date; virtualIbans: FrickVirtualIban[]; } @@ -222,13 +214,8 @@ export class VirtualIbanFrickIssuanceReconciliationService { if (virtualIbans.length > 0) { listingMatches.push({ intentId: intent.id, - requestReference: intent.requestReference, referenceAccountIban: intent.referenceAccountIban, - userDataId: intent.userDataId, - currencyId: intent.currencyId, bankId: intent.bankId, - status: intent.status, - updated: intent.updated, virtualIbans, }); } else { @@ -256,13 +243,8 @@ export class VirtualIbanFrickIssuanceReconciliationService { if (virtualIbans.length > 0) { listingMatches.push({ intentId: intent.id, - requestReference: intent.requestReference, referenceAccountIban: intent.referenceAccountIban, - userDataId: intent.userDataId, - currencyId: intent.currencyId, bankId: intent.bankId, - status: intent.status, - updated: intent.updated, virtualIbans, }); continue; @@ -775,13 +757,10 @@ export class VirtualIbanFrickIssuanceReconciliationService { abandonedReference, eventId: event.id, intentId: event.intentId, - userDataId: event.userDataId, - currencyId: event.currencyId, bankId: event.bankId, provider: event.provider, referenceAccountIban: event.referenceAccountIban, referenceAccountReceive: event.referenceAccountReceive, - created: event.created, }); } return { hits, unresolvedCount }; diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts index 255f5e1634..f7c0cf47fd 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts @@ -53,9 +53,10 @@ class IssuanceIntegrityError extends Error { /** * Prefixes written into issuance-event `nextError` when a Frick requestReference is retired. - * Current writers are deactivation reopen and account-merge supersede (via - * CREATE_PATH_REFERENCE_MARKER co-located in the merge-fail message). Historical reconciliation - * events may also contain either marker. Request-path issuance never retires references on its own. + * Current writers are deactivation reopen, account-merge supersede (via + * CREATE_PATH_REFERENCE_MARKER co-located in the merge-fail message), and automatic + * collection-account fallback. Historical reconciliation events may also contain either marker. + * Request-path issuance never retires references on its own. * Phase 2 of the reconciliation job parses these markers; keep writer and parser on the same constants. * * CREATE_PATH_REFERENCE_MARKER is the current writer format. @@ -92,12 +93,6 @@ export type FrickReconciliationRecoveryResult = export class VirtualIbanService { private readonly logger = new DfxLogger(VirtualIbanService); - /** - * Longest local window from intent claim through create processing: authorization preflight - * (30s) plus create, re-authorization, and one retried create request (90s). - */ - static readonly FRICK_CREATE_MAX_PROCESSING_MS = 120_000; - /** Providers eligible for implicit/default personal-IBAN behavior, selected by their supported currency. */ private readonly genericProviders: VibanProvider[]; @@ -1616,7 +1611,9 @@ export class VirtualIbanService { * UserData.virtualIbans has no cascade, so that save does not reassign VirtualIban.userData. * 3. Reconcile both accounts' Frick intents for the pair: winner-side Completed stays Completed and * moves to masterId; loser-side Pending/InFlight/Failed is permanently merge-failed (never left - * reopenable under a retired userDataId). COMPLETED non-winner historical rows stay untouched. + * reopenable under a retired userDataId). COMPLETED non-winner historical rows stay untouched by + * this merge path; Phase 1 does not load Completed, but the separate Completed-intent cleanup + * does load and process them. */ private async resolveMergedVirtualIbanPairLocked( manager: EntityManager, @@ -1696,7 +1693,8 @@ export class VirtualIbanService { // PENDING / IN_FLIGHT / FAILED: permanently mark merge-superseded so runPhase1StuckIntents // excludes the retired userDataId from automatic recovery. COMPLETED non-winner historical - // rows are left untouched (reconciliation never loads Completed). + // rows stay untouched here: Phase 1 does not load Completed; the separate Completed-intent + // cleanup loads and processes them. if ( intent.status === VirtualIbanIssuanceIntentStatus.PENDING || intent.status === VirtualIbanIssuanceIntentStatus.IN_FLIGHT || From 99268b6d4136bfc30b5a967c2017fffb234ea806 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:23:58 +0200 Subject: [PATCH 09/10] Normalize Frick runbook assertions --- .../bank/virtual-iban/__tests__/bank-frick-operations.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts b/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts index 9ddcbfe24c..cebeb756f4 100644 --- a/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts +++ b/src/subdomains/supporting/bank/virtual-iban/__tests__/bank-frick-operations.spec.ts @@ -18,7 +18,7 @@ describe('Bank Frick operations runbook', () => { expect(runbook).toContain('authorization preflight before the create call can consume 30s'); expect(runbook).toContain('120s is not a Bank Frick SLA or processing deadline'); expect(runbook).toContain('not a retry or automatic-fallback precondition'); - expect(runbook).toContain('Bank Frick may queue or finish work after the local HTTP attempt has ended'); + expect(compactRunbook).toContain('Bank Frick may queue or finish work after the local HTTP attempt has ended'); expect(runbook).not.toContain('FRICK_CREATE_MAX_PROCESSING_MS'); expect(runbook).not.toContain('latestPossibleCreateProcessedAt'); }); From 615bbcdfe904748cc323173f669f96fa0f43d898 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:35:29 +0200 Subject: [PATCH 10/10] Clarify Frick fallback deactivation log --- .../supporting/bank/virtual-iban/virtual-iban.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts index f7c0cf47fd..7977d355d7 100644 --- a/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts +++ b/src/subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts @@ -1531,10 +1531,10 @@ export class VirtualIbanService { `currencyId=${currencyId}, bankId=${bankId}, intentStatus=${intent.status})`, ); } else { - // PENDING / IN_FLIGHT / FAILED: this intent's current cycle has nothing to do with the vIBAN + // PENDING / IN_FLIGHT / FAILED / FALLBACK: this intent's current cycle has nothing to do with the vIBAN // being deactivated — only a COMPLETED intent can ever point at a live vIBAN. Benign, expected. this.logger.info( - `Issuance intent non-terminal on virtual IBAN deactivation; leaving intent unchanged ` + + `Issuance intent non-completed on virtual IBAN deactivation; leaving intent unchanged ` + `(virtualIbanId=${virtualIban.id}, intentId=${intent.id}, userDataId=${userDataId}, ` + `currencyId=${currencyId}, bankId=${bankId}, intentStatus=${intent.status})`, );