From 882cfc0661b02602bae91b928a46ceea754f7f29 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:36:35 +0200 Subject: [PATCH] =?UTF-8?q?fix(workflows):=20held=20booking=20invoices=20a?= =?UTF-8?q?re=20'scheduled',=20not=20'pending=5Fdelivery'=20=E2=80=94=20so?= =?UTF-8?q?=20send=5Fdocument=20can=20issue=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quote with no explicit payment timing falls back to a single after_delivery installment. spawnInstallmentInvoices marked those 'pending_delivery' even in hold mode, so the booking flow's send_document -> sendInvoice threw 'Cannot send invoice with status pending_delivery', the run failed, and no invoice email went out (the symptom: approve the quote->invoice flow, receive nothing). In hold mode the flow's review gate + explicit send_document IS the delivery release, so a held invoice is always 'scheduled' (editable + sendable) regardless of trigger; scheduled_send_at stays null so the scheduler never auto-sends it. Non-hold after_delivery invoices keep 'pending_delivery' as before. Adds a regression test (default after_delivery term -> draft -> scheduled+null). --- .../integration/bookingCutover.test.js | 23 +++++++++++++++++++ backend/src/services/invoiceService.js | 14 +++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js index ce938f23..3f4d7764 100644 --- a/backend/__tests__/integration/bookingCutover.test.js +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -76,6 +76,29 @@ describe('booking cutover — draft invoices on hold', () => { expect(q.converted_event_id).toBe(res.eventId); }); + it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => { + // Reproduces the booking_invoice_only flow on a quote with no explicit + // payment timing: the default installment is after_delivery, which would + // otherwise be pending_delivery — a status sendInvoice (send_document) rejects. + const dealUuid = crypto.randomUUID(); + const [quoteId] = 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: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000, + // No payment_term_snapshot → spawnInstallmentInvoices falls back to a single + // 100% after_delivery installment. + deal_uuid: dealUuid, + created_by_admin_id: adminId, + }); + const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true }); + const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first(); + expect(inv.status).toBe('scheduled'); // sendInvoice accepts this + expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send + }); + it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => { const quoteId = await acceptedQuote(); const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true }); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 426c5370..13258ccb 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -1077,11 +1077,15 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre // status `scheduled`, so they sit idle until the admin clicks // "Release for delivery" on the invoice detail page. const isDeliveryTrigger = inst.trigger === 'after_delivery'; - const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; - // `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. + // `hold` (workflow draft-seam): the booking flow's review gate + explicit + // send_document IS the release, so a held invoice is always `scheduled` + // (editable + sendable via sendInvoice) regardless of trigger — never + // `pending_delivery`, which sendInvoice refuses. Without hold, an + // after_delivery invoice stays `pending_delivery` as before. + const rowStatus = (isDeliveryTrigger && !hold) ? 'pending_delivery' : 'scheduled'; + // Held invoices carry no scheduled_send_at so the scheduler never auto-sends + // them — they wait for send_document. after_delivery rows are likewise null + // (the scheduler can't infer a delivery date). const rowScheduledSendAt = (isDeliveryTrigger || hold) ? null : scheduledSendAt; const invoiceNumber = await nextInvoiceNumber(trx);