diff --git a/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts b/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts index 389562e266..b60f683abc 100644 --- a/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts +++ b/src/subdomains/core/accounting/services/consumers/__tests__/bank-tx.consumer.spec.ts @@ -304,6 +304,28 @@ describe('BankTxConsumer', () => { expect(cents(legs)).toBe(0); }); + // prod reality: a return bank_tx is linked from buy_crypto.chargebackBankTx — its buyCrypto (inbound-payment + // inverse) is null. The owed anchor must come from buyCryptoChargeback (bank_tx 206858/206864 regression). + it('books BUY_CRYPTO_RETURN linked only via buyCryptoChargeback (buyCrypto is null on returns)', async () => { + const buyCryptoChargeback = { id: 128422, amountInChf: 9226.11, totalFeeAmountChf: 0 } as any; + mockBatch([ + bankTx({ + type: BankTxType.BUY_CRYPTO_RETURN, + creditDebitIndicator: BankTxIndicator.DEBIT, + accountIban: 'CHF-IBAN', + amount: 9226.11, + buyCrypto: undefined, + buyCryptoChargeback, + }), + ]); + await consumer.process(); + + const legs = booked[0].legs; + const owed = legs.find((l) => l.account.name === 'LIABILITY/buyCrypto-owed'); + expect(owed.amountChf).toBe(9226.11); // completion CHF from the chargeback relation, no throw + expect(cents(legs)).toBe(0); + }); + it('books KRAKEN DBIT as Dr TRANSIT/bank↔Kraken / Cr ASSET/bank (CHF, route nets to 0)', async () => { mockBatch([ bankTx({ diff --git a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts index 7a39ed1602..b1842e0318 100644 --- a/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts +++ b/src/subdomains/core/accounting/services/consumers/bank-tx.consumer.ts @@ -88,7 +88,7 @@ export class BankTxConsumer { SOURCE_TYPE, afterForward, this.bankTxRepo, - { buyCrypto: true }, + { buyCrypto: true, buyCryptoChargeback: true }, async (tx: BankTx) => { const t = tx.bookingDate ?? tx.created; await this.reconcileBooking( @@ -104,7 +104,7 @@ export class BankTxConsumer { // DBIT only after 5 min (analog assignTransactions); settlement = bookingDate ?? created (§4.2) const batch = await this.bankTxRepo.find({ where: { id: MoreThan(watermark.lastProcessedId), created: LessThan(Util.minutesBefore(5)) }, - relations: { buyCrypto: true }, + relations: { buyCrypto: true, buyCryptoChargeback: true }, order: { id: 'ASC' }, take: Config.ledger.backfillBatchSize, }); @@ -283,12 +283,15 @@ export class BankTxConsumer { // the CHF the buyCrypto-owed was opened with: §4.6 completion (amountInChf − totalFeeAmountChf), or — for a // cutover-straddling buy_crypto whose owed was opened by the cutover (§6.1 per-row marker) — the opening CHF. private async buyCryptoOwedChf(tx: BankTx): Promise { - const openingChf = await this.cutoverOwedOpeningChf(tx.buyCrypto?.id); + // a return bank_tx is linked from buy_crypto.chargebackBankTx — tx.buyCrypto is the inbound-payment + // inverse and is null on returns + const buyCrypto = tx.buyCryptoChargeback ?? tx.buyCrypto; + const openingChf = await this.cutoverOwedOpeningChf(buyCrypto?.id); if (openingChf != null) return openingChf; // cutover-straddling: debit the exact opening CHF anchor - const amountInChf = tx.buyCrypto?.amountInChf; + const amountInChf = buyCrypto?.amountInChf; if (amountInChf == null) throw new Error(`bank_tx ${tx.id} BUY_CRYPTO_RETURN without buyCrypto.amountInChf`); - return Util.round(amountInChf - (tx.buyCrypto?.totalFeeAmountChf ?? 0), 2); // completion CHF (additive null-strategy) + return Util.round(amountInChf - (buyCrypto?.totalFeeAmountChf ?? 0), 2); // completion CHF (additive null-strategy) } // looks up the cutover per-row owed-opening leg CHF (§6.1 marker `${snapshotLogId}:buy_crypto-owed:${id}`); the