Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading