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:
Luca
2026-06-28 14:30:26 +02:00
parent 882cfc0661
commit 539a83711d
4 changed files with 144 additions and 3 deletions
@@ -99,6 +99,40 @@ describe('booking cutover — draft invoices on hold', () => {
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 () => {
const quoteId = await acceptedQuote();
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 invoiceService = require('./invoiceService');
const eventReminderService = require('./eventReminderService');
const quoteService = require('./quoteService');
const logger = require('../utils/logger');
let task = null;
@@ -42,6 +43,15 @@ async function runTick() {
} catch (err) {
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 {
// Resume workflow runs whose wait has elapsed. No-op (fails closed) when
// the `workflows` feature flag is off. Independent try/catch so a workflow
+70 -3
View File
@@ -1114,6 +1114,65 @@ async function emitQuoteEvent(quote, status) {
} 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 }) {
if (!['accept', 'decline'].includes(action)) {
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');
} 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 };
}
@@ -1296,7 +1358,9 @@ async function adminAcceptQuote(id, adminId) {
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 };
}
@@ -1361,7 +1425,9 @@ 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');
// 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 };
}
@@ -2022,6 +2088,7 @@ module.exports = {
recordResponse,
adminAcceptQuote,
adminDeclineQuote,
finalizeQuoteResponses,
convertToEvent,
convertToInvoiceOnly,