From 978a6dde7beba2bac38141edbe5a51449a451708 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:10:50 +0100 Subject: [PATCH 1/4] fix: filter third-party transactions and fix receiver trimming in pending balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues caused Scrypt EUR pending balances to be invisible: 1. Third-party EUR deposits (txId=null) inflated the receiver sum 2. Third-party bank_tx (instructedCurrency=null) inflated the sender count 3. filterSenderPendingList did not trim orphaned receivers when sender and receiver counts were equal — old receivers whose senders aged out of the 21-day window absorbed new unmatched senders, netting to 0 Fix: filter foreign transactions from both lists, and when counts are equal, detect senders newer than all receivers (definitely unmatched) and trim corresponding orphaned old receivers. --- src/subdomains/supporting/log/log-job.service.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 149962fce9..6ae3eef8b5 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -395,7 +395,10 @@ export class LogJobService { // EUR: Bank -> Scrypt const eurSenderScryptBankTx = recentScryptBankTx.filter( - (b) => eurBankIbans.includes(b.accountIban) && b.creditDebitIndicator === BankTxIndicator.DEBIT, + (b) => + eurBankIbans.includes(b.accountIban) && + b.creditDebitIndicator === BankTxIndicator.DEBIT && + b.instructedCurrency, ); const eurReceiverScryptExchangeTx = recentScryptExchangeTx.filter( (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'EUR' && k.txId, @@ -1140,6 +1143,16 @@ export class LogJobService { : filtered21ReceiverTx.length; filtered21ReceiverTx = filtered21ReceiverTx.slice(filtered21ReceiverTx.length - senderTxLength); + } else if (filtered21ReceiverTx.length === filtered21SenderTx.length && filtered21SenderTx.length > 0) { + const newestReceiverDate = filtered21ReceiverTx.reduce( + (max, r) => (r.created > max ? r.created : max), + filtered21ReceiverTx[0].created, + ); + const unmatchedCount = filtered21SenderTx.filter((s) => s.created > newestReceiverDate).length; + + if (unmatchedCount > 0) { + filtered21ReceiverTx = filtered21ReceiverTx.slice(unmatchedCount); + } } return { From 52e016b038ac1c4c7dee7a7e15f1d55a6261007c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:08:57 +0100 Subject: [PATCH 2/4] fix: replace sum-based Scrypt pending balance with 1:1 transaction matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach used filterSenderPendingList + sum difference to calculate pending Scrypt balances. This was fundamentally flawed: old receivers whose senders aged out of the 21-day window created false negatives, making pending transfers invisible. New approach: getUnmatchedSenders() matches each sender with a receiver 1:1 by amount and chronological order (same criteria as findSenderReceiverPair). Only truly unmatched senders contribute to the pending balance — no receiver subtraction needed. Applied to all 4 Scrypt directions (CHF/EUR × Bank→Scrypt/Scrypt→Bank), both filtered and unfiltered paths. Kraken paths unchanged. --- .../supporting/log/log-job.service.ts | 166 ++++++++---------- 1 file changed, 72 insertions(+), 94 deletions(-) diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 6ae3eef8b5..88539fc549 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -420,25 +420,13 @@ export class LogJobService { (b) => eurBankIbans.includes(b.accountIban) && b.creditDebitIndicator === BankTxIndicator.CREDIT, ); - // sender and receiver data for Bank -> Scrypt - const { sender: recentChfYapealScryptTx, receiver: recentChfBankTxScrypt } = this.filterSenderPendingList( - chfSenderScryptBankTx, - chfReceiverScryptExchangeTx, - ); - const { sender: recentEurBankToScryptTx, receiver: recentEurBankTxScrypt } = this.filterSenderPendingList( - eurSenderScryptBankTx, - eurReceiverScryptExchangeTx, - ); + // Bank -> Scrypt: 1:1 matching, unmatched senders only + const recentChfYapealScryptTx = this.getUnmatchedSenders(chfSenderScryptBankTx, chfReceiverScryptExchangeTx); + const recentEurBankToScryptTx = this.getUnmatchedSenders(eurSenderScryptBankTx, eurReceiverScryptExchangeTx); - // sender and receiver data for Scrypt -> Bank - const { sender: recentChfScryptYapealTx, receiver: recentChfScryptBankTx } = this.filterSenderPendingList( - chfSenderScryptExchangeTx, - chfReceiverScryptBankTx, - ); - const { sender: recentEurScryptToBankTx, receiver: recentEurScryptBankTx } = this.filterSenderPendingList( - eurSenderScryptExchangeTx, - eurReceiverScryptBankTx, - ); + // Scrypt -> Bank: 1:1 matching, unmatched senders only + const recentChfScryptYapealTx = this.getUnmatchedSenders(chfSenderScryptExchangeTx, chfReceiverScryptBankTx); + const recentEurScryptToBankTx = this.getUnmatchedSenders(eurSenderScryptExchangeTx, eurReceiverScryptBankTx); // assetLog return assets.reduce((prev, curr) => { @@ -596,55 +584,38 @@ export class LogJobService { [...recentChfYapealScryptTx, ...recentEurBankToScryptTx], BankTxType.SCRYPT, ); - const pendingChfBankScryptMinusAmount = this.getPendingBankAmount( - [curr], - recentChfBankTxScrypt, - ExchangeTxType.DEPOSIT, - yapealChfBank.iban, - ); - const pendingEurBankScryptMinusAmount = isScryptEurAsset - ? this.getPendingBankAmount([curr], recentEurBankTxScrypt, ExchangeTxType.DEPOSIT) - : isEurBankAsset - ? 0 - : this.getPendingBankAmount([curr], recentEurBankTxScrypt, ExchangeTxType.DEPOSIT, yapealEurBank.iban); + // With 1:1 matching, matched receivers are already excluded from sender lists — no minus needed + const pendingChfBankScryptMinusAmount = 0; + const pendingEurBankScryptMinusAmount = 0; - // unfiltered lists + // unfiltered lists (1:1 matching) const pendingBankScryptPlusAmountUnfiltered = isScryptEurAsset ? this.getPendingBankAmount( eurBankAssets, - eurSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.bankTxId), + this.getUnmatchedSenders( + eurSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.bankTxId), + eurReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.exchangeTxId), + ), BankTxType.SCRYPT, ) : isEurBankAsset ? 0 : this.getPendingBankAmount( [curr], - [ - ...chfSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.bankTxId), - ...eurSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.bankTxId), - ], + this.getUnmatchedSenders( + [ + ...chfSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.bankTxId), + ...eurSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.bankTxId), + ], + [ + ...chfReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.exchangeTxId), + ...eurReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.exchangeTxId), + ], + ), BankTxType.SCRYPT, ); - const pendingChfBankScryptMinusAmountUnfiltered = this.getPendingBankAmount( - [curr], - chfReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.exchangeTxId), - ExchangeTxType.DEPOSIT, - yapealChfBank.iban, - ); - const pendingEurBankScryptMinusAmountUnfiltered = isScryptEurAsset - ? this.getPendingBankAmount( - [curr], - eurReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.exchangeTxId), - ExchangeTxType.DEPOSIT, - ) - : isEurBankAsset - ? 0 - : this.getPendingBankAmount( - [curr], - eurReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.exchangeTxId), - ExchangeTxType.DEPOSIT, - yapealEurBank.iban, - ); + const pendingChfBankScryptMinusAmountUnfiltered = 0; + const pendingEurBankScryptMinusAmountUnfiltered = 0; // Scrypt to Bank // @@ -660,17 +631,16 @@ export class LogJobService { : isEurBankAsset ? 0 : this.getPendingBankAmount([curr], recentEurScryptToBankTx, ExchangeTxType.WITHDRAWAL, yapealEurBank.iban); - const pendingScryptBankMinusAmount = isScryptEurAsset - ? this.getPendingBankAmount(eurBankAssets, recentEurScryptBankTx, BankTxType.SCRYPT) - : isEurBankAsset - ? 0 - : this.getPendingBankAmount([curr], [...recentChfScryptBankTx, ...recentEurScryptBankTx], BankTxType.SCRYPT); + const pendingScryptBankMinusAmount = 0; - // unfiltered lists + // unfiltered lists (1:1 matching) const pendingChfScryptBankPlusAmountUnfiltered = financeLogPairIds?.fromScrypt?.chf?.exchangeTxId ? this.getPendingBankAmount( [curr], - chfSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.chf.exchangeTxId), + this.getUnmatchedSenders( + chfSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.chf.exchangeTxId), + chfReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.chf.bankTxId), + ), ExchangeTxType.WITHDRAWAL, yapealChfBank.iban, ) @@ -679,7 +649,10 @@ export class LogJobService { ? financeLogPairIds?.fromScrypt?.eur?.exchangeTxId ? this.getPendingBankAmount( [curr], - eurSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.exchangeTxId), + this.getUnmatchedSenders( + eurSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.exchangeTxId), + eurReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.bankTxId), + ), ExchangeTxType.WITHDRAWAL, ) : 0 @@ -688,31 +661,15 @@ export class LogJobService { : financeLogPairIds?.fromScrypt?.eur?.exchangeTxId ? this.getPendingBankAmount( [curr], - eurSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.exchangeTxId), + this.getUnmatchedSenders( + eurSenderScryptExchangeTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.exchangeTxId), + eurReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.bankTxId), + ), ExchangeTxType.WITHDRAWAL, yapealEurBank.iban, ) : 0; - const pendingScryptBankMinusAmountUnfiltered = isScryptEurAsset - ? financeLogPairIds?.fromScrypt?.eur?.bankTxId - ? this.getPendingBankAmount( - eurBankAssets, - eurReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.bankTxId), - BankTxType.SCRYPT, - ) - : 0 - : isEurBankAsset - ? 0 - : financeLogPairIds?.fromScrypt?.chf?.bankTxId || financeLogPairIds?.fromScrypt?.eur?.bankTxId - ? this.getPendingBankAmount( - [curr], - [ - ...chfReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.chf.bankTxId), - ...eurReceiverScryptBankTx.filter((t) => t.id >= financeLogPairIds.fromScrypt.eur.bankTxId), - ], - BankTxType.SCRYPT, - ) - : 0; + const pendingScryptBankMinusAmountUnfiltered = 0; const fromKrakenUnfiltered = pendingChfKrakenYapealPlusAmountUnfiltered + @@ -1100,6 +1057,37 @@ export class LogJobService { ); } + public getUnmatchedSenders( + senderTx: (BankTx | ExchangeTx)[], + receiverTx: (BankTx | ExchangeTx)[], + ): (BankTx | ExchangeTx)[] { + if (!senderTx.length || !receiverTx.length) return [...senderTx]; + + const sortedSenders = [...senderTx].sort((a, b) => a.id - b.id); + const sortedReceivers = [...receiverTx].sort((a, b) => a.id - b.id); + const matchedSenderIds = new Set(); + + for (const receiver of sortedReceivers) { + const receiverAmount = receiver instanceof BankTx ? receiver.instructedAmount : receiver.amount; + + const match = sortedSenders.find((s) => { + if (matchedSenderIds.has(s.id)) return false; + + const senderAmount = s instanceof BankTx ? s.instructedAmount : s.amount; + const senderDate = s instanceof BankTx ? s.valueDate : s.created; + const daysDiff = Math.abs(Util.daysDiff(senderDate, receiver.created)); + + return s instanceof BankTx + ? senderAmount === receiverAmount && daysDiff <= 5 && receiver.created > s.created + : senderAmount === receiverAmount && receiver.created > s.created; + }); + + if (match) matchedSenderIds.add(match.id); + } + + return sortedSenders.filter((s) => !matchedSenderIds.has(s.id)); + } + public filterSenderPendingList( senderTx: (BankTx | ExchangeTx)[], receiverTx: (BankTx | ExchangeTx)[] | undefined, @@ -1143,16 +1131,6 @@ export class LogJobService { : filtered21ReceiverTx.length; filtered21ReceiverTx = filtered21ReceiverTx.slice(filtered21ReceiverTx.length - senderTxLength); - } else if (filtered21ReceiverTx.length === filtered21SenderTx.length && filtered21SenderTx.length > 0) { - const newestReceiverDate = filtered21ReceiverTx.reduce( - (max, r) => (r.created > max ? r.created : max), - filtered21ReceiverTx[0].created, - ); - const unmatchedCount = filtered21SenderTx.filter((s) => s.created > newestReceiverDate).length; - - if (unmatchedCount > 0) { - filtered21ReceiverTx = filtered21ReceiverTx.slice(unmatchedCount); - } } return { From a6d9533040beba70ac9be5cef4e33831095f8364 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:22:15 +0100 Subject: [PATCH 3/4] fix: add 21-day filter on senders in getUnmatchedSenders Filter senders to the last 21 days (only recent transfers are pending) but keep all receivers available for matching (prevents orphaned receivers from aged-out senders). --- src/subdomains/supporting/log/log-job.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 88539fc549..57177f2fd2 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -1061,9 +1061,12 @@ export class LogJobService { senderTx: (BankTx | ExchangeTx)[], receiverTx: (BankTx | ExchangeTx)[], ): (BankTx | ExchangeTx)[] { - if (!senderTx.length || !receiverTx.length) return [...senderTx]; + const before21Days = Util.daysBefore(21); + const recentSenders = senderTx.filter((s) => s.created > before21Days); + + if (!recentSenders.length || !receiverTx.length) return [...recentSenders]; - const sortedSenders = [...senderTx].sort((a, b) => a.id - b.id); + const sortedSenders = [...recentSenders].sort((a, b) => a.id - b.id); const sortedReceivers = [...receiverTx].sort((a, b) => a.id - b.id); const matchedSenderIds = new Set(); From a849c42a9c0897b78b1a0ceb1bad75b62b4ec258 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:27:15 +0100 Subject: [PATCH 4/4] fix: match CHF and EUR separately in unfiltered non-ScryptEur path Combined CHF+EUR matching could cause cross-currency matches (e.g. CHF sender 30k matching EUR receiver 30k). Match each currency separately, then combine results. --- .../supporting/log/log-job.service.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 57177f2fd2..e24c2be8b8 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -602,16 +602,16 @@ export class LogJobService { ? 0 : this.getPendingBankAmount( [curr], - this.getUnmatchedSenders( - [ - ...chfSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.bankTxId), - ...eurSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.bankTxId), - ], - [ - ...chfReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.exchangeTxId), - ...eurReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.exchangeTxId), - ], - ), + [ + ...this.getUnmatchedSenders( + chfSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.bankTxId), + chfReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.chf?.exchangeTxId), + ), + ...this.getUnmatchedSenders( + eurSenderScryptBankTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.bankTxId), + eurReceiverScryptExchangeTx.filter((t) => t.id >= financeLogPairIds?.toScrypt?.eur?.exchangeTxId), + ), + ], BankTxType.SCRYPT, ); const pendingChfBankScryptMinusAmountUnfiltered = 0;