From 4458edb0d1b24d4d571f856a43e0f92f038b8726 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 8 Sep 2026 18:30:16 +0200 Subject: [PATCH] Prune stale unconfirmed invoice payments during wallet polling. Refactor processInvoice into upsert and prune helpers so absent 0-conf payments are removed when the wallet no longer reports them. --- .../services/InvoicePaymentService.spec.ts | 55 +++++++++++- .../payment/services/InvoicePaymentService.ts | 86 ++++++++++++------- 2 files changed, 109 insertions(+), 32 deletions(-) diff --git a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts index fb31435..c2d1bb9 100644 --- a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts +++ b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts @@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => { }; let paymentRepo: { update: jest.Mock; + delete: jest.Mock; createQueryBuilder: jest.Mock; }; let insertQueryBuilder: { @@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => { paymentRepo = { update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder) }; @@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => { expect(paymentRepo.update).not.toHaveBeenCalled(); }); - it('does nothing when there are no transfers to process', async () => { + it('does not mutate payments when there are no transfers and no existing payments', async () => { transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice()); await service.processInvoice('invoice-1', []); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled(); + expect(paymentRepo.delete).not.toHaveBeenCalled(); + }); + + it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( + buildBtcInvoice({ + payments: [ + { + id: 'payment-ghost', + txHash: 'ghost', + amountAtomic: '50000', + confirmations: 0 + } as InvoicePayment + ] + }) + ); + + await service.processInvoice('invoice-btc-1', []); + + expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost'); + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(paymentRepo.update).not.toHaveBeenCalled(); }); it('skips transfers below the configured minimum', async () => { @@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => { confirmations: 1 }); }); + + it('removes unconfirmed payments that are no longer reported', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( + buildBtcInvoice({ + payments: [ + { + id: 'payment-original', + txHash: 'original', + amountAtomic: '50000', + confirmations: 0 + } as InvoicePayment, + { + id: 'payment-replacement', + txHash: 'replacement', + amountAtomic: '50000', + confirmations: 3 + } as InvoicePayment + ] + }) + ); + + await service.processInvoice('invoice-btc-1', [ + buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 }) + ]); + + expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original'); + expect(paymentRepo.update).not.toHaveBeenCalled(); + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); }); }); diff --git a/backend/src/modules/payment/services/InvoicePaymentService.ts b/backend/src/modules/payment/services/InvoicePaymentService.ts index 1d11f83..f307480 100644 --- a/backend/src/modules/payment/services/InvoicePaymentService.ts +++ b/backend/src/modules/payment/services/InvoicePaymentService.ts @@ -135,8 +135,6 @@ export class InvoicePaymentService { } private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise { - const { minByMethod } = this.configService.get('invoice') as Config['invoice']; - await this.dataSource.transaction(async manager => { const invoiceRepo = manager.getRepository(Invoice); const paymentRepo = manager.getRepository(InvoicePayment); @@ -152,36 +150,62 @@ export class InvoicePaymentService { return; } - const minIncomingAtomic = minByMethod[invoice.paymentMethod]; - const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment])); + await this.upsertIncomingPayments(paymentRepo, invoice, transfers); - for (const transfer of transfers) { - const existing = knownByTxHash.get(transfer.txHash); - - if (existing) { - if (existing.confirmations !== transfer.confirmations) { - await paymentRepo.update(existing.id, { confirmations: transfer.confirmations }); - } - - continue; - } - - if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) { - continue; - } - - await paymentRepo - .createQueryBuilder() - .insert() - .values({ - invoice: { id: invoiceId }, - txHash: transfer.txHash, - amountAtomic: transfer.amountAtomic, - confirmations: transfer.confirmations - }) - .orIgnore() - .execute(); - } + await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers); }); } + + private async upsertIncomingPayments( + paymentRepo: Repository, + invoice: Invoice, + transfers: InvoiceIncomingTransfer[] + ): Promise { + const { minByMethod } = this.configService.get('invoice') as Config['invoice']; + + const minIncomingAtomic = minByMethod[invoice.paymentMethod]; + const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment])); + + for (const transfer of transfers) { + const existing = knownByTxHash.get(transfer.txHash); + + if (existing) { + if (existing.confirmations !== transfer.confirmations) { + await paymentRepo.update(existing.id, { confirmations: transfer.confirmations }); + } + + continue; + } + + if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) { + continue; + } + + await paymentRepo + .createQueryBuilder() + .insert() + .values({ + invoice: { id: invoice.id }, + txHash: transfer.txHash, + amountAtomic: transfer.amountAtomic, + confirmations: transfer.confirmations + }) + .orIgnore() + .execute(); + } + } + + private async pruneAbsentUnconfirmedPayments( + paymentRepo: Repository, + invoice: Invoice, + transfers: InvoiceIncomingTransfer[] + ): Promise { + const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash)); + + for (const payment of invoice.payments ?? []) { + if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) { + await paymentRepo.delete(payment.id); + } + } + } }