From 5200fec9558989cc82000ba19140f1387ca19d77 Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Thu, 16 Jul 2026 14:50:33 -0300 Subject: [PATCH] fix(accounting): key buy-fiat owed cutover marks by the payout bank asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark cache is keyed by asset-table ids (FinanceLog.assets), but openBuyFiatOwed looked up the outputAsset Fiat id — a different keyspace that never matches (fiat 2/EUR reads asset 2/dBTC, a feedless DeFiChain token). Every non-CHF open buy_fiat row therefore hit the m6 fail-loud throw on each run and the cutover could never complete, with no retry able to heal it. Resolve the mark the way the forward consumer's outputMark does: the row's assigned fiatOutput bank asset first, falling back to any bank in the output currency from the bankByIban map already loaded per cutover. --- .../__tests__/ledger-cutover.service.spec.ts | 83 ++++++++++++++++--- .../services/ledger-cutover.service.ts | 40 +++++++-- 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts index 80c277d650..61ed6b5233 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-cutover.service.spec.ts @@ -597,15 +597,25 @@ describe('LedgerCutoverService', () => { }); }); - it('opens buyFiat-owed per row CHF = outputAmount × fiat-mark for a foreign-currency (EUR) output (R6-1)', async () => { + // R6-1 + keyspace: the mark cache is keyed by ASSET-table ids (FinanceLog.assets), never Fiat ids — the row's + // Fiat id (2) deliberately has no cache entry here, so a fiat-id lookup would find nothing. The mark must resolve + // via a bank in the output currency (bank.asset 269), exactly like the forward outputMark (buy-fiat.consumer §4.7). + it('opens buyFiat-owed per row CHF = outputAmount × bank-asset mark for a foreign-currency (EUR) output (R6-1)', async () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); jest .spyOn(markService, 'preload') - .mockResolvedValue(new LedgerMarkCache(new Map([[7, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]]))); + .mockResolvedValue( + new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]])), + ); + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { - // owed query: isComplete false, no outputAmount filter → return the owed row + // owed query: isComplete false, no outputAmount filter → return the owed row (no fiatOutput assigned yet) if (!where?.outputAmount) { - return Promise.resolve([buyFiat({ id: 43, outputAmount: 1000, outputAsset: { id: 7, name: 'EUR' } as any })]); + return Promise.resolve([buyFiat({ id: 43, outputAmount: 1000, outputAsset: { id: 2, name: 'EUR' } as any })]); } return Promise.resolve([]); }); @@ -619,6 +629,45 @@ describe('LedgerCutoverService', () => { expect(liabilityLeg.amountChf).toBe(-950); // 1000 EUR × 0.95, NOT the raw 1000 (FX basis, R6-1) }); + // the assigned payout bank wins over the currency fallback: the row's fiatOutput.bank.asset (267) carries a + // different mark than the other EUR bank (269) — the opening must value at the bank the payout actually uses, + // mirroring the forward outputMark keyed on fiatOutput.bank.asset (no opening-vs-settlement basis drift). + it('values buyFiat-owed at the assigned fiatOutput bank asset, not the first same-currency bank', async () => { + jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + jest.spyOn(markService, 'preload').mockResolvedValue( + new LedgerMarkCache( + new Map([ + [267, [{ created: new Date('2026-06-01'), priceChf: 0.94 }]], + [269, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]], + ]), + ), + ); + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); + jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { + if (!where?.outputAmount) { + return Promise.resolve([ + buyFiat({ + id: 49, + outputAmount: 1000, + outputAsset: { id: 2, name: 'EUR' } as any, + fiatOutput: { bank: { currency: 'EUR', asset: { id: 267 } } } as any, + }), + ]); + } + return Promise.resolve([]); + }); + + await service.run(); + + const owedTx = booked.find((b) => b.sourceId === '1557344:buy_fiat-owed:49'); + const liabilityLeg = owedTx.legs.find((l) => l.account.type === AccountType.LIABILITY); + expect(liabilityLeg.amountChf).toBe(-940); // 1000 EUR × 0.94 (assigned bank 267), not 0.95 (fallback 269) + }); + it('opens buyFiat-owed at mark 1 for a CHF output', async () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { @@ -1533,10 +1582,11 @@ describe('LedgerCutoverService', () => { // THROWS and leaves the ledger-ready flag unset (retry once the mark feed is back). it('throws (m6) on a foreign-currency buyFiat-owed opening when its fiat-mark is missing', async () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); - jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no mark for asset 7 + jest.spyOn(markService, 'preload').mockResolvedValue(new LedgerMarkCache(new Map())); // no marks at all + // default bankRepo.find mock → no banks: neither an assigned fiatOutput bank nor a same-currency fallback exists jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { if (!where?.outputAmount) { - return Promise.resolve([buyFiat({ id: 51, outputAmount: 1000, outputAsset: { id: 7, name: 'EUR' } as any })]); + return Promise.resolve([buyFiat({ id: 51, outputAmount: 1000, outputAsset: { id: 2, name: 'EUR' } as any })]); } return Promise.resolve([]); }); @@ -1548,14 +1598,25 @@ describe('LedgerCutoverService', () => { expect(booked.some((b) => b.sourceId === '1557344:buy_fiat-owed:51')).toBe(false); // no zero-opening booked }); - // fiatMark (`assetId != null` false side): a foreign-currency buyFiat-owed whose outputAsset has NO id → - // fiatMark(undefined, …) returns undefined → amountChf undefined → the same m6 fail-loud as the missing-mark case. - it('throws (m6) on a foreign-currency buyFiat-owed opening whose outputAsset has no id (fiatMark undefined)', async () => { + // buyFiatOutputMark degenerate row: a foreign-currency buyFiat-owed whose outputAsset has NO name → no assigned + // bank and no currency to match a fallback bank → undefined → the same m6 fail-loud as the missing-mark case. + it('throws (m6) on a foreign-currency buyFiat-owed opening whose outputAsset has no name (no currency key)', async () => { jest.spyOn(logService, 'getFinancialLogs').mockResolvedValue([snapshotLog({})]); + // the EUR bank mark IS available — the throw must come solely from the row's missing currency key + jest + .spyOn(markService, 'preload') + .mockResolvedValue( + new LedgerMarkCache(new Map([[269, [{ created: new Date('2026-06-01'), priceChf: 0.95 }]]])), + ); + jest + .spyOn(bankRepo, 'find') + .mockResolvedValue([ + Object.assign(new Bank(), { iban: 'EUR-IBAN', name: 'Olkypay', currency: 'EUR', asset: { id: 269 } }), + ] as any); jest.spyOn(buyFiatRepo, 'find').mockImplementation(({ where }: any) => { if (!where?.outputAmount) { - // outputAsset.name 'EUR' (not CHF) but id undefined → fiatMark(undefined) → undefined → throw - return Promise.resolve([buyFiat({ id: 53, outputAmount: 1000, outputAsset: { name: 'EUR' } as any })]); + // outputAsset id without a name: not CHF, matches no bank currency → mark undefined → throw + return Promise.resolve([buyFiat({ id: 53, outputAmount: 1000, outputAsset: { id: 2 } as any })]); } return Promise.resolve([]); }); diff --git a/src/subdomains/core/accounting/services/ledger-cutover.service.ts b/src/subdomains/core/accounting/services/ledger-cutover.service.ts index 77a48d32b4..219af99dde 100644 --- a/src/subdomains/core/accounting/services/ledger-cutover.service.ts +++ b/src/subdomains/core/accounting/services/ledger-cutover.service.ts @@ -266,7 +266,7 @@ export class LedgerCutoverService { // (with an alarm) instead of wedging, and the Card seq0 does not double-book the gross once the row is priced. const unpricedBuyFiat = [ ...(await this.openBuyFiatReceived(snapshot, snapshotDate, lookback, equity)), - ...(await this.openBuyFiatOwed(snapshot, snapshotDate, lookback, marks, equity)), + ...(await this.openBuyFiatOwed(snapshot, snapshotDate, lookback, marks, equity, bankByIban)), ]; await this.pinUnpricedIds('buy_fiat', unpricedBuyFiat); @@ -331,7 +331,7 @@ export class LedgerCutoverService { return unpriced; } - // buyFiat-owed: open rows with outputAmount NOT NULL → CHF = outputAmount × mark(outputAsset-Fiat ≤ snapshot) (R6-1). + // buyFiat-owed: open rows with outputAmount NOT NULL → CHF = outputAmount × output-currency mark ≤ snapshot (R6-1). // Returns the ids of paymentLink rows with a NULL amountInChf (no paymentLink anchor) so the caller pins them (F2). private async openBuyFiatOwed( snapshot: Log, @@ -339,12 +339,18 @@ export class LedgerCutoverService { lookback: Date, marks: LedgerMarkCache, equity: LedgerAccount, + bankByIban: Map, ): Promise { const rows = await this.buyFiatRepo.find({ where: { isComplete: false, created: Between(lookback, date) }, // F1: load paymentLinkPayment to detect a paymentLink row (its opening goes to LIABILITY/paymentLink, not -owed). // cryptoInput.id is read below (G-a) to check coverage against the pinned cutover boundary (isCoveredByCutoverOpening). - relations: { outputAsset: true, cryptoInput: { paymentLinkPayment: true } }, + // fiatOutput.bank.asset keys the output-currency mark (buyFiatOutputMark), same shape the forward consumer loads. + relations: { + outputAsset: true, + cryptoInput: { paymentLinkPayment: true }, + fiatOutput: { bank: { asset: true } }, + }, }); const owed = await this.liability('buyFiat-owed'); const paymentLink = await this.liability('paymentLink'); @@ -383,8 +389,8 @@ export class LedgerCutoverService { continue; } - // outputAsset is a Fiat; CHF-output → mark 1, foreign-currency output → fiat-mark ≤ snapshot - const fiatMark = row.outputAsset?.name === CHF ? 1 : this.fiatMark(row.outputAsset?.id, date, marks); + // outputAsset is a Fiat; CHF-output → mark 1, foreign-currency output → bank-asset mark ≤ snapshot + const fiatMark = row.outputAsset?.name === CHF ? 1 : this.buyFiatOutputMark(row, date, marks, bankByIban); const amountChf = fiatMark != null ? Util.round(row.outputAmount * fiatMark, 2) : undefined; // missing fiat-mark → amountChf undefined → bookReceivedOwedOpening throws (m6 fail-loud): the forward path can @@ -1015,9 +1021,27 @@ export class LedgerCutoverService { return (await this.bookingService.nextSeq(SOURCE_TYPE, sourceId)) > seq; } - // foreign-fiat mark from the asset mark cache (priceChf of the fiat asset ≤ snapshot) - private fiatMark(assetId: number | undefined, date: Date, marks: LedgerMarkCache): number | undefined { - return assetId != null ? marks.getMarkAt(assetId, date) : undefined; + // the output-currency mark for an owed opening. The cache is keyed by ASSET-table ids (FinanceLog.assets) — a Fiat + // id is a different keyspace and NEVER matches (fiat 2/EUR reads asset 2/dBTC: feedless → permanent fail-loud wedge). + // Resolve via the payout bank's currency Asset instead, exactly like the forward outputMark (buy-fiat.consumer + // §4.7): the row's assigned fiatOutput bank first, else any bank in the output currency. + private buyFiatOutputMark( + row: BuyFiat, + date: Date, + marks: LedgerMarkCache, + bankByIban: Map, + ): number | undefined { + const assignedAssetId = row.fiatOutput?.bank?.asset?.id; + const assignedMark = assignedAssetId != null ? marks.getMarkAt(assignedAssetId, date) : undefined; + if (assignedMark != null) return assignedMark; + + const currency = row.outputAsset?.name; + for (const bank of bankByIban.values()) { + if (bank.currency !== currency || bank.asset?.id == null) continue; + const mark = marks.getMarkAt(bank.asset.id, date); + if (mark != null) return mark; + } + return undefined; } private liability(qualifier: string): Promise {