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
179 changes: 179 additions & 0 deletions src/subdomains/supporting/log/__tests__/log-job.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1489,6 +1489,185 @@ describe('LogJobService', () => {
expect(result).toEqual([senderNoMatch]);
});

// --- getUnmatchedSenders pass-2: receivers with unconsumed (mismatched) references ---

it('should retire a bank sender against a Scrypt deposit whose txId does not match the payout ref (prod phantom pending)', () => {
// Production: Bank->Scrypt sender reference "82101" never equals the deposit txId
// (e.g. "2bee731f-cf1f-40f7-ad5e-ffccd22406e2"). Pass 1 finds nothing; previously pass 2
// ignored any receiver that still carried a reference, leaving a phantom pending amount.
// With the fix, unconsumed receivers participate in amount+date matching.
const senderTx = [
createCustomBankTx({
id: 1,
created: Util.hoursBefore(24),
valueDate: Util.hoursBefore(24),
instructedAmount: 80000,
instructedCurrency: 'EUR',
remittanceInfo: 'DFX Payout 82101',
}),
];
const receiverTx = [
createCustomExchangeTx({
id: 1,
created: Util.hoursBefore(20),
type: ExchangeTxType.DEPOSIT,
status: 'ok',
currency: 'EUR',
amount: 80000,
txId: '2bee731f-cf1f-40f7-ad5e-ffccd22406e2',
}),
];

expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual([]);
});

it('should not double-consume a pass-1-matched receiver for a second sender via amount+date', () => {
// S1 retires E1 in pass 1 by reference. S2 has a near-identical amount/date to E1 but no
// matching reference — E1's ref was consumed in pass 1, so S2 must stay unmatched.
const sender1 = createCustomBankTx({
id: 1,
created: Util.hoursBefore(48),
valueDate: Util.hoursBefore(48),
instructedAmount: 50000,
instructedCurrency: 'EUR',
remittanceInfo: 'DFX Payout 90111',
});
const sender2 = createCustomBankTx({
id: 2,
created: Util.hoursBefore(36),
valueDate: Util.hoursBefore(36),
instructedAmount: 50000,
instructedCurrency: 'EUR',
remittanceInfo: 'manual no matching ref',
});
const receiver1 = createCustomExchangeTx({
id: 1,
created: Util.hoursBefore(40),
amount: 50000,
currency: 'EUR',
txId: 'DEPOSIT-90111',
});

expect(service.getUnmatchedSenders([sender1, sender2], [receiver1])).toEqual([sender2]);
});

it('should not false-match a referenced-but-unrelated receiver outside the amount tolerance', () => {
// Receiver carries a reference that matches no sender (pass 1 misses). Amount is far outside
// the 1%/min-1.0 tolerance, so the expanded pass 2 must not invent a match either.
const senderTx = [
createCustomBankTx({
id: 1,
created: Util.hoursBefore(24),
valueDate: Util.hoursBefore(24),
instructedAmount: 30000,
instructedCurrency: 'EUR',
remittanceInfo: 'DFX Payout 82200',
}),
];
const receiverTx = [
createCustomExchangeTx({
id: 1,
created: Util.hoursBefore(20),
type: ExchangeTxType.DEPOSIT,
status: 'ok',
currency: 'EUR',
amount: 35000,
txId: 'pmt_unrelated_deposit_ref',
}),
];

expect(service.getUnmatchedSenders(senderTx, receiverTx)).toEqual(senderTx);
});

it('should not let a receiver whose own sender aged out of the 7-day window retire an unrelated recent sender via amount+date', () => {
// senderOld is 8 days old and falls out of recentSenders, but its ref "83001" still matches
// receiver's ref from DEPOSIT-83001. receiver must count as consumed (consumedRefs built over
// full senderTx, not only recentSenders). Otherwise receiver stays available and would falsely
// retire senderNew by amount+date (same 30000 EUR; dateDiff between daysBefore(6) and
// daysBefore(1) is well under DATE_WINDOW_MS).
const senderOld = createCustomBankTx({
id: 1,
created: Util.daysBefore(8),
valueDate: Util.daysBefore(8),
remittanceInfo: 'DFX Payout 83001',
instructedAmount: 30000,
instructedCurrency: 'EUR',
});
const receiver = createCustomExchangeTx({
id: 1,
created: Util.daysBefore(6),
txId: 'DEPOSIT-83001',
amount: 30000,
currency: 'EUR',
});
const senderNew = createCustomBankTx({
id: 2,
created: Util.daysBefore(1),
valueDate: Util.daysBefore(1),
instructedAmount: 30000,
instructedCurrency: 'EUR',
remittanceInfo: 'DFX Payout 83999',
});

expect(service.getUnmatchedSenders([senderOld, senderNew], [receiver])).toEqual([senderNew]);
});

