From c2e3e5690af8e74177beddd15391fc66e191d49a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:49:34 +0200 Subject: [PATCH] fix(sell): keep buy-fiat payout liability until bank booking The Yapeal shortcut in pendingOutputAmount dropped the customer liability from the financial log as soon as the payment was transmitted. The bank only debits the account when it books the payment, which can be days later for weekend/cut-off batches. During that window the total balance showed phantom equity (verified case: transmitted Friday 14:32, booked Sunday 23:00, +14'980 CHF overstated for 2.3 days). A transmitted payment can also still fail, which would have removed the liability permanently. The liability now stays counted until the entity completes, i.e. the booked bank transaction is matched. Booking import (webhook) and the complete cron both run within 1-2 minutes, so the remaining window matches the general sync noise floor. --- .../process/__tests__/buy-fiat.entity.spec.ts | 58 +++++++++++++++++++ .../sell-crypto/process/buy-fiat.entity.ts | 5 +- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts new file mode 100644 index 0000000000..954239b405 --- /dev/null +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.entity.spec.ts @@ -0,0 +1,58 @@ +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { createCustomFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; +import { createCustomSell } from 'src/subdomains/core/sell-crypto/route/__mocks__/sell.entity.mock'; +import { createCustomBank, olkyEUR, yapealCHF } from 'src/subdomains/supporting/bank/bank/__mocks__/bank.entity.mock'; +import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; +import { createCustomFiatOutput } from 'src/subdomains/supporting/fiat-output/__mocks__/fiat-output.entity.mock'; +import { createCustomBuyFiat } from '../__mocks__/buy-fiat.entity.mock'; + +describe('BuyFiat', () => { + describe('pendingOutputAmount', () => { + const chfYapealAsset = createCustomAsset({ name: 'CHF', dexName: 'CHF', bank: yapealCHF }); + const eurOlkyAsset = createCustomAsset({ name: 'EUR', dexName: 'EUR', bank: olkyEUR }); + + it('counts the output as liability while transmitted but not yet completed (Yapeal)', () => { + const buyFiat = createCustomBuyFiat({ + outputAmount: 14980.12, + sell: createCustomSell({ fiat: createCustomFiat({ name: 'CHF' }) }), + fiatOutput: createCustomFiatOutput({ + bank: createCustomBank({ name: IbanBankName.YAPEAL, currency: 'CHF' }), + isTransmittedDate: new Date(), + }), + }); + + expect(buyFiat.pendingOutputAmount(chfYapealAsset)).toEqual(14980.12); + }); + + it('counts the output under the payout bank asset only', () => { + const buyFiat = createCustomBuyFiat({ + outputAmount: 100, + sell: createCustomSell({ fiat: createCustomFiat({ name: 'EUR' }) }), + fiatOutput: createCustomFiatOutput({ bank: olkyEUR, isTransmittedDate: new Date() }), + }); + + expect(buyFiat.pendingOutputAmount(eurOlkyAsset)).toEqual(100); + expect(buyFiat.pendingOutputAmount(chfYapealAsset)).toEqual(0); + }); + + it('defaults to Yapeal when no fiat output exists yet', () => { + const buyFiat = createCustomBuyFiat({ + outputAmount: 50, + sell: createCustomSell({ fiat: createCustomFiat({ name: 'CHF' }) }), + fiatOutput: undefined, + }); + + expect(buyFiat.pendingOutputAmount(chfYapealAsset)).toEqual(50); + expect(buyFiat.pendingOutputAmount(eurOlkyAsset)).toEqual(0); + }); + + it('returns 0 before the output amount is set', () => { + const buyFiat = createCustomBuyFiat({ + outputAmount: undefined, + sell: createCustomSell({ fiat: createCustomFiat({ name: 'CHF' }) }), + }); + + expect(buyFiat.pendingOutputAmount(chfYapealAsset)).toEqual(0); + }); + }); +}); diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts index ecbbcc3005..73ce177b7f 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts @@ -557,8 +557,9 @@ export class BuyFiat extends IEntity { pendingOutputAmount(asset: Asset): number { const payoutBankName = this.fiatOutput?.bank?.name ?? IbanBankName.YAPEAL; - if (payoutBankName === IbanBankName.YAPEAL && this.fiatOutput?.isTransmittedDate) return 0; - + // the output stays a liability until the entity completes (outgoing bankTx booked): the bank + // balance only drops when the bank books the debit, which can be days after transmission + // (weekend/cut-off batches) - and a transmitted payment may still fail return this.outputAmount && asset.dexName === this.sell.fiat.name && asset.bank?.name === payoutBankName ? this.outputAmount : 0;