feat(workflows): emit lifecycle events from invoice + quote services
Wires the workflow event bus into the hot paths, AFTER each commit: - invoiceService.sendInvoice → invoice.sent (idempotent per invoice id, so overdue re-sends don't double-fire) - invoiceService.markPaid → invoice.paid, only on the transition into paid (transaction result captured so the emit runs post-commit, never rolling back a recorded payment) - quoteService.recordResponse / adminAcceptQuote / adminDeclineQuote → quote.accepted / quote.declined via a shared emitQuoteEvent helper that resolves the customer email for downstream send_email actions All emits are best-effort and fail closed when the workflows flag is off. Existing invoice/quote integration tests still green.
This commit is contained in:
@@ -2039,6 +2039,28 @@ async function sendInvoice(id, adminId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
|
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 };
|
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)
|
? Math.max(0, ensureInt(invoice.total_amount_minor) - amount)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return await db.transaction(async (trx) => {
|
const markResult = await db.transaction(async (trx) => {
|
||||||
await trx('invoice_payment_log').insert({
|
await trx('invoice_payment_log').insert({
|
||||||
invoice_id: id,
|
invoice_id: id,
|
||||||
amount_minor: amount,
|
amount_minor: amount,
|
||||||
@@ -2145,6 +2167,26 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
|
|||||||
|
|
||||||
return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status };
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1022,6 +1022,34 @@ async function persistDocPdf(type, doc, buffer) {
|
|||||||
* the same token may flip accept↔decline. After the window expires the
|
* the same token may flip accept↔decline. After the window expires the
|
||||||
* response is locked.
|
* 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 }) {
|
async function recordResponse({ token, action, ip, tosAccepted }) {
|
||||||
if (!['accept', 'decline'].includes(action)) {
|
if (!['accept', 'decline'].includes(action)) {
|
||||||
throw new AppError('Invalid action', 400);
|
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');
|
await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public');
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
await emitQuoteEvent(quote, newStatus);
|
||||||
|
|
||||||
return { status: newStatus, lockedAt: responseLockedAt };
|
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 });
|
logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await emitQuoteEvent(quote, 'accepted');
|
||||||
|
|
||||||
return { status: 'accepted', lockedAt: responseLockedAt };
|
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}`);
|
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
await emitQuoteEvent(quote, 'declined');
|
||||||
|
|
||||||
return { status: 'declined', declinedAt: now };
|
return { status: 'declined', declinedAt: now };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user