From d1aecaa1804c0039744eb79a4032d1ed923e0b85 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 27 May 2026 22:08:38 +0200 Subject: [PATCH] fix(crm): thread trx through sequence-claim sites to unblock SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback on #555: nextQuoteNumber inside createQuote's db.transaction was called without passing the outer trx, so claimNextSequence opened its own connection — Postgres tolerated this via the pool, SQLite (1-connection default) deadlocked on every quote creation. Audited the same pattern across invoiceService + contractService and found five more matching call sites: - createInvoice (single-row path after installment auto-route) - spawnInstallmentInvoices (per-sibling claim inside the loop) - createStorno - createContract - createFromQuote All now thread trx through to nextXxxNumber → claimNextSequence so the claim joins the caller's transaction on both engines. convertToInvoiceOnly's Path B (standalone-contract) is the lone remaining nextInvoiceNumber() call without trx — that path isn't wrapped in a transaction at all (separate concern: sequence-number leak on insert failure, tracked separately). --- backend/src/services/contractService.js | 8 ++++++-- backend/src/services/invoiceService.js | 10 +++++++--- backend/src/services/quoteService.js | 6 +++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 4eae5f6f..e443b4ef 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -765,7 +765,9 @@ async function createContract(payload, adminId) { const hasEventCols = await hasColumnCached('contracts', 'event_name'); return await db.transaction(async (trx) => { - const contractNumber = await nextContractNumber(); + // Pass trx so the sequence claim joins our outer transaction — + // SQLite deadlocks otherwise (1-connection default). + const contractNumber = await nextContractNumber(trx); const row = { contract_number: contractNumber, customer_account_id: payload.customerAccountId, @@ -1594,7 +1596,9 @@ async function createFromQuote(quoteId, adminId) { const hasContractEventCols = await hasColumnCached('contracts', 'event_name'); return await db.transaction(async (trx) => { - const contractNumber = await nextContractNumber(); + // Pass trx so the sequence claim joins our outer transaction — + // SQLite deadlocks otherwise (1-connection default). + const contractNumber = await nextContractNumber(trx); const contractRow = { contract_number: contractNumber, customer_account_id: quote.customer_account_id, diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index c50fe62f..ab02c247 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -812,7 +812,9 @@ async function createInvoice(payload, adminId, trx = db) { // has been ruled out. Previously this was at the top of the function // which leaked one number per multi-installment save (the spawner // claims its own numbers and never used this one). - const invoiceNumber = await nextInvoiceNumber(); + // Pass trx so the sequence claim joins our outer transaction — + // SQLite deadlocks otherwise (1-connection default). + const invoiceNumber = await nextInvoiceNumber(trx); const row = { invoice_number: invoiceNumber, customer_account_id: payload.customerAccountId, @@ -976,7 +978,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; - const invoiceNumber = await nextInvoiceNumber(); + const invoiceNumber = await nextInvoiceNumber(trx); const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10); const row = { @@ -2049,7 +2051,9 @@ async function createStorno(originalId, adminId, trx = db) { // Generate the Storno's sequence number from the same gap-free // series as regular invoices (single sequence — decision locked // with the maintainer; satisfies §14 (4) Nr. 4 UStG). - const stornoNumber = await nextInvoiceNumber(); + // Pass trx so the sequence claim joins the caller's transaction — + // SQLite deadlocks otherwise (1-connection default). + const stornoNumber = await nextInvoiceNumber(trx); const now = new Date(); const issueDate = now.toISOString().slice(0, 10); diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index b93c7528..0c993ca0 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -491,7 +491,11 @@ async function createQuote(payload, adminId) { const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); return await db.transaction(async (trx) => { - const quoteNumber = await nextQuoteNumber(); + // SQLite's 1-connection default deadlocks when claimNextSequence + // opens its own micro-transaction inside this outer one — thread + // trx so both run on the same connection. Postgres tolerates + // either form but the consistency is worth it. + const quoteNumber = await nextQuoteNumber(trx); const row = { quote_number: quoteNumber, customer_account_id: payload.customerAccountId,