fix(workflows): held booking invoices are 'scheduled', not 'pending_delivery' — so send_document can issue them

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).
This commit is contained in:
Luca
2026-06-27 12:36:35 +02:00
parent 7727b6714b
commit 882cfc0661
2 changed files with 32 additions and 5 deletions
@@ -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 });
+9 -5
View File
@@ -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);