From 2a584c0e885149bfd3df5befdfa8872235846a65 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 9 Jul 2026 17:08:09 +0545 Subject: [PATCH] fix(OUT-3951): default sync-log entityType when paying without a prior created log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncPaidInvoiceToXero built its success-log payload by spreading prevSyncLog without setting the NOT-NULL entityType. When no invoice.created log exists the log INSERT fails inside the transaction, rolling back the synced_invoices status update and the synced_payments write — but markInvoicePaid already ran outside the transaction. The resulting failed_syncs retry re-pays because the local payment record was rolled back, so getPaymentForInvoiceId finds nothing and markInvoicePaid runs again, duplicating the payment in Xero. Hardcode entityType like the catch block already does, and cover it with a regression test. Co-Authored-By: Claude Opus 4.8 --- .../lib/SyncedInvoices.service.ts | 1 + .../invoicePaid/noPriorCreatedLog.test.ts | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 test/integration/webhook/invoicePaid/noPriorCreatedLog.test.ts diff --git a/src/features/invoice-sync/lib/SyncedInvoices.service.ts b/src/features/invoice-sync/lib/SyncedInvoices.service.ts index 2d6d44c..6c3ae3b 100644 --- a/src/features/invoice-sync/lib/SyncedInvoices.service.ts +++ b/src/features/invoice-sync/lib/SyncedInvoices.service.ts @@ -308,6 +308,7 @@ class SyncedInvoicesService extends AuthenticatedXeroService { syncLogsService.setTx(tx) await syncLogsService.createSyncLog({ ...prevSyncLog, + entityType: SyncEntityType.INVOICE, eventType: SyncEventType.PAID, status: SyncStatus.SUCCESS, syncDate: new Date(), diff --git a/test/integration/webhook/invoicePaid/noPriorCreatedLog.test.ts b/test/integration/webhook/invoicePaid/noPriorCreatedLog.test.ts new file mode 100644 index 0000000..b426562 --- /dev/null +++ b/test/integration/webhook/invoicePaid/noPriorCreatedLog.test.ts @@ -0,0 +1,45 @@ +import { buildPaidInvoiceWebhook } from '@test/fixtures/paidInvoice.webhook' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedPayments } from '@/db/schema/syncedPayments.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.paid without a prior created log', () => { + const apis = setupWebhookTest() + + it('pays successfully even when no invoice.created log exists', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + // No seedSyncLog(): the invoice.created log is absent, so the paid log must + // default its NOT-NULL entityType rather than inherit it. Without the + // default the log INSERT rolls back the transaction after markInvoicePaid + // already ran, leaving a failed_syncs row that re-pays on retry. + + const res = await postWebhook(buildPaidInvoiceWebhook()) + expect(res.status).toBe(200) + + expect(apis.xero.markInvoicePaid).toHaveBeenCalledTimes(1) + + // The payment record is committed, so a retry short-circuits (no double pay). + expect(await db.select().from(syncedPayments)).toHaveLength(1) + + // The paid success log still writes, defaulting entityType to invoice. + const paidLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.PAID)) + expect(paidLogs).toHaveLength(1) + expect(paidLogs[0]).toMatchObject({ + status: SyncStatus.SUCCESS, + entityType: SyncEntityType.INVOICE, + }) + + // The payment succeeded, so nothing is recorded for retry. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +})