diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js new file mode 100644 index 00000000..6ecb9f9e --- /dev/null +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -0,0 +1,68 @@ +/** + * Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft}) + * must create the invoice(s) but leave scheduled_send_at NULL so the scheduler + * never auto-sends them before the workflow's review gate + explicit + * send_document. + */ +const crypto = require('crypto'); +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +jest.setTimeout(30000); + +describe('booking cutover — draft invoices on hold', () => { + let db; let cleanup; let adminId; let customerId; let quoteService; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + quoteService = require('../../src/services/quoteService'); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + async function acceptedQuote() { + const dealUuid = crypto.randomUUID(); + const [id] = await db('quotes').insert({ + quote_number: `Q-${dealUuid.slice(0, 8)}`, + customer_account_id: customerId, + status: 'accepted', + currency: 'CHF', + issue_date: '2026-01-01', + net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000, + // A non-delivery installment so the contrast (scheduled date vs null) is meaningful. + payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }), + deal_uuid: dealUuid, + created_by_admin_id: adminId, + }); + return id; + } + + it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => { + const quoteId = await acceptedQuote(); + const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true }); + expect(Array.isArray(res.invoiceIds)).toBe(true); + expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1); + + const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first(); + expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it + expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send + }); + + it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => { + const quoteId = await acceptedQuote(); + const res = await quoteService.convertToInvoiceOnly(quoteId, adminId); + const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first(); + expect(inv.status).toBe('scheduled'); + expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set + }); + + it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => { + const contractService = require('../../src/services/contractService'); + const quoteId = await acceptedQuote(); + const res = await contractService.createFromQuote(quoteId, adminId); + expect(res.contractId).toBeGreaterThanOrEqual(1); + expect(res.alreadyConverted).toBe(false); + const c = await db('contracts').where({ id: res.contractId }).first(); + expect(c).toBeTruthy(); + }); +}); diff --git a/backend/__tests__/integration/workflowRoutes.test.js b/backend/__tests__/integration/workflowRoutes.test.js index 8b283db9..2a1fb357 100644 --- a/backend/__tests__/integration/workflowRoutes.test.js +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -74,13 +74,34 @@ describe('admin workflows API', () => { test('refuses to enable a flow that uses an unimplemented action', async () => { const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({ name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false, - nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'prepare_invoice' } }], + nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'prepare_event' } }], edges: [{ from_node: 't', to_node: 'a' }], }); expect(create.status).toBe(201); const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true }); expect(res.status).toBe(409); - expect(res.body.error).toMatch(/not.*implemented|prepare_invoice/i); + expect(res.body.error).toMatch(/not.*implemented|prepare_event/i); + }); + + test('allows enabling a flow using the now-implemented booking invoice actions', async () => { + const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({ + name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false, + nodes: [ + { node_key: 't', type: 'trigger' }, + { node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } }, + { node_key: 'g', type: 'gate', config: {} }, + { node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } }, + ], + edges: [ + { from_node: 't', to_node: 'p' }, + { from_node: 'p', to_node: 'g' }, + { from_node: 'g', from_handle: 'confirm', to_node: 's' }, + ], + }); + expect(create.status).toBe(201); + const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true }); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); }); test('get one returns the graph', async () => { diff --git a/backend/src/database/db.js b/backend/src/database/db.js index e9657402..982ea4eb 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -645,8 +645,14 @@ async function ensureGlobalCategories() { } // Helper function to log activities -async function logActivity(activityType, metadata = {}, eventId = null, actor = null) { +async function logActivity(activityType, metadata = {}, eventId = null, actor = null, executor = null) { try { + // Callers issuing the log from inside a knex transaction must pass that + // trx as `executor`, otherwise the global-`db` insert tries to grab a + // second connection from the single-connection SQLite pool while the + // trx still holds it → deadlock. Defaults to the global db for the + // common after-commit / outside-trx callers. + const conn = executor || db; // actor_id is integer-typed; some legacy callers pass a hex-string // identifier (e.g. a 16-char guest fingerprint) which makes Postgres // throw "invalid input syntax for type integer" and drop the entire @@ -659,7 +665,7 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor = const actorName = actor?.name || (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null); - await db('activity_logs').insert({ + await conn('activity_logs').insert({ activity_type: activityType, actor_type: actor?.type || 'system', actor_id: actorIdInt, diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 31a959ea..88de2ad2 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -167,7 +167,10 @@ function formatNumberInTemplate(format, year, seq) { * emit `C-2026-AB12C3` after 5 retries. */ async function nextContractNumber(trx) { - const format = (await getAppSetting('crm_contracts_number_format')) || 'C-{YEAR}-{SEQ:04d}'; + // Read through `trx` when present — getAppSetting on the global db inside an + // open transaction deadlocks the single-connection SQLite pool (the booking + // flow's prepare_contract action runs createFromQuote unattended). + const format = (await getAppSetting('crm_contracts_number_format', null, trx || db)) || 'C-{YEAR}-{SEQ:04d}'; const year = new Date().getFullYear(); const seq = await claimNextSequence('contract', year, trx); return formatNumberInTemplate(format, year, seq); @@ -1661,6 +1664,11 @@ async function createFromQuote(quoteId, adminId) { const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id'); const hasContractEventCols = await hasColumnCached('contracts', 'event_name'); + // Resolve the actor BEFORE opening the transaction — adminActor reads + // admin_users via the global db, which deadlocks the single-connection + // SQLite pool if evaluated inside the trx (prepare_contract runs unattended). + const actor = await adminActor(adminId); + return await db.transaction(async (trx) => { // Pass trx so the sequence claim joins our outer transaction — // SQLite deadlocks otherwise (1-connection default). @@ -1735,9 +1743,11 @@ async function createFromQuote(quoteId, adminId) { } try { + // Pass `trx` so the audit insert rides the transaction's connection; + // the global db here deadlocks the single-connection SQLite pool. await logActivity('contract_created_from_quote', { contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number }, - null, await adminActor(adminId)); + null, actor, trx); } catch (_) { /* logging is best-effort */ } logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id }); return { contractId, alreadyConverted: false }; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 497ce2a6..426c5370 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -59,7 +59,9 @@ function formatNumberInTemplate(format, year, seq) { // admin creates and emitted a random `R-2026-AB12C3` after 5 retries, // breaking the §14 UStG single-sequence requirement. async function nextInvoiceNumber(trx) { - const format = (await getAppSetting('crm_invoices_number_format')) || 'R-{YEAR}-{SEQ:04d}'; + // Read through `trx` when present — getAppSetting on the global db inside an + // open transaction deadlocks the single-connection SQLite pool. + const format = (await getAppSetting('crm_invoices_number_format', null, trx || db)) || 'R-{YEAR}-{SEQ:04d}'; const year = new Date().getFullYear(); const seq = await claimNextSequence('invoice', year, trx); return formatNumberInTemplate(format, year, seq); @@ -998,7 +1000,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre ccPdfEmail, netDays, eventName, eventTimeStart, eventTimeEnd, paymentNetDaysTemplateId, paymentTimingTemplateId, - paymentTermSnapshot, dealUuid }) { + paymentTermSnapshot, dealUuid, hold = false }) { // Monthly-billing intercept (migration 128). Quote → invoice // conversion for a monthly-mode customer doesn't fan out N // installment invoices — the customer pays one consolidated bill @@ -1029,7 +1031,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre // absent we fall back to the crm_payment_default_net_days setting // (then 30) rather than silently using 30, matching createInvoice. const resolvedNetDays = ensureInt(netDays) - || ensureInt(await getAppSetting('crm_payment_default_net_days')) + || ensureInt(await getAppSetting('crm_payment_default_net_days', null, trx || db)) || 30; const total = installments.length; const acceptanceTime = new Date(); @@ -1076,7 +1078,11 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre // "Release for delivery" on the invoice detail page. const isDeliveryTrigger = inst.trigger === 'after_delivery'; const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; - const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; + // `hold` (workflow draft-seam): create the invoice but leave scheduled_send_at + // NULL so the scheduler never auto-sends it — a draft awaiting an explicit + // send_document after the admin's review gate. Status stays 'scheduled' so + // it's editable and sendInvoice can later issue it. + const rowScheduledSendAt = (isDeliveryTrigger || hold) ? null : scheduledSendAt; const invoiceNumber = await nextInvoiceNumber(trx); const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10); @@ -1219,8 +1225,11 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre } try { + // Pass `trx` so the audit insert rides the transaction's connection — + // logging via the global db here deadlocks the single-connection SQLite + // pool (this runs unattended from the booking flow's prepare_invoice). await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt }, - eventId, `admin:${adminId}`); + eventId, `admin:${adminId}`, trx); } catch (_) {} invoiceIds.push(invoiceId); } diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 98aefae9..0d9435ba 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -1415,12 +1415,12 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { const invoiceService = require('./invoiceService'); - return await db.transaction(async (trx) => { + const result = await db.transaction(async (trx) => { const installments = Array.isArray(paymentTermSnapshot?.installments) ? paymentTermSnapshot.installments : [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }]; - await invoiceService.scheduleInvoicesForEvent({ + const spawnResult = await invoiceService.scheduleInvoicesForEvent({ trx, // eventId omitted → invoices have source_quote_id but no event_id. eventId: null, @@ -1459,6 +1459,10 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { // Migration 140 — every spawned invoice inherits the source // quote's deal_uuid so quote + N invoices group under one deal. dealUuid: quote.deal_uuid, + // Workflow draft-seam: when called by the booking flow's prepare_invoice + // action, create the invoices on HOLD (no scheduled_send_at) so they wait + // for the explicit send_document after the review gate. + hold: options.draft === true, }); // Mark quote `converted` without a converted_event_id so the @@ -1470,14 +1474,20 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { updated_at: new Date(), }); - try { - await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: installments.length }, - null, `admin:${adminId}`); - } catch (_) {} - - logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: installments.length }); - return { installmentsCreated: installments.length }; + return { installmentsCreated: installments.length, invoiceIds: spawnResult?.invoiceIds || [] }; }); + + // Audit log AFTER commit — logActivity writes via the global `db`, which + // deadlocks the single-connection SQLite pool if issued inside the trx + // (the booking flow's prepare_invoice action runs this unattended, so a + // hang here would wedge the workflow executor, not just a request). + try { + await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated }, + null, `admin:${adminId}`); + } catch (_) {} + + logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated }); + return result; } async function convertToEvent(quoteId, adminId, options = {}) { diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 468a32bb..11ae3e7d 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -16,13 +16,13 @@ */ const registry = require('./registry'); +// Document actions still awaiting wiring (the enable-guard refuses flows that use +// any of these). prepare_contract / prepare_invoice / send_document are now +// implemented below (draft-seam booking cutover), so they're off this list. const DOCUMENT_ACTIONS = [ 'prepare_quote', - 'prepare_contract', 'prepare_event', 'prepare_gallery', - 'prepare_invoice', - 'send_document', 'reserve_date', ]; @@ -208,6 +208,89 @@ registry.registerAction('webhook', async (ctx) => { : { skipped: true, reason: res.reason }; }); +// --- Booking document actions (draft-seam cutover) --- +// +// The booking flows trigger on quote.accepted, so the run entity is the QUOTE. +// prepare_* create DRAFT documents (idempotent, reusing the proven converters) +// and stash the created ids in the run context; send_document then dispatches +// the matching draft. Flows run system-side, so the actor is resolved from the +// quote's creator (else the workflow's creator, else the first admin). + +async function resolveActor(ctx) { + try { + if (ctx.run.entity_type === 'quote' && ctx.run.entity_id) { + const q = await ctx.db('quotes').where({ id: ctx.run.entity_id }).first('created_by_admin_id'); + if (q?.created_by_admin_id) return q.created_by_admin_id; + } + const wf = await ctx.db('workflows').where({ id: ctx.run.workflow_id }).first('created_by'); + if (wf?.created_by) return wf.created_by; + const admin = await ctx.db('admin_users').orderBy('id', 'asc').first('id'); + return admin?.id || null; + } catch (_) { return null; } +} + +// Prepare a DRAFT contract from the accepted quote (idempotent via the quote's +// converted_contract_id back-pointer). +registry.registerAction('prepare_contract', async (ctx) => { + const quoteId = ctx.run.entity_id; + if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_contract needs a quote entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_contract', quoteId }; + const adminId = await resolveActor(ctx); + const res = await require('../contractService').createFromQuote(quoteId, adminId); + ctx.vars.preparedContractId = res.contractId; + return { contract_prepared: res.contractId, alreadyConverted: !!res.alreadyConverted }; +}); + +// Prepare DRAFT invoice(s) from the accepted quote — created on HOLD (no +// scheduled_send_at) so the scheduler won't auto-send before the review gate. +registry.registerAction('prepare_invoice', async (ctx) => { + const quoteId = ctx.run.entity_id; + if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_invoice needs a quote entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_invoice', quoteId }; + if (Array.isArray(ctx.vars.preparedInvoiceIds) && ctx.vars.preparedInvoiceIds.length) { + return { already: true, invoiceIds: ctx.vars.preparedInvoiceIds }; + } + const adminId = await resolveActor(ctx); + let invoiceIds; + try { + const res = await require('../quoteService').convertToInvoiceOnly(quoteId, adminId, { draft: true }); + invoiceIds = res.invoiceIds || []; + } catch (err) { + // Crash-recovery re-run: the quote may already be 'converted' (convert + // throws). Recover the drafts by the quote's deal_uuid so we don't lose them. + const quote = await ctx.db('quotes').where({ id: quoteId }).first('deal_uuid'); + invoiceIds = quote?.deal_uuid + ? (await ctx.db('invoices').where({ deal_uuid: quote.deal_uuid }).select('id')).map((r) => r.id) + : []; + if (!invoiceIds.length) throw err; + } + ctx.vars.preparedInvoiceIds = invoiceIds; + return { invoice_prepared: invoiceIds }; +}); + +// Send a prepared draft document (config.document = 'invoice' | 'contract'). +registry.registerAction('send_document', async (ctx) => { + const doc = ctx.node.config?.document || 'invoice'; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_document', document: doc }; + const adminId = await resolveActor(ctx); + + if (doc === 'invoice') { + const ids = ctx.vars.preparedInvoiceIds || []; + if (!ids.length) return { skipped: true, reason: 'no prepared invoice to send' }; + const invoiceService = require('../invoiceService'); + let sent = 0; + for (const id of ids) { await invoiceService.sendInvoice(id, adminId); sent += 1; } + return { invoices_sent: sent }; + } + if (doc === 'contract') { + const cid = ctx.vars.preparedContractId; + if (!cid) return { skipped: true, reason: 'no prepared contract to send' }; + await require('../contractService').sendContract(cid, adminId); + return { contract_sent: cid }; + } + return { skipped: true, reason: `send_document for '${doc}' not implemented yet` }; +}); + // Create/prepare-document actions — registered so flows referencing them are // valid; service wiring is a follow-up. Records a skipped step (observable). for (const key of DOCUMENT_ACTIONS) { diff --git a/backend/src/utils/appSettings.js b/backend/src/utils/appSettings.js index 29a989d6..df88036e 100644 --- a/backend/src/utils/appSettings.js +++ b/backend/src/utils/appSettings.js @@ -25,8 +25,11 @@ const { db } = require('../database/db'); * JSON.parse on the way out. Falls back to the raw string on * malformed JSON so legacy text values still work. */ -async function getAppSetting(key, defaultValue = null) { - const row = await db('app_settings').where({ setting_key: key }).first(); +async function getAppSetting(key, defaultValue = null, conn = db) { + // Callers reading from inside a knex transaction must pass that trx as + // `conn`, otherwise the global-`db` read grabs a second connection from + // the single-connection SQLite pool while the trx holds it → deadlock. + const row = await conn('app_settings').where({ setting_key: key }).first(); if (!row || row.setting_value == null) return defaultValue; try { return JSON.parse(row.setting_value);