Skip to content
223 changes: 117 additions & 106 deletions docs/bank-frick-operations.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion jest.frick.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/integration/bank/dto/frick-vban.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,11 @@ export interface FrickVirtualIbansResponse {
export interface FrickApproveVirtualIbanActivationRequest {
vban: string;
}

export interface FrickDeactivateVirtualIbanRequest {
vban: string;
}

export interface FrickApproveVirtualIbanDeactivationRequest {
vban: string;
}
116 changes: 115 additions & 1 deletion src/integration/bank/services/__tests__/frick.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -1229,13 +1229,97 @@ 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');

// 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');

// 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');

http.request.mockResolvedValueOnce({ ...virtualIbanResponse(), createdBy: 456 });
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);
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);
Expand All @@ -1257,6 +1341,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';
Expand Down
52 changes: 51 additions & 1 deletion src/integration/bank/services/frick.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,7 +9,9 @@ import { Util } from 'src/shared/utils/util';
import { BankTx, BankTxIndicator } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity';
import {
FrickApproveVirtualIbanActivationRequest,
FrickApproveVirtualIbanDeactivationRequest,
FrickCreateVirtualIbanRequest,
FrickDeactivateVirtualIbanRequest,
FrickVirtualIban,
FrickVirtualIbanState,
FrickVirtualIbansResponse,
Expand Down Expand Up @@ -61,7 +64,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;
Expand Down Expand Up @@ -254,6 +257,40 @@ export class BankFrickService {
return response;
}

async deactivateViban(vban: string): Promise<FrickVirtualIban> {
this.assertVibanAvailable();
this.validateString(vban, 'vban', 34, true);
const request: FrickDeactivateVirtualIbanRequest = { vban };
const response = await this.callVbanApi<FrickVirtualIban>(
'virtual-ibans/deactivations',
'PUT',
request,
'application/json',
'json',
true,
false,
);
this.validateVirtualIbanResponse(response);
return response;
}

async approveVibanDeactivation(vban: string): Promise<FrickVirtualIban> {
this.assertVibanAvailable();
this.validateString(vban, 'vban', 34, true);
const request: FrickApproveVirtualIbanDeactivationRequest = { vban };
const response = await this.callVbanApi<FrickVirtualIban>(
'virtual-ibans/deactivations/approvals',
'PUT',
request,
'application/json',
'json',
true,
false,
);
this.validateVirtualIbanResponse(response);
return response;
}

async getViban(vban: string): Promise<FrickVirtualIban> {
this.assertVibanAvailable();
this.validateString(vban, 'vban', 34, true);
Expand Down Expand Up @@ -1149,6 +1186,19 @@ 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.
// 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([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))
)
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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('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).toContain('120s is not a Bank Frick SLA or processing deadline');
expect(runbook).toContain('not a retry or automatic-fallback precondition');
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');
});

it('documents durable per-effect completion and target verification before manual replay', () => {
Expand All @@ -36,28 +38,28 @@ 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`',
);
expect(compactRunbook).toContain(
'it is not compared with `latestPossibleCreateProcessedAt` and establishes no temporal coverage',
'`listingCompletedAt` is checked for a valid `Date` and for not preceding `listingStartedAt`',
);
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');
});

Expand Down
Loading