diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js index 3f4d7764..563d4fd6 100644 --- a/backend/__tests__/integration/bookingCutover.test.js +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -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 }); diff --git a/backend/migrations/core/149_add_quote_workflow_emitted_at.js b/backend/migrations/core/149_add_quote_workflow_emitted_at.js new file mode 100644 index 00000000..e3689521 --- /dev/null +++ b/backend/migrations/core/149_add_quote_workflow_emitted_at.js @@ -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. 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')); + } +}; diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js index a6ec2644..d10f4ace 100644 --- a/backend/src/services/invoiceSchedulerService.js +++ b/backend/src/services/invoiceSchedulerService.js @@ -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 diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index ac1b2f2d..a29bcc6d 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -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,