it('should not match a referenced-but-unconsumed receiver outside the 7-day date window even when amount and currency match', () => {
// The receiver carries a reference ("pmt_far_outside_window") that matches no sender, so it
// belongs to the group of receivers this PR newly admits into the amount+date fallback
// (previously only ref-less receivers were admitted). Even so, DATE_WINDOW_MS must still
// apply independently of that admission: same amount/currency is not enough when the
// receiver is more than 7 days away from the sender.
// Guard test, not a delta test: this is also green before the fix, because on that code
// path the referenced receiver was already excluded from the candidate set before the
// date check ever ran.
const sender = createCustomBankTx({
id: 1,
created: Util.daysBefore(2),
valueDate: Util.daysBefore(2),
instructedAmount: 15000,
instructedCurrency: 'EUR',
remittanceInfo: 'DFX Payout 84001',
});
const receiver = createCustomExchangeTx({
id: 1,
created: Util.daysBefore(10),
amount: 15000,
currency: 'EUR',
txId: 'pmt_far_outside_window',
});

expect(service.getUnmatchedSenders([sender], [receiver])).toEqual([sender]);
});

it('should retire an ExchangeTx withdrawal sender against a BankTx receiver by amount+date (fromScrypt direction, ref-less sender)', () => {
// fromScrypt (Scrypt withdrawal -> bank receipt): Scrypt withdrawals real-world have no txId
// (verified in prod). Pass 1 cannot match. The receiver intentionally carries a set reference
// that matches no sender (no trailing 4+ digit payout id, full normalized ref unmatched).
// Without the fix (filter `!this.getTxReference(r)` instead of `!ref || !consumedRefs.has(ref)`),
// this receiver would be excluded from the amount/date fallback and the sender would stay unmatched.
// With the fix, the referenced-but-unconsumed receiver participates and retires the sender.
const sender = createCustomExchangeTx({
id: 1,
created: Util.hoursBefore(24),
externalCreated: Util.hoursBefore(24),
type: ExchangeTxType.WITHDRAWAL,
amount: 12000,
currency: 'CHF',
txId: undefined,
});
const receiver = createCustomBankTx({
id: 1,
created: Util.hoursBefore(20),
valueDate: Util.hoursBefore(20),
instructedAmount: 12000,
instructedCurrency: 'CHF',
remittanceInfo: 'SCRYPT PAYOUT 12000 CHF',
});

expect(service.getUnmatchedSenders([sender], [receiver])).toEqual([]);
});

// --- settlement-anchored buy_fiat liability (FinanceLog) ---

// Yapeal CHF payout-bank asset: dexName = currency, bank = settling bank.
Expand Down
22 changes: 19 additions & 3 deletions src/subdomains/supporting/log/log-job.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,17 +1338,33 @@ export class LogJobService {
if (ref) receiverRefs.add(ref);
}

// A receiver counts as consumed as soon as its reference matches ANY known sender, including
// senders that already dropped out of the 7-day reporting window. Deriving this from the recent
// senders alone would let a receiver whose own sender has aged out re-enter the amount+date
// fallback below and retire an unrelated sender whose money is still genuinely in transit.
const consumedRefs = new Set<string>();
for (const sender of senderTx) {
const ref = this.getTxReference(sender);
if (ref && receiverRefs.has(ref)) consumedRefs.add(ref);
}

const unmatchedByRef = recentSenders.filter((s) => {
const ref = this.getTxReference(s);
return !ref || !receiverRefs.has(ref);
});

// Pass 2 — amount+date fallback for receivers without a usable reference only
// (e.g. already-arrived Scrypt EUR deposits with empty txId).
// Pass 2 — amount+date fallback for receivers that are not yet consumed. A receiver counts
// as consumed as soon as its reference matches ANY known sender in the full senderTx array —
// regardless of whether that sender is still inside the 7-day recency window and regardless
// of whether it was the sender actually retired by pass 1. Only truly unconsumed receivers
// (ref-less OR referenced-but-matching-no-sender) take part in the fallback below.
// Date window: 7 days in ms — mirrors the existing sender recency window.
const DATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;

const availableReceivers = receiverTx.filter((r) => !this.getTxReference(r));
const availableReceivers = receiverTx.filter((r) => {
const ref = this.getTxReference(r);
return !ref || !consumedRefs.has(ref);
});
if (!unmatchedByRef.length || !availableReceivers.length) return unmatchedByRef;

// Maximum-cardinality bipartite matching (Kuhn's augmenting-path algorithm).
Expand Down
Loading