Files
picpeak/backend/src/services/quoteService.js
T
Luca a702f33004 feat(projects): linking a quote/contract cascades the whole deal into the project
linkDealToProject(dealUuid, projectId): links every quote + contract sharing
the deal_uuid, re-points the events the deal converted into (so their
invoices/emails/gallery roll up), and adopts the deal's customer onto an
empty project. Invoked from the assign endpoints AND the quote/contract
editors' project picker (create + update). Drop a quote on an empty project
and its linked contract, event and invoices populate the cockpit automatically.

Verified on a booted DB: assignQuote on an empty project propagates project_id
to the contract + event, adopts the customer, and the overview rolls up all
four document types.
2026-06-07 01:03:27 +02:00

1916 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* quoteService — orchestrates the lifecycle of `quotes`, their
* `quote_line_items`, and the public `quote_action_tokens` used by the
* accept/decline link in the customer email.
*
* Mirrors the layered shape of customerAccountsService: pure functions
* doing one thing each, with a small set of transformation helpers at
* the top. Routes (adminQuotes.js / publicQuotes.js) stay thin.
*
* Money is stored as INTEGER minor units (cents/Rappen). The service
* re-computes line totals + net/vat/total on save, never trusting the
* payload — the editor sends a hint for live UX, the server is the
* source of truth.
*
* Statuses (`quotes.status`):
* draft freshly created or edited after send; not visible publicly
* sent emailed to customer; public token live
* accepted customer accepted; ready to convert to event
* declined customer declined; admin can resend after edits
* expired valid_until passed without a response (set by the scheduler)
* converted accepted + event created from it
*
* Per-customer feature override: when `customer_accounts.feature_quotes`
* is false (toggled by admin on the customer detail page) the service
* refuses to create / send / convert quotes for that customer. Admins
* can still view existing rows for audit.
*/
const crypto = require('crypto');
const { db, withRetry, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const { getAppSetting } = require('../utils/appSettings');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { claimNextSequence } = require('../utils/documentSequences');
const { formatShortDate } = require('../utils/dateFormatter');
const businessProfileService = require('./businessProfileService');
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
const pdfService = require('./pdfService');
const emailProcessor = require('./emailProcessor');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const { hasColumnCached } = require('../utils/schemaCache');
const fs = require('fs');
const path = require('path');
const VALID_QUOTE_TRANSITIONS = {
draft: new Set(['sent', 'declined']),
sent: new Set(['draft', 'accepted', 'declined', 'expired']),
accepted: new Set(['converted', 'declined']),
declined: new Set(['draft', 'accepted']),
expired: new Set(['draft']),
converted: new Set([]),
};
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
// `ensureInt` + `ensureNumber` moved to utils/numericHelpers (D.2 cleanup).
const { ensureInt, ensureNumber } = require('../utils/numericHelpers');
/**
* Compute line totals + document totals authoritatively from the
* supplied line items + VAT rate. Returns BigInt-safe integers (minor
* units). Discount is applied before VAT.
*
* Hierarchy rules (migration 119):
* - Items with `parent_position` are SUB-ITEMS of the referenced
* top-level item.
* - Each sub-item's `line_total_minor` is computed (qty × unit ×
* (1 discount)) so the renderer can show its individual price
* in parentheses for transparency.
* - **Parent total auto-resolves from sub-items when any are
* priced.** If at least one sub-item under a given parent has
* `unit_price_minor > 0`, the parent's effective line_total is
* the SUM of those sub-items' line_totals — the parent's own
* stored unit_price is ignored. Mental model: when you list
* itemised equipment with individual prices, the parent line
* becomes a header that auto-totals what's under it.
* - If all sub-items are priceless (transparency-only bullets), the
* parent's own qty × unit × discount math stands as today.
* - Sub-items NEVER contribute to the document net directly —
* only the parent's effective line_total does. So sub-items
* don't double-count, and the parent's "sum-of-sub-items" total
* is what lands in net + VAT.
*
* The empty-payload check upstream ensures `lineItems` is always an
* array; we treat anything truthy on `parent_position` (number or
* string that parses to int) as "I'm a sub-item".
*/
function computeTotals(lineItems, vatRate, shippingAmountMinor = 0) {
// Phase 1: compute raw line_total_minor for every row from its own
// qty × unit × discount. Sub-item lines are computed here too so
// the renderer can display their individual amounts.
const computed = lineItems.map((li) => {
const qty = ensureNumber(li.quantity, 1);
const unit = ensureInt(li.unit_price_minor);
const discount = Math.max(0, Math.min(100, ensureNumber(li.discount_percent, 0)));
const rawLineMinor = Math.round(qty * unit);
const discountedMinor = Math.round(rawLineMinor * (1 - discount / 100));
const parentPosition = li.parent_position == null || li.parent_position === ''
? null : ensureInt(li.parent_position);
return { ...li, line_total_minor: discountedMinor, parent_position: parentPosition };
});
// Phase 2: resolve parents. For each top-level item, sum its priced
// sub-items; if the sum > 0, override the parent's line_total_minor.
// Index by position for O(n) lookup.
const childrenByParent = new Map();
for (const li of computed) {
if (li.parent_position == null) continue;
if (!childrenByParent.has(li.parent_position)) childrenByParent.set(li.parent_position, []);
childrenByParent.get(li.parent_position).push(li);
}
for (const li of computed) {
if (li.parent_position != null) continue; // skip sub-items
const children = childrenByParent.get(ensureInt(li.position)) || [];
const pricedChildrenSum = children.reduce(
(s, c) => s + (ensureInt(c.unit_price_minor) > 0 ? ensureInt(c.line_total_minor) : 0),
0,
);
if (pricedChildrenSum > 0) {
// Override the parent's effective line total with the sum of
// its priced sub-items. The parent's own stored unit_price is
// intentionally ignored here (the editor disables the parent
// input when sub-items become priced — but the backend is the
// source of truth either way).
li.line_total_minor = pricedChildrenSum;
}
}
// Phase 3: net = sum of top-level line totals (resolved).
let netMinor = 0;
for (const li of computed) {
if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor);
}
const vatPercent = ensureNumber(vatRate, 0);
const vatMinor = Math.round(netMinor * vatPercent / 100);
const shipping = ensureInt(shippingAmountMinor);
const totalMinor = netMinor + vatMinor + shipping;
return {
netAmountMinor: netMinor,
vatAmountMinor: vatMinor,
shippingAmountMinor: shipping,
totalAmountMinor: totalMinor,
lineItems: computed,
};
}
/**
* Resolve parent line_total_minor from priced sub-items, in place.
* Mirrors the phase-2 step of computeTotals so non-quote callers
* (invoiceService.createInvoice, the PUT-invoice route) can apply
* the same hierarchy math without going through full totals.
*
* Each item must already have line_total_minor pre-computed (the
* raw qty × unit × discount product). After this call, top-level
* items whose sub-items include at least one priced row will have
* their line_total_minor overwritten with the sum of priced
* sub-items' line_totals.
*/
function resolveParentTotalsFromSubItems(items) {
if (!Array.isArray(items) || items.length === 0) return;
const childrenByParent = new Map();
for (const li of items) {
const pp = li.parent_position == null || li.parent_position === '' ? null : ensureInt(li.parent_position);
if (pp == null) continue;
if (!childrenByParent.has(pp)) childrenByParent.set(pp, []);
childrenByParent.get(pp).push(li);
}
for (const li of items) {
const pp = li.parent_position == null || li.parent_position === '' ? null : ensureInt(li.parent_position);
if (pp != null) continue;
const children = childrenByParent.get(ensureInt(li.position)) || [];
const pricedSum = children.reduce(
(s, c) => s + (ensureInt(c.unit_price_minor) > 0 ? ensureInt(c.line_total_minor) : 0),
0,
);
if (pricedSum > 0) li.line_total_minor = pricedSum;
}
}
/**
* Validate the hierarchy of a line-item payload BEFORE insert. Throws
* AppError on:
* - duplicate positions
* - sub-item's parent_position not found in the payload
* - sub-item's parent is itself a sub-item (max 1 level deep)
* - circular reference (item references itself)
*
* Used by both quote + invoice services so the rules stay identical
* across both flows (and so the quote→invoice cloner doesn't have to
* re-validate).
*/
function validateLineItemHierarchy(lineItems) {
if (!Array.isArray(lineItems) || lineItems.length === 0) return;
const positions = new Set();
const parentPositions = new Map(); // position → parent_position (or null)
for (const li of lineItems) {
const pos = ensureInt(li.position);
if (!pos) {
throw new AppError('Every line item must have a positive position', 400, 'LINE_ITEM_POSITION_REQUIRED');
}
if (positions.has(pos)) {
throw new AppError(`Duplicate line item position: ${pos}`, 400, 'LINE_ITEM_POSITION_DUPLICATE');
}
positions.add(pos);
const pp = li.parent_position == null || li.parent_position === '' ? null : ensureInt(li.parent_position);
parentPositions.set(pos, pp);
}
for (const [pos, pp] of parentPositions) {
if (pp == null) continue;
if (pp === pos) {
throw new AppError(`Line item ${pos} cannot be its own parent`, 400, 'LINE_ITEM_SELF_PARENT');
}
if (!parentPositions.has(pp)) {
throw new AppError(`Sub-item ${pos} references missing parent position ${pp}`, 400, 'LINE_ITEM_PARENT_NOT_FOUND');
}
if (parentPositions.get(pp) != null) {
throw new AppError(`Sub-item ${pos} cannot nest under another sub-item (max one level deep)`, 400, 'LINE_ITEM_NESTING_TOO_DEEP');
}
}
}
/**
* Two-phase insert into a *_line_items table to resolve the
* parent_position → parent_line_item_id remap. The payload uses
* position numbers to express parent/child relationships because the
* DB ids don't exist until rows are inserted; this helper handles
* the round-trip.
*
* trx — db or transaction handle
* tableName — 'quote_line_items' | 'invoice_line_items'
* ownerColumn — 'quote_id' | 'invoice_id'
* ownerId — the parent quote/invoice id
* items — array of line-item rows with `position` +
* optional `parent_position`. All other columns
* passed through verbatim (except parent_position
* which is stripped — it's a wire-only field, not a
* DB column).
*
* Caller must have already run `validateLineItemHierarchy` on the
* items, so this function trusts the hierarchy is sound.
*/
async function insertLineItemsHierarchical(trx, tableName, ownerColumn, ownerId, items) {
if (!Array.isArray(items) || items.length === 0) return;
// Phase 1: top-level items, captured into a position→id map for
// phase 2.
const topLevel = items.filter((li) => li.parent_position == null || li.parent_position === '');
const subItems = items.filter((li) => li.parent_position != null && li.parent_position !== '');
const stripWireOnly = ({ parent_position: _pp, parent_line_item_id: _pid, ...rest }) => rest;
const positionToId = new Map();
for (const li of topLevel) {
const row = {
...stripWireOnly(li),
[ownerColumn]: ownerId,
parent_line_item_id: null,
details_text: li.details_text == null ? null : String(li.details_text),
created_at: new Date(),
updated_at: new Date(),
};
const inserted = await trx(tableName).insert(row).returning('id');
const newId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
positionToId.set(ensureInt(li.position), newId);
}
for (const li of subItems) {
const parentId = positionToId.get(ensureInt(li.parent_position));
if (!parentId) {
// Defensive — validateLineItemHierarchy should have caught
// this. Rethrow as a 500 so we don't silently swallow.
throw new AppError(`Sub-item position ${li.position} references unknown parent ${li.parent_position}`, 500);
}
const row = {
...stripWireOnly(li),
[ownerColumn]: ownerId,
parent_line_item_id: parentId,
details_text: li.details_text == null ? null : String(li.details_text),
created_at: new Date(),
updated_at: new Date(),
};
await trx(tableName).insert(row);
}
}
function formatNumberInTemplate(format, year, seq) {
// Tokens: {YEAR}, {MONTH}, {SEQ:04d}. Defaults handle padding via
// a tiny formatter, kept inline to avoid a new dependency.
return format
.replace(/\{YEAR\}/g, String(year))
.replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0'))
.replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0'))
.replace(/\{SEQ\}/g, String(seq));
}
// Atomic gap-free quote number generator. See utils/documentSequences.js
// for the locking story; migration 132 created the underlying table.
// The previous SELECT-MAX-then-INSERT path raced under concurrent
// admin creates and could emit `Q-2026-AB12C3` after 5 retries.
async function nextQuoteNumber(trx) {
const format = (await getAppSetting('crm_quotes_number_format')) || 'Q-{YEAR}-{SEQ:04d}';
const year = new Date().getFullYear();
const seq = await claimNextSequence('quote', year, trx);
return formatNumberInTemplate(format, year, seq);
}
function ensureCustomerFeatureEnabled(customer, feature) {
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
// is checked at the route layer (feature flag); here we only enforce
// the per-customer override.
if (!customer) {
throw new AppError('Customer not found', 404);
}
if (customer.is_active === false || customer.is_active === 0) {
throw new AppError('Customer is deactivated', 409);
}
const flagField = feature === 'quotes' ? 'feature_quotes' : 'feature_bills';
const flagValue = customer[flagField];
if (flagValue === false || flagValue === 0 || flagValue === '0') {
throw new AppError(`This customer has ${feature} disabled`, 409, 'CUSTOMER_FEATURE_DISABLED');
}
}
// ---------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------
/**
* List quotes with filter + sort + pagination support. Returns a flat
* list (transformed by the route layer); pagination metadata is in the
* wrapper.
*
* Filters: { status[], customerAccountId, from, to, q }
* Sort: 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc'
*/
async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('quotes')
.leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id')
.select(
'quotes.*',
'customer_accounts.email as customer_email',
'customer_accounts.display_name as customer_display_name',
'customer_accounts.first_name as customer_first_name',
'customer_accounts.last_name as customer_last_name',
'customer_accounts.company_name as customer_company_name',
// Surfaced so the route's transformQuote can compute the
// customer.isPassive flag. Hash itself never leaves the API.
'customer_accounts.password_hash as customer_password_hash',
);
if (Array.isArray(filters.status) && filters.status.length > 0) {
query = query.whereIn('quotes.status', filters.status);
}
if (filters.customerAccountId) {
query = query.where('quotes.customer_account_id', filters.customerAccountId);
}
if (filters.from) {
query = query.where('quotes.issue_date', '>=', filters.from);
}
if (filters.to) {
query = query.where('quotes.issue_date', '<=', filters.to);
}
if (filters.q && String(filters.q).trim()) {
const term = `%${String(filters.q).trim()}%`;
query = query.andWhere(function() {
this.where('quotes.quote_number', 'like', term)
.orWhere('quotes.event_name', 'like', term)
.orWhere('customer_accounts.email', 'like', term)
.orWhere('customer_accounts.company_name', 'like', term);
});
}
// Total before pagination.
const countQuery = query.clone().clearSelect().clearOrder().count('quotes.id as total').first();
const totalRow = await countQuery;
const total = ensureInt(totalRow?.total || 0);
switch (sort) {
// "Newest" / "Oldest" sort by CREATION time, not issue_date —
// the latter is admin-controlled (retro-dated quotes, future-
// dated quotes for accruals) and drifts from actual chronology.
// Sorting by created_at always puts a just-saved quote at the
// top of the "Newest first" list.
case 'oldest':
query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc');
break;
case 'issue_asc':
query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc');
break;
case 'issue_desc':
query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc');
break;
case 'customer_asc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('quotes.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('quotes.id', 'desc');
break;
case 'value_asc':
query = query.orderBy('quotes.total_amount_minor', 'asc');
break;
case 'value_desc':
query = query.orderBy('quotes.total_amount_minor', 'desc');
break;
case 'newest':
default:
query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc');
break;
}
const offset = Math.max(0, (page - 1) * pageSize);
query = query.offset(offset).limit(pageSize);
const rows = await query;
return { rows, total, page, pageSize };
});
}
async function getQuoteById(id) {
return await withRetry(async () => {
// LEFT JOIN customer_accounts so transformQuote (which reads
// q.customer_email / q.customer_display_name etc.) has populated
// fields. Without this the API returns nulls for the recipient
// block and the editor shows "undefined undefined" in its summary.
const quote = await db('quotes')
.leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id')
// Migration 130 lineage: the human contract_number of the
// contract this quote was converted into, so the detail view
// shows "Linked contract LBM-C-2026-0010" instead of just "#10".
// LEFT join — most quotes never get converted to a contract.
.leftJoin('contracts as conv_contract', 'quotes.converted_contract_id', 'conv_contract.id')
.where('quotes.id', id)
.select(
'quotes.*',
'customer_accounts.email as customer_email',
'customer_accounts.display_name as customer_display_name',
'customer_accounts.first_name as customer_first_name',
'customer_accounts.last_name as customer_last_name',
'customer_accounts.company_name as customer_company_name',
// For transformQuote.customer.isPassive — never leaves the API.
'customer_accounts.password_hash as customer_password_hash',
'conv_contract.contract_number as converted_contract_number',
)
.first();
if (!quote) return null;
// Self-join so the response carries parent_position alongside
// parent_line_item_id. The editor uses position (1-based, stable
// within the payload) to thread sub-items; the DB id is just for
// unrelated callers.
const lineItems = await db('quote_line_items as li')
.leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id')
.where('li.quote_id', id)
.orderBy('li.position', 'asc')
.select('li.*', 'parent.position as parent_position');
return { quote, lineItems };
});
}
/**
* Create a quote. Validates the customer + recomputes totals.
* Returns the new quote id.
*/
async function createQuote(payload, adminId) {
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
ensureCustomerFeatureEnabled(customer, 'quotes');
const profile = (await businessProfileService.getProfile()).profile;
const currency = (payload.currency || profile?.default_currency || 'CHF').toUpperCase();
const language = payload.language || customer.preferred_language || profile?.default_locale || 'de';
// Default validity = 7 days. Admin can override via Settings →
// CRM → "Quote default validity (days)" (key
// `crm_quotes_default_valid_days`).
const validDays = ensureInt(await getAppSetting('crm_quotes_default_valid_days')) || 7;
const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10);
const validUntil = payload.validUntil || new Date(Date.now() + validDays * 24 * 60 * 60 * 1000)
.toISOString().slice(0, 10);
// Authoritative totals.
const totals = computeTotals(
Array.isArray(payload.lineItems) ? payload.lineItems : [],
payload.vatRate,
payload.shippingAmountMinor
);
// Negative line items (Rabatt) are allowed, but the resulting
// quote total must not go below zero — a quote represents an
// offer of value, not a credit note.
if (totals.totalAmountMinor < 0) {
throw new AppError(
'Quote total cannot be negative. Reduce the discount amount.',
400,
'QUOTE_TOTAL_NEGATIVE',
);
}
// Resolve bank account for the chosen currency.
const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId);
return await db.transaction(async (trx) => {
// SQLite's 1-connection default deadlocks when claimNextSequence
// opens its own micro-transaction inside this outer one — thread
// trx so both run on the same connection. Postgres tolerates
// either form but the consistency is worth it.
const quoteNumber = await nextQuoteNumber(trx);
const row = {
quote_number: quoteNumber,
customer_account_id: payload.customerAccountId,
status: 'draft',
language,
currency,
issue_date: issueDate,
valid_until: validUntil,
event_name: payload.eventName || null,
event_date: payload.eventDate || null,
event_time_start: payload.eventTimeStart || null,
event_time_end: payload.eventTimeEnd || null,
expected_duration_hours: payload.expectedDurationHours == null ? null : ensureNumber(payload.expectedDurationHours),
payment_term_template_id: payload.paymentTermTemplateId || null,
// Migration 124 — split payment-term picker. Editor stops writing
// to the legacy single FK once both new ones are present; the
// legacy column stays nullable for backward compatibility.
payment_net_days_template_id: payload.paymentNetDaysTemplateId || null,
payment_timing_template_id: payload.paymentTimingTemplateId || null,
// Migration 142 — ad-hoc installments override (commit #6). When
// the editor's InstallmentsPanel is set the array lands here;
// composeSnapshotFromSplitFks then substitutes it for the
// template's installments field at every snapshot-read site
// (send, convertToEvent, convertToInvoiceOnly).
payment_term_installments_override: Array.isArray(payload.installments) && payload.installments.length > 0
? JSON.stringify(payload.installments)
: null,
net_amount_minor: totals.netAmountMinor,
vat_rate: ensureNumber(payload.vatRate, 0),
vat_amount_minor: totals.vatAmountMinor,
shipping_amount_minor: totals.shippingAmountMinor,
total_amount_minor: totals.totalAmountMinor,
intro_text: payload.introText || null,
outro_text: payload.outroText || null,
internal_notes: payload.internalNotes || null,
cc_pdf_email: payload.ccPdfEmail || null,
business_bank_account_id: bank?.id || null,
// Migration 140 — cross-document lineage UUID. A freshly-created
// quote is always the root of its deal chain; mint a new one
// here and let convertQuoteToContract / convertQuoteToInvoices
// propagate it down.
deal_uuid: crypto.randomUUID(),
created_by_admin_id: adminId,
created_at: new Date(),
updated_at: new Date(),
};
// Migration 121 — optional link to a Project Overview project.
if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) {
row.project_id = payload.projectId || null;
}
const inserted = await trx('quotes').insert(row).returning('id');
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Cascade the project link across the deal lineage (no-op for a brand-new
// quote with no contract/event yet — just adopts the customer onto an
// empty project).
if (row.project_id) {
await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
}
if (totals.lineItems.length > 0) {
// Normalise rows for the hierarchical-insert helper. We preserve
// the wire-only `parent_position` field here so the helper can
// resolve it; the helper strips it before the actual DB insert.
const rows = totals.lineItems.map((li, idx) => ({
position: ensureInt(li.position) || (idx + 1),
quantity: ensureNumber(li.quantity, 1),
description: String(li.description || ''),
unit_price_minor: ensureInt(li.unit_price_minor),
discount_percent: ensureNumber(li.discount_percent, 0),
line_total_minor: li.line_total_minor,
details_text: li.details_text || null,
parent_position: li.parent_position || null,
}));
validateLineItemHierarchy(rows);
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', quoteId, rows);
}
try {
await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`);
} catch (_) {}
logger.info('Quote created', { adminId, quoteId, quoteNumber });
return quoteId;
});
}
/**
* Update a quote (line items + scalar fields). Editing a `sent` quote
* reverts it to draft so a fresh send is required to push the change.
*/
async function updateQuote(id, payload, adminId) {
const existing = await db('quotes').where({ id }).first();
if (!existing) {
throw new AppError('Quote not found', 404);
}
// Once a customer has responded (accept / decline) or the quote has
// been converted to an event/invoice, edits would invalidate the
// record the customer agreed to. Lock these states the same way
// sent invoices are locked. `draft` and `sent` remain editable;
// `sent` reverts to `draft` further down so the admin must resend.
// `expired` is left editable — quote can be revised and re-sent.
if (['accepted', 'declined', 'converted'].includes(existing.status)) {
throw new AppError(
`Cannot edit quote with status '${existing.status}'. Duplicate the quote and start fresh if changes are needed.`,
409,
'QUOTE_LOCKED',
);
}
const totals = computeTotals(
Array.isArray(payload.lineItems) ? payload.lineItems : [],
payload.vatRate ?? existing.vat_rate,
payload.shippingAmountMinor ?? existing.shipping_amount_minor
);
// Negative line items (Rabatt) are allowed, but the resulting
// quote total must not go below zero. See createQuote.
if (totals.totalAmountMinor < 0) {
throw new AppError(
'Quote total cannot be negative. Reduce the discount amount.',
400,
'QUOTE_TOTAL_NEGATIVE',
);
}
return await db.transaction(async (trx) => {
const updates = {
updated_at: new Date(),
net_amount_minor: totals.netAmountMinor,
vat_amount_minor: totals.vatAmountMinor,
shipping_amount_minor: totals.shippingAmountMinor,
total_amount_minor: totals.totalAmountMinor,
vat_rate: ensureNumber(payload.vatRate ?? existing.vat_rate, 0),
};
// Revert sent → draft on edit so the admin must explicitly resend.
if (existing.status === 'sent') updates.status = 'draft';
const map = {
eventName: 'event_name',
eventDate: 'event_date',
eventTimeStart: 'event_time_start',
eventTimeEnd: 'event_time_end',
expectedDurationHours: 'expected_duration_hours',
paymentTermTemplateId: 'payment_term_template_id',
// Migration 124 — split picker. Both legacy + new FKs accepted
// on the update path so the editor can transition without breaking.
paymentNetDaysTemplateId: 'payment_net_days_template_id',
paymentTimingTemplateId: 'payment_timing_template_id',
introText: 'intro_text',
outroText: 'outro_text',
internalNotes: 'internal_notes',
ccPdfEmail: 'cc_pdf_email',
businessBankAccountId: 'business_bank_account_id',
validUntil: 'valid_until',
language: 'language',
};
for (const [api, col] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(payload, api)) {
updates[col] = payload[api];
}
}
// Migration 142 — ad-hoc installments override (commit #6). Treated
// separately because it needs JSON encoding + "empty array means
// clear the override" semantics.
if (Object.prototype.hasOwnProperty.call(payload, 'installments')) {
updates.payment_term_installments_override =
Array.isArray(payload.installments) && payload.installments.length > 0
? JSON.stringify(payload.installments)
: null;
}
// Migration 121 — optional Project Overview link.
if (Object.prototype.hasOwnProperty.call(payload, 'projectId') && await hasColumnCached('quotes', 'project_id')) {
updates.project_id = payload.projectId || null;
}
await trx('quotes').where({ id }).update(updates);
// When linked to a project, cascade across the deal lineage so the linked
// contract / event / invoices roll up into the same project automatically.
if (updates.project_id) {
const dealRow = await trx('quotes').where({ id }).select('deal_uuid').first();
await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
}
// Delete + reinsert keeps the editor flow simple: the frontend
// sends the canonical line-item set on every save, we drop the
// old rows and rebuild from scratch. CASCADE on parent_line_item_id
// means deleting parents sweeps their sub-items too, so there's
// no orphan risk here.
await trx('quote_line_items').where({ quote_id: id }).del();
if (totals.lineItems.length > 0) {
const rows = totals.lineItems.map((li, idx) => ({
position: ensureInt(li.position) || (idx + 1),
quantity: ensureNumber(li.quantity, 1),
description: String(li.description || ''),
unit_price_minor: ensureInt(li.unit_price_minor),
discount_percent: ensureNumber(li.discount_percent, 0),
line_total_minor: li.line_total_minor,
details_text: li.details_text || null,
parent_position: li.parent_position || null,
}));
validateLineItemHierarchy(rows);
await insertLineItemsHierarchical(trx, 'quote_line_items', 'quote_id', id, rows);
}
try {
await logActivity('quote_updated', { quoteId: id }, null, `admin:${adminId}`);
} catch (_) {}
});
}
/**
* Build the renderer context object from the quote + DB lookups. Shared
* by sendQuote (where we persist the PDF) and previewQuote* (where we
* just return the buffer to the admin).
*/
async function buildRenderContext(quote, lineItems) {
const { profile } = await businessProfileService.getProfile();
const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
const bank = quote.business_bank_account_id
? await db('business_bank_accounts').where({ id: quote.business_bank_account_id }).first()
: await businessProfileService.resolveBankAccountForCurrency(quote.currency);
const paymentTerm = quote.payment_term_template_id
? await db('payment_term_templates').where({ id: quote.payment_term_template_id }).first()
: null;
// Resolve the PDF logo to a verified absolute disk path. The
// helper exhaustively tries:
// 1. business_profile.logo_path
// 2. app_settings.branding_logo_path (absolute multer path)
// 3. app_settings.branding_logo_url (URL path)
// …and for each, generates ~7 candidate disk locations before
// giving up. Returns null + logs a detailed warning when nothing
// resolves. Already-verified path means the renderer never has
// to second-guess.
const { resolveLogoFile } = require('../utils/resolveLogoFile');
const resolvedLogoPath = await resolveLogoFile(profile);
// Resolve Skonto values for the PDF payment block:
// - if the chosen template defines its own skonto_percent +
// skonto_within_days, use those (per-template wins);
// - otherwise fall back to the global CRM defaults
// (crm_invoices_skonto_percent_default + _business_days);
// - the whole row is suppressed when the global
// `crm_quotes_skonto_enabled` toggle is off.
const skontoEnabled = (await getAppSetting('crm_quotes_skonto_enabled')) !== false;
let skontoPercent = paymentTerm?.skonto_percent;
let skontoWithinDays = paymentTerm?.skonto_within_days;
if (skontoEnabled && (skontoPercent == null || skontoWithinDays == null)) {
const defaultPct = Number(await getAppSetting('crm_invoices_skonto_percent_default'));
const defaultDays = parseInt(await getAppSetting('crm_invoices_skonto_business_days'), 10);
if (skontoPercent == null && Number.isFinite(defaultPct) && defaultPct > 0) skontoPercent = defaultPct;
if (skontoWithinDays == null && Number.isFinite(defaultDays) && defaultDays > 0) skontoWithinDays = defaultDays;
}
if (!skontoEnabled) {
skontoPercent = null;
skontoWithinDays = null;
}
// Global date format from Settings → General (general_date_format).
// Stored as JSON `{ format, locale }`; missing or malformed entries
// fall back to DD.MM.YYYY in the renderer.
let dateFormat = null;
try {
const raw = await getAppSetting('general_date_format');
if (raw && typeof raw === 'object' && raw.format) dateFormat = raw;
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
} catch (_) { /* fall back to default */ }
return {
locale: quote.language || profile?.default_locale || 'de',
currency: quote.currency,
qrFormat: 'none', // quotes never carry a Swiss QR-bill
dateFormat,
// Issuer + recipient blocks are shared across all three doc services.
// The quote variant opts into the two extra payment-block toggles.
// See backend/src/services/_renderContext.js for the spec + drift
// history.
issuer: buildIssuerBlock(profile, resolvedLogoPath, { quoteToggles: true }),
recipient: buildRecipientBlock(profile, customer),
bank: bank ? {
accountHolder: bank.account_holder || profile?.company_name,
iban: bank.iban,
bic: bank.bic,
currency: bank.currency,
} : null,
// Resolved above so Skonto honours the global enable toggle + the
// default-rate fallback. If no template is selected at all we
// still pass the Skonto defaults through so the PDF can show a
// sensible "X% discount if paid within Y days" line.
paymentTerm: paymentTerm || skontoPercent || skontoWithinDays ? {
description: paymentTerm?.description,
netDays: paymentTerm?.net_days,
skontoPercent,
skontoWithinDays,
} : null,
lineItems: lineItems.map((li) => ({
quantity: li.quantity,
description: li.description,
unitPriceMinor: li.unit_price_minor,
discountPercent: li.discount_percent,
lineTotalMinor: li.line_total_minor,
// Migration 119 hierarchy + details — surfaced to the PDF
// renderer so drawLineItems can indent sub-items + render
// details_text below.
parentLineItemId: li.parent_line_item_id || null,
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
detailsText: li.details_text || null,
})),
totals: {
netAmountMinor: quote.net_amount_minor,
vatRate: quote.vat_rate,
vatAmountMinor: quote.vat_amount_minor,
shippingAmountMinor: quote.shipping_amount_minor,
totalAmountMinor: quote.total_amount_minor,
},
doc: {
quoteNumber: quote.quote_number,
issueDate: quote.issue_date,
validUntil: quote.valid_until,
introText: quote.intro_text,
outroText: quote.outro_text,
totalAmountMinor: quote.total_amount_minor,
},
};
}
async function renderQuotePdfBuffer(quoteId) {
const data = await getQuoteById(quoteId);
if (!data) throw new AppError('Quote not found', 404);
const ctx = await buildRenderContext(data.quote, data.lineItems);
return await pdfService.renderQuoteToBuffer(ctx);
}
/**
* Preview a quote PDF from an unsaved payload — never touches the DB.
* The frontend "Preview" button on the editor calls this with the
* current form state so the admin can validate before saving.
*/
async function renderQuotePdfFromPayload(payload) {
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
const totals = computeTotals(
Array.isArray(payload.lineItems) ? payload.lineItems : [],
payload.vatRate,
payload.shippingAmountMinor
);
const fakeQuote = {
quote_number: 'PREVIEW',
customer_account_id: payload.customerAccountId,
language: payload.language || customer?.preferred_language || 'de',
currency: (payload.currency || 'CHF').toUpperCase(),
issue_date: payload.issueDate || new Date().toISOString().slice(0, 10),
valid_until: payload.validUntil,
intro_text: payload.introText,
outro_text: payload.outroText,
payment_term_template_id: payload.paymentTermTemplateId,
business_bank_account_id: payload.businessBankAccountId,
net_amount_minor: totals.netAmountMinor,
vat_rate: ensureNumber(payload.vatRate, 0),
vat_amount_minor: totals.vatAmountMinor,
shipping_amount_minor: totals.shippingAmountMinor,
total_amount_minor: totals.totalAmountMinor,
};
// Carry position + parent_position + details_text through to the
// renderer so the preview matches the saved-quote PDF: sub-items
// render indented with parenthesised totals, parent shows its
// resolved total (sum of priced sub-items), and details_text rows
// appear under their parent. Without these fields the renderer
// treats every row as a top-level item and shows the parent at 0.
const ctx = await buildRenderContext(fakeQuote, totals.lineItems.map((li, idx) => ({
position: li.position == null ? idx + 1 : Number(li.position),
quantity: li.quantity,
description: li.description,
unit_price_minor: li.unit_price_minor,
discount_percent: li.discount_percent,
line_total_minor: li.line_total_minor,
parent_position: li.parent_position == null || li.parent_position === '' ? null : Number(li.parent_position),
details_text: li.details_text || null,
})));
return await pdfService.renderQuoteToBuffer(ctx);
}
/**
* Send a quote: render PDF, persist snapshot, generate accept/decline
* tokens, queue email. Transitions status draft|declined → sent.
*/
async function sendQuote(id, adminId) {
const data = await getQuoteById(id);
if (!data) throw new AppError('Quote not found', 404);
const { quote, lineItems } = data;
if (!['draft', 'declined', 'expired'].includes(quote.status)) {
throw new AppError(`Cannot send a quote with status '${quote.status}'`, 409);
}
const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
ensureCustomerFeatureEnabled(customer, 'quotes');
// Render PDF + persist snapshot.
const ctx = await buildRenderContext(quote, lineItems);
const buffer = await pdfService.renderQuoteToBuffer(ctx);
const pdfPath = await persistDocPdf('quote', quote, buffer);
// Snapshot payment term so future template edits don't mutate the doc.
// Migration 124 — prefer the two new split FKs; fall back to the legacy
// single FK when the quote was authored before the split was deployed.
// Output shape is unchanged: { description, net_days, skonto_percent,
// skonto_within_days, installments } — that's what pdfService and the
// scheduler already read.
const paymentTermSnapshot = await composeSnapshotFromSplitFks(quote)
|| (quote.payment_term_template_id
? await db('payment_term_templates').where({ id: quote.payment_term_template_id }).first()
: null);
// Mint a single shared token; accept and decline are differentiated
// by the request body. This makes the email link survive a customer
// changing their mind inside the 15-min window without sending two
// links.
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = quote.valid_until
? new Date(new Date(quote.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000)
: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
await db.transaction(async (trx) => {
await trx('quote_action_tokens').insert({
quote_id: id,
token,
expires_at: expiresAt,
created_at: new Date(),
});
await trx('quotes').where({ id }).update({
status: 'sent',
sent_at: new Date(),
pdf_path: pdfPath,
payment_term_snapshot: paymentTermSnapshot ? JSON.stringify(paymentTermSnapshot) : null,
updated_at: new Date(),
});
});
// Queue customer email (with PDF + cc) — honour the global
// crm_quotes_pdf_attachment_enabled toggle.
const attachPdf = await getAppSetting('crm_quotes_pdf_attachment_enabled');
const frontendUrl = await getFrontendBaseUrl() || 'http://localhost:3000';
const responseUrl = `${frontendUrl}/quote/${token}`;
await emailProcessor.queueEmail(null, customer.email, 'quote_sent', {
quote_number: quote.quote_number,
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
response_url: responseUrl,
accept_url: `${responseUrl}?action=accept`,
decline_url: `${responseUrl}?action=decline`,
valid_until: formatShortDate(quote.valid_until),
event_name: quote.event_name || '',
total_amount: formatMajor(quote.total_amount_minor, quote.currency, ctx.locale, ctx.issuer?.countryCode),
cc: quote.cc_pdf_email || undefined,
attachments: (attachPdf !== false && pdfPath) ? [{
filename: `${quote.quote_number}.pdf`,
contentPath: pdfPath,
contentType: 'application/pdf',
}] : undefined,
});
try {
await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`);
} catch (_) {}
logger.info('Quote sent', { adminId, quoteId: id });
return { token, pdfPath };
}
function formatMajor(minor, currency, locale, issuerCountryCode) {
// Per maintainer: every DACH-region issuer (FL/CH/DE/AT) writes
// 1'000.00 with an apostrophe separator regardless of document
// language. de-CH is the only Intl locale that produces that
// format, so we force it whenever the issuer sits in that region.
// Outside DACH we still honour the document locale.
const cc = (issuerCountryCode || '').toUpperCase();
const intlLocale = ['CH', 'LI', 'DE', 'AT'].includes(cc)
? 'de-CH'
: (locale === 'de' ? 'de-CH' : 'en-GB');
return new Intl.NumberFormat(intlLocale, {
style: 'currency', currency: (currency || 'CHF').toUpperCase(),
}).format(Number(minor || 0) / 100);
}
/**
* Persist a rendered PDF under storage/business-docs/quote/<YEAR>/<NUMBER>.pdf
*/
async function persistDocPdf(type, doc, buffer) {
const number = doc.quote_number || doc.invoice_number;
if (!number) return null;
const year = (doc.issue_date ? new Date(doc.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', type, String(year));
fs.mkdirSync(root, { recursive: true });
const filePath = path.join(root, `${number}.pdf`);
fs.writeFileSync(filePath, buffer);
return filePath;
}
/**
* Record a customer response from the public accept/decline link.
*
* 15-min toggle rule: the first response opens a window equal to
* crm_quotes_accept_window_minutes (default 15). Within that window
* the same token may flip accept↔decline. After the window expires the
* response is locked.
*/
async function recordResponse({ token, action, ip, tosAccepted }) {
if (!['accept', 'decline'].includes(action)) {
throw new AppError('Invalid action', 400);
}
const tokenRow = await db('quote_action_tokens').where({ token }).first();
if (!tokenRow) {
throw new AppError('Token not found', 404);
}
if (tokenRow.expires_at && new Date(tokenRow.expires_at).getTime() < Date.now()) {
throw new AppError('Token expired', 410);
}
const quote = await db('quotes').where({ id: tokenRow.quote_id }).first();
if (!quote) {
throw new AppError('Quote not found', 404);
}
if (!['sent', 'accepted', 'declined'].includes(quote.status)) {
throw new AppError(`Quote cannot be responded to in status '${quote.status}'`, 409);
}
// Terms of Service handling on accept:
// - Setting OFF: ignored.
// - Setting ON + box ticked: normal acceptance; ToS snapshot
// stored on the quote for audit.
// - Setting ON + box NOT ticked: server returns TOS_REQUIRED;
// the frontend keeps Accept disabled until ticked. To refuse
// the engagement the customer clicks Decline explicitly, which
// records `declined` like any other decline (no ToS needed for
// decline since the customer is rejecting the terms anyway).
const tosRequired = await getAppSetting('crm_quotes_tos_required', false) === true;
const tosText = await getAppSetting('crm_quotes_tos_text', '');
if (action === 'accept' && tosRequired && !tosAccepted) {
throw new AppError('Terms of Service must be accepted before the quote can be accepted.',
400, 'TOS_REQUIRED');
}
const effectiveAction = action;
const now = new Date();
const windowMinutes = ensureInt(await getAppSetting('crm_quotes_accept_window_minutes')) || 15;
// If there's already a response, check if we're inside the toggle window.
if (quote.responded_at && quote.response_locked_at) {
if (now.getTime() > new Date(quote.response_locked_at).getTime()) {
const err = new AppError('Response window has closed', 423, 'RESPONSE_LOCKED');
err.lockedAt = quote.response_locked_at;
err.currentStatus = quote.status;
throw err;
}
}
const isAccept = effectiveAction === 'accept';
const newStatus = isAccept ? 'accepted' : 'declined';
const respondedAt = quote.responded_at || now;
const responseLockedAt = new Date(new Date(respondedAt).getTime() + windowMinutes * 60 * 1000);
await db.transaction(async (trx) => {
const updates = {
status: newStatus,
responded_at: respondedAt,
response_locked_at: responseLockedAt,
accepted_at: isAccept ? now : null,
declined_at: !isAccept ? now : null,
updated_at: now,
};
// Snapshot the ToS text the customer agreed to. Only set on the
// FIRST acceptance — subsequent toggles inside the 15-min window
// don't overwrite, so the audit trail captures the original
// agreement moment.
if (isAccept && tosAccepted && !quote.tos_accepted_at) {
updates.tos_accepted_at = now;
updates.tos_text_snapshot = tosText || null;
}
await trx('quotes').where({ id: quote.id }).update(updates);
await trx('quote_action_tokens').where({ id: tokenRow.id }).update({
used_at: now,
used_action: newStatus,
used_ip: ip || null,
});
});
try {
await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public');
} catch (_) {}
return { status: newStatus, lockedAt: responseLockedAt };
}
/**
* Admin "accept on behalf of customer" — records the quote as
* accepted directly, bypassing the public token + response window.
* Used when the admin is on the phone with the customer and they
* verbally accept; the admin wants the quote flipped to `accepted`
* immediately so they can convert it to an event/invoice.
*
* Unlike recordResponse:
* - No token required
* - No response-window lockout (admin can accept stale / expired
* quotes too — useful for retroactive bookkeeping)
* - Skips the ToS-required guard (admin is responsible for
* confirming verbally; ToS_snapshot stays null)
*
* Refuses to act on quotes that are already terminal: `accepted`,
* `declined`, or `converted` rows would silently overwrite history.
* Admins use the cancel/duplicate flow for those cases.
*/
async function adminAcceptQuote(id, adminId) {
const quote = await db('quotes').where({ id }).first();
if (!quote) throw new AppError('Quote not found', 404);
if (quote.status === 'accepted') {
throw new AppError('Quote already accepted', 409, 'QUOTE_ALREADY_ACCEPTED');
}
if (quote.status === 'declined') {
throw new AppError('Quote was declined; duplicate it to start a fresh round.', 409, 'QUOTE_DECLINED');
}
if (quote.status === 'converted') {
throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED');
}
const now = new Date();
const windowMinutes = ensureInt(await getAppSetting('crm_quotes_accept_window_minutes')) || 15;
const responseLockedAt = new Date(now.getTime() + windowMinutes * 60 * 1000);
await db('quotes').where({ id }).update({
status: 'accepted',
responded_at: now,
response_locked_at: responseLockedAt,
accepted_at: now,
// accept_on_behalf flag intentionally NOT stored as a separate
// column — the audit log entry below captures who accepted and
// when, which is the legally relevant breadcrumb.
updated_at: now,
});
try {
await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`);
} catch (_) {}
// ---- customer confirmation email -------------------------------
// Renders the quote PDF + queues a "quote accepted — on your
// behalf" email so the customer has a paper trail of what they
// just verbally agreed to on the phone. Failures here don't roll
// back the acceptance — the DB row is already updated and the
// admin can re-send via the resend flow if SMTP is down.
try {
const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
if (customer?.email) {
const fresh = await db('quotes').where({ id }).first();
const lineItems = await db('quote_line_items').where({ quote_id: id }).orderBy('position', 'asc');
const ctx = await buildRenderContext(fresh, lineItems);
const buffer = await pdfService.renderQuoteToBuffer(ctx);
// Persist PDF snapshot under the same convention sendQuote uses
// — keeps every issued PDF on disk for the audit trail.
const pdfPath = await persistDocPdf('quote', fresh, buffer);
const formatMoney = (minor, currency, locale) =>
new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', {
style: 'currency', currency: (currency || 'CHF').toUpperCase(),
}).format(Number(minor || 0) / 100);
const lang = customer.preferred_language || ctx.locale || 'de';
await emailProcessor.queueEmail(null, customer.email, 'quote_accepted_customer', {
quote_number: fresh.quote_number,
customer_name: customer.display_name
|| [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|| customer.email.split('@')[0],
event_name: fresh.event_name || '',
total_amount: formatMoney(fresh.total_amount_minor, fresh.currency, lang),
accepted_on_behalf: true,
attachments: [{
filename: `${fresh.quote_number}.pdf`,
contentPath: pdfPath,
contentType: 'application/pdf',
}],
});
}
} catch (err) {
// Email failure is not fatal — log + move on. The acceptance
// itself is recorded; the admin can use Resend later.
logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message });
}
return { status: 'accepted', lockedAt: responseLockedAt };
}
/**
* Admin "decline on behalf of customer" — records the quote as
* `declined` directly, bypassing the public token + response window.
* Used when the customer says no by phone/email and the admin wants the
* pipeline reflected without asking them to click the decline link.
*
* Mirrors adminAcceptQuote's guards: refuses quotes that are already
* terminal (`accepted`, `declined`, `converted`) — those would overwrite
* history. Allowed from `draft` / `sent` / `expired`.
*
* `reason` is optional free text persisted to `quotes.decline_reason`
* (migration 115) and surfaced on the quote detail page.
*
* Any outstanding accept/decline tokens are invalidated so the customer
* can't flip the quote back to accepted via a still-live emailed link.
*/
async function adminDeclineQuote(id, adminId, reason = null) {
const quote = await db('quotes').where({ id }).first();
if (!quote) throw new AppError('Quote not found', 404);
if (quote.status === 'declined') {
throw new AppError('Quote already declined', 409, 'QUOTE_ALREADY_DECLINED');
}
if (quote.status === 'accepted') {
throw new AppError('Quote already accepted; duplicate it to start a fresh round.', 409, 'QUOTE_ALREADY_ACCEPTED');
}
if (quote.status === 'converted') {
throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED');
}
const now = new Date();
const cleanReason = typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 5000) : null;
const hasReasonColumn = await hasColumnCached('quotes', 'decline_reason');
await db.transaction(async (trx) => {
const updates = {
status: 'declined',
responded_at: quote.responded_at || now,
// Close the public response window immediately so a customer link
// can't toggle the quote afterwards (recordResponse rejects once
// now > response_locked_at).
response_locked_at: now,
declined_at: now,
accepted_at: null,
updated_at: now,
};
if (hasReasonColumn) updates.decline_reason = cleanReason;
await trx('quotes').where({ id }).update(updates);
// Burn any unused tokens for this quote — defense in depth alongside
// the closed response window above.
await trx('quote_action_tokens')
.where({ quote_id: id })
.whereNull('used_at')
.update({ used_at: now, used_action: 'declined' });
});
try {
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
} catch (_) {}
return { status: 'declined', declinedAt: now };
}
/**
* Convert an accepted quote to an event + scheduled invoices.
* Wraps everything in a transaction so a half-finished conversion
* doesn't litter the DB.
*
* Implementation note: invoice creation delegates to invoiceService —
* required by Commit 7. We `require` lazily to dodge the circular
* dependency between quoteService and invoiceService.
*/
/**
* Convert an accepted quote directly into an invoice — no event, no
* gallery, just the financial document. Used for engagements that
* don't produce a photo deliverable (consulting, equipment hire, etc).
*
* Creates ONE invoice per installment in the payment-term snapshot —
* same fan-out as convertToEvent, but without the events / event_
* payment_plans rows. The first installment is scheduled to send
* immediately; later ones use the same trigger-relative-to-event
* date logic the schedule pass uses, anchored on the quote's event_
* date if any, else the issue date.
*
* Leaves the quote `accepted` → `converted` state machine intact so
* the same status badge logic works for both paths.
*/
async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
const { quote, lineItems } = (await getQuoteById(quoteId)) || {};
if (!quote) throw new AppError('Quote not found', 404);
if (quote.status !== 'accepted') {
throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409);
}
if (quote.converted_event_id) {
// Already has a linked event — nothing to do here; tell the
// caller to use the event-detail page for new invoices.
throw new AppError('This quote was already converted to an event; create the invoice from the event instead.', 409, 'ALREADY_CONVERTED_TO_EVENT');
}
// Guard against double-spending a quote that already has a contract
// in flight. contractService.convertToInvoiceOnly re-enters this
// path on the contract→invoice button — it passes
// { fromContract: true } so the guard yields.
if (quote.converted_contract_id && !options.fromContract) {
throw new AppError(
'This quote already has a pending contract. Convert the contract to invoices instead, or cancel the contract first.',
409, 'CONTRACT_IN_FLIGHT',
);
}
const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
ensureCustomerFeatureEnabled(customer, 'quotes');
// The customer must also have the bills feature enabled or the
// generated invoice can't be sent.
if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') {
throw new AppError('This customer has Bills disabled — enable it on the customer detail page first.',
409, 'CUSTOMER_FEATURE_DISABLED');
}
const paymentTermSnapshot = quote.payment_term_snapshot
? (typeof quote.payment_term_snapshot === 'string'
? JSON.parse(quote.payment_term_snapshot)
: quote.payment_term_snapshot)
: null;
const invoiceService = require('./invoiceService');
return await db.transaction(async (trx) => {
const installments = Array.isArray(paymentTermSnapshot?.installments)
? paymentTermSnapshot.installments
: [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }];
await invoiceService.scheduleInvoicesForEvent({
trx,
// eventId omitted → invoices have source_quote_id but no event_id.
eventId: null,
quoteId: quote.id,
customer,
currency: quote.currency,
language: quote.language,
lineItems,
totals: {
net: quote.net_amount_minor,
vatRate: quote.vat_rate,
vat: quote.vat_amount_minor,
shipping: quote.shipping_amount_minor,
total: quote.total_amount_minor,
},
installments,
eventDate: quote.event_date,
// Inline event snapshot — copied so the converted invoice
// keeps the quote's event label / times for accounting + UI
// even when there's no `events` row to fall back to (migration 123).
eventName: quote.event_name,
eventTimeStart: quote.event_time_start,
eventTimeEnd: quote.event_time_end,
// Migration 124 — pass the split payment-term FKs + the
// composed snapshot through so the converted invoice carries
// them on both the FK and snapshot paths.
paymentNetDaysTemplateId: quote.payment_net_days_template_id,
paymentTimingTemplateId: quote.payment_timing_template_id,
paymentTermSnapshot,
adminId,
ccPdfEmail: quote.cc_pdf_email,
// Net 14 / 30 / 60 / 90 carry through from the quote's
// selected payment-term template so each scheduled invoice's
// due_date reflects what the customer agreed to on the quote.
netDays: paymentTermSnapshot?.net_days,
// Migration 140 — every spawned invoice inherits the source
// quote's deal_uuid so quote + N invoices group under one deal.
dealUuid: quote.deal_uuid,
});
// Mark quote `converted` without a converted_event_id so the
// existing transition rules still apply (can't be edited / sent
// again). The list view's status badge says "converted"; admin
// sees the linked invoices in the customer detail panel.
await trx('quotes').where({ id: quote.id }).update({
status: 'converted',
updated_at: new Date(),
});
try {
await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: installments.length },
null, `admin:${adminId}`);
} catch (_) {}
logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: installments.length });
return { installmentsCreated: installments.length };
});
}
async function convertToEvent(quoteId, adminId, options = {}) {
const { quote, lineItems } = (await getQuoteById(quoteId)) || {};
if (!quote) throw new AppError('Quote not found', 404);
if (quote.status !== 'accepted') {
throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409);
}
if (quote.converted_event_id) {
return { eventId: quote.converted_event_id, alreadyConverted: true };
}
// Same guard as convertToInvoiceOnly — refuse if a contract is in
// flight unless the contract→event button re-entered this path.
if (quote.converted_contract_id && !options.fromContract) {
throw new AppError(
'This quote already has a pending contract. Convert the contract to an event instead, or cancel the contract first.',
409, 'CONTRACT_IN_FLIGHT',
);
}
const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
ensureCustomerFeatureEnabled(customer, 'quotes');
const paymentTermSnapshot = quote.payment_term_snapshot
? (typeof quote.payment_term_snapshot === 'string'
? JSON.parse(quote.payment_term_snapshot)
: quote.payment_term_snapshot)
: null;
// Lazy import to avoid the circular dep.
const invoiceService = require('./invoiceService');
return await db.transaction(async (trx) => {
// The events table schema has drifted across migrations:
// installs that ran the original 060 series have
// host_name/host_email; later ones renamed to customer_*; some
// have both. Rather than hard-code one set and fail on the
// other, introspect the columns at runtime and only insert
// fields the table actually has.
const adminRow = await trx('admin_users').where({ id: adminId }).first();
const oneYearAfterEvent = new Date(quote.event_date || quote.issue_date);
oneYearAfterEvent.setFullYear(oneYearAfterEvent.getFullYear() + 1);
const placeholder = crypto.randomBytes(32).toString('hex');
const shareLink = crypto.randomBytes(32).toString('hex');
const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|| customer.display_name || customer.company_name || quote.quote_number;
const customerEmail = customer.email || `${quote.quote_number.toLowerCase()}@picpeak.local`;
const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local';
// Each candidate column is paired with the value we'd write. We
// ask the DB which columns exist and only keep the matching pairs
// — bullet-proof against schema drift in either direction.
const eventCols = await trx('events').columnInfo();
const candidate = {
slug: `quote-${quote.quote_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
event_name: quote.event_name || `Event ${quote.quote_number}`,
event_date: quote.event_date || quote.issue_date,
host_name: fullName,
host_email: customerEmail,
customer_name: fullName,
customer_email: customerEmail,
customer_phone: customer.phone,
admin_email: adminEmail,
event_type: 'wedding',
password_hash: placeholder,
share_link: shareLink,
share_token: shareLink,
expires_at: oneYearAfterEvent,
is_active: true,
is_archived: false,
is_draft: true,
created_by: adminId,
quote_id: quote.id,
created_at: new Date(),
updated_at: new Date(),
};
const eventRow = {};
for (const [k, v] of Object.entries(candidate)) {
if (Object.prototype.hasOwnProperty.call(eventCols, k)) eventRow[k] = v;
}
const inserted = await trx('events').insert(eventRow).returning('id');
const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Junction row so the customer can already see the event in their
// dashboard once the admin activates it.
await trx('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customer.id,
assigned_by_admin_id: adminId,
assigned_at: new Date(),
});
// Payment-plan glue.
await trx('event_payment_plans').insert({
event_id: eventId,
quote_id: quote.id,
payment_term_snapshot: JSON.stringify(paymentTermSnapshot || {}),
created_at: new Date(),
updated_at: new Date(),
});
// Build the invoice schedule from installments.
const installments = Array.isArray(paymentTermSnapshot?.installments)
? paymentTermSnapshot.installments
: [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }];
await invoiceService.scheduleInvoicesForEvent({
trx,
eventId,
quoteId: quote.id,
customer,
currency: quote.currency,
language: quote.language,
lineItems,
totals: {
net: quote.net_amount_minor,
vatRate: quote.vat_rate,
vat: quote.vat_amount_minor,
shipping: quote.shipping_amount_minor,
total: quote.total_amount_minor,
},
installments,
eventDate: quote.event_date,
// Inline event snapshot — same rationale as convertToInvoiceOnly
// above (migration 123).
eventName: quote.event_name,
eventTimeStart: quote.event_time_start,
eventTimeEnd: quote.event_time_end,
adminId,
ccPdfEmail: quote.cc_pdf_email,
// Net 14 / 30 / 60 / 90 carry through from the quote's
// payment-term template (same as convertToInvoiceOnly).
netDays: paymentTermSnapshot?.net_days,
// Migration 140 — propagate the quote's deal_uuid down through
// every spawned invoice (same as convertToInvoiceOnly above).
dealUuid: quote.deal_uuid,
});
await trx('quotes').where({ id: quote.id }).update({
status: 'converted',
converted_event_id: eventId,
updated_at: new Date(),
});
try {
await logActivity('quote_converted', { quoteId: quote.id, eventId }, eventId, `admin:${adminId}`);
} catch (_) {}
logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId });
return { eventId, alreadyConverted: false };
});
}
async function duplicateQuote(id, adminId) {
const { quote, lineItems } = (await getQuoteById(id)) || {};
if (!quote) throw new AppError('Quote not found', 404);
return await createQuote({
customerAccountId: quote.customer_account_id,
language: quote.language,
currency: quote.currency,
eventName: quote.event_name,
eventDate: quote.event_date,
eventTimeStart: quote.event_time_start,
eventTimeEnd: quote.event_time_end,
expectedDurationHours: quote.expected_duration_hours,
paymentTermTemplateId: quote.payment_term_template_id,
vatRate: quote.vat_rate,
shippingAmountMinor: quote.shipping_amount_minor,
introText: quote.intro_text,
outroText: quote.outro_text,
internalNotes: quote.internal_notes,
ccPdfEmail: quote.cc_pdf_email,
businessBankAccountId: quote.business_bank_account_id,
lineItems: lineItems.map((li) => ({
position: li.position,
quantity: li.quantity,
description: li.description,
unit_price_minor: li.unit_price_minor,
discount_percent: li.discount_percent,
})),
}, adminId);
}
// ---------------------------------------------------------------------
// Presets (line items + payment terms)
// ---------------------------------------------------------------------
async function listLineItemPresets() {
return await db('quote_line_item_presets')
.where({ is_active: formatBoolean(true) })
.orderBy('display_order', 'asc').orderBy('id', 'asc');
}
async function createLineItemPreset(payload) {
const row = {
name: payload.name,
description: payload.description || '',
unit_price_minor: ensureInt(payload.unit_price_minor),
currency: (payload.currency || 'CHF').toUpperCase(),
quantity_default: ensureNumber(payload.quantity_default, 1),
display_order: ensureInt(payload.display_order),
is_active: formatBoolean(true),
created_at: new Date(),
updated_at: new Date(),
};
const inserted = await db('quote_line_item_presets').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
return await db('quote_line_item_presets').where({ id }).first();
}
async function updateLineItemPreset(id, payload) {
const map = {
name: 'name', description: 'description', currency: 'currency',
unit_price_minor: 'unit_price_minor', quantity_default: 'quantity_default',
display_order: 'display_order', is_active: 'is_active',
};
const updates = { updated_at: new Date() };
for (const [api, col] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(payload, api)) {
updates[col] = col === 'is_active' ? formatBoolean(Boolean(payload[api])) : payload[api];
}
}
await db('quote_line_item_presets').where({ id }).update(updates);
return await db('quote_line_item_presets').where({ id }).first();
}
async function deleteLineItemPreset(id) {
// Soft delete via is_active = false to preserve historical references.
await db('quote_line_item_presets').where({ id })
.update({ is_active: formatBoolean(false), updated_at: new Date() });
return { deleted: true };
}
async function listPaymentTermTemplates() {
return await db('payment_term_templates')
.where({ is_active: formatBoolean(true) })
.orderBy('display_order', 'asc').orderBy('id', 'asc');
}
async function createPaymentTermTemplate(payload) {
if (!Array.isArray(payload.installments) || payload.installments.length === 0) {
throw new AppError('At least one installment is required', 400);
}
const sum = payload.installments.reduce((s, x) => s + ensureNumber(x.percent, 0), 0);
if (Math.abs(sum - 100) > 0.01) {
throw new AppError('Installment percentages must sum to 100', 400);
}
const row = {
name: payload.name,
description: payload.description || '',
net_days: ensureInt(payload.net_days) || 30,
skonto_percent: payload.skonto_percent == null ? null : ensureNumber(payload.skonto_percent),
skonto_within_days: payload.skonto_within_days == null ? null : ensureInt(payload.skonto_within_days),
installments: JSON.stringify(payload.installments),
is_system: formatBoolean(false),
is_active: formatBoolean(true),
display_order: ensureInt(payload.display_order),
created_at: new Date(),
updated_at: new Date(),
};
const inserted = await db('payment_term_templates').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
return await db('payment_term_templates').where({ id }).first();
}
async function updatePaymentTermTemplate(id, payload) {
const existing = await db('payment_term_templates').where({ id }).first();
if (!existing) throw new AppError('Not found', 404);
if (existing.is_system && Object.prototype.hasOwnProperty.call(payload, 'installments')) {
// Allow renaming + description tweaks on system rows but never let
// an admin reshape the installment array — keeps the "factory
// presets" semantically stable for migrations & docs.
delete payload.installments;
}
const updates = { updated_at: new Date() };
for (const k of ['name', 'description', 'net_days', 'skonto_percent', 'skonto_within_days', 'display_order', 'is_active']) {
if (Object.prototype.hasOwnProperty.call(payload, k)) {
updates[k] = k === 'is_active' ? formatBoolean(Boolean(payload[k])) : payload[k];
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'installments')) {
updates.installments = JSON.stringify(payload.installments);
}
await db('payment_term_templates').where({ id }).update(updates);
return await db('payment_term_templates').where({ id }).first();
}
async function deletePaymentTermTemplate(id) {
const existing = await db('payment_term_templates').where({ id }).first();
if (!existing) throw new AppError('Not found', 404);
if (existing.is_system) {
throw new AppError('Cannot delete a system payment-term template', 409);
}
// Soft-delete to keep snapshots referenced by sent quotes coherent.
await db('payment_term_templates').where({ id })
.update({ is_active: formatBoolean(false), updated_at: new Date() });
return { deleted: true };
}
// ---------------------------------------------------------------------
// Split payment-term templates — net-days + timing (migration 124).
//
// The two new tables decouple the "Net X days" choice from the
// "payment timing / split" choice. CRUD shape mirrors the legacy
// payment_term_templates helpers above so adminQuotes routes can drop
// in matching endpoints without re-deriving validation rules.
// ---------------------------------------------------------------------
async function listPaymentNetDaysTemplates() {
return await db('payment_net_days_templates')
.where({ is_active: formatBoolean(true) })
.orderBy('display_order', 'asc').orderBy('id', 'asc');
}
async function createPaymentNetDaysTemplate(payload) {
if (payload.net_days == null) {
throw new AppError('net_days is required', 400);
}
const row = {
name: payload.name,
description: payload.description || null,
// Allow 0 ("Sofort fällig"). ensureInt would coerce non-numbers
// to 0 which is fine for missing values but we already null-check
// above to catch the genuinely-missing case.
net_days: ensureInt(payload.net_days),
skonto_percent: payload.skonto_percent == null ? null : ensureNumber(payload.skonto_percent),
skonto_within_days: payload.skonto_within_days == null ? null : ensureInt(payload.skonto_within_days),
is_system: formatBoolean(false),
is_active: formatBoolean(true),
display_order: ensureInt(payload.display_order),
created_at: new Date(),
updated_at: new Date(),
};
const inserted = await db('payment_net_days_templates').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
return await db('payment_net_days_templates').where({ id }).first();
}
async function updatePaymentNetDaysTemplate(id, payload) {
const existing = await db('payment_net_days_templates').where({ id }).first();
if (!existing) throw new AppError('Not found', 404);
const updates = { updated_at: new Date() };
for (const k of ['name', 'description', 'net_days', 'skonto_percent', 'skonto_within_days', 'display_order', 'is_active']) {
if (Object.prototype.hasOwnProperty.call(payload, k)) {
updates[k] = k === 'is_active' ? formatBoolean(Boolean(payload[k])) : payload[k];
}
}
await db('payment_net_days_templates').where({ id }).update(updates);
return await db('payment_net_days_templates').where({ id }).first();
}
async function deletePaymentNetDaysTemplate(id) {
const existing = await db('payment_net_days_templates').where({ id }).first();
if (!existing) throw new AppError('Not found', 404);
if (existing.is_system) {
throw new AppError('Cannot delete a system net-days template', 409);
}
// Soft-delete — sent quote/invoice snapshots survive independently.
await db('payment_net_days_templates').where({ id })
.update({ is_active: formatBoolean(false), updated_at: new Date() });
return { deleted: true };
}
async function listPaymentTimingTemplates() {
return await db('payment_timing_templates')
.where({ is_active: formatBoolean(true) })
.orderBy('display_order', 'asc').orderBy('id', 'asc');
}
async function createPaymentTimingTemplate(payload) {
if (!Array.isArray(payload.installments) || payload.installments.length === 0) {
throw new AppError('At least one installment is required', 400);
}
const sum = payload.installments.reduce((s, x) => s + ensureNumber(x.percent, 0), 0);
if (Math.abs(sum - 100) > 0.01) {
throw new AppError('Installment percentages must sum to 100', 400);
}
const row = {
name: payload.name,
description: payload.description || null,
installments: JSON.stringify(payload.installments),
is_system: formatBoolean(false),
is_active: formatBoolean(true),
display_order: ensureInt(payload.display_order),
created_at: new Date(),
updated_at: new Date(),
};
const inserted = await db('payment_timing_templates').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
return await db('payment_timing_templates').where({ id }).first();
}
async function updatePaymentTimingTemplate(id, payload) {
const existing = await db('payment_timing_templates').where({ id }).first();
if (!existing) throw new AppError('Not found', 404);
// Same rule as the legacy helper — system rows can be renamed but
// their installments array is locked so migrations + docs stay
// semantically stable.
if (existing.is_system && Object.prototype.hasOwnProperty.call(payload, 'installments')) {
delete payload.installments;
}
const updates = { updated_at: new Date() };
for (const k of ['name', 'description', 'display_order', 'is_active']) {
if (Object.prototype.hasOwnProperty.call(payload, k)) {
updates[k] = k === 'is_active' ? formatBoolean(Boolean(payload[k])) : payload[k];
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'installments')) {
updates.installments = JSON.stringify(payload.installments);
}
await db('payment_timing_templates').where({ id }).update(updates);
return await db('payment_timing_templates').where({ id }).first();
}
/**
* Compose a legacy-shape `payment_term_snapshot` JSON object from the
* two new split FKs on a quote or invoice row (migration 124).
*
* Returns null when at least one of the two FKs is unset — the caller
* then falls back to reading the legacy `payment_term_template_id`
* column for backward compat. We deliberately don't blend partial
* data with legacy data; either the split path applies cleanly or it
* doesn't.
*
* Output shape is identical to the legacy template row so downstream
* consumers (pdfService, scheduleInvoicesForEvent, dunning) work
* without changes:
*
* { description, net_days, skonto_percent, skonto_within_days,
* installments }
*/
async function composeSnapshotFromSplitFks(row) {
if (!row.payment_net_days_template_id || !row.payment_timing_template_id) return null;
const netDays = await db('payment_net_days_templates')
.where({ id: row.payment_net_days_template_id }).first();
const timing = await db('payment_timing_templates')
.where({ id: row.payment_timing_template_id }).first();
if (!netDays || !timing) return null;
// Migration 142 — ad-hoc installments override. When the quote
// carries a populated `payment_term_installments_override`, those
// rows replace the template's installments in the snapshot. Keeps
// every other snapshot field (net_days / skonto) coming from the
// chosen templates so the override only touches what the admin
// explicitly customised.
let override = null;
if (row.payment_term_installments_override) {
try {
override = typeof row.payment_term_installments_override === 'string'
? JSON.parse(row.payment_term_installments_override)
: row.payment_term_installments_override;
if (!Array.isArray(override) || override.length === 0) override = null;
} catch (_) { override = null; }
}
const templateInstallments = typeof timing.installments === 'string'
? JSON.parse(timing.installments)
: timing.installments;
return {
description: timing.description || netDays.description || null,
net_days: netDays.net_days,
skonto_percent: netDays.skonto_percent,
skonto_within_days: netDays.skonto_within_days,
installments: override || templateInstallments,
};
}
async function deletePaymentTimingTemplate(id) {
const existing = await db('payment_timing_templates').where({ id }).first();
if (!existing) throw new AppError('Not found', 404);
if (existing.is_system) {
throw new AppError('Cannot delete a system timing template', 409);
}
await db('payment_timing_templates').where({ id })
.update({ is_active: formatBoolean(false), updated_at: new Date() });
return { deleted: true };
}
module.exports = {
// Lifecycle
listQuotes,
getQuoteById,
createQuote,
updateQuote,
sendQuote,
duplicateQuote,
recordResponse,
adminAcceptQuote,
adminDeclineQuote,
convertToEvent,
convertToInvoiceOnly,
// Preview / PDF
renderQuotePdfBuffer,
renderQuotePdfFromPayload,
// Presets
listLineItemPresets,
createLineItemPreset,
updateLineItemPreset,
deleteLineItemPreset,
listPaymentTermTemplates,
createPaymentTermTemplate,
updatePaymentTermTemplate,
deletePaymentTermTemplate,
// Split payment-term templates (migration 124).
listPaymentNetDaysTemplates,
createPaymentNetDaysTemplate,
updatePaymentNetDaysTemplate,
deletePaymentNetDaysTemplate,
listPaymentTimingTemplates,
createPaymentTimingTemplate,
updatePaymentTimingTemplate,
deletePaymentTimingTemplate,
// Internals exposed for tests + invoiceService re-use.
_internal: {
computeTotals,
ensureCustomerFeatureEnabled,
nextQuoteNumber,
persistDocPdf,
buildRenderContext,
// Migration 119: hierarchy helpers — shared with invoiceService
// (commit 3) so the quote → invoice cloner stays consistent.
validateLineItemHierarchy,
insertLineItemsHierarchical,
resolveParentTotalsFromSubItems,
},
};