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
113 changes: 113 additions & 0 deletions migration/1784600000011-FixFiatOutputValutaDateSerials.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class FixFiatOutputValutaDateSerials1784600000011 {
name = 'FixFiatOutputValutaDateSerials1784600000011';

/**
* Spreadsheet date serials were sent as numbers and class-transformer interpreted them as
* milliseconds since 1970-01-01, yielding broken 1970 timestamps. The millisecond portion equals
* the original serial (days since 1899-12-30); conversion is deterministic within the serial
* window for dates from 2000-01-01 (serial 36526) to the observed maximum (serial ~46300 /
* 1970-01-01 00:00:46.300):
* "valutaDate" >= TIMESTAMP '1970-01-01 00:00:36.526' AND "valutaDate" < TIMESTAMP '1970-01-01 00:00:46.300'
* Known 1970-01-01T00:00:00.001Z legacy values exist only in "isReadyDate", never in "valutaDate",
* and lie outside this window (no overlap).
*
* Audit and repair run in a single atomic statement with a shared row snapshot (data-modifying
* CTEs): there is no window between audit insert and mutation in which concurrent changes could
* be repaired without being logged. Affected rows are locked with FOR UPDATE. The final UPDATE
* is gated by EXISTS on the audit CTE so audit-before-mutation is structurally enforced, not only
* implied by statement atomicity.
*
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// Millisecond epoch offset is the spreadsheet serial; add that many days to 1899-12-30.
// Audit + repair share one snapshot via data-modifying CTEs (fail closed if insert fails).
await queryRunner.query(`
WITH "affected" AS (
SELECT "id", "valutaDate",
TIMESTAMP '1899-12-30 00:00:00' + ROUND(EXTRACT(EPOCH FROM "valutaDate") * 1000) * INTERVAL '1 day' AS "repairedDate"
FROM "fiat_output"
WHERE "valutaDate" >= TIMESTAMP '1970-01-01 00:00:36.526' AND "valutaDate" < TIMESTAMP '1970-01-01 00:00:46.300'
FOR UPDATE
),
"audit" AS (
INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message")
SELECT now(), now(), 'FiatOutput', 'ValutaDateSerialRepair', 'Info',
json_agg(json_build_object(
'id', "id",
'before', to_char("valutaDate", 'YYYY-MM-DD HH24:MI:SS.MS'),
'after', to_char("repairedDate", 'YYYY-MM-DD HH24:MI:SS.MS')
))::text
FROM "affected"
HAVING count(*) > 0
RETURNING 1
)
UPDATE "fiat_output" f
SET "valutaDate" = a."repairedDate"
FROM "affected" a
WHERE f."id" = a."id" AND EXISTS (SELECT 1 FROM "audit");
`);
}

/**
* Restore previous "valutaDate" values exclusively from the newest audit log entry written by
* up() (system='FiatOutput', subsystem='ValutaDateSerialRepair'). Restores only rows still holding
* the audited 'after' value (guard against silently destroying legitimate post-repair corrections).
* Writes its own rollback audit log entry (subsystem='ValutaDateSerialRepairRollback') before the
* mutation in the same atomic statement. No calendar-date or midnight heuristic: legitimate
* production valutaDates frequently are exactly midnight UTC (00:00:00.000), so a
* date_trunc/midnight check would falsely reverse thousands of valid rows. Missing audit log
* yields a no-op (0 rows). Restorable rows are locked with FOR UPDATE OF f; the after-guard is
* re-checked at update time. The final UPDATE is gated by EXISTS on the rollback audit CTE so
* audit-before-mutation is structurally enforced, not only implied by statement atomicity.
*
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`
WITH "auditSource" AS (
SELECT "id" AS "logId", "message" FROM "log"
WHERE "system" = 'FiatOutput' AND "subsystem" = 'ValutaDateSerialRepair'
ORDER BY "id" DESC LIMIT 1
),
"entries" AS (
SELECT s."logId", jsonb_array_elements(s."message"::jsonb) AS "elem" FROM "auditSource" s
),
"restorable" AS (
SELECT e."logId", (e."elem"->>'id')::int AS "rowId",
(e."elem"->>'before')::timestamp AS "beforeValue",
(e."elem"->>'after')::timestamp AS "afterValue"
FROM "entries" e
JOIN "fiat_output" f ON f."id" = (e."elem"->>'id')::int
WHERE f."valutaDate" = (e."elem"->>'after')::timestamp
FOR UPDATE OF f
),
"rollbackAudit" AS (
INSERT INTO "log" ("created", "updated", "system", "subsystem", "severity", "message")
SELECT now(), now(), 'FiatOutput', 'ValutaDateSerialRepairRollback', 'Info',
json_agg(json_build_object(
'sourceLogId', "logId",
'id', "rowId",
'before', to_char("afterValue", 'YYYY-MM-DD HH24:MI:SS.MS'),
'after', to_char("beforeValue", 'YYYY-MM-DD HH24:MI:SS.MS')
))::text
FROM "restorable"
HAVING count(*) > 0
RETURNING 1
)
UPDATE "fiat_output" f
SET "valutaDate" = r."beforeValue"
FROM "restorable" r
WHERE f."id" = r."rowId" AND f."valutaDate" = r."afterValue" AND EXISTS (SELECT 1 FROM "rollbackAudit");
`);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { YapealService } from 'src/integration/bank/services/yapeal.service';
import { CheckoutService } from 'src/integration/checkout/services/checkout.service';
import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock';
import { BankTxBatchService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service';
import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity';
import { BankService } from 'src/subdomains/supporting/bank/bank/bank.service';
import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto';
import { LiquidityManagementAsset } from '../../../interfaces';
Expand Down Expand Up @@ -37,32 +38,120 @@ describe('BankAdapter', () => {
);
});

function frickAsset(dexName: string): LiquidityManagementAsset {
return Object.assign(createCustomAsset({ dexName }), { context: IbanBankName.FRICK });
function frickAsset(dexName: string, id?: number): LiquidityManagementAsset {
return Object.assign(createCustomAsset({ dexName, id }), { context: IbanBankName.FRICK });
}

it('routes FRICK to frickService.getBalances() and creates a LiquidityBalance per matching asset currency, mirroring the Yapeal case', async () => {
function frickBank(asset: LiquidityManagementAsset, iban: string): Bank {
return createMock<Bank>({ asset, iban });
}

it('matches same-currency Bank Frick accounts to assets by their linked IBAN', async () => {
const firstEurAsset = frickAsset('EUR', 1);
const secondEurAsset = frickAsset('EUR', 2);
jest
.spyOn(bankService, 'getBanksWithAsset')
.mockResolvedValue([
frickBank(firstEurAsset, 'LI01 0000 0000 0000 0001'),
frickBank(secondEurAsset, 'LI02 0000 0000 0000 0002'),
]);
jest.spyOn(frickService, 'getBalances').mockResolvedValue([
{
iban: 'LI010000000000000001',
currency: 'EUR',
balance: 1000,
availableBalance: 900,
},
{
iban: 'li020000000000000002',
currency: 'EUR',
balance: 2000,
availableBalance: 1800,
},
]);

const result = await adapter.getForBank(IbanBankName.FRICK, [firstEurAsset, secondEurAsset]);

expect(result).toHaveLength(2);
expect(result[0].asset).toBe(firstEurAsset);
expect(result[0].amount).toBe(900);
expect(result[1].asset).toBe(secondEurAsset);
expect(result[1].amount).toBe(1800);
});

it('fails closed when a Bank Frick asset has no linked bank account', async () => {
jest.spyOn(bankService, 'getBanksWithAsset').mockResolvedValue([]);
jest
.spyOn(frickService, 'getBalances')
.mockResolvedValue([{ iban: 'LI-EUR', currency: 'EUR', balance: 1000, availableBalance: 900 }]);
const eurAsset = frickAsset('EUR');
const chfAsset = frickAsset('CHF');

const result = await adapter.getForBank(IbanBankName.FRICK, [eurAsset, chfAsset]);
await expect(adapter.getForBank(IbanBankName.FRICK, [frickAsset('EUR', 1)])).rejects.toThrow(
'Bank Frick account is not linked to asset',
);
});

it('fails closed when the linked Bank Frick IBAN is missing from the API response', async () => {
const eurAsset = frickAsset('EUR', 1);
jest.spyOn(bankService, 'getBanksWithAsset').mockResolvedValue([frickBank(eurAsset, 'LI-LINKED')]);
jest
.spyOn(frickService, 'getBalances')
.mockResolvedValue([{ iban: 'LI-OTHER', currency: 'EUR', balance: 1000, availableBalance: 900 }]);

await expect(adapter.getForBank(IbanBankName.FRICK, [eurAsset])).rejects.toThrow(
'No Bank Frick account found for IBAN LI-LINKED',
);
});

it('ignores Bank Frick API accounts that are not linked to a liquidity asset', async () => {
const eurAsset = frickAsset('EUR', 1);
jest.spyOn(bankService, 'getBanksWithAsset').mockResolvedValue([frickBank(eurAsset, 'LI-LINKED')]);
jest.spyOn(frickService, 'getBalances').mockResolvedValue([
{ iban: 'LI-LINKED', currency: 'EUR', balance: 1000, availableBalance: 900 },
{ iban: 'LI-OPERATING', currency: 'EUR', balance: 5000, availableBalance: 4500 },
]);

const result = await adapter.getForBank(IbanBankName.FRICK, [eurAsset]);

expect(result).toHaveLength(1);
expect(result[0].asset).toBe(eurAsset);
expect(result[0].amount).toBe(900);
});

it('fails closed when Bank Frick reports no available balance for an account, instead of silently using the booked balance', async () => {
it('fails closed when Bank Frick reports no available balance for a linked account, instead of silently using the booked balance', async () => {
const eurAsset = frickAsset('EUR', 1);
jest.spyOn(bankService, 'getBanksWithAsset').mockResolvedValue([frickBank(eurAsset, 'LI-EUR')]);
jest.spyOn(frickService, 'getBalances').mockResolvedValue([{ iban: 'LI-EUR', currency: 'EUR', balance: 1000 }]);

await expect(adapter.getForBank(IbanBankName.FRICK, [frickAsset('EUR')])).rejects.toThrow(
await expect(adapter.getForBank(IbanBankName.FRICK, [eurAsset])).rejects.toThrow(
'Missing available balance for Bank Frick account LI-EUR',
);
});

it('ignores an unmatched Bank Frick account with no available balance and still processes the linked account', async () => {
const eurAsset = frickAsset('EUR', 1);
jest.spyOn(bankService, 'getBanksWithAsset').mockResolvedValue([frickBank(eurAsset, 'LI-LINKED')]);
jest.spyOn(frickService, 'getBalances').mockResolvedValue([
{ iban: 'LI-LINKED', currency: 'EUR', balance: 1000, availableBalance: 900 },
{ iban: 'LI-OPERATING', currency: 'EUR', balance: 5000 },
]);

const result = await adapter.getForBank(IbanBankName.FRICK, [eurAsset]);

expect(result).toHaveLength(1);
expect(result[0].asset).toBe(eurAsset);
expect(result[0].amount).toBe(900);
});

it('fails closed when the linked Bank Frick account currency does not match the asset', async () => {
const eurAsset = frickAsset('EUR', 1);
jest.spyOn(bankService, 'getBanksWithAsset').mockResolvedValue([frickBank(eurAsset, 'LI-LINKED')]);
jest
.spyOn(frickService, 'getBalances')
.mockResolvedValue([{ iban: 'LI-LINKED', currency: 'CHF', balance: 1000, availableBalance: 900 }]);

await expect(adapter.getForBank(IbanBankName.FRICK, [eurAsset])).rejects.toThrow(/currency mismatch/i);
});

it('propagates and logs a getBalances() failure the same way as the other cases', async () => {
jest.spyOn(frickService, 'getBalances').mockRejectedValue(new Error('Bank Frick unavailable'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,52 @@ export class BankAdapter implements LiquidityBalanceIntegration {
}

case IbanBankName.FRICK: {
const normalizeIban = (iban: string): string => iban.replace(/\s/g, '').toUpperCase();

const frickBalances = await this.frickService.getBalances();

for (const balance of frickBalances) {
// Bank Frick's `available` field is optional in its own account-listing contract. Falling
// back to the booked `balance` would overstate spendable liquidity (it ignores pending
// debits) - exactly the overdraft risk this case exists to close - so a missing available
// balance fails loud instead of silently substituting a different, unsafe number.
const banks = await this.bankService.getBanksWithAsset();
const matchedIbans = new Set<string>();

for (const asset of assets) {
const bank = banks.find((bank) => bank.asset?.id === asset.id);
if (!bank) throw new Error(`Bank Frick account is not linked to asset ${asset.uniqueName}`);

const bankIban = normalizeIban(bank.iban);
const balance = frickBalances.find((balance) => {
const balanceIban = normalizeIban(balance.iban);
return balanceIban === bankIban;
});
if (!balance)
throw new Error(`No Bank Frick account found for IBAN ${bank.iban} (asset ${asset.uniqueName})`);

// A wrongly linked IBAN (e.g. a CHF account linked to a Frick EUR asset) must fail loud instead of
// silently recording the wrong currency's balance as this asset's liquidity.
if (balance.currency !== asset.dexName)
throw new Error(
`Currency mismatch for Bank Frick account ${bank.iban}: ` +
`expected ${asset.dexName}, found ${balance.currency} (asset ${asset.uniqueName})`,
);

// Bank Frick's `available` field is optional in its own account-listing contract. Falling back to
// the booked `balance` would overstate spendable liquidity (it ignores pending debits) - exactly
// the overdraft risk this case exists to close - so a missing available balance fails loud instead
// of silently substituting a different, unsafe number. This only applies to the account actually
// matched to an asset here - an unmatched account without an available balance (e.g. the operating
// account) plays no role in any asset's liquidity figure and must not crash the refresh.
if (!Number.isFinite(balance.availableBalance))
throw new Error(`Missing available balance for Bank Frick account ${balance.iban}`);

const matchingAssets = assets.filter((asset) => asset.dexName === balance.currency);
matchingAssets.forEach((asset) => balances.push(LiquidityBalance.create(asset, balance.availableBalance)));
matchedIbans.add(normalizeIban(balance.iban));
balances.push(LiquidityBalance.create(asset, balance.availableBalance));
}

const unmatchedIbans = frickBalances
.filter((balance) => !matchedIbans.has(normalizeIban(balance.iban)))
.map((balance) => balance.iban);
if (unmatchedIbans.length)
this.logger.verbose(`Ignored unmatched Bank Frick accounts: ${unmatchedIbans.join(', ')}`);

break;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { LiquidityBalanceIntegrationFactory } from '../factories/liquidity-balan
import { LiquidityBalanceRepository } from '../repositories/liquidity-balance.repository';

export interface BankBalanceUpdate {
bank: Bank;
bank: Bank | null;
iban: string;
balance: number;
}

Expand Down Expand Up @@ -59,7 +60,17 @@ export class LiquidityManagementBalanceService implements OnModuleInit {
}

async refreshBankBalance(dto: BankBalanceUpdate): Promise<void> {
if (!dto.bank) {
this.logger.verbose(`Skipping bank balance refresh: no bank found for IBAN ${dto.iban}`);
return;
}

const entity = await this.balanceRepo.findOne({ where: { asset: { bank: { id: dto.bank.id } } } });
if (!entity) {
this.logger.verbose(`Skipping bank balance refresh for bank ${dto.bank.id}: liquidity balance not found`);
return;
}

await this.balanceRepo.update(entity.id, { amount: dto.balance });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,12 @@ describe('BuyFiatPreparationService', () => {
it.each<[string, string, string, IbanBankName]>([
['CHF through Yapeal', 'CHF', 'CH1234567890', IbanBankName.YAPEAL],
['EUR through Olkypay', 'EUR', 'DE1234567890', IbanBankName.OLKY],
['Frick-eligible EUR through Bank Frick', 'EUR', 'LI1234567890', IbanBankName.FRICK],
[
'EUR through Olkypay for a Liechtenstein IBAN (Bank Frick is never auto-selected)',
'EUR',
'LI1234567890',
IbanBankName.OLKY,
],
])('predicts %s and passes the selected bank to fee matching', async (_, currency, iban, bankName) => {
const bank = createCustomBank({ name: bankName, currency });
const { entity, country } = arrangeRefreshFee(currency, iban, bank);
Expand All @@ -172,7 +177,6 @@ describe('BuyFiatPreparationService', () => {
entity.outputAsset.name,
FiatOutputType.BUY_FIAT,
entity.userData,
false,
country,
);
expect(transactionHelper.getTxFeeInfos).toHaveBeenCalledWith(
Expand All @@ -188,7 +192,7 @@ describe('BuyFiatPreparationService', () => {
entity.user,
);

if ([IbanBankName.OLKY, IbanBankName.FRICK].includes(bankName)) {
if (bankName === IbanBankName.OLKY) {
const bankOut = jest.mocked(transactionHelper.getTxFeeInfos).mock.calls[0][8];
expect(bankOut).not.toBe(IbanBankName.YAPEAL);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,6 @@ export class BuyFiatPreparationService {
entity.outputAsset.name,
FiatOutputType.BUY_FIAT,
entity.userData,
false,
country,
);

Expand All @@ -294,7 +293,7 @@ export class BuyFiatPreparationService {
undefined,
// This prediction and the later FiatOutput bank assignment share the single source of truth,
// FiatOutputService.selectPayoutBank, and agree while the underlying state is unchanged. Active
// virtual IBANs, sender-bank send/sendPriority, and Bank Frick availability are live and mutable;
// virtual IBANs and sender-bank send/sendPriority are live and mutable;
// because this prediction is neither persisted nor reconciled, bankOut is best-effort and can
// differ if, for example, a virtual IBAN activates or a bank's send/priority changes between calls.
// Fee.verifyForTx in fee.entity.ts matches bank-scoped Fees against its banks array, so an unknown
Expand Down
Loading
Loading