diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index b25400bd..821e9186 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -2039,6 +2039,28 @@ async function sendInvoice(id, adminId) { }); try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + + // Fire the workflow engine's invoice.sent trigger (after the row is updated + + // the email queued). Idempotent per invoice id; no-op when the workflows flag + // is off. Never throws into the send path. + try { + await require('./workflows').emitWorkflowEvent('invoice.sent', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + customerEmail: invoiceTo, + dueDate: invoice.due_date, + issueDate: invoice.issue_date, + totalMinor: invoice.total_amount_minor, + currency: invoice.currency, + }, + }); + } catch (_) {} + return { sent: true, pdfPath }; } @@ -2068,7 +2090,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not ? Math.max(0, ensureInt(invoice.total_amount_minor) - amount) : null; - return await db.transaction(async (trx) => { + const markResult = await db.transaction(async (trx) => { await trx('invoice_payment_log').insert({ invoice_id: id, amount_minor: amount, @@ -2145,6 +2167,26 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status }; }); + + // Fire invoice.paid for the workflow engine ONLY on the transition into + // 'paid' (mirrors the admin-notification guard above). After the commit so a + // workflow side effect can never roll back the recorded payment. + if (markResult.status === 'paid' && invoice.status !== 'paid') { + try { + await require('./workflows').emitWorkflowEvent('invoice.paid', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + paidTotalMinor: markResult.paidTotalMinor, + }, + }); + } catch (_) {} + } + return markResult; } /** diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 8c83686e..3db3614b 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -1022,6 +1022,34 @@ async function persistDocPdf(type, doc, buffer) { * the same token may flip accept↔decline. After the window expires the * response is locked. */ +/** + * Fire a quote lifecycle event for the workflow engine. Best-effort: resolves + * the customer email (so send_email actions have a recipient) and never throws + * into the caller. No-op when the workflows flag is off (emit fails closed). + */ +async function emitQuoteEvent(quote, status) { + try { + let customerEmail = null; + if (quote.customer_account_id) { + const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + customerEmail = c?.email || null; + } + await require('./workflows').emitWorkflowEvent(`quote.${status}`, { + entityType: 'quote', + entityId: quote.id, + payload: { + quoteId: quote.id, + quoteNumber: quote.quote_number, + customerAccountId: quote.customer_account_id || null, + customerEmail, + eventName: quote.event_name || null, + eventDate: quote.event_date || null, + totalMinor: quote.total_amount_minor ?? null, + }, + }); + } catch (_) { /* best-effort */ } +} + async function recordResponse({ token, action, ip, tosAccepted }) { if (!['accept', 'decline'].includes(action)) { throw new AppError('Invalid action', 400); @@ -1105,6 +1133,8 @@ async function recordResponse({ token, action, ip, tosAccepted }) { await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public'); } catch (_) {} + await emitQuoteEvent(quote, newStatus); + return { status: newStatus, lockedAt: responseLockedAt }; } @@ -1202,6 +1232,8 @@ async function adminAcceptQuote(id, adminId) { logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message }); } + await emitQuoteEvent(quote, 'accepted'); + return { status: 'accepted', lockedAt: responseLockedAt }; } @@ -1265,6 +1297,8 @@ async function adminDeclineQuote(id, adminId, reason = null) { await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`); } catch (_) {} + await emitQuoteEvent(quote, 'declined'); + return { status: 'declined', declinedAt: now }; }