fix(workflows): defer quote.accepted/declined emit until the 15-min response window locks
The customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
(default 15) before it locks, and the public page promises exactly that. But the
booking workflow fired on the FIRST accept click and immediately converted the
quote (status -> 'converted'), so a decline within the window was rejected
('Quote cannot be responded to in status converted') — the grace period was dead
on arrival.
recordResponse / adminAcceptQuote now DEFER the workflow emit while the toggle
window is open; the new scheduler sweep finalizeQuoteResponses fires the FINAL
status once response_locked_at passes (idempotent via the new
quotes.workflow_response_emitted_at column, migration 149). A response recorded
with the window already closed (0-min window, or admin decline which locks
immediately) still emits inline. So toggling accept->decline->accept inside the
window converts at most once, for the final state, after the customer's grace
period — and a plain decline never converts.
Trade-off: with the hourly CRM scheduler, the booking flow now starts up to ~1h
after the window locks instead of instantly. Acceptable — the flow gates on admin
review anyway, and the alternative (graph-level wait) wouldn't reach already-
enabled built-ins (admin_toggled_at blocks re-seed).
Adds a finalize sweep test (deferred while open, fires + stamps once locked,
idempotent).
This commit is contained in:
@@ -99,6 +99,40 @@ describe('booking cutover — draft invoices on hold', () => {
|
|||||||
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
|
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
|
||||||
|
const mk = async (lockOffsetMs) => {
|
||||||
|
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: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
|
||||||
|
responded_at: new Date().toISOString(),
|
||||||
|
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
|
||||||
|
accepted_at: new Date().toISOString(),
|
||||||
|
deal_uuid: dealUuid,
|
||||||
|
created_by_admin_id: adminId,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const openId = await mk(15 * 60 * 1000); // still inside the window
|
||||||
|
const lockedId = await mk(-60 * 1000); // window already closed
|
||||||
|
|
||||||
|
const emitted = await quoteService.finalizeQuoteResponses();
|
||||||
|
expect(emitted).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const open = await db('quotes').where({ id: openId }).first();
|
||||||
|
const locked = await db('quotes').where({ id: lockedId }).first();
|
||||||
|
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
|
||||||
|
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
|
||||||
|
|
||||||
|
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
|
||||||
|
const again = await db('quotes').where({ id: lockedId })
|
||||||
|
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
|
||||||
|
expect(again).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
|
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
|
||||||
const quoteId = await acceptedQuote();
|
const quoteId = await acceptedQuote();
|
||||||
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
|
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Migration 149: defer the quote.accepted/declined workflow emit past the
|
||||||
|
* 15-min response window.
|
||||||
|
*
|
||||||
|
* A customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
|
||||||
|
* (default 15) before it locks. The booking workflow used to fire on the FIRST
|
||||||
|
* click and immediately convert the quote (status -> 'converted'), which made the
|
||||||
|
* quote un-declinable inside that window — defeating the grace period the public
|
||||||
|
* page promises ("you can change your answer within 15 minutes").
|
||||||
|
*
|
||||||
|
* The fix moves the response emit to AFTER the window locks: the scheduler sweeps
|
||||||
|
* locked-but-not-yet-emitted responses and fires quote.<final status> once. This
|
||||||
|
* column is the idempotency marker so each response is emitted exactly once,
|
||||||
|
* regardless of how many times the customer toggled inside the window.
|
||||||
|
*/
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||||
|
if (!(await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at'))) {
|
||||||
|
await knex.schema.alterTable('quotes', (t) => {
|
||||||
|
t.timestamp('workflow_response_emitted_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||||
|
if (await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at')) {
|
||||||
|
await knex.schema.alterTable('quotes', (t) => t.dropColumn('workflow_response_emitted_at'));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const invoiceService = require('./invoiceService');
|
const invoiceService = require('./invoiceService');
|
||||||
const eventReminderService = require('./eventReminderService');
|
const eventReminderService = require('./eventReminderService');
|
||||||
|
const quoteService = require('./quoteService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
let task = null;
|
let task = null;
|
||||||
@@ -42,6 +43,15 @@ async function runTick() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Event reminder pass failed', { err: err.message });
|
logger.error('Event reminder pass failed', { err: err.message });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
// Fire workflow events for quote responses whose 15-min toggle window has
|
||||||
|
// now locked (deferred at response time so accepting can't convert the quote
|
||||||
|
// before the customer's grace period to change their mind expires).
|
||||||
|
const finalized = await quoteService.finalizeQuoteResponses();
|
||||||
|
if (finalized) logger.info('CRM scheduler: finalized locked quote responses', { finalized });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Quote response finalize pass failed', { err: err.message });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Resume workflow runs whose wait has elapsed. No-op (fails closed) when
|
// Resume workflow runs whose wait has elapsed. No-op (fails closed) when
|
||||||
// the `workflows` feature flag is off. Independent try/catch so a workflow
|
// the `workflows` feature flag is off. Independent try/catch so a workflow
|
||||||
|
|||||||
@@ -1114,6 +1114,65 @@ async function emitQuoteEvent(quote, status) {
|
|||||||
} catch (_) { /* best-effort */ }
|
} catch (_) { /* best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a quote accept/decline to the workflow engine — but only once the
|
||||||
|
* customer's response window has LOCKED. While the window is open (the public
|
||||||
|
* page lets them flip accept↔decline for crm_quotes_accept_window_minutes), an
|
||||||
|
* immediate emit would let the booking flow convert the quote right away,
|
||||||
|
* defeating the grace period (the quote went straight to 'converted' and could
|
||||||
|
* no longer be declined). So:
|
||||||
|
* - window already closed (0-minute window, or admin decline) → emit now and
|
||||||
|
* stamp `workflow_response_emitted_at` (idempotent claim).
|
||||||
|
* - window still open → defer; `finalizeQuoteResponses` (scheduler) fires the
|
||||||
|
* FINAL status once it locks, so toggling inside the window never converts.
|
||||||
|
* Returns true if it emitted, false if deferred / already emitted.
|
||||||
|
*/
|
||||||
|
async function maybeEmitQuoteResponse(quote, status, responseLockedAt) {
|
||||||
|
const locked = !responseLockedAt || new Date(responseLockedAt).getTime() <= Date.now();
|
||||||
|
if (!locked) return false; // deferred to the finalize sweep
|
||||||
|
const hasCol = await hasColumnCached('quotes', 'workflow_response_emitted_at');
|
||||||
|
if (hasCol) {
|
||||||
|
// Atomically claim the emit so a concurrent finalize sweep can't double-fire.
|
||||||
|
const claimed = await db('quotes').where({ id: quote.id })
|
||||||
|
.whereNull('workflow_response_emitted_at')
|
||||||
|
.update({ workflow_response_emitted_at: new Date() });
|
||||||
|
if (!claimed) return false; // already emitted elsewhere
|
||||||
|
}
|
||||||
|
await emitQuoteEvent(quote, status);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduler sweep: fire the workflow event for quote responses whose toggle
|
||||||
|
* window has now locked but which were deferred at response time. Idempotent via
|
||||||
|
* `workflow_response_emitted_at` (atomic claim). Called from the CRM scheduler
|
||||||
|
* tick. Returns the number emitted.
|
||||||
|
*/
|
||||||
|
async function finalizeQuoteResponses(limit = 200) {
|
||||||
|
const hasCol = await hasColumnCached('quotes', 'workflow_response_emitted_at');
|
||||||
|
if (!hasCol) return 0; // pre-migration install — nothing to finalise
|
||||||
|
// The unemitted accept/decline set is naturally small (a row leaves it the
|
||||||
|
// moment it's emitted), so fetch the candidates and compare the lock time in
|
||||||
|
// JS — avoids SQLite/Postgres date-string comparison pitfalls.
|
||||||
|
const now = Date.now();
|
||||||
|
const candidates = await db('quotes')
|
||||||
|
.whereIn('status', ['accepted', 'declined'])
|
||||||
|
.whereNull('workflow_response_emitted_at')
|
||||||
|
.whereNotNull('response_locked_at')
|
||||||
|
.limit(limit);
|
||||||
|
const rows = candidates.filter((q) => new Date(q.response_locked_at).getTime() <= now);
|
||||||
|
let emitted = 0;
|
||||||
|
for (const q of rows) {
|
||||||
|
const claimed = await db('quotes').where({ id: q.id })
|
||||||
|
.whereNull('workflow_response_emitted_at')
|
||||||
|
.update({ workflow_response_emitted_at: new Date() });
|
||||||
|
if (!claimed) continue; // raced with another tick / the inline emit
|
||||||
|
await emitQuoteEvent(q, q.status);
|
||||||
|
emitted += 1;
|
||||||
|
}
|
||||||
|
return emitted;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -1197,7 +1256,10 @@ 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);
|
// Defer the workflow emit until the 15-min toggle window locks — so accepting
|
||||||
|
// (then converting) can't strip the customer's ability to decline. The
|
||||||
|
// scheduler's finalize sweep fires the final status once it locks.
|
||||||
|
await maybeEmitQuoteResponse(quote, newStatus, responseLockedAt);
|
||||||
|
|
||||||
return { status: newStatus, lockedAt: responseLockedAt };
|
return { status: newStatus, lockedAt: responseLockedAt };
|
||||||
}
|
}
|
||||||
@@ -1296,7 +1358,9 @@ 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');
|
// Same deferral as the public path — an admin "accept on behalf" also opens
|
||||||
|
// the toggle window, so don't convert until it locks.
|
||||||
|
await maybeEmitQuoteResponse(quote, 'accepted', responseLockedAt);
|
||||||
|
|
||||||
return { status: 'accepted', lockedAt: responseLockedAt };
|
return { status: 'accepted', lockedAt: responseLockedAt };
|
||||||
}
|
}
|
||||||
@@ -1361,7 +1425,9 @@ 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');
|
// Admin decline locks the window immediately (response_locked_at = now), so
|
||||||
|
// this emits straight away (and stamps emitted) rather than deferring.
|
||||||
|
await maybeEmitQuoteResponse(quote, 'declined', now);
|
||||||
|
|
||||||
return { status: 'declined', declinedAt: now };
|
return { status: 'declined', declinedAt: now };
|
||||||
}
|
}
|
||||||
@@ -2022,6 +2088,7 @@ module.exports = {
|
|||||||
recordResponse,
|
recordResponse,
|
||||||
adminAcceptQuote,
|
adminAcceptQuote,
|
||||||
adminDeclineQuote,
|
adminDeclineQuote,
|
||||||
|
finalizeQuoteResponses,
|
||||||
convertToEvent,
|
convertToEvent,
|
||||||
convertToInvoiceOnly,
|
convertToInvoiceOnly,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user