diff --git a/backend/src/routes/adminEvents/archiveBulk.js b/backend/src/routes/adminEvents/archiveBulk.js new file mode 100644 index 00000000..01168464 --- /dev/null +++ b/backend/src/routes/adminEvents/archiveBulk.js @@ -0,0 +1,196 @@ +// Extracted verbatim from the original routes/adminEvents.js (see ./index.js). +// Exports a register function; ./index.js calls the sub-routers in the original +// registration order so Express route matching is unchanged. + +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../../database/db'); +const { formatBoolean } = require('../../utils/dbCompat'); +const { adminAuth } = require('../../middleware/auth'); +const { requirePermission } = require('../../middleware/permissions'); +const { archiveEvent } = require('../../services/archiveService'); +const logger = require('../../utils/logger'); +const { errorResponse } = require('../../utils/routeHelpers'); +const { requireEventOwnership } = require('../../middleware/ownership'); +const { deleteEventCascade } = require('./helpers'); + + +// Bulk delete — destructive, irreversible. Caps at 100 events per request +// to keep request time bounded; the per-event cascade touches 5 DB tables +// + 3 filesystem paths so 1000 events would risk timing out the request. +// Loops via deleteEventCascade so the per-event delete behaviour stays in +// lock-step with DELETE /:id. +// +// Confirmation is enforced client-side via the typed-DELETE pattern in +// BulkDeleteModal (#417). The previous server-side bcrypt-password gate +// was dropped because the destructive single-event DELETE /:id has never +// required a password either — events.delete permission + admin session +// is the auth boundary for both. The typed-literal client gate is the +// "accidental click" safeguard, and unlike a password input it isn't +// affected by passkey/Windows Hello autofill that auto-submits the form. +const BULK_DELETE_MAX = 100; + +module.exports = (router) => { + + +// Archive event +router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (event.is_archived) { + return res.status(400).json({ error: 'Event is already archived' }); + } + + // Use the archive service to create ZIP archive + await archiveEvent(event); + + // Log activity + await logActivity('event_archived', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event archived successfully' }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to archive event'); + } +}); + +// Bulk archive events +router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ + body('eventIds').isArray().withMessage('eventIds must be an array'), + body('eventIds.*').isInt().withMessage('Each eventId must be an integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { eventIds } = req.body; + + if (eventIds.length === 0) { + return res.status(400).json({ error: 'No events selected for archiving' }); + } + + // Get all events to archive + const events = await db('events') + .whereIn('id', eventIds) + .where('is_archived', formatBoolean(false)); + + if (events.length === 0) { + return res.status(400).json({ error: 'No valid events found to archive' }); + } + + const results = { + successful: [], + failed: [] + }; + + // Process each event + for (const event of events) { + try { + // Use the archive service to create ZIP archive + await archiveEvent(event); + + // Log activity + await logActivity('event_archived', + { eventName: event.event_name, bulkOperation: true }, + event.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + results.successful.push({ + id: event.id, + name: event.event_name + }); + } catch (error) { + logger.error(`Failed to archive event ${event.id}:`, error); + results.failed.push({ + id: event.id, + name: event.event_name, + error: 'Failed to archive event. Check server logs for details.' + }); + } + } + + // Log bulk archive activity + await logActivity('bulk_archive_completed', + { + totalEvents: eventIds.length, + successfulCount: results.successful.length, + failedCount: results.failed.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, + results + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to perform bulk archive'); + } +}); +router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ + body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`), + body('eventIds.*').isInt().withMessage('Each eventId must be an integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { eventIds } = req.body; + + // Editor-role events.delete permission is already gated by the route + // middleware. We do NOT additionally filter to created_by here because + // the per-event delete-cascade is global (matches DELETE /:id which + // also has no role-based filter — that's why events.delete is a + // sensitive permission). + + const results = { successful: [], failed: [] }; + const adminContext = { id: req.admin.id, username: req.admin.username }; + + for (const eventId of eventIds) { + try { + const deleted = await deleteEventCascade(eventId, adminContext); + results.successful.push(deleted); + } catch (err) { + results.failed.push({ + id: eventId, + name: null, + error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event' + }); + logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message }); + } + } + + await logActivity('bulk_delete_completed', + { + totalEvents: eventIds.length, + successfulCount: results.successful.length, + failedCount: results.failed.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, + results + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to perform bulk delete'); + } +}); + +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents/crud.js similarity index 64% rename from backend/src/routes/adminEvents.js rename to backend/src/routes/adminEvents/crud.js index 591fca39..6b9e5599 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents/crud.js @@ -1,349 +1,35 @@ -const express = require('express'); +// Extracted verbatim from the original routes/adminEvents.js (see ./index.js). +// Exports a register function; ./index.js calls the sub-routers in the original +// registration order so Express route matching is unchanged. + const { body, validationResult } = require('express-validator'); -const { db, logActivity } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -const { slugify } = require('../utils/slug'); -const { adminAuth } = require('../middleware/auth'); -const { requirePermission } = require('../middleware/permissions'); -const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization'); -const router = express.Router(); +const { db, logActivity } = require('../../database/db'); +const { formatBoolean } = require('../../utils/dbCompat'); +const { slugify } = require('../../utils/slug'); +const { adminAuth } = require('../../middleware/auth'); +const { requirePermission } = require('../../middleware/permissions'); +const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); const fs = require('fs').promises; const path = require('path'); -const multer = require('multer'); -const { archiveEvent } = require('../services/archiveService'); -const { queueEmail } = require('../services/emailProcessor'); -const { escapeLikePattern } = require('../utils/sqlSecurity'); -// formatDate import removed - dates are formatted by email processor -const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); -const logger = require('../utils/logger'); -const { errorResponse } = require('../utils/routeHelpers'); -const { buildShareLinkVariants } = require('../services/shareLinkService'); -const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); -const eventTypeService = require('../services/eventTypeService'); -const { normaliseEventTimeTriple } = require('../services/eventService'); -const { hasColumnCached } = require('../utils/schemaCache'); -const { validateFileType } = require('../utils/fileSecurityUtils'); -const { requireEventOwnership } = require('../middleware/ownership'); -const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); -const { getAppSetting } = require('../utils/appSettings'); -const { getFrontendBaseUrl } = require('../utils/frontendUrl'); -const downloadZipService = require('../services/downloadZipService'); +const { escapeLikePattern } = require('../../utils/sqlSecurity'); +const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation'); +const logger = require('../../utils/logger'); +const { errorResponse } = require('../../utils/routeHelpers'); +const { buildShareLinkVariants } = require('../../services/shareLinkService'); +const { parseBooleanInput } = require('../../utils/parsers'); +const eventTypeService = require('../../services/eventTypeService'); +const { normaliseEventTimeTriple } = require('../../services/eventService'); +const { hasColumnCached } = require('../../utils/schemaCache'); +const { requireEventOwnership } = require('../../middleware/ownership'); +const { getAppSetting } = require('../../utils/appSettings'); +const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); +const downloadZipService = require('../../services/downloadZipService'); +const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); -// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point -const validateHeroImageAnchor = (value) => { - if (['top', 'center', 'bottom'].includes(value)) return true; - if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { - const [x, y] = value.split(/\s+/).map(v => parseInt(v)); - if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; - } - throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); -}; +module.exports = (router) => { -// Get storage path from environment or default -const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - -// Configure multer for event logo uploads -const eventLogoStorage = multer.diskStorage({ - destination: async (req, file, cb) => { - const uploadDir = path.join(getStoragePath(), 'uploads/logos/events'); - await fs.mkdir(uploadDir, { recursive: true }); - cb(null, uploadDir); - }, - filename: (req, file, cb) => { - const ext = path.extname(file.originalname); - cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`); - } -}); - -const eventLogoUpload = multer({ - storage: eventLogoStorage, - limits: { fileSize: 5 * 1024 * 1024 }, // 5MB - fileFilter: (req, file, cb) => { - const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']; - if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { - return cb(null, true); - } else { - cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed')); - } - } -}); - -// Helper to get event field requirements from settings -const getEventFieldRequirements = async () => { - try { - const settings = await db('app_settings') - .whereIn('setting_key', [ - 'event_require_customer_name', - 'event_require_customer_email', - 'event_require_admin_email', - 'event_require_event_date', - 'event_require_expiration' - ]) - .select('setting_key', 'setting_value'); - - const requirements = { - require_customer_name: true, - require_customer_email: true, - require_admin_email: true, - require_event_date: true, - require_expiration: true - }; - - settings.forEach(s => { - let value = s.setting_value; - if (typeof value === 'string') { - try { - value = JSON.parse(value); - } catch (e) { - value = value === 'true'; - } - } - if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value; - if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value; - if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value; - if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value; - if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value; - }); - - return requirements; - } catch (error) { - logger.error('Failed to get event field requirements', { error: error.message }); - return { - require_customer_name: true, - require_customer_email: true, - require_admin_email: true, - require_event_date: true, - require_expiration: true - }; - } -}; - -// Helper to read app_settings booleans by key, used to inherit per-setting -// defaults onto new events. Returns `undefined` for missing/non-boolean rows -// so callers can fall back to a legacy default. -const readBooleanSetting = async (key) => { - try { - const setting = await db('app_settings').where('setting_key', key).first(); - if (!setting) return undefined; - let value = setting.setting_value; - if (typeof value === 'string') { - try { value = JSON.parse(value); } catch { /* keep raw */ } - } - return typeof value === 'boolean' ? value : undefined; - } catch (error) { - logger.error('Failed to read app setting', { key, error: error.message }); - return undefined; - } -}; - -// Helper to read the global "enable_devtools_protection" admin setting so -// new events inherit it instead of always falling back to the DB column default -// (#317 — admin disabled it globally but new events still got it ON). -const getDownloadProtectionDefaults = async () => { - return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') }; -}; - -// Helper to get branding defaults for new events (Feature 7: Branding Inheritance). -// -// Note: `branding_logo_position` (header bar — left/center/right) is a -// different concept from `hero_logo_position` (hero block — top/center/ -// bottom) and must NOT be mapped here. A previous version copied the -// branding value over, which wrote 'left'/'right' into per-event -// hero_logo_position columns and broke any subsequent PUT validation -// (#357). Migration 084 heals existing rows. -const getBrandingDefaults = async () => { - try { - const settings = await db('app_settings') - .whereIn('setting_key', [ - 'branding_logo_display_hero', - 'branding_logo_size' - ]) - .select('setting_key', 'setting_value'); - - const defaults = { - hero_logo_visible: true, - hero_logo_size: 'medium', - hero_logo_position: 'top' - }; - - settings.forEach(s => { - let value = s.setting_value; - if (typeof value === 'string') { - try { value = JSON.parse(value); } catch (e) { /* use as-is */ } - } - if (s.setting_key === 'branding_logo_display_hero') { - defaults.hero_logo_visible = value !== false; - } - if (s.setting_key === 'branding_logo_size' && value) { - defaults.hero_logo_size = value; - } - }); - - return defaults; - } catch (error) { - logger.error('Failed to get branding defaults', { error: error.message }); - return { - hero_logo_visible: true, - hero_logo_size: 'medium', - hero_logo_position: 'top' - }; - } -}; - -// Use parseStringInput from shared parsers for customer data extraction -const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); -const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); -const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); - -// Whether the global "phone field" toggle (#322) is enabled. Cached for -// the request via a module-level read; drift is acceptable since this -// only governs whether to persist the field, not security boundaries. -const isPhoneFieldEnabled = async () => { - try { - const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); - if (!row) return false; - let value = row.setting_value; - if (typeof value === 'string') { - try { value = JSON.parse(value); } catch { /* keep raw */ } - } - return value === true; - } catch (error) { - logger.debug('Failed to read event_phone_field_enabled', { error: error.message }); - return false; - } -}; - -const mapEventForApi = (event) => { - if (!event || typeof event !== 'object') { - return event; - } - - const { - host_name, - host_email, - customer_name, - customer_email, - customer_phone, - password_hash: _ph, - client_password_hash: _cph, - ...rest - } = event; - - return { - ...rest, - customer_name: customer_name ?? host_name ?? null, - customer_email: customer_email ?? host_email ?? null, - customer_phone: customer_phone ?? null - }; -}; - -let customerColumnCache = null; -const hasCustomerContactColumns = async () => { - if (customerColumnCache === true) { - return true; - } - - try { - const hasColumn = await db.schema.hasColumn('events', 'customer_email'); - if (hasColumn) { - customerColumnCache = true; - } - return hasColumn; - } catch (error) { - logger.debug('Failed to detect customer_email column', { error: error.message }); - return false; - } -}; - -// Cascade-delete a single event: photos, audit/access logs, queued emails, -// the event row itself (in one transaction), then the on-disk folder / -// archive zip / hero logo (best-effort — file failures don't unwind the DB -// changes since the source of truth is the database). Used by both the -// per-event DELETE /:id route and the bulk-delete route to avoid drift. -// -// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the -// bulk-delete loop can report it as a per-id failure without aborting the -// whole batch. Any other error propagates and is the caller's problem. -async function deleteEventCascade(eventId, adminContext) { - const event = await db('events').where('id', eventId).first(); - if (!event) { - const err = new Error('Event not found'); - err.code = 'EVENT_NOT_FOUND'; - throw err; - } - - await db.transaction(async (trx) => { - // 1. Delete activity logs (audit trail) - await trx('activity_logs').where('event_id', eventId).del(); - // 2. Delete access logs - await trx('access_logs').where('event_id', eventId).del(); - // 3. Delete email queue entries - await trx('email_queue').where('event_id', eventId).del(); - // 4. Delete photos (also handles hero_photo_id foreign key) - await trx('photos').where('event_id', eventId).del(); - // 5. Finally delete the event row - await trx('events').where('id', eventId).del(); - - // Best-effort filesystem cleanup. Failures are logged but don't unwind - // the transaction — the canonical state lives in the DB; orphan files - // are recoverable noise, a half-deleted DB row is a permanent mess. - // - // #608 — previous code read `event.folder_path`, but that column is - // never written anywhere in the codebase (grep confirms: two reads in - // this function, zero writes). It's always undefined, so the - // `if (event.folder_path)` branch silently no-op'd and every event - // delete since this cascade landed left its photos orphaned on disk. - // jodrmx's Pi report (v3.44.0) was the first surfacing. - // - // Files actually live at: - // {STORAGE_PATH}/events/active/{slug}/... (uploaded photos) - // {STORAGE_PATH}/events/archived/{slug}/... (after the event - // was archived — folder copy survives the archive flow) - // - // `event.slug` is NOT NULL on the events table and is slugify-sanitized - // on every write (lower-case ASCII + dashes only via utils/slug.js), - // so path-traversal isn't a concern. - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - for (const sub of ['active', 'archived']) { - const eventFolderPath = path.join(storagePath, 'events', sub, event.slug); - try { - await fs.rm(eventFolderPath, { recursive: true, force: true }); - } catch (fsErr) { - logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message }); - } - } - - if (event.archive_path) { - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - const archiveFile = path.join(storagePath, event.archive_path); - try { - await fs.unlink(archiveFile); - } catch (fsErr) { - logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message }); - } - } - - if (event.hero_logo_path) { - try { - await fs.unlink(event.hero_logo_path); - } catch (fsErr) { - logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message }); - } - } - }); - - // Audit trail (outside the transaction so a logging failure can't undo - // the actual delete). - await logActivity('event_deleted', - { event_name: event.event_name }, - null, - { type: 'admin', id: adminContext.id, name: adminContext.username } - ); - - return { id: event.id, name: event.event_name }; -} // Create new event router.post('/', adminAuth, requirePermission('events.create'), [ @@ -625,7 +311,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ } // Create folder structure - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); const eventPath = path.join(storagePath, 'events/active', slug); await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); @@ -762,7 +448,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // them rather than 403 the entire create. if (Array.isArray(req.body.customer_account_ids)) { try { - const customerAccountsService = require('../services/customerAccountsService'); + const customerAccountsService = require('../../services/customerAccountsService'); if (await customerAccountsService.isCustomerPortalEnabled()) { await customerAccountsService.setAssignmentsForEvent( eventId, @@ -806,7 +492,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // Payload uses canonical event subject (#341) so receivers always see // the same shape (id/slug/event_name + customer contact + share_*). try { - const webhookService = require('../services/webhookService'); + const webhookService = require('../../services/webhookService'); await webhookService.fire('event.created', { event: { ...webhookService.buildEventSubject({ @@ -869,7 +555,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // never block gallery creation. if (!isDraft && customerPhone) { try { - const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor'); + const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); const waConfig = await getWhatsAppConfig(); if (waConfig && waConfig.enabled) { await queueWhatsapp(eventId, customerPhone, 'gallery_created', { @@ -891,7 +577,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ // this covers the "create-and-publish in one shot" path. if (!isDraft) { try { - const webhookService = require('../services/webhookService'); + const webhookService = require('../../services/webhookService'); await webhookService.fire('event.published', { event: webhookService.buildEventSubject({ id: eventId, @@ -1081,7 +767,7 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) // an empty array on installs missing the table (e.g. pre-migrate). let customerAccounts = []; try { - const customerAccountsService = require('../services/customerAccountsService'); + const customerAccountsService = require('../../services/customerAccountsService'); customerAccounts = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10)); } catch (e) { logger.warn('Failed to load customer assignments for event', { eventId: id, error: e.message }); @@ -1193,7 +879,7 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require // (customer_gallery_assigned, in the customer's own language) instead of // the gallery_created mail, which needs an inline recipient. Best-effort. try { - const customerAccountsService = require('../services/customerAccountsService'); + const customerAccountsService = require('../../services/customerAccountsService'); const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10)); for (const c of assigned.filter((a) => a.is_active !== false && a.is_active !== 0 && a.email)) { await customerAccountsService @@ -1210,7 +896,7 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require // WhatsApp as well. Uses customer_phone from the persisted event row. if (event.customer_phone) { try { - const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor'); + const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); const waConfig = await getWhatsAppConfig(); if (waConfig && waConfig.enabled) { const { shareUrl: shareUrlForWa } = await buildShareLinkVariants({ @@ -1242,7 +928,7 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require // Fire event.published webhook (#327) — draft → live transition. // Canonical payload (#341): includes customer contact + share_token. try { - const webhookService = require('../services/webhookService'); + const webhookService = require('../../services/webhookService'); const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); await webhookService.fire('event.published', { event: webhookService.buildEventSubject({ @@ -1293,7 +979,7 @@ router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), req const { event_name, event_date, customer_name, customer_email } = req.body; // Generate a fresh unique slug using the same shape as the create path. - const slugify = require('../utils/slug').slugify; + const slugify = require('../../utils/slug').slugify; const processedEventName = slugify(event_name); const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); const baseSlug = `${source.event_type}-${processedEventName}-${slugSuffix}`; @@ -1334,7 +1020,7 @@ router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), req const password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); // Create the storage folder structure (same as create path). - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); const eventPath = path.join(storagePath, 'events/active', slug); await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); @@ -1797,7 +1483,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne // 4xx the whole edit. if (Array.isArray(req.body.customer_account_ids)) { try { - const customerAccountsService = require('../services/customerAccountsService'); + const customerAccountsService = require('../../services/customerAccountsService'); if (await customerAccountsService.isCustomerPortalEnabled()) { await customerAccountsService.setAssignmentsForEvent( parseInt(id, 10), @@ -1890,593 +1576,4 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), r } }); -// --------------------------------------------------------------------------- -// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live -// events that auto-picks-up new uploads (migration 138). Mirrors the -// client-access second-token pattern: the link is minted on demand, rotatable -// and disable-able, independent of the gallery password / share link. -// --------------------------------------------------------------------------- - -// Allowed slide transition styles (kept in sync with the SlideshowPage). -// dipwhite/dipblack = fade through highlights / lowlights between images. -const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; -// Allowed per-slide color filters. -const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; -// The watermark LOOK (source/position/opacity/style/size) is global-only -// (app_settings, Settings → Slideshow); events only carry the show_watermark -// mode (NULL=inherit / true / false), so no per-event look enums live here. - -// Build the public slideshow URL for a freshly-minted/existing token. -async function buildSlideshowUrl(slug, token) { - if (!token) return null; - const base = await getFrontendBaseUrl(); - return `${base.replace(/\/$/, '')}/gallery/${slug}/show/${token}`; -} - -// Fetch the event respecting the editor-role ownership scope (requireEventOwnership -// already gates the route; this re-applies the created_by filter for editors so the -// 404 is identical to the rest of this file). -async function loadOwnedEvent(req) { - let q = db('events').where('id', req.params.id); - if (req.admin.roleName === 'editor') { - q = q.where('created_by', req.admin.id); - } - return q.first(); -} - -// Generate (or rotate) the slideshow share token. Idempotent in intent: each -// call mints a fresh token, which both "Generate" (first time) and "Regenerate" -// (rotate, kills the old link) use. -router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => { - try { - const event = await loadOwnedEvent(req); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - const token = crypto.randomBytes(32).toString('hex'); - // NB: the events table has no updated_at column (only created_at), so we - // must not set it here or the UPDATE throws. - await db('events').where('id', req.params.id).update({ - show_share_token: token - }); - - await logActivity('slideshow_link_generated', - { eventName: event.event_name, rotated: Boolean(event.show_share_token) }, - req.params.id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - show_share_token: token, - slideshow_url: await buildSlideshowUrl(event.slug, token) - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to generate slideshow link'); - } -}); - -// Disable the slideshow link (null the token). The public /show/ route dies on -// its next poll, killing any projector currently pointed at the old link. -router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const event = await loadOwnedEvent(req); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - await db('events').where('id', req.params.id).update({ - show_share_token: null - }); - - await logActivity('slideshow_link_disabled', - { eventName: event.event_name }, - req.params.id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ show_share_token: null }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to disable slideshow link'); - } -}); - -// Update the LIVE slideshow settings (display time / transition style / speed). -// A running projector picks these up via the show-page settings poll within a -// few seconds — no need to regenerate the link. -router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, [ - body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }), - body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), - body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), - body('show_watermark').optional({ nullable: true }), - body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() }); - } - - const event = await loadOwnedEvent(req); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - // events has no updated_at column — don't set it. - const updates = {}; - if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10); - if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition; - if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10); - // Tri-state: explicit null = inherit the global default. - if (req.body.show_watermark !== undefined) { - updates.show_watermark = req.body.show_watermark === null - ? null - : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); - } - if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; - - // Knex throws on an empty update; only write if something changed. - if (Object.keys(updates).length > 0) { - await db('events').where('id', req.params.id).update(updates); - } - - res.json({ - show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000, - show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', - show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, - show_watermark: updates.show_watermark ?? event.show_watermark ?? null, - show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none' - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to update slideshow settings'); - } -}); - -// Reset event password -router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - const { sendEmail = true, password: clientPassword } = req.body; - - let eventQuery = db('events').where('id', id); - // Editor role can only edit their own events - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - if (event.is_archived) { - return res.status(400).json({ error: 'Cannot reset password for archived event' }); - } - - // Use the admin-supplied password when provided; otherwise auto-generate - // (preserves the previous one-click behaviour for callers/cron that don't - // pass a body). Validation matches the create-event flow so the same - // strength rules apply both ways. - let newPassword; - if (typeof clientPassword === 'string' && clientPassword.length > 0) { - const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', { - eventName: event.event_name - }); - if (!passwordValidation.valid) { - return res.status(400).json({ - error: 'Password does not meet security requirements', - details: passwordValidation.errors, - score: passwordValidation.score, - feedback: passwordValidation.feedback - }); - } - newPassword = clientPassword; - } else { - const { generateReadablePassword } = require('../utils/passwordGenerator'); - newPassword = generateReadablePassword(); - } - const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds()); - - // Update event with new password - await db('events') - .where('id', id) - .update({ - password_hash: passwordHash - }); - - // Log activity - await logActivity('password_reset', - { eventName: event.event_name, emailSent: sendEmail }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - // Queue email notification if requested - if (sendEmail) { - const recipientEmail = event.customer_email || event.host_email; - const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); - // event.share_link is the path-only form (`/gallery//`). - // Use the full URL so customers can click straight from the email. - const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); - - await queueEmail(id, recipientEmail, 'gallery_created', { - customer_name: recipientName, - customer_email: recipientEmail, - host_name: recipientName, - event_name: event.event_name, - event_date: event.event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareUrl, - gallery_password: newPassword, - expiry_date: event.expires_at // Pass raw date - will be formatted by email processor - }); - } - - res.json({ - message: 'Password reset successfully', - newPassword: newPassword, - emailSent: sendEmail - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to reset password'); - } -}); - -// Resend creation email -router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - - // Get event details - let eventQuery = db('events').where('id', id); - // Editor role can only edit their own events - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - // The email processor will determine the language based on: - // 1. Event language setting - // 2. App settings general_default_language - // 3. Email config default language - // 4. Domain-based detection - // So we don't need to determine it here - - // For resending creation email, we need the actual password - // First, try to get it from the request body if provided - // Use optional chaining to handle cases where req.body might be undefined - let galleryPassword = req.body?.password; - - // If no password provided, we can't decrypt the existing one - // So we'll show a security message - if (!galleryPassword) { - // We'll let the email processor determine the language for the security message - galleryPassword = '{{password_security_message}}'; - } - - // Dates will be formatted by the email processor based on recipient language - - // Queue the email - const recipientEmail = event.customer_email || event.host_email; - const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); - // event.share_link is the path-only form; use the full URL so the - // customer's mail client renders a clickable absolute link. - const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); - - await queueEmail(id, recipientEmail, 'gallery_created', { - customer_name: recipientName, - customer_email: recipientEmail, - host_name: recipientName, - event_name: event.event_name, - event_date: event.event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareUrl, - gallery_password: galleryPassword, - expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor - welcome_message: event.welcome_message || '', - eventId: id, - isResend: true // Flag to indicate this is a resend - }); - - // Log the activity using the proper schema - try { - await logActivity('email_resent', { - email_type: 'gallery_created', - recipient: recipientEmail, - ip_address: req.ip || '0.0.0.0', - user_agent: req.get('user-agent') || 'Unknown' - }, id, { - type: 'admin', - id: req.admin.id, - name: req.admin.username - }); - } catch (logError) { - logger.error('Warning: Failed to log activity:', logError); - // Don't fail the request if activity logging fails - } - - res.json({ - success: true, - message: 'Creation email has been queued for sending' - }); - } catch (error) { - logger.error('Error resending creation email:', error); - errorResponse(res, error, 500, 'Failed to resend creation email'); - } -}); - -// Archive event -router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - - const event = await db('events').where('id', id).first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - if (event.is_archived) { - return res.status(400).json({ error: 'Event is already archived' }); - } - - // Use the archive service to create ZIP archive - await archiveEvent(event); - - // Log activity - await logActivity('event_archived', - { eventName: event.event_name }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ message: 'Event archived successfully' }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to archive event'); - } -}); - -// Bulk archive events -router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ - body('eventIds').isArray().withMessage('eventIds must be an array'), - body('eventIds.*').isInt().withMessage('Each eventId must be an integer') -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { eventIds } = req.body; - - if (eventIds.length === 0) { - return res.status(400).json({ error: 'No events selected for archiving' }); - } - - // Get all events to archive - const events = await db('events') - .whereIn('id', eventIds) - .where('is_archived', formatBoolean(false)); - - if (events.length === 0) { - return res.status(400).json({ error: 'No valid events found to archive' }); - } - - const results = { - successful: [], - failed: [] - }; - - // Process each event - for (const event of events) { - try { - // Use the archive service to create ZIP archive - await archiveEvent(event); - - // Log activity - await logActivity('event_archived', - { eventName: event.event_name, bulkOperation: true }, - event.id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - results.successful.push({ - id: event.id, - name: event.event_name - }); - } catch (error) { - logger.error(`Failed to archive event ${event.id}:`, error); - results.failed.push({ - id: event.id, - name: event.event_name, - error: 'Failed to archive event. Check server logs for details.' - }); - } - } - - // Log bulk archive activity - await logActivity('bulk_archive_completed', - { - totalEvents: eventIds.length, - successfulCount: results.successful.length, - failedCount: results.failed.length - }, - null, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, - results - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to perform bulk archive'); - } -}); - -// Bulk delete — destructive, irreversible. Caps at 100 events per request -// to keep request time bounded; the per-event cascade touches 5 DB tables -// + 3 filesystem paths so 1000 events would risk timing out the request. -// Loops via deleteEventCascade so the per-event delete behaviour stays in -// lock-step with DELETE /:id. -// -// Confirmation is enforced client-side via the typed-DELETE pattern in -// BulkDeleteModal (#417). The previous server-side bcrypt-password gate -// was dropped because the destructive single-event DELETE /:id has never -// required a password either — events.delete permission + admin session -// is the auth boundary for both. The typed-literal client gate is the -// "accidental click" safeguard, and unlike a password input it isn't -// affected by passkey/Windows Hello autofill that auto-submits the form. -const BULK_DELETE_MAX = 100; -router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ - body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`), - body('eventIds.*').isInt().withMessage('Each eventId must be an integer') -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { eventIds } = req.body; - - // Editor-role events.delete permission is already gated by the route - // middleware. We do NOT additionally filter to created_by here because - // the per-event delete-cascade is global (matches DELETE /:id which - // also has no role-based filter — that's why events.delete is a - // sensitive permission). - - const results = { successful: [], failed: [] }; - const adminContext = { id: req.admin.id, username: req.admin.username }; - - for (const eventId of eventIds) { - try { - const deleted = await deleteEventCascade(eventId, adminContext); - results.successful.push(deleted); - } catch (err) { - results.failed.push({ - id: eventId, - name: null, - error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event' - }); - logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message }); - } - } - - await logActivity('bulk_delete_completed', - { - totalEvents: eventIds.length, - successfulCount: results.successful.length, - failedCount: results.failed.length - }, - null, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, - results - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to perform bulk delete'); - } -}); - -// Upload event custom logo -router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => { - try { - const { id } = req.params; - - // Check if event exists - let eventQuery = db('events').where('id', id); - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - if (!req.file) { - return res.status(400).json({ error: 'No logo file provided' }); - } - - // Delete old logo file if exists - if (event.hero_logo_path) { - try { - await fs.unlink(event.hero_logo_path); - logger.debug('Deleted old event logo file', { path: event.hero_logo_path }); - } catch (err) { - logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message }); - } - } - - const logoUrl = `/uploads/logos/events/${req.file.filename}`; - const logoPath = req.file.path; - - await db('events') - .where('id', id) - .update({ - hero_logo_url: logoUrl, - hero_logo_path: logoPath - }); - - await logActivity('event_logo_uploaded', - { eventName: event.event_name, filename: req.file.filename }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - message: 'Event logo uploaded successfully', - hero_logo_url: logoUrl - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to upload event logo'); - } -}); - -// Delete event custom logo -router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - - let eventQuery = db('events').where('id', id); - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - // Delete logo file if exists - if (event.hero_logo_path) { - try { - await fs.unlink(event.hero_logo_path); - logger.debug('Deleted event logo file', { path: event.hero_logo_path }); - } catch (err) { - logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message }); - } - } - - await db('events') - .where('id', id) - .update({ - hero_logo_url: null, - hero_logo_path: null - }); - - await logActivity('event_logo_removed', - { eventName: event.event_name }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ message: 'Event logo removed successfully' }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to delete event logo'); - } -}); - -module.exports = router; +}; diff --git a/backend/src/routes/adminEvents/helpers.js b/backend/src/routes/adminEvents/helpers.js new file mode 100644 index 00000000..01ef769d --- /dev/null +++ b/backend/src/routes/adminEvents/helpers.js @@ -0,0 +1,326 @@ +// Extracted verbatim from the original routes/adminEvents.js (see ./index.js). +// Shared helpers + module-level caches used across the adminEvents sub-routers. + +const { db, logActivity } = require('../../database/db'); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../../utils/logger'); +const { parseStringInput } = require('../../utils/parsers'); + +// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point +const validateHeroImageAnchor = (value) => { + if (['top', 'center', 'bottom'].includes(value)) return true; + if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { + const [x, y] = value.split(/\s+/).map(v => parseInt(v)); + if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; + } + throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); +}; + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); + +// Helper to get event field requirements from settings +const getEventFieldRequirements = async () => { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'event_require_customer_name', + 'event_require_customer_email', + 'event_require_admin_email', + 'event_require_event_date', + 'event_require_expiration' + ]) + .select('setting_key', 'setting_value'); + + const requirements = { + require_customer_name: true, + require_customer_email: true, + require_admin_email: true, + require_event_date: true, + require_expiration: true + }; + + settings.forEach(s => { + let value = s.setting_value; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch (e) { + value = value === 'true'; + } + } + if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value; + if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value; + if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value; + if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value; + if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value; + }); + + return requirements; + } catch (error) { + logger.error('Failed to get event field requirements', { error: error.message }); + return { + require_customer_name: true, + require_customer_email: true, + require_admin_email: true, + require_event_date: true, + require_expiration: true + }; + } +}; + +// Helper to read app_settings booleans by key, used to inherit per-setting +// defaults onto new events. Returns `undefined` for missing/non-boolean rows +// so callers can fall back to a legacy default. +const readBooleanSetting = async (key) => { + try { + const setting = await db('app_settings').where('setting_key', key).first(); + if (!setting) return undefined; + let value = setting.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return typeof value === 'boolean' ? value : undefined; + } catch (error) { + logger.error('Failed to read app setting', { key, error: error.message }); + return undefined; + } +}; + +// Helper to read the global "enable_devtools_protection" admin setting so +// new events inherit it instead of always falling back to the DB column default +// (#317 — admin disabled it globally but new events still got it ON). +const getDownloadProtectionDefaults = async () => { + return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') }; +}; + +// Helper to get branding defaults for new events (Feature 7: Branding Inheritance). +// +// Note: `branding_logo_position` (header bar — left/center/right) is a +// different concept from `hero_logo_position` (hero block — top/center/ +// bottom) and must NOT be mapped here. A previous version copied the +// branding value over, which wrote 'left'/'right' into per-event +// hero_logo_position columns and broke any subsequent PUT validation +// (#357). Migration 084 heals existing rows. +const getBrandingDefaults = async () => { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'branding_logo_display_hero', + 'branding_logo_size' + ]) + .select('setting_key', 'setting_value'); + + const defaults = { + hero_logo_visible: true, + hero_logo_size: 'medium', + hero_logo_position: 'top' + }; + + settings.forEach(s => { + let value = s.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch (e) { /* use as-is */ } + } + if (s.setting_key === 'branding_logo_display_hero') { + defaults.hero_logo_visible = value !== false; + } + if (s.setting_key === 'branding_logo_size' && value) { + defaults.hero_logo_size = value; + } + }); + + return defaults; + } catch (error) { + logger.error('Failed to get branding defaults', { error: error.message }); + return { + hero_logo_visible: true, + hero_logo_size: 'medium', + hero_logo_position: 'top' + }; + } +}; + +// Use parseStringInput from shared parsers for customer data extraction +const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); +const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); +const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); + +// Whether the global "phone field" toggle (#322) is enabled. Cached for +// the request via a module-level read; drift is acceptable since this +// only governs whether to persist the field, not security boundaries. +const isPhoneFieldEnabled = async () => { + try { + const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + if (!row) return false; + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return value === true; + } catch (error) { + logger.debug('Failed to read event_phone_field_enabled', { error: error.message }); + return false; + } +}; + +const mapEventForApi = (event) => { + if (!event || typeof event !== 'object') { + return event; + } + + const { + host_name, + host_email, + customer_name, + customer_email, + customer_phone, + password_hash: _ph, + client_password_hash: _cph, + ...rest + } = event; + + return { + ...rest, + customer_name: customer_name ?? host_name ?? null, + customer_email: customer_email ?? host_email ?? null, + customer_phone: customer_phone ?? null + }; +}; + +let customerColumnCache = null; +const hasCustomerContactColumns = async () => { + if (customerColumnCache === true) { + return true; + } + + try { + const hasColumn = await db.schema.hasColumn('events', 'customer_email'); + if (hasColumn) { + customerColumnCache = true; + } + return hasColumn; + } catch (error) { + logger.debug('Failed to detect customer_email column', { error: error.message }); + return false; + } +}; + +// Cascade-delete a single event: photos, audit/access logs, queued emails, +// the event row itself (in one transaction), then the on-disk folder / +// archive zip / hero logo (best-effort — file failures don't unwind the DB +// changes since the source of truth is the database). Used by both the +// per-event DELETE /:id route and the bulk-delete route to avoid drift. +// +// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the +// bulk-delete loop can report it as a per-id failure without aborting the +// whole batch. Any other error propagates and is the caller's problem. +async function deleteEventCascade(eventId, adminContext) { + const event = await db('events').where('id', eventId).first(); + if (!event) { + const err = new Error('Event not found'); + err.code = 'EVENT_NOT_FOUND'; + throw err; + } + + await db.transaction(async (trx) => { + // 1. Delete activity logs (audit trail) + await trx('activity_logs').where('event_id', eventId).del(); + // 2. Delete access logs + await trx('access_logs').where('event_id', eventId).del(); + // 3. Delete email queue entries + await trx('email_queue').where('event_id', eventId).del(); + // 4. Delete photos (also handles hero_photo_id foreign key) + await trx('photos').where('event_id', eventId).del(); + // 5. Finally delete the event row + await trx('events').where('id', eventId).del(); + + // Best-effort filesystem cleanup. Failures are logged but don't unwind + // the transaction — the canonical state lives in the DB; orphan files + // are recoverable noise, a half-deleted DB row is a permanent mess. + // + // #608 — previous code read `event.folder_path`, but that column is + // never written anywhere in the codebase (grep confirms: two reads in + // this function, zero writes). It's always undefined, so the + // `if (event.folder_path)` branch silently no-op'd and every event + // delete since this cascade landed left its photos orphaned on disk. + // jodrmx's Pi report (v3.44.0) was the first surfacing. + // + // Files actually live at: + // {STORAGE_PATH}/events/active/{slug}/... (uploaded photos) + // {STORAGE_PATH}/events/archived/{slug}/... (after the event + // was archived — folder copy survives the archive flow) + // + // `event.slug` is NOT NULL on the events table and is slugify-sanitized + // on every write (lower-case ASCII + dashes only via utils/slug.js), + // so path-traversal isn't a concern. + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); + for (const sub of ['active', 'archived']) { + const eventFolderPath = path.join(storagePath, 'events', sub, event.slug); + try { + await fs.rm(eventFolderPath, { recursive: true, force: true }); + } catch (fsErr) { + logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message }); + } + } + + if (event.archive_path) { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); + const archiveFile = path.join(storagePath, event.archive_path); + try { + await fs.unlink(archiveFile); + } catch (fsErr) { + logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message }); + } + } + + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + } catch (fsErr) { + logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message }); + } + } + }); + + // Audit trail (outside the transaction so a logging failure can't undo + // the actual delete). + await logActivity('event_deleted', + { event_name: event.event_name }, + null, + { type: 'admin', id: adminContext.id, name: adminContext.username } + ); + + return { id: event.id, name: event.event_name }; +} + +// --------------------------------------------------------------------------- +// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live +// events that auto-picks-up new uploads (migration 138). Mirrors the +// client-access second-token pattern: the link is minted on demand, rotatable +// and disable-able, independent of the gallery password / share link. +// --------------------------------------------------------------------------- + +// Allowed slide transition styles (kept in sync with the SlideshowPage). +// dipwhite/dipblack = fade through highlights / lowlights between images. +const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; +// Allowed per-slide color filters. +const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; +module.exports = { + validateHeroImageAnchor, + getStoragePath, + getEventFieldRequirements, + readBooleanSetting, + getDownloadProtectionDefaults, + getBrandingDefaults, + getCustomerNameFromPayload, + getCustomerEmailFromPayload, + getCustomerPhoneFromPayload, + isPhoneFieldEnabled, + mapEventForApi, + hasCustomerContactColumns, + deleteEventCascade, + SLIDESHOW_TRANSITIONS, + SLIDESHOW_COLORFILTERS, +}; diff --git a/backend/src/routes/adminEvents/index.js b/backend/src/routes/adminEvents/index.js new file mode 100644 index 00000000..5c393f5e --- /dev/null +++ b/backend/src/routes/adminEvents/index.js @@ -0,0 +1,17 @@ +// adminEvents router — decomposed move-code refactor of the original +// routes/adminEvents.js god file. Each sub-module attaches its routes onto the +// shared router below. CRITICAL: the require(...)(router) calls preserve the +// original registration order — Express matches in registration order, so +// literal segments and '/:id' patterns must keep their relative positions. + +const express = require('express'); + +const router = express.Router(); + +require('./crud')(router); +require('./slideshow')(router); +require('./resets')(router); +require('./archiveBulk')(router); +require('./logo')(router); + +module.exports = router; diff --git a/backend/src/routes/adminEvents/logo.js b/backend/src/routes/adminEvents/logo.js new file mode 100644 index 00000000..86cf90bf --- /dev/null +++ b/backend/src/routes/adminEvents/logo.js @@ -0,0 +1,145 @@ +// Extracted verbatim from the original routes/adminEvents.js (see ./index.js). +// Exports a register function; ./index.js calls the sub-routers in the original +// registration order so Express route matching is unchanged. + +const { db, logActivity } = require('../../database/db'); +const { adminAuth } = require('../../middleware/auth'); +const { requirePermission } = require('../../middleware/permissions'); +const fs = require('fs').promises; +const path = require('path'); +const multer = require('multer'); +const logger = require('../../utils/logger'); +const { errorResponse } = require('../../utils/routeHelpers'); +const { validateFileType } = require('../../utils/fileSecurityUtils'); +const { requireEventOwnership } = require('../../middleware/ownership'); +const { getStoragePath } = require('./helpers'); + + +// Configure multer for event logo uploads +const eventLogoStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(getStoragePath(), 'uploads/logos/events'); + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`); + } +}); + +const eventLogoUpload = multer({ + storage: eventLogoStorage, + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB + fileFilter: (req, file, cb) => { + const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']; + if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { + return cb(null, true); + } else { + cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed')); + } + } +}); + +module.exports = (router) => { + + +// Upload event custom logo +router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => { + try { + const { id } = req.params; + + // Check if event exists + let eventQuery = db('events').where('id', id); + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (!req.file) { + return res.status(400).json({ error: 'No logo file provided' }); + } + + // Delete old logo file if exists + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + logger.debug('Deleted old event logo file', { path: event.hero_logo_path }); + } catch (err) { + logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message }); + } + } + + const logoUrl = `/uploads/logos/events/${req.file.filename}`; + const logoPath = req.file.path; + + await db('events') + .where('id', id) + .update({ + hero_logo_url: logoUrl, + hero_logo_path: logoPath + }); + + await logActivity('event_logo_uploaded', + { eventName: event.event_name, filename: req.file.filename }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: 'Event logo uploaded successfully', + hero_logo_url: logoUrl + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to upload event logo'); + } +}); + +// Delete event custom logo +router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; + + let eventQuery = db('events').where('id', id); + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Delete logo file if exists + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + logger.debug('Deleted event logo file', { path: event.hero_logo_path }); + } catch (err) { + logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message }); + } + } + + await db('events') + .where('id', id) + .update({ + hero_logo_url: null, + hero_logo_path: null + }); + + await logActivity('event_logo_removed', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event logo removed successfully' }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to delete event logo'); + } +}); + + +}; diff --git a/backend/src/routes/adminEvents/resets.js b/backend/src/routes/adminEvents/resets.js new file mode 100644 index 00000000..56de957b --- /dev/null +++ b/backend/src/routes/adminEvents/resets.js @@ -0,0 +1,193 @@ +// Extracted verbatim from the original routes/adminEvents.js (see ./index.js). +// Exports a register function; ./index.js calls the sub-routers in the original +// registration order so Express route matching is unchanged. + +const { db, logActivity } = require('../../database/db'); +const { adminAuth } = require('../../middleware/auth'); +const { requirePermission } = require('../../middleware/permissions'); +const bcrypt = require('bcrypt'); +const { queueEmail } = require('../../services/emailProcessor'); +const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation'); +const logger = require('../../utils/logger'); +const { errorResponse } = require('../../utils/routeHelpers'); +const { buildShareLinkVariants } = require('../../services/shareLinkService'); +const { requireEventOwnership } = require('../../middleware/ownership'); + +module.exports = (router) => { + + +// Reset event password +router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; + const { sendEmail = true, password: clientPassword } = req.body; + + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (event.is_archived) { + return res.status(400).json({ error: 'Cannot reset password for archived event' }); + } + + // Use the admin-supplied password when provided; otherwise auto-generate + // (preserves the previous one-click behaviour for callers/cron that don't + // pass a body). Validation matches the create-event flow so the same + // strength rules apply both ways. + let newPassword; + if (typeof clientPassword === 'string' && clientPassword.length > 0) { + const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', { + eventName: event.event_name + }); + if (!passwordValidation.valid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + newPassword = clientPassword; + } else { + const { generateReadablePassword } = require('../../utils/passwordGenerator'); + newPassword = generateReadablePassword(); + } + const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds()); + + // Update event with new password + await db('events') + .where('id', id) + .update({ + password_hash: passwordHash + }); + + // Log activity + await logActivity('password_reset', + { eventName: event.event_name, emailSent: sendEmail }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Queue email notification if requested + if (sendEmail) { + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + // event.share_link is the path-only form (`/gallery//`). + // Use the full URL so customers can click straight from the email. + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + + await queueEmail(id, recipientEmail, 'gallery_created', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, + event_name: event.event_name, + event_date: event.event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareUrl, + gallery_password: newPassword, + expiry_date: event.expires_at // Pass raw date - will be formatted by email processor + }); + } + + res.json({ + message: 'Password reset successfully', + newPassword: newPassword, + emailSent: sendEmail + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to reset password'); + } +}); + +// Resend creation email +router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; + + // Get event details + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // The email processor will determine the language based on: + // 1. Event language setting + // 2. App settings general_default_language + // 3. Email config default language + // 4. Domain-based detection + // So we don't need to determine it here + + // For resending creation email, we need the actual password + // First, try to get it from the request body if provided + // Use optional chaining to handle cases where req.body might be undefined + let galleryPassword = req.body?.password; + + // If no password provided, we can't decrypt the existing one + // So we'll show a security message + if (!galleryPassword) { + // We'll let the email processor determine the language for the security message + galleryPassword = '{{password_security_message}}'; + } + + // Dates will be formatted by the email processor based on recipient language + + // Queue the email + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + // event.share_link is the path-only form; use the full URL so the + // customer's mail client renders a clickable absolute link. + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + + await queueEmail(id, recipientEmail, 'gallery_created', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, + event_name: event.event_name, + event_date: event.event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareUrl, + gallery_password: galleryPassword, + expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor + welcome_message: event.welcome_message || '', + eventId: id, + isResend: true // Flag to indicate this is a resend + }); + + // Log the activity using the proper schema + try { + await logActivity('email_resent', { + email_type: 'gallery_created', + recipient: recipientEmail, + ip_address: req.ip || '0.0.0.0', + user_agent: req.get('user-agent') || 'Unknown' + }, id, { + type: 'admin', + id: req.admin.id, + name: req.admin.username + }); + } catch (logError) { + logger.error('Warning: Failed to log activity:', logError); + // Don't fail the request if activity logging fails + } + + res.json({ + success: true, + message: 'Creation email has been queued for sending' + }); + } catch (error) { + logger.error('Error resending creation email:', error); + errorResponse(res, error, 500, 'Failed to resend creation email'); + } +}); + +}; diff --git a/backend/src/routes/adminEvents/slideshow.js b/backend/src/routes/adminEvents/slideshow.js new file mode 100644 index 00000000..fef17f8e --- /dev/null +++ b/backend/src/routes/adminEvents/slideshow.js @@ -0,0 +1,151 @@ +// Extracted verbatim from the original routes/adminEvents.js (see ./index.js). +// Exports a register function; ./index.js calls the sub-routers in the original +// registration order so Express route matching is unchanged. + +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../../database/db'); +const { formatBoolean } = require('../../utils/dbCompat'); +const { adminAuth } = require('../../middleware/auth'); +const { requirePermission } = require('../../middleware/permissions'); +const crypto = require('crypto'); +const { errorResponse } = require('../../utils/routeHelpers'); +const { parseBooleanInput } = require('../../utils/parsers'); +const { requireEventOwnership } = require('../../middleware/ownership'); +const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag'); +const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); +const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); + +// The watermark LOOK (source/position/opacity/style/size) is global-only +// (app_settings, Settings → Slideshow); events only carry the show_watermark +// mode (NULL=inherit / true / false), so no per-event look enums live here. + +// Build the public slideshow URL for a freshly-minted/existing token. +async function buildSlideshowUrl(slug, token) { + if (!token) return null; + const base = await getFrontendBaseUrl(); + return `${base.replace(/\/$/, '')}/gallery/${slug}/show/${token}`; +} + +// Fetch the event respecting the editor-role ownership scope (requireEventOwnership +// already gates the route; this re-applies the created_by filter for editors so the +// 404 is identical to the rest of this file). +async function loadOwnedEvent(req) { + let q = db('events').where('id', req.params.id); + if (req.admin.roleName === 'editor') { + q = q.where('created_by', req.admin.id); + } + return q.first(); +} + +module.exports = (router) => { + + +// Generate (or rotate) the slideshow share token. Idempotent in intent: each +// call mints a fresh token, which both "Generate" (first time) and "Regenerate" +// (rotate, kills the old link) use. +router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => { + try { + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const token = crypto.randomBytes(32).toString('hex'); + // NB: the events table has no updated_at column (only created_at), so we + // must not set it here or the UPDATE throws. + await db('events').where('id', req.params.id).update({ + show_share_token: token + }); + + await logActivity('slideshow_link_generated', + { eventName: event.event_name, rotated: Boolean(event.show_share_token) }, + req.params.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + show_share_token: token, + slideshow_url: await buildSlideshowUrl(event.slug, token) + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to generate slideshow link'); + } +}); + +// Disable the slideshow link (null the token). The public /show/ route dies on +// its next poll, killing any projector currently pointed at the old link. +router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + await db('events').where('id', req.params.id).update({ + show_share_token: null + }); + + await logActivity('slideshow_link_disabled', + { eventName: event.event_name }, + req.params.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ show_share_token: null }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to disable slideshow link'); + } +}); + +// Update the LIVE slideshow settings (display time / transition style / speed). +// A running projector picks these up via the show-page settings poll within a +// few seconds — no need to regenerate the link. +router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, [ + body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }), + body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), + body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), + body('show_watermark').optional({ nullable: true }), + body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() }); + } + + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // events has no updated_at column — don't set it. + const updates = {}; + if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10); + if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition; + if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10); + // Tri-state: explicit null = inherit the global default. + if (req.body.show_watermark !== undefined) { + updates.show_watermark = req.body.show_watermark === null + ? null + : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); + } + if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; + + // Knex throws on an empty update; only write if something changed. + if (Object.keys(updates).length > 0) { + await db('events').where('id', req.params.id).update(updates); + } + + res.json({ + show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000, + show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', + show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, + show_watermark: updates.show_watermark ?? event.show_watermark ?? null, + show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none' + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to update slideshow settings'); + } +}); + +}; diff --git a/backend/src/services/contract/conversions.js b/backend/src/services/contract/conversions.js new file mode 100644 index 00000000..23230383 --- /dev/null +++ b/backend/src/services/contract/conversions.js @@ -0,0 +1,409 @@ +// Extracted verbatim from contractService.js — see ../contractService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const { db, logActivity } = require('../../database/db'); +const logger = require('../../utils/logger'); +const { getAppSetting } = require('../../utils/appSettings'); +const { AppError } = require('../../utils/errors'); +const { hasColumnCached } = require('../../utils/schemaCache'); +const businessProfileService = require('../businessProfileService'); +const { ensureSystemBlocksSeeded } = require('../contractBlocksService'); +const { ensureInt } = require('../../utils/numericHelpers'); +const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers'); + + +/** + * Convert an accepted quote into a fresh draft contract, pre-populating + * the customer, language, title, valid-until window, and source_quote_id + * back-pointer. Idempotent — if the quote already has a linked contract + * (quote.converted_contract_id set), returns that contract's id without + * creating a duplicate. + * + * Does NOT flip quote.status — the quote stays 'accepted' while the + * contract is the active deliverable. The quote→event / quote→invoice + * paths are gated against the converted_contract_id back-pointer so an + * admin can't accidentally double-spend the quote. + */ +async function createFromQuote(quoteId, adminId) { + // Same self-heal as createContract — the quote-conversion path seeds + // the contract with every active system block, and the new + // quote_line_items_table block needs to be present for it to land + // in the default inclusion list. + await ensureSystemBlocksSeeded(); + + const quote = await db('quotes').where({ id: quoteId }).first(); + 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, 'QUOTE_NOT_ACCEPTED'); + } + if (quote.converted_contract_id) { + return { contractId: quote.converted_contract_id, alreadyConverted: true }; + } + if (quote.converted_event_id) { + throw new AppError( + 'This quote was already converted to an event. Create the contract from the event instead.', + 409, 'ALREADY_CONVERTED_TO_EVENT', + ); + } + + const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + ensureCustomerActive(customer); + + const profile = (await businessProfileService.getProfile()).profile; + const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; + const issueDate = new Date().toISOString().slice(0, 10); + const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000) + .toISOString().slice(0, 10); + + const title = quote.event_name + ? `Contract — ${quote.event_name}` + : `Contract from quote ${quote.quote_number}`; + + // Schema-drift safety: the lineage columns landed in migration 130 + // as in-place edits. Dev installs that ran 130 BEFORE that edit + // won't have these columns yet. hasColumn() lets us skip the + // affected writes instead of crashing with a generic 500. + const hasContractSourceQuote = await hasColumnCached('contracts', 'source_quote_id'); + const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id'); + const hasContractEventCols = await hasColumnCached('contracts', 'event_name'); + + // Resolve the actor BEFORE opening the transaction — adminActor reads + // admin_users via the global db, which deadlocks the single-connection + // SQLite pool if evaluated inside the trx (prepare_contract runs unattended). + const actor = await adminActor(adminId); + + return await db.transaction(async (trx) => { + // Pass trx so the sequence claim joins our outer transaction — + // SQLite deadlocks otherwise (1-connection default). + const contractNumber = await nextContractNumber(trx); + const contractRow = { + contract_number: contractNumber, + customer_account_id: quote.customer_account_id, + status: 'draft', + language: quote.language || customer.preferred_language || profile?.default_locale || 'de', + issue_date: issueDate, + valid_until: validUntil, + title, + intro_text: quote.intro_text || null, + outro_text: quote.outro_text || null, + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + if (hasContractSourceQuote) contractRow.source_quote_id = quote.id; + // Migration 140 — contract from quote inherits the quote's + // deal_uuid so both documents belong to the same deal chain. + // Falls back to a fresh UUID only if the source quote predates the + // backfill (shouldn't happen on a migrated install, but defensive). + contractRow.deal_uuid = quote.deal_uuid || crypto.randomUUID(); + // Propagate the quote's event snapshot — same fields the quote + // already carries (set by createQuote). Means contract-from-quote + // chains preserve "this contract is for the Wedding Doe / Müller" + // labelling all the way through to the resulting invoice's + // event_name field. + if (hasContractEventCols) { + contractRow.event_name = quote.event_name || null; + contractRow.event_date = quote.event_date || null; + contractRow.event_time_start = quote.event_time_start || null; + contractRow.event_time_end = quote.event_time_end || null; + } + const inserted = await trx('contracts').insert(contractRow).returning('id'); + const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Seed every active system block. Same shape as createContract. + // D.3 — batched insert (one DB round-trip vs N). + const systemBlocks = await trx('contract_blocks') + .where({ is_system: true, is_active: true }) + .orderBy(['section', 'display_order']); + const sectionCounters = {}; + const inclusionRows = systemBlocks.map((block) => { + sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; + return { + contract_id: contractId, + block_id: block.id, + section: block.section, + position: sectionCounters[block.section], + body_text_snapshot: null, + body_text_de_snapshot: null, + included: true, + created_at: new Date(), + updated_at: new Date(), + }; + }); + if (inclusionRows.length > 0) { + await trx('contract_block_inclusions').insert(inclusionRows); + } + + // Back-pointer so the quote detail page can deep-link to its + // resulting contract and the convert-to-event/invoice paths know + // to refuse double conversion. Skipped silently when the column + // hasn't migrated — the contract is still created cleanly. + if (hasQuoteContractBackPointer) { + await trx('quotes').where({ id: quote.id }).update({ + converted_contract_id: contractId, + updated_at: new Date(), + }); + } + + try { + // Pass `trx` so the audit insert rides the transaction's connection; + // the global db here deadlocks the single-connection SQLite pool. + await logActivity('contract_created_from_quote', + { contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number }, + null, actor, trx); + } catch (_) { /* logging is best-effort */ } + logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id }); + return { contractId, alreadyConverted: false }; + }); +} + +/** + * Convert a fully-signed contract into an event + scheduled invoices. + * Delegates to quoteService.convertToEvent using the contract's + * source_quote_id so the line items + payment plan come from the + * original quote. The quote MUST still be in 'accepted' status (i.e. + * not previously converted) — createFromQuote keeps it that way. + * + * On success the contract's converted_event_id is set (back-pointer) + * and the source quote flips to 'converted'. + */ +async function convertToEvent(contractId, adminId) { + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (contract.status !== 'fully_signed') { + throw new AppError( + `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, + 409, 'CONTRACT_NOT_FULLY_SIGNED', + ); + } + if (contract.converted_event_id) { + return { eventId: contract.converted_event_id, alreadyConverted: true }; + } + + const hasContractConvertedEvent = await hasColumnCached('contracts', 'converted_event_id'); + + // Path A: source quote present → delegate to quoteService which + // replays the full installment schedule into invoices alongside + // the event row. + if (contract.source_quote_id) { + const quoteService = require('../quoteService'); + const result = await quoteService.convertToEvent(contract.source_quote_id, adminId, { fromContract: true }); + if (hasContractConvertedEvent) { + await db('contracts').where({ id: contractId }).update({ + converted_event_id: result.eventId, + updated_at: new Date(), + }); + } + try { + await logActivity('contract_converted_to_event', + { contractId, eventId: result.eventId, quoteId: contract.source_quote_id }, + result.eventId, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return result; + } + + // Path B: standalone contract → mint an empty placeholder event + // row the admin fleshes out from the events admin page. Same + // column-introspection trick quoteService uses so installs with + // old/new host_*/customer_* column variants both work. + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + ensureCustomerActive(customer); + const adminRow = await db('admin_users').where({ id: adminId }).first(); + const today = new Date(); + const oneYearFromNow = new Date(today.getTime()); + oneYearFromNow.setFullYear(today.getFullYear() + 1); + + const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.display_name || customer.company_name || contract.contract_number; + const customerEmail = customer.email || `${contract.contract_number.toLowerCase()}@picpeak.local`; + const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local'; + const placeholderHash = crypto.randomBytes(32).toString('hex'); + const shareToken = crypto.randomBytes(32).toString('hex'); + + const eventCols = await db('events').columnInfo(); + const candidate = { + slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`, + // Prefer the contract's event_name snapshot (set on the contract + // editor or inherited from the source quote) over the contract + // title. Falls back to a deterministic placeholder so the event + // row never has a blank name. + event_name: contract.event_name || contract.title || `Event ${contract.contract_number}`, + event_date: contract.event_date || contract.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: placeholderHash, + share_link: shareToken, + share_token: shareToken, + expires_at: oneYearFromNow, + is_active: true, + is_archived: false, + is_draft: true, + created_by: adminId, + quote_id: null, + 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 db('events').insert(eventRow).returning('id'); + const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Link the customer so they see the event on their portal once + // the admin activates it. Best-effort — older installs without + // the junction table still get the event row. + try { + if (await db.schema.hasTable('event_customer_assignments')) { + await db('event_customer_assignments').insert({ + event_id: eventId, + customer_account_id: customer.id, + assigned_by_admin_id: adminId, + assigned_at: new Date(), + }); + } + } catch (_) { /* best-effort */ } + + if (hasContractConvertedEvent) { + await db('contracts').where({ id: contractId }).update({ + converted_event_id: eventId, + updated_at: new Date(), + }); + } + + try { + await logActivity('contract_converted_to_empty_event', + { contractId, eventId }, eventId, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + return { eventId, alreadyConverted: false }; +} + +/** + * Convert a fully-signed contract directly into invoice(s) without + * creating an event row. Same delegation pattern as convertToEvent. + */ +async function convertToInvoiceOnly(contractId, adminId) { + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (contract.status !== 'fully_signed') { + throw new AppError( + `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, + 409, 'CONTRACT_NOT_FULLY_SIGNED', + ); + } + + // Schema-drift guard — the lineage columns are in-place edits to + // migration 130. Skip the back-pointer update silently when the + // column hasn't migrated yet. + const hasInvoiceContractBackPointer = await hasColumnCached('invoices', 'source_contract_id'); + + // Path A: contract has a source quote → replay its line items + + // payment plan via quoteService (full installment schedule). + if (contract.source_quote_id) { + const quoteService = require('../quoteService'); + const result = await quoteService.convertToInvoiceOnly(contract.source_quote_id, adminId, { fromContract: true }); + if (hasInvoiceContractBackPointer) { + await db('invoices') + .where({ source_quote_id: contract.source_quote_id }) + .whereNull('source_contract_id') + .update({ source_contract_id: contractId }); + } + try { + await logActivity('contract_converted_to_invoices', + { contractId, quoteId: contract.source_quote_id, installments: result.installmentsCreated }, + null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return result; + } + + // Path B: standalone contract (no source quote) → direct DB insert + // of an empty draft. We deliberately bypass invoiceService.createInvoice + // because that runs ensureCustomerCanBill, which throws if the + // customer doesn't have feature_bills enabled. Admin clicking + // "Convert to invoice" on the contract detail page IS the + // authorisation; the admin will fill in line items manually before + // sending. + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + ensureCustomerActive(customer); + + const invoiceService = require('../invoiceService'); + const profile = (await businessProfileService.getProfile()).profile || {}; + const currency = (profile.default_currency || 'CHF').toUpperCase(); + const language = contract.language || customer.preferred_language || profile.default_locale || 'de'; + const issueDate = new Date().toISOString().slice(0, 10); + const netDays = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; + const dueDate = new Date(Date.now() + netDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + // Pre-resolve which event-snapshot columns the invoices table has + // (migration 123) so we can copy contract.event_name etc onto the + // new invoice. Falls back to contract.title when event_name is + // empty — gives standalone contracts a useful label even when + // the admin didn't fill out the event field. + const invoiceHasEventName = await hasColumnCached('invoices', 'event_name'); + const eventNameSnapshot = (contract.event_name || contract.title || null); + + const invoiceNumber = await invoiceService.nextInvoiceNumber(); + const invoiceRow = { + invoice_number: invoiceNumber, + customer_account_id: contract.customer_account_id, + source_quote_id: null, + event_id: null, + language, + currency, + issue_date: issueDate, + due_date: dueDate, + installment_index: 0, + installment_total: 1, + status: 'scheduled', + net_amount_minor: 0, + vat_rate: 0, + vat_amount_minor: 0, + shipping_amount_minor: 0, + total_amount_minor: 0, + paid_amount_minor: 0, + reminder_level: 0, + late_fee_amount_minor: 0, + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + if (hasInvoiceContractBackPointer) invoiceRow.source_contract_id = contractId; + // Migration 140 — invoice inherits the contract's deal_uuid so the + // contract + invoice belong to the same deal chain. Fresh UUID if + // the contract predates the backfill (defensive). + invoiceRow.deal_uuid = contract.deal_uuid || crypto.randomUUID(); + // Snapshot the contract's event fields onto the invoice so the + // BillDetailPage + customer portal show the same "Wedding Doe / + // Müller" label that the contract carries. event_name is also the + // field the dunning emails reference in their templates. + if (invoiceHasEventName) { + invoiceRow.event_name = eventNameSnapshot; + invoiceRow.event_date = contract.event_date || null; + invoiceRow.event_time_start = contract.event_time_start || null; + invoiceRow.event_time_end = contract.event_time_end || null; + } + const inserted = await db('invoices').insert(invoiceRow).returning('id'); + const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + try { + await logActivity('contract_converted_to_empty_invoice', + { contractId, invoiceId, invoiceNumber }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + // Match the result shape of the source-quote path so the frontend + // toast can use the same translation key. `installmentsCreated` is + // always 1 here (single empty invoice). + return { installmentsCreated: 1, invoiceId }; +} +module.exports = { + createFromQuote, + convertToEvent, + convertToInvoiceOnly, +}; diff --git a/backend/src/services/contract/crud.js b/backend/src/services/contract/crud.js new file mode 100644 index 00000000..7a84c7b8 --- /dev/null +++ b/backend/src/services/contract/crud.js @@ -0,0 +1,384 @@ +// Extracted verbatim from contractService.js — see ../contractService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +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 { hasColumnCached } = require('../../utils/schemaCache'); +const businessProfileService = require('../businessProfileService'); +const { ensureSystemBlocksSeeded } = require('../contractBlocksService'); +const { ensureInt } = require('../../utils/numericHelpers'); +const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers'); + + +// --------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------- + +async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { + return await withRetry(async () => { + let query = db('contracts') + .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') + .select( + 'contracts.*', + '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', + ); + + if (Array.isArray(filters.status) && filters.status.length > 0) { + query = query.whereIn('contracts.status', filters.status); + } + if (filters.customerAccountId) { + query = query.where('contracts.customer_account_id', filters.customerAccountId); + } + if (filters.q && String(filters.q).trim()) { + const term = `%${String(filters.q).trim()}%`; + query = query.andWhere(function() { + this.where('contracts.contract_number', 'like', term) + .orWhere('contracts.title', 'like', term) + .orWhere('customer_accounts.email', 'like', term) + .orWhere('customer_accounts.company_name', 'like', term); + }); + } + + const countQuery = query.clone().clearSelect().clearOrder().count('contracts.id as total').first(); + const totalRow = await countQuery; + const total = ensureInt(totalRow?.total || 0); + + switch (sort) { + case 'oldest': + query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); + break; + case 'issue_asc': + query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc'); + break; + case 'issue_desc': + query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc'); + break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('contracts.id', 'desc'); + break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('contracts.id', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.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 getContractById(id) { + return await withRetry(async () => { + const contract = await db('contracts') + .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') + .where('contracts.id', id) + .select( + 'contracts.*', + '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', + 'customer_accounts.preferred_language as customer_preferred_language', + ) + .first(); + if (!contract) return null; + + const inclusions = await db('contract_block_inclusions as inc') + .leftJoin('contract_blocks as blk', 'blk.id', 'inc.block_id') + .where('inc.contract_id', id) + .orderByRaw(` + CASE inc.section + WHEN 'basics' THEN 1 + WHEN 'scope' THEN 2 + WHEN 'privacy' THEN 3 + WHEN 'commercial' THEN 4 + WHEN 'nda' THEN 5 + WHEN 'closing' THEN 6 + ELSE 99 + END + `) + .orderBy('inc.position', 'asc') + .select( + 'inc.*', + 'blk.slug as block_slug', + 'blk.name as block_name', + 'blk.description as block_description', + 'blk.body_text as block_body_text', + 'blk.body_text_de as block_body_text_de', + // Migration 131 — locale variants. Pulled with column-existence + // guard so installs that haven't run migration 131 still load + // contracts (just without the new columns). + ...(await hasColumnCached('contract_blocks', 'body_text_ru') + ? ['blk.body_text_ru as block_body_text_ru'] : []), + ...(await hasColumnCached('contract_blocks', 'body_text_pt') + ? ['blk.body_text_pt as block_body_text_pt'] : []), + ...(await hasColumnCached('contract_blocks', 'body_text_nl') + ? ['blk.body_text_nl as block_body_text_nl'] : []), + ...(await hasColumnCached('contract_blocks', 'body_text_fr') + ? ['blk.body_text_fr as block_body_text_fr'] : []), + 'blk.is_system as block_is_system', + ); + return { contract, inclusions }; + }); +} + +/** + * Create a draft contract. Pre-populates `contract_block_inclusions` + * with every active system block toggled ON so the admin sees a + * sensible starting point and just toggles off what they don't need. + * + * Custom (non-system) blocks are NOT auto-included — admin opts in to + * those explicitly so a runaway block library doesn't pollute every + * new contract. + */ +async function createContract(payload, adminId) { + // Self-heal: ensure runtime-seeded system blocks (e.g. the + // quote_line_items_table added after migration 131 was deployed) + // exist before we copy active system blocks into the new contract's + // inclusion list. Idempotent — only fires if rows are missing. + await ensureSystemBlocksSeeded(); + + const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); + ensureCustomerActive(customer); + + const profile = (await businessProfileService.getProfile()).profile; + const language = payload.language || customer.preferred_language || profile?.default_locale || 'de'; + const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; + 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); + + // Schema-drift guard for the event-snapshot columns added as + // in-place migration 130 edits. We only write them when the DB + // actually has them; older dev installs that haven't re-migrated + // simply skip these fields (contract still saves successfully). + const hasEventCols = await hasColumnCached('contracts', 'event_name'); + + return await db.transaction(async (trx) => { + // Pass trx so the sequence claim joins our outer transaction — + // SQLite deadlocks otherwise (1-connection default). + const contractNumber = await nextContractNumber(trx); + const row = { + contract_number: contractNumber, + customer_account_id: payload.customerAccountId, + status: 'draft', + language, + issue_date: issueDate, + valid_until: validUntil, + title: payload.title || null, + intro_text: payload.introText || null, + outro_text: payload.outroText || null, + // Migration 140 — standalone contract is a deal root; mint a + // fresh UUID. The createFromQuote path (line ~1557) sets this + // from the source quote's deal_uuid instead. + deal_uuid: crypto.randomUUID(), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + if (hasEventCols) { + row.event_name = payload.eventName || null; + row.event_date = payload.eventDate || null; + row.event_time_start = payload.eventTimeStart || null; + row.event_time_end = payload.eventTimeEnd || null; + } + // Migration 121 — optional link to a Project Overview project. + if (payload.projectId !== undefined && await hasColumnCached('contracts', 'project_id')) { + row.project_id = payload.projectId || null; + } + const inserted = await trx('contracts').insert(row).returning('id'); + if (row.project_id && row.deal_uuid) { + await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx); + } + const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Seed with every active system block, toggled on. Per-section + // position = display_order from the source block. + // + // D.3 — batched insert. Previously this loop fired one INSERT per + // block (12+ round-trips inside the transaction on a fresh contract). + // Batched into a single `.insert(rows)` since the row count is + // bounded (system block count) and the inserts are independent. + const systemBlocks = await trx('contract_blocks') + .where({ is_system: true, is_active: true }) + .orderBy(['section', 'display_order']); + const sectionCounters = {}; + const inclusionRows = systemBlocks.map((block) => { + sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; + return { + contract_id: contractId, + block_id: block.id, + section: block.section, + position: sectionCounters[block.section], + body_text_snapshot: null, + body_text_de_snapshot: null, + included: true, + created_at: new Date(), + updated_at: new Date(), + }; + }); + if (inclusionRows.length > 0) { + await trx('contract_block_inclusions').insert(inclusionRows); + } + + try { + await logActivity('contract_created', { contractId, contractNumber, customerAccountId: payload.customerAccountId }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + logger.info('Contract created', { adminId, contractId, contractNumber }); + return contractId; + }); +} + +/** + * Update a draft contract. Editing a sent contract is refused — admin + * must cancel + create a fresh one (avoids invalidating the customer's + * signed copy). + * + * payload.blocks is an array of `{ blockId, included, position }` + * tuples; the service rewrites the contract_block_inclusions rows + * accordingly. + */ +async function updateContract(id, payload, adminId) { + const existing = await db('contracts').where({ id }).first(); + if (!existing) throw new AppError('Contract not found', 404); + if (existing.status !== 'draft') { + throw new AppError( + `Cannot edit a contract with status '${existing.status}'. Cancel and create a new contract for amendments.`, + 409, + 'CONTRACT_LOCKED', + ); + } + + const hasEventCols = await hasColumnCached('contracts', 'event_name'); + + return await db.transaction(async (trx) => { + const updates = { updated_at: new Date() }; + const map = { + title: 'title', + introText: 'intro_text', + outroText: 'outro_text', + language: 'language', + validUntil: 'valid_until', + issueDate: 'issue_date', + }; + // Event-snapshot fields only flow through when the DB has them + // (in-place migration 130 edit). Guarded so dev installs that + // haven't re-migrated don't crash the update. + if (hasEventCols) { + Object.assign(map, { + eventName: 'event_name', + eventDate: 'event_date', + eventTimeStart: 'event_time_start', + eventTimeEnd: 'event_time_end', + }); + } + for (const [api, col] of Object.entries(map)) { + if (api in payload) updates[col] = payload[api] || null; + } + // Migration 121 — optional Project Overview link. + if ('projectId' in payload && await hasColumnCached('contracts', 'project_id')) { + updates.project_id = payload.projectId || null; + } + await trx('contracts').where({ id }).update(updates); + + // Cascade across the deal lineage (linked quote / event / invoices). + if (updates.project_id) { + const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first(); + await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx); + } + + // Replace inclusions only when the caller sent an explicit list. + // (Editor's "save" sends every row; an inline "toggle" save could + // send a partial update — current frontend always sends full list.) + if (Array.isArray(payload.blocks)) { + await trx('contract_block_inclusions').where({ contract_id: id }).del(); + // Recompute per-section position so we don't trust caller order + // for ordering integrity; caller controls only the section + // sequence via the order of items in payload.blocks. + // + // Previously this loop did one SELECT per block to look up its + // section. On a contract with 12 included blocks that's 12 + // round-trips inside the transaction — pure N+1. Batch the + // lookup into a single WHERE…IN, build a Map, and read it in + // the loop. The insert itself stays sequential because the + // editor's payload size is bounded (<30 blocks in practice) and + // a single batch insert would lose row-by-row insert ordering + // guarantees we don't actually need. + const blockIds = [ + ...new Set(payload.blocks.map((e) => e.blockId).filter((id) => Number.isFinite(id))), + ]; + const blocksFound = blockIds.length > 0 + ? await trx('contract_blocks').whereIn('id', blockIds).select('id', 'section') + : []; + const sectionByBlockId = new Map(blocksFound.map((b) => [b.id, b.section])); + const sectionCounters = {}; + for (const entry of payload.blocks) { + const section = sectionByBlockId.get(entry.blockId); + if (!section) continue; + sectionCounters[section] = (sectionCounters[section] || 0) + 1; + await trx('contract_block_inclusions').insert({ + contract_id: id, + block_id: entry.blockId, + section, + position: ensureInt(entry.position) || sectionCounters[section], + body_text_snapshot: null, + body_text_de_snapshot: null, + included: entry.included === false ? false : true, + created_at: new Date(), + updated_at: new Date(), + }); + } + } + + try { + await logActivity('contract_updated', { contractId: id }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return id; + }); +} + +async function cancelContract(id, adminId) { + const contract = await db('contracts').where({ id }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['draft', 'sent'].includes(contract.status)) { + throw new AppError(`Cannot cancel a contract with status '${contract.status}'`, 409); + } + await db('contracts').where({ id }).update({ + status: 'cancelled', + updated_at: new Date(), + }); + // Invalidate any outstanding tokens. + await db('contract_action_tokens').where({ contract_id: id, used_at: null }).update({ + expires_at: new Date(), + }); + try { + await logActivity('contract_cancelled', { contractId: id }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + return { status: 'cancelled' }; +} + +module.exports = { + listContracts, + getContractById, + createContract, + updateContract, + cancelContract, +}; diff --git a/backend/src/services/contract/helpers.js b/backend/src/services/contract/helpers.js new file mode 100644 index 00000000..eee85bd0 --- /dev/null +++ b/backend/src/services/contract/helpers.js @@ -0,0 +1,134 @@ +// Extracted verbatim from contractService.js — see ../contractService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const { db } = require('../../database/db'); +const logger = require('../../utils/logger'); +const { getAppSetting } = require('../../utils/appSettings'); +const { AppError } = require('../../utils/errors'); +const { nextDocumentNumber } = require('../../utils/documentSequences'); + + +const SECTIONS_ORDER = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing']; + +/** + * Build a proper {id, type, name} actor object for logActivity. The + * db.js helper silently downgrades string actors (e.g. 'admin:1') to + * actor_type='system' with null name, so the audit timeline showed + * "system" for every admin-driven event. Fetching the admin's name + * once per service call is a small read cost on a non-hot path. + * + * Pass `customerPublic()` for events triggered by the public token + * (customer signing, customer wet-signed PDF upload). + */ +async function adminActor(adminId) { + if (!adminId) return { type: 'system' }; + try { + // admin_users only carries username + email (no first/last/name + // columns — confirmed from db.js:265). Prefer username for the + // audit timeline because it's the operator-chosen identifier + // shown elsewhere in the admin UI; fall back to email when an + // older install seeded a row without a username. + const row = await db('admin_users') + .where({ id: adminId }) + .select('id', 'username', 'email') + .first(); + if (!row) return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; + const displayName = row.username || row.email || `Admin #${adminId}`; + return { id: adminId, type: 'admin', name: displayName }; + } catch (_) { + return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; + } +} + +function customerPublicActor() { + return { type: 'customer', name: 'Customer (public link)' }; +} + +/** + * Fire a contract lifecycle event for the workflow engine. Best-effort: + * resolves the customer email (so send_email actions have a recipient) and + * never throws into the caller. No-op when the workflows flag is off (emit + * fails closed). Mirrors quoteService.emitQuoteEvent. + */ +async function emitContractEvent(contract, status) { + try { + let customerEmail = null; + if (contract.customer_account_id) { + const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + customerEmail = c?.email || null; + } + await require('../workflows').emitWorkflowEvent(`contract.${status}`, { + entityType: 'contract', + entityId: contract.id, + payload: { + contractId: contract.id, + contractNumber: contract.contract_number, + customerAccountId: contract.customer_account_id || null, + customerEmail, + eventName: contract.event_name || null, + title: contract.title || null, + }, + }); + } catch (err) { + logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message }); + } +} + +/** + * Privacy gate for the customer/admin IP captured at signing time. + * The `crm_contracts_store_ip` setting (default true) controls + * whether the IP is persisted into the DB. When off, this helper + * returns null regardless of what the route passed in — same shape + * the rest of the code expects, just with no IP data. + * + * Default-true means upgrades preserve current behaviour. Operators + * with strict data-minimisation requirements opt out in Settings → + * CRM-Settings → Contracts. + */ +async function maybeStoreIp(ip) { + if (!ip) return null; + const enabled = await getAppSetting('crm_contracts_store_ip'); + // Default true: only block when EXPLICITLY opted out. The audit + // flagged that `enabled === false` missed legacy installs where + // app_settings stored the toggle as a string ('false', '0') — those + // would slip through and the IP would still get persisted despite + // the operator's intent. Cover string/number/bool variants + // defensively. Anything else (null, undefined, true) preserves + // the default-on behavior. + if (enabled === false) return null; + if (enabled === 0 || enabled === '0') return null; + if (typeof enabled === 'string' && enabled.toLowerCase() === 'false') return null; + return ip; +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + + +/** + * Gap-free per-year contract number sequence. See + * utils/documentSequences.js for the locking story; migration 132 + * created the underlying table. Atomic against concurrent admin + * creates — the previous SELECT-MAX-then-INSERT raced and could + * emit `C-2026-AB12C3` after 5 retries. + */ +async function nextContractNumber(trx) { + return nextDocumentNumber('contract', 'crm_contracts_number_format', 'C-{YEAR}-{SEQ:04d}', trx); +} + +function ensureCustomerActive(customer) { + 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); + } +} +module.exports = { + SECTIONS_ORDER, + adminActor, + customerPublicActor, + emitContractEvent, + maybeStoreIp, + nextContractNumber, + ensureCustomerActive, +}; diff --git a/backend/src/services/contract/renderContext.js b/backend/src/services/contract/renderContext.js new file mode 100644 index 00000000..ddae552a --- /dev/null +++ b/backend/src/services/contract/renderContext.js @@ -0,0 +1,282 @@ +// Extracted verbatim from contractService.js — see ../contractService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const { db } = require('../../database/db'); +const { getAppSetting } = require('../../utils/appSettings'); +const { formatShortDate } = require('../../utils/dateFormatter'); +const businessProfileService = require('../businessProfileService'); +const { buildIssuerBlock, buildRecipientBlock } = require('../_renderContext'); +const { ensureInt } = require('../../utils/numericHelpers'); +const { SECTIONS_ORDER } = require('./helpers'); + + +/** + * Handlebars-lite renderer: + * - `{{#if var}}…{{/if}}` blocks resolved by truthiness of variables[var]. + * - `{{var}}` substituted with the matching variable. Missing + * placeholders are left literally as `{{var}}` so the admin + * notices the unresolved field in preview. + * + * Mirrors safeTemplateReplace in emailProcessor.js (lines 424-461) but + * without HTML escaping — contract bodies are rendered into PDF via + * pdfService.drawText, which doesn't need HTML safety. + */ +function renderTemplatedBody(template, variables) { + if (typeof template !== 'string' || template.length === 0) return template; + const conditionalsResolved = template.replace( + /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, key, inner) => { + const v = variables ? variables[key] : undefined; + const truthy = v !== undefined && v !== null && v !== '' && v !== false && v !== 0; + return truthy ? inner : ''; + } + ); + return conditionalsResolved.replace(/\{\{(\w+)\}\}/g, (match, key) => { + if (!variables || !Object.prototype.hasOwnProperty.call(variables, key)) return match; + return String(variables[key]); + }); +} + +/** + * Build the variable bag used by renderTemplatedBody. Reads the + * customer record, business profile, and (when available) the + * customer's active payment-term defaults so block placeholders for + * net_days / skonto_percent / etc. resolve. Returns plain strings — + * dates formatted DD.MM.YYYY in DE-CH style, numbers as-is. + */ +async function buildPlaceholderContext(contract, customer) { + const profile = (await businessProfileService.getProfile()).profile || {}; + const issuerCompany = profile.company_name || ''; + const issuerAddress = [profile.address_line1, profile.postal_code, profile.city] + .filter(Boolean) + .join(', '); + + // Resolve net_days + skonto from app_settings defaults so the + // payment_terms_reference block has sensible numbers to substitute + // when the admin hasn't tied the contract to a specific quote. + const netDaysDefault = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; + const skontoPercentDefault = await getAppSetting('crm_invoices_skonto_percent_default'); + const skontoWithinDaysDefault = ensureInt(await getAppSetting('crm_invoices_skonto_business_days')) || 5; + + // {{source_quote_number}} placeholder — substituted into the body of + // the `quote_line_items_table` system block (and any admin-authored + // block that wants to reference the quote). Empty string when the + // contract wasn't generated from a quote. + let sourceQuoteNumber = ''; + if (contract.source_quote_id) { + const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) + .select('quote_number').first(); + if (srcQuote) sourceQuoteNumber = srcQuote.quote_number || ''; + } + + const customerName = customer + ? (customer.company_name + || [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.display_name + || customer.email + || '') + : ''; + const customerAddress = customer + ? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city] + .filter(Boolean) + .join(', ') + : ''; + + return { + customer_name: customerName, + customer_address: customerAddress, + event_name: contract.event_name || '', + event_date: formatShortDate(contract.event_date), + issue_date: formatShortDate(contract.issue_date), + contract_number: contract.contract_number || '', + title: contract.title || '', + net_days: String(netDaysDefault), + skonto_percent: skontoPercentDefault == null ? '0' : String(skontoPercentDefault), + skonto_within_days: String(skontoWithinDaysDefault), + cancellation_30d_percent: '25', + currency: (profile.default_currency || 'CHF').toUpperCase(), + issuer_company_name: issuerCompany, + issuer_address: issuerAddress, + source_quote_number: sourceQuoteNumber, + }; +} + +// --------------------------------------------------------------------- +// Render-context builder + PDF helpers +// --------------------------------------------------------------------- + +/** + * Build the data shape pdfService.renderContractToBuffer expects. + * Sections are emitted in canonical SECTIONS_ORDER; blocks within a + * section are emitted in `position` order. Bodies are run through + * renderTemplatedBody so {{placeholders}} are substituted. + * + * When the contract has been sent, `body_text_snapshot` is used (so + * later edits to the source block don't mutate the rendered document). + * Before send (preview from editor) the live `contract_blocks.body_text` + * is used so the admin can iterate on block bodies and see the result. + */ +async function buildRenderContext(contract, inclusions) { + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const placeholders = await buildPlaceholderContext(contract, customer); + + // Pull source-quote line items when this contract was generated from a + // quote. Surfaced on the render context so the renderer can draw a real + // table at the location of the `quote_line_items_table` system block. + // Sub-items keep their parent's position via the LEFT JOIN so the + // renderer can indent them with a `↳` prefix. + let quoteLineItems = []; + let quoteCurrency = null; + let quoteNumber = null; + if (contract.source_quote_id) { + const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) + .select('quote_number', 'currency').first(); + if (srcQuote) { + quoteCurrency = srcQuote.currency; + quoteNumber = srcQuote.quote_number; + quoteLineItems = await db('quote_line_items as li') + .leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.quote_id', contract.source_quote_id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + } + } + + const locale = contract.language || customer?.preferred_language || profile.default_locale || 'de'; + + // Group inclusions by section + render each block body. + const blocksBySection = {}; + for (const section of SECTIONS_ORDER) blocksBySection[section] = []; + const sortedInclusions = [...inclusions] + .filter((row) => row.included === true || row.included === 1 || row.included === '1') + .sort((a, b) => { + const sa = SECTIONS_ORDER.indexOf(a.section); + const sb = SECTIONS_ORDER.indexOf(b.section); + if (sa !== sb) return sa - sb; + return (a.position || 0) - (b.position || 0); + }); + + for (const row of sortedInclusions) { + if (!blocksBySection[row.section]) continue; + // The inclusion row carries the JOINED block columns aliased with + // a `block_` prefix (see getContractById). Pre-send drafts have + // null snapshots, so fall through to the live block body. + // Migration 131 added ru/pt/nl/fr columns. The body resolver + // picks the locale-matching column first, falls back through + // DE → EN, so an admin can stage translations one locale at a + // time without breaking contracts in other languages. + const bodyEn = row.body_text_snapshot || row.block_body_text || ''; + const bodyDe = row.body_text_de_snapshot || row.block_body_text_de || ''; + const bodyRu = row.block_body_text_ru || ''; + const bodyPt = row.block_body_text_pt || ''; + const bodyNl = row.block_body_text_nl || ''; + const bodyFr = row.block_body_text_fr || ''; + const localeBody = ({ + de: bodyDe, + ru: bodyRu, + pt: bodyPt, + nl: bodyNl, + fr: bodyFr, + })[locale] || ''; + const sourceBody = localeBody || bodyEn || bodyDe; + // Substitute placeholders, then strip any leading `**Title**\n` + // line — the block's `name` field is already rendered as a bold + // sub-heading by the PDF/public layouts, so a bold first line in + // the body produces a duplicated title. Inline `**bold**` markers + // elsewhere in the body are preserved (the PDF renders them as + // actual bold via renderBodyMarkdown; the public route strips + // them since the React page has no inline-bold UI). + const rendered = renderTemplatedBody(sourceBody, placeholders) + .replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, ''); + blocksBySection[row.section].push({ + slug: row.block_slug || null, + name: row.block_name, + section: row.section, + body: rendered, + }); + } + + // Use the same robust logo resolver quote/invoice use — checks + // business_profile.logo_path → app_settings.branding_logo_path → + // app_settings.branding_logo_url, with ~7 disk-location candidates + // before giving up. + const { resolveLogoFile } = require('../../utils/resolveLogoFile'); + const resolvedLogoPath = await resolveLogoFile(profile); + + // Global date format from Settings → General (general_date_format). + 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, + dateFormat, + // Mirror the quote/invoice issuer shape EXACTLY so drawIssuerBlock + // honours the same business-profile toggles (pdf_show_logo, + // pdf_show_company_name, pdf_logo_height, pdf_company_name_inline, + // pdf_folding_marks) across all three document types. Per maintainer: + // contracts reuse the same toggles — no contract-specific knobs. + // Shared issuer + recipient builders. Contracts use the base toggle + // set (no quote-only payment-block fields). The renderer-aware + // recipient gating means contractService's previously-drifted + // local attentionLine logic now matches quote + invoice exactly. + issuer: buildIssuerBlock(profile, resolvedLogoPath), + recipient: buildRecipientBlock(profile, customer), + doc: { + contractNumber: contract.contract_number, + title: contract.title || '', + issueDate: contract.issue_date, + validUntil: contract.valid_until, + introText: contract.intro_text ? renderTemplatedBody(contract.intro_text, placeholders) : null, + outroText: contract.outro_text ? renderTemplatedBody(contract.outro_text, placeholders) : null, + }, + // Blocks grouped + ordered by canonical section order. + sections: SECTIONS_ORDER + .map((section) => ({ section, blocks: blocksBySection[section] })) + .filter((s) => s.blocks.length > 0), + // Source-quote line items, surfaced at the top level so the PDF + // renderer can draw a formatted table where the + // `quote_line_items_table` system block is included. Empty array + // when the contract has no source quote. + quoteLineItems, + quoteCurrency, + quoteSourceNumber: quoteNumber, + // Signature evidence (used by the PDF renderer to stamp signatures + // into the closing section when present). + signatures: { + customer: contract.signed_customer_name ? { + name: contract.signed_customer_name, + signedAt: contract.signed_by_customer_at, + ip: contract.signed_customer_ip, + signaturePath: contract.signed_customer_signature_path, + } : null, + admin: contract.signed_admin_name ? { + name: contract.signed_admin_name, + signedAt: contract.signed_by_admin_at, + ip: contract.signed_admin_ip, + signaturePath: contract.signed_admin_signature_path, + } : null, + }, + // Audit-trail evidence appended to the rendered PDF as a final + // page (issue #3). The renderer skips the page when this is null + // OR when the contract isn't signed yet, so unsigned PDFs stay + // unchanged. Hashes are best-effort: pdfSha256 may be null on + // installs that haven't migrated to the new schema column yet — + // the page still renders the rest of the evidence. + audit: (contract.signed_customer_name || contract.signed_admin_name) ? { + contractNumber: contract.contract_number, + issuedAt: contract.sent_at, + pdfSha256: contract.pdf_sha256 || null, + signedPdfSha256: contract.signed_pdf_sha256 || null, + } : null, + }; +} +module.exports = { + renderTemplatedBody, + buildPlaceholderContext, + buildRenderContext, +}; diff --git a/backend/src/services/contract/sending.js b/backend/src/services/contract/sending.js new file mode 100644 index 00000000..d1752f08 --- /dev/null +++ b/backend/src/services/contract/sending.js @@ -0,0 +1,135 @@ +// Extracted verbatim from contractService.js — see ../contractService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const { db, logActivity } = require('../../database/db'); +const logger = require('../../utils/logger'); +const { getAppSetting } = require('../../utils/appSettings'); +const { AppError } = require('../../utils/errors'); +const { hasColumnCached } = require('../../utils/schemaCache'); +const { formatShortDate } = require('../../utils/dateFormatter'); +const pdfService = require('../pdfService'); +const emailProcessor = require('../emailProcessor'); +const { ensureContractEmailTemplatesSeeded } = require('../contractEmailTemplates'); +const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); +const { adminActor, emitContractEvent, ensureCustomerActive } = require('./helpers'); +const { buildRenderContext } = require('./renderContext'); +const { persistContractPdf } = require('./signatureAssets'); +const { getContractById } = require('./crud'); + + +/** + * Render PDF for a saved contract (preview before send, or re-render + * after signing). + */ +async function renderContractPdfBuffer(contractId) { + const data = await getContractById(contractId); + if (!data) throw new AppError('Contract not found', 404); + const ctx = await buildRenderContext(data.contract, data.inclusions); + return await pdfService.renderContractToBuffer(ctx); +} + +/** + * Send the contract: snapshot every included block's body, render PDF, + * persist, mint a signing token, queue the customer email. + */ +async function sendContract(id, adminId) { + // Self-heal: dev installs that ran migration 130 BEFORE we added + // contract_fully_signed to the seed list won't have all three + // contract templates in email_templates. Insert any missing rows + // before we queue the email. Idempotent + module-cached. + await ensureContractEmailTemplatesSeeded(db, logger); + + const data = await getContractById(id); + if (!data) throw new AppError('Contract not found', 404); + const { contract, inclusions } = data; + + if (!['draft'].includes(contract.status)) { + throw new AppError(`Cannot send a contract with status '${contract.status}'`, 409); + } + + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + ensureCustomerActive(customer); + + // Snapshot every included block's body into the inclusion row so + // future block edits don't mutate the sent contract. + await db.transaction(async (trx) => { + for (const inc of inclusions) { + if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue; + await trx('contract_block_inclusions').where({ id: inc.id }).update({ + body_text_snapshot: inc.block_body_text || null, + body_text_de_snapshot: inc.block_body_text_de || null, + updated_at: new Date(), + }); + } + }); + + // Re-fetch with snapshots populated so the renderer uses the frozen + // bodies (matches post-send reads). + const refreshed = await getContractById(id); + const ctx = await buildRenderContext(refreshed.contract, refreshed.inclusions); + const buffer = await pdfService.renderContractToBuffer(ctx); + const { filePath: pdfPath, sha256: pdfSha256 } = await persistContractPdf(refreshed.contract, buffer); + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = contract.valid_until + ? new Date(new Date(contract.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000) + : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); + + // Schema-drift guard for the new pdf_sha256 column (migration 130 + // in-place edit). Dev installs that haven't re-migrated skip the + // hash write; the send still succeeds. + const hasPdfSha = await hasColumnCached('contracts', 'pdf_sha256'); + + await db.transaction(async (trx) => { + await trx('contract_action_tokens').insert({ + contract_id: id, + token, + expires_at: expiresAt, + created_at: new Date(), + }); + const updates = { + status: 'sent', + sent_at: new Date(), + pdf_path: pdfPath, + updated_at: new Date(), + }; + if (hasPdfSha) updates.pdf_sha256 = pdfSha256; + await trx('contracts').where({ id }).update(updates); + }); + + const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; + const responseUrl = `${frontendUrl}/contract/${token}`; + // Honour the admin's "Attach contract PDF to email" toggle. Default + // ON; an admin who prefers a link-only email turns it off and the + // customer reaches the PDF via the public sign page instead. + const attachPdf = await getAppSetting('crm_contracts_pdf_attachment_enabled'); + await emailProcessor.queueEmail(null, customer.email, 'contract_sent', { + contract_number: contract.contract_number, + customer_name: customer.display_name + || [customer.first_name, customer.last_name].filter(Boolean).join(' ') + || customer.email.split('@')[0], + response_url: responseUrl, + title: contract.title || '', + event_name: contract.event_name || '', + valid_until: formatShortDate(contract.valid_until), + attachments: (attachPdf !== false && pdfPath) ? [{ + filename: `${contract.contract_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }] : undefined, + }); + + try { + await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + await emitContractEvent(contract, 'sent'); + + logger.info('Contract sent', { adminId, contractId: id }); + return { token, pdfPath }; +} +module.exports = { + renderContractPdfBuffer, + sendContract, +}; diff --git a/backend/src/services/contract/signatureAssets.js b/backend/src/services/contract/signatureAssets.js new file mode 100644 index 00000000..c6394336 --- /dev/null +++ b/backend/src/services/contract/signatureAssets.js @@ -0,0 +1,221 @@ +// Extracted verbatim from contractService.js — see ../contractService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const logger = require('../../utils/logger'); +const { AppError } = require('../../utils/errors'); +const pdfStampService = require('../pdfStampService'); + + +/** + * SHA-256 hex digest of a Buffer or file path. Used at every PDF + * write so we can persist a content hash alongside the path — + * either party can later re-hash the PDF they hold and prove (or + * disprove) it matches what we issued. + */ +function sha256OfBuffer(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} +function sha256OfFile(filePath) { + try { + return sha256OfBuffer(fs.readFileSync(filePath)); + } catch (_) { + return null; + } +} + +/** + * Write a contract PDF to disk and return both the path AND the + * SHA-256 hash of the buffer we just wrote. Callers persist BOTH on + * the contracts row so audit defence is single-query: SELECT + * pdf_path, pdf_sha256 FROM contracts WHERE id = ? then re-hash the + * file on disk and compare. + * + * History-preserving (per requirement #6): every write appends a + * deterministic suffix so old versions stay on disk. The contract + * row's `pdf_path` / `signed_pdf_path` always points at the most + * recent one; earlier versions remain available for forensic + * comparison. + */ +async function persistContractPdf(contract, buffer, suffix = '') { + if (!contract.contract_number) return { filePath: null, sha256: null }; + const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); + fs.mkdirSync(root, { recursive: true }); + // Always append a millisecond timestamp to the filename so writes + // never overwrite an earlier version on disk. Forensic preservation. + // Example filenames: + // C-2026-0001_2026-05-19T1830-22-413.pdf (unsigned) + // C-2026-0001_signed-by-customer_2026-05-19T1845-10-002.pdf + // C-2026-0001_fully-signed_2026-05-19T1912-44-877.pdf + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = suffix + ? `${contract.contract_number}_${suffix}_${stamp}.pdf` + : `${contract.contract_number}_${stamp}.pdf`; + const filePath = path.join(root, fileName); + fs.writeFileSync(filePath, buffer); + return { filePath, sha256: sha256OfBuffer(buffer) }; +} + +// Maximum decoded signature image size. Defends against a customer +// (or attacker holding a captured signing token) POSTing a multi-MB +// signature data URL to fill the disk. A typical signature_pad PNG +// is 10–80 KB; even with retina upscaling we don't expect to see +// 1 MB. The cap is enforced on the BASE64 length before decoding so +// we never allocate the full Buffer for an oversized payload. +// +// The frontend (ContractResponsePage) downscales the canvas to a +// fixed max width before exporting via `toDataURL`, so well-behaved +// clients land well under this cap. This server-side check is the +// authoritative guard. +const MAX_SIGNATURE_BASE64_BYTES = 1024 * 1024; // 1 MB of base64 → ~750 KB decoded + +async function persistSignatureImage(contract, role, dataUrl) { + if (!dataUrl || typeof dataUrl !== 'string') return null; + if (dataUrl.length > MAX_SIGNATURE_BASE64_BYTES + 100 /* prefix slack */) { + throw new AppError( + `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, + 413, 'SIGNATURE_TOO_LARGE', + ); + } + const match = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/); + if (!match) { + throw new AppError('Signature must be a base64-encoded PNG or JPEG data URL', 400, 'BAD_SIGNATURE_FORMAT'); + } + if (match[2].length > MAX_SIGNATURE_BASE64_BYTES) { + throw new AppError( + `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, + 413, 'SIGNATURE_TOO_LARGE', + ); + } + const ext = match[1] === 'jpeg' ? 'jpg' : 'png'; + const root = path.join( + process.cwd(), + 'storage', + 'business-docs', + 'contract', + 'signatures', + String(contract.id), + ); + fs.mkdirSync(root, { recursive: true }); + // Filename already carries Date.now() so re-stamping a signature + // never overwrites an earlier capture — forensic preservation. + // Per role, the contract row's signed_*_signature_path always + // points at the most recent; older files stay alongside. + const filePath = path.join(root, `${role}-${Date.now()}.${ext}`); + fs.writeFileSync(filePath, Buffer.from(match[2], 'base64')); + return filePath; +} + +/** + * Build the stamp sequence the pdf-lib stamp service expects from a + * single contract row. Customer first, admin second — provenance + * order matches the visual order on the signature page. + * + * Used by the recovery paths (rerenderAndResend, restampSignatures). + * The hot path (recordCustomerSignature / recordAdminCountersignature) + * stamps incrementally so it constructs the stamp inline. + */ +function buildSignatureStamps(contract) { + const locale = contract.language || 'de'; + const nameLabel = 'Name'; + const dateLabel = locale === 'de' ? 'Datum' : 'Date'; + const stamps = []; + if (contract.signed_customer_signature_path) { + stamps.push({ + signaturePngPath: contract.signed_customer_signature_path, + role: 'customer', + caption: { + name: contract.signed_customer_name || '', + signedAt: contract.signed_by_customer_at, + nameLabel, + dateLabel, + }, + }); + } + if (contract.signed_admin_signature_path) { + stamps.push({ + signaturePngPath: contract.signed_admin_signature_path, + role: 'admin', + caption: { + name: contract.signed_admin_name || '', + signedAt: contract.signed_by_admin_at, + nameLabel, + dateLabel, + }, + }); + } + return stamps; +} + +/** + * Build the audit-certificate context expected by + * pdfStampService.renderAuditCertificate from a fully-signed + * contract row. Returns null when the contract isn't signed enough + * to warrant a certificate (no customer + no admin signature data). + */ +function buildAuditCertContext(contract) { + const hasCustomerSig = contract.signed_by_customer_at || contract.signed_customer_name; + const hasAdminSig = contract.signed_by_admin_at || contract.signed_admin_name; + if (!hasCustomerSig && !hasAdminSig) return null; + return { + contract: { + contract_number: contract.contract_number, + sent_at: contract.sent_at, + pdf_sha256: contract.pdf_sha256 || null, + signed_pdf_sha256: contract.signed_pdf_sha256 || null, + }, + customer: hasCustomerSig ? { + name: contract.signed_customer_name, + signedAt: contract.signed_by_customer_at, + ip: contract.signed_customer_ip, + } : null, + admin: hasAdminSig ? { + name: contract.signed_admin_name, + signedAt: contract.signed_by_admin_at, + ip: contract.signed_admin_ip, + } : null, + locale: contract.language || 'de', + }; +} + +/** + * Generate the audit certificate PDF, write it to disk under the same + * year directory as the contract PDFs (suffix `audit`), and return + * its file path. Returns null when there's nothing to certify or when + * rendering fails (the email still goes out without the cert — the + * stamped PDF alone remains delivered). + */ +async function persistAuditCertificate(contract) { + const ctx = buildAuditCertContext(contract); + if (!ctx) return null; + try { + const { buffer } = await pdfStampService.renderAuditCertificate(ctx); + const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); + fs.mkdirSync(root, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`); + fs.writeFileSync(filePath, buffer); + return filePath; + } catch (err) { + logger.error('Failed to render audit certificate', { + contractId: contract.id, + contractNumber: contract.contract_number, + message: err.message, + }); + return null; + } +} +module.exports = { + sha256OfBuffer, + sha256OfFile, + persistContractPdf, + MAX_SIGNATURE_BASE64_BYTES, + persistSignatureImage, + buildSignatureStamps, + buildAuditCertContext, + persistAuditCertificate, +}; diff --git a/backend/src/services/contract/signatures.js b/backend/src/services/contract/signatures.js new file mode 100644 index 00000000..22c2c9ce --- /dev/null +++ b/backend/src/services/contract/signatures.js @@ -0,0 +1,861 @@ +// Extracted verbatim from contractService.js — see ../contractService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const fs = require('fs'); +const { db, logActivity } = require('../../database/db'); +const logger = require('../../utils/logger'); +const { getAppSetting } = require('../../utils/appSettings'); +const { AppError } = require('../../utils/errors'); +const { hasColumnCached } = require('../../utils/schemaCache'); +const businessProfileService = require('../businessProfileService'); +const pdfStampService = require('../pdfStampService'); +const emailProcessor = require('../emailProcessor'); +const { ensureContractEmailTemplatesSeeded } = require('../contractEmailTemplates'); +const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); +const { adminActor, customerPublicActor, emitContractEvent, maybeStoreIp } = require('./helpers'); +const { buildSignatureStamps, persistAuditCertificate, persistContractPdf, persistSignatureImage, sha256OfFile } = require('./signatureAssets'); +const { getContractById } = require('./crud'); + + +/** + * Record a customer's in-browser signature (canvas + typed name + + * "I accept" checkbox). Validates the token, persists the signature + * PNG, re-renders the PDF with the signature stamped, flips status + * to `signed_by_customer`, and queues the admin notification email. + */ +async function recordCustomerSignature({ token, name, ip, signatureDataUrl, accepted }) { + // Self-heal contract email templates. The contract_signed_admin_notification + // email fires from this function — if its row is missing, the admin + // never learns the customer signed. + await ensureContractEmailTemplatesSeeded(db, logger); + + if (accepted !== true) { + throw new AppError('You must confirm that you have read and agree to the terms.', 400, 'TOS_REQUIRED'); + } + if (!name || !String(name).trim()) { + throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); + } + // Server-side guard for the "require drawn signature" admin toggle. + // The public sign page also enforces this client-side, but the + // server is the source of truth — a malicious caller posting + // directly to /sign with a blank signatureDataUrl would otherwise + // bypass the requirement. + const requireDrawn = await getAppSetting('crm_contracts_require_drawn_signature'); + if (requireDrawn === true && (!signatureDataUrl || !String(signatureDataUrl).trim())) { + throw new AppError( + 'A drawn signature is required for this contract — typing your name alone is not sufficient.', + 400, 'SIGNATURE_REQUIRED', + ); + } + const tokenRow = await db('contract_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('This signing link has expired', 410); + } + if (tokenRow.used_at) { + throw new AppError('This contract has already been signed', 410, 'TOKEN_ALREADY_USED'); + } + + const contract = await db('contracts').where({ id: tokenRow.contract_id }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['sent'].includes(contract.status)) { + throw new AppError(`Contract cannot be signed in status '${contract.status}'`, 409); + } + + const signaturePath = signatureDataUrl + ? await persistSignatureImage(contract, 'customer', signatureDataUrl) + : null; + + const now = new Date(); + // Resolve the IP gate ONCE before the transaction so both writes + // (contracts row + tokens row) agree. Setting flip mid-transaction + // can't happen anyway, but doing it upfront keeps the data + // consistent and saves a redundant read. + const persistedIp = await maybeStoreIp(ip); + try { + await db.transaction(async (trx) => { + await trx('contracts').where({ id: contract.id }).update({ + status: 'signed_by_customer', + signed_by_customer_at: now, + signed_customer_name: String(name).trim(), + signed_customer_ip: persistedIp, + signed_customer_signature_path: signaturePath, + updated_at: now, + }); + await trx('contract_action_tokens').where({ id: tokenRow.id }).update({ + used_at: now, + used_action: 'signed_by_customer', + used_ip: persistedIp, + }); + }); + } catch (txErr) { + // C.7 — clean up the orphan signature PNG we wrote before the + // transaction. The DB rollback already undid the contract + + // token writes; the file would otherwise sit forever in + // storage/business-docs/contract/.../signatures/. Best-effort + // unlink — if the cleanup itself fails, log and re-throw the + // original transaction error so the caller still sees the real + // failure cause. + if (signaturePath) { + try { + if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); + } catch (cleanupErr) { + logger.warn('Orphan signature PNG cleanup failed', { + path: signaturePath, message: cleanupErr.message, + }); + } + } + throw txErr; + } + + // Stamp the customer's signature onto the UNSIGNED PDF on disk. + // Byte-immutable approach (see pdfStampService): we read pdf_path + // (the immutable as-sent PDF), stamp the customer's signature PNG + // at the fixed coordinates on the signature page, save as a new + // timestamped file, and update signed_pdf_path. Original file + // stays untouched on disk. + const refreshed = await getContractById(contract.id); + try { + if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { + throw new Error(`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}`); + } + const originalPdfBuffer = fs.readFileSync(refreshed.contract.pdf_path); + const stampedBuffer = await pdfStampService.stampSignature({ + pdfBuffer: originalPdfBuffer, + signaturePngPath: signaturePath, + role: 'customer', + caption: { + name: String(name).trim(), + signedAt: now, + nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', + dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', + }, + }); + const { filePath: signedPath, sha256: signedSha256 } = await persistContractPdf( + refreshed.contract, stampedBuffer, 'signed-by-customer', + ); + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: signedPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + // Migration 136 — clear any pre-existing render-failed marker; the + // most recent stamp attempt just succeeded. + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } catch (err) { + // Signature recorded; PDF re-render is best-effort. The admin can + // re-render manually from the detail page if this fails. Logged as + // error (not warn) so persistent failures surface in monitoring. + logger.error('Failed to re-render contract PDF after customer signature', { + contractId: contract.id, + message: err.message, + stack: err.stack, + }); + // Migration 136 — surface the failure on the contract row so the + // admin detail page can render a recovery banner instead of the + // admin only discovering this through monitoring. err.message is + // truncated to 2 KB; the full stack stays in server logs. + try { + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + await db('contracts').where({ id: contract.id }).update({ + signed_pdf_render_failed_at: new Date(), + signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), + updated_at: new Date(), + }); + } + } catch (markErr) { + // Marker write itself failed — log + swallow so the customer + // sign response still succeeds. The orphan stays orphan but + // we've at least surfaced both errors. + logger.error('Failed to record signed_pdf_render_failed marker', { + contractId: contract.id, message: markErr.message, + }); + } + } + + // Notify admin. + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; + try { + await emailProcessor.queueEmail(null, null, 'contract_signed_admin_notification', { + contract_number: contract.contract_number, + customer_email: customer?.email || '', + signed_customer_name: String(name).trim(), + admin_dashboard_url: `${frontendUrl}/admin/clients/contracts/${contract.id}`, + }); + } catch (err) { + logger.warn('Failed to queue admin notification after customer signature', { + contractId: contract.id, error: err.message, + }); + } + + try { + await logActivity('contract_signed_by_customer', { contractId: contract.id, token }, null, customerPublicActor()); + } catch (_) { /* logging is best-effort */ } + + return { status: 'signed_by_customer', signedAt: now }; +} + +/** + * Admin counter-signature. Bumps status to `fully_signed` (or + * `signed_by_admin` if the customer hasn't signed yet — edge case + * where admin signs first, e.g. issuer-side framework agreement). + */ +async function recordAdminCountersignature(contractId, { name, ip, signatureDataUrl }, adminId) { + // Self-heal: ensure the contract_fully_signed template exists + // before we counter-sign. The dual-party send fires from this + // function on the fully_signed transition; without the template + // it silently fails and the customer never receives the PDF. + await ensureContractEmailTemplatesSeeded(db, logger); + + if (!name || !String(name).trim()) { + throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); + } + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['signed_by_customer', 'sent'].includes(contract.status)) { + throw new AppError(`Cannot counter-sign a contract with status '${contract.status}'`, 409); + } + + const signaturePath = signatureDataUrl + ? await persistSignatureImage(contract, 'admin', signatureDataUrl) + : null; + + const now = new Date(); + const newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin'; + const persistedAdminIp = await maybeStoreIp(ip); + try { + await db('contracts').where({ id: contract.id }).update({ + status: newStatus, + signed_by_admin_at: now, + signed_admin_name: String(name).trim(), + signed_admin_ip: persistedAdminIp, + signed_admin_signature_path: signaturePath, + updated_at: now, + }); + } catch (updateErr) { + // C.7 — clean up the orphan signature PNG if the contract row + // update threw. Best-effort; log on cleanup failure and re-throw + // the original update error. + if (signaturePath) { + try { + if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); + } catch (cleanupErr) { + logger.warn('Orphan admin signature PNG cleanup failed', { + path: signaturePath, message: cleanupErr.message, + }); + } + } + throw updateErr; + } + + // Stamp the admin's signature ON TOP of whatever signed_pdf_path + // currently holds (the customer-stamped PDF, in the normal flow) + // — or directly onto the unsigned pdf_path if the admin is the + // first to sign (edge case). Byte-immutable: each prior PDF stays + // on disk; the new file is a fresh timestamped version. + const refreshed = await getContractById(contract.id); + let signedPath = null; + let signedSha256 = null; + try { + const baseFile = (refreshed.contract.signed_pdf_path && fs.existsSync(refreshed.contract.signed_pdf_path)) + ? refreshed.contract.signed_pdf_path + : refreshed.contract.pdf_path; + if (!baseFile || !fs.existsSync(baseFile)) { + throw new Error(`Contract base PDF missing on disk for stamping (signed_pdf_path=${refreshed.contract.signed_pdf_path}, pdf_path=${refreshed.contract.pdf_path})`); + } + const baseBuffer = fs.readFileSync(baseFile); + const stampedBuffer = await pdfStampService.stampSignature({ + pdfBuffer: baseBuffer, + signaturePngPath: signaturePath, + role: 'admin', + caption: { + name: String(name).trim(), + signedAt: now, + nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', + dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', + }, + }); + const suffix = newStatus === 'fully_signed' ? 'fully-signed' : 'signed-by-admin'; + const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, suffix); + signedPath = persisted.filePath; + signedSha256 = persisted.sha256; + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: signedPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } catch (err) { + logger.error('Failed to stamp contract PDF after admin signature', { + contractId: contract.id, + newStatus, + message: err.message, + stack: err.stack, + }); + // Migration 136 — mirror the customer-sign branch: persist a + // recovery marker so the admin detail page can surface a banner. + try { + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + await db('contracts').where({ id: contract.id }).update({ + signed_pdf_render_failed_at: new Date(), + signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), + updated_at: new Date(), + }); + } + } catch (markErr) { + logger.error('Failed to record signed_pdf_render_failed marker (admin sign)', { + contractId: contract.id, message: markErr.message, + }); + } + } + + // When the admin's signature is what FINALISED the contract (i.e. + // status flipped to fully_signed), email a copy of the freshly + // re-rendered PDF to both parties. We send two separate queueEmail + // calls so each recipient gets the email rendered with their own + // greeting + name. The admin BCC is delivered as "to the issuer" + // so it lands in the same inbox the contract_sent email originated + // from. + if (newStatus === 'fully_signed') { + try { + // Pick the best available PDF as the attachment, in priority + // order: this counter-sign's freshly-rendered signed copy → + // the customer-only signed copy we wrote earlier → the + // original unsigned PDF. Falling all the way through to no + // attachment is acceptable; the email still goes out with the + // contract number so the customer knows it's binding. + const refetched = await db('contracts').where({ id: contract.id }).first(); + const attachmentPath = signedPath + || refetched?.signed_pdf_path + || refetched?.pdf_path + || null; + + const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const adminRow = await db('admin_users').where({ id: adminId }).first(); + const customerName = customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email?.split('@')[0] + || ''; + // Generate the audit certificate as a SIBLING document (separate + // PDF) and attach it alongside the stamped contract. Audit cert + // captures timestamps, IPs, names, and SHA-256 hashes — the legal + // provenance record. Reproducible from contract data so safe to + // regenerate on demand; we still persist a copy to disk for the + // forensic trail. + const auditCertPath = await persistAuditCertificate(refetched || refreshed.contract); + + const attachments = []; + if (attachmentPath) { + attachments.push({ + filename: `${refreshed.contract.contract_number}-signed.pdf`, + contentPath: attachmentPath, + contentType: 'application/pdf', + }); + } + if (auditCertPath) { + attachments.push({ + filename: `${refreshed.contract.contract_number}-audit.pdf`, + contentPath: auditCertPath, + contentType: 'application/pdf', + }); + } + const attachmentsArg = attachments.length > 0 ? attachments : undefined; + + // 1. Customer copy + if (customer?.email) { + await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { + contract_number: refreshed.contract.contract_number, + customer_name: customerName, + title: refreshed.contract.title || '', + attachments: attachmentsArg, + }); + } + // 2. Admin copy. Prefer business_profile.email (the inbox the + // contract was sent FROM); fall back to the counter-signing + // admin's account email so the audit trail still reaches a + // human even on installs where business_profile.email is blank. + const adminEmail = profile.email || adminRow?.email; + if (adminEmail && adminEmail !== customer?.email) { + await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { + contract_number: refreshed.contract.contract_number, + customer_name: profile.company_name || adminRow?.first_name || 'Team', + title: refreshed.contract.title || '', + attachments: attachmentsArg, + }); + } + } catch (err) { + logger.error('Failed to send contract_fully_signed emails', { + contractId: contract.id, + message: err.message, + stack: err.stack, + }); + } + } + + try { + await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + // The binding moment — fire contract.signed once the contract is fully signed + // (matches the editor's trigger). Best-effort / fail-closed. + if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed'); + + return { status: newStatus, signedAt: now }; +} + +/** + * Attach a wet-signed PDF as the authoritative signed copy. Either + * party can upload (admin via admin route, customer via public token + * route). When the customer uploads, status flips to `fully_signed` + * because the wet signature is treated as a full agreement (admin + * would normally also sign the wet copy before sending it to the + * customer). + */ +async function attachSignedPdfUpload(contractId, filePath, uploaderRole) { + // Self-heal contract email templates — same reason as the + // sendContract + recordAdminCountersignature paths. + await ensureContractEmailTemplatesSeeded(db, logger); + + if (!filePath) throw new AppError('No file uploaded', 400); + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (['cancelled', 'draft'].includes(contract.status)) { + throw new AppError(`Cannot attach a signed PDF to a contract in status '${contract.status}'`, 409); + } + + const now = new Date(); + const updates = { + signed_pdf_path: filePath, + status: 'fully_signed', + updated_at: now, + }; + // Migration 135 — durable wet-upload discriminator. Persists the + // "this row holds an authoritative wet upload, do not auto-overwrite" + // signal as a column rather than inferring from the file path. See + // the migration body for the full rationale. + if (await hasColumnCached('contracts', 'signed_pdf_is_wet_upload')) { + updates.signed_pdf_is_wet_upload = true; + } + // Hash the uploaded PDF on disk so we can later prove it wasn't + // tampered with after upload. Multer wrote the file synchronously + // before this handler runs, so reading it here is safe. + if (await hasColumnCached('contracts', 'signed_pdf_sha256')) { + updates.signed_pdf_sha256 = sha256OfFile(filePath); + } + if (uploaderRole === 'customer' && !contract.signed_by_customer_at) { + updates.signed_by_customer_at = now; + } + if (uploaderRole === 'admin' && !contract.signed_by_admin_at) { + updates.signed_by_admin_at = now; + } + await db('contracts').where({ id: contractId }).update(updates); + + // attachSignedPdfUpload always transitions to fully_signed (see + // updates.status above), so the dual-party send fires here too — + // same pattern as recordAdminCountersignature. The uploaded PDF + // IS the authoritative copy so we attach it directly. + try { + const refreshedContract = await db('contracts').where({ id: contractId }).first(); + const customer = await db('customer_accounts').where({ id: refreshedContract.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const customerName = customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email?.split('@')[0] + || ''; + const attachments = [{ + filename: `${refreshedContract.contract_number}-signed.pdf`, + contentPath: filePath, + contentType: 'application/pdf', + }]; + // Sibling audit certificate — same legal-provenance record as the + // in-browser sign path. Best-effort; missing cert doesn't block the + // wet-signed PDF from reaching the parties. + const auditCertPath = await persistAuditCertificate(refreshedContract); + if (auditCertPath) { + attachments.push({ + filename: `${refreshedContract.contract_number}-audit.pdf`, + contentPath: auditCertPath, + contentType: 'application/pdf', + }); + } + if (customer?.email) { + await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { + contract_number: refreshedContract.contract_number, + customer_name: customerName, + title: refreshedContract.title || '', + attachments, + }); + } + if (profile.email && profile.email !== customer?.email) { + await emailProcessor.queueEmail(null, profile.email, 'contract_fully_signed', { + contract_number: refreshedContract.contract_number, + customer_name: profile.company_name || 'Team', + title: refreshedContract.title || '', + attachments, + }); + } + } catch (err) { + logger.warn('Failed to send contract_fully_signed emails after PDF upload', { + contractId, error: err.message, + }); + } + + try { + await logActivity('contract_signed_pdf_uploaded', { contractId, uploaderRole }, null, + uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor()); + } catch (_) { /* logging is best-effort */ } + + await emitContractEvent(contract, 'signed'); + + return { status: 'fully_signed', signedPdfPath: filePath }; +} + +/** + * Recovery helper: re-render the signed PDF + resend the + * contract_fully_signed email to both parties. Used by the admin + * detail page when: + * - a previous render silently failed (signed_pdf_path is empty + * on a fully_signed contract) + * - the customer reports they didn't receive the email + * - the bodies of the seeded blocks were updated post-signing and + * the admin wants the latest text on file + * + * Only available on fully_signed contracts. The wet-signed PDF path + * is preserved: when signed_pdf_path already points at an uploaded + * file (not a re-render path) we DO NOT overwrite — the uploaded PDF + * is the authoritative copy. We still resend the email with that + * uploaded PDF as the attachment. + */ +async function rerenderAndResend(contractId, adminId) { + // Self-heal contract email templates. This is the most likely + // recovery path the admin reaches when a prior dual-party send + // failed silently — including when the failure was caused by the + // template being missing in the first place. + const newlySeeded = await ensureContractEmailTemplatesSeeded(db, logger); + if (newlySeeded.length > 0) { + logger.warn('rerenderAndResend self-healed missing email templates', { + contractId, seeded: newlySeeded, + }); + } + + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (contract.status !== 'fully_signed') { + throw new AppError( + `Re-send is only available on fully-signed contracts (status: ${contract.status})`, + 409, 'NOT_FULLY_SIGNED', + ); + } + + let attachmentPath = contract.signed_pdf_path || null; + // Migration 135 — `signed_pdf_is_wet_upload` is the durable + // authoritative-source discriminator. It's set TRUE only by + // attachSignedPdfUpload, so any non-wet path here is a system + // stamp safe to replace. We still null-check the path so missing + // (re-stamp recovery) cases trigger the re-stamp branch below. + const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); + const isWetSignedUpload = hasWetFlagColumn + ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) + // Fallback ONLY for installs where the migration hasn't applied yet: + // preserve the historical substring rule so we don't accidentally + // overwrite uploads on an un-migrated DB. + : !!(attachmentPath && attachmentPath.includes('uploads/contracts/signed')); + if (!attachmentPath || !isWetSignedUpload) { + // Stamp signatures onto the immutable unsigned pdf_path using + // pdf-lib (NOT a full re-render). This preserves the exact bytes + // the customer originally agreed to and side-steps the silent re- + // render failure that left signed_pdf_path NULL on prior contracts. + const refreshed = await getContractById(contract.id); + if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { + throw new AppError( + `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, + 500, 'UNSIGNED_PDF_MISSING', + ); + } + const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); + const stamps = buildSignatureStamps(refreshed.contract); + const { buffer: stampedBuffer, sha256: signedSha256 } = + await pdfStampService.stampSignatures(originalBuffer, stamps); + const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, 'fully-signed'); + attachmentPath = persisted.filePath; + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: attachmentPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + // Migration 136 — this branch is a recovery path; clear any + // existing failed-render marker. + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } + + // Resend the dual-party email with the now-guaranteed attachment. + const refetched = await db('contracts').where({ id: contract.id }).first(); + const customer = await db('customer_accounts').where({ id: refetched.customer_account_id }).first(); + const profile = (await businessProfileService.getProfile()).profile || {}; + const adminRow = await db('admin_users').where({ id: adminId }).first(); + const customerName = customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email?.split('@')[0] + || ''; + // Sibling audit certificate (timestamps + IPs + hashes). Best-effort: + // missing certificate doesn't block the email — the stamped contract + // alone is the primary attachment. + const auditCertPath = await persistAuditCertificate(refetched); + + const attachments = [{ + filename: `${refetched.contract_number}-signed.pdf`, + contentPath: attachmentPath, + contentType: 'application/pdf', + }]; + if (auditCertPath) { + attachments.push({ + filename: `${refetched.contract_number}-audit.pdf`, + contentPath: auditCertPath, + contentType: 'application/pdf', + }); + } + + if (customer?.email) { + await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { + contract_number: refetched.contract_number, + customer_name: customerName, + title: refetched.title || '', + attachments, + }); + } + const adminEmail = profile.email || adminRow?.email; + if (adminEmail && adminEmail !== customer?.email) { + await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { + contract_number: refetched.contract_number, + customer_name: profile.company_name || adminRow?.first_name || 'Team', + title: refetched.title || '', + attachments, + }); + } + + try { + await logActivity('contract_resent_signed', { contractId }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + return { signedPdfPath: attachmentPath, resent: true }; +} + +/** + * Recovery helper: admin re-stamps signatures (customer and/or admin) + * on a contract whose signature_path columns are null/broken because + * the original sign happened before the canvas worked correctly. + * + * The admin draws BOTH signatures on the detail page — the customer's + * signature is admin-attested in this flow (the customer already + * agreed via the original sign; this just makes the PDF show + * something). Original signed_by_*_at + signed_*_name + signed_*_ip + * stay untouched; only the *_signature_path columns + the rendered + * PDF get refreshed. + * + * Available on contracts in status: + * signed_by_customer (re-stamp customer, optionally admin too) + * signed_by_admin (re-stamp admin, optionally customer too) + * fully_signed (re-stamp either or both) + */ +async function restampSignatures(contractId, { customerSignatureDataUrl, adminSignatureDataUrl }, adminId) { + const contract = await db('contracts').where({ id: contractId }).first(); + if (!contract) throw new AppError('Contract not found', 404); + if (!['signed_by_customer', 'signed_by_admin', 'fully_signed'].includes(contract.status)) { + throw new AppError( + `Cannot re-stamp signatures on a contract in status '${contract.status}'.`, + 409, 'WRONG_STATUS', + ); + } + if (!customerSignatureDataUrl && !adminSignatureDataUrl) { + throw new AppError('At least one signature data URL must be provided.', 400, 'NO_SIGNATURE'); + } + + const updates = { updated_at: new Date() }; + if (customerSignatureDataUrl) { + updates.signed_customer_signature_path = await persistSignatureImage(contract, 'customer', customerSignatureDataUrl); + } + if (adminSignatureDataUrl) { + updates.signed_admin_signature_path = await persistSignatureImage(contract, 'admin', adminSignatureDataUrl); + } + await db('contracts').where({ id: contract.id }).update(updates); + + // Re-stamp signature images onto the immutable unsigned pdf_path + // using pdf-lib (NOT a full re-render). This is the recovery path + // for contracts where signature images existed on disk but the + // earlier re-render approach failed silently and left signed_pdf_path + // NULL or pointing at a stale file. We always rebuild the stamp from + // pdf_path (the as-sent bytes) so the result is reproducible from + // the audit record. + // + // Wet-signed PDF uploads remain authoritative — if signed_pdf_path + // already points at an uploaded PDF we still produce a stamped copy + // on disk for the audit trail, but signed_pdf_path is not updated. + const refreshed = await getContractById(contract.id); + if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { + throw new AppError( + `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, + 500, 'UNSIGNED_PDF_MISSING', + ); + } + const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); + const stamps = buildSignatureStamps(refreshed.contract); + const { buffer: stampedBuffer, sha256: signedSha256 } = + await pdfStampService.stampSignatures(originalBuffer, stamps); + const { filePath: signedPath } = await persistContractPdf(refreshed.contract, stampedBuffer, + contract.status === 'fully_signed' ? 'fully-signed' : 'partially-signed'); + + // Migration 135 — read the discriminator column. Fall back to the + // historical substring rule only when the column is absent (un- + // migrated install) so we never accidentally overwrite a wet upload. + const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); + const isWetSignedUpload = hasWetFlagColumn + ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) + : !!(contract.signed_pdf_path + && contract.signed_pdf_path.includes('uploads/contracts/signed')); + if (!isWetSignedUpload) { + const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); + const updates = { + signed_pdf_path: signedPath, + updated_at: new Date(), + }; + if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; + // Migration 136 — restamp is a recovery path; clear the marker. + if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { + updates.signed_pdf_render_failed_at = null; + updates.signed_pdf_render_error = null; + } + await db('contracts').where({ id: contract.id }).update(updates); + } + + try { + await logActivity('contract_signatures_restamped', { + contractId, + stamped: { + customer: !!customerSignatureDataUrl, + admin: !!adminSignatureDataUrl, + }, + }, null, await adminActor(adminId)); + } catch (_) { /* logging is best-effort */ } + + return { + signedPdfPath: isWetSignedUpload ? contract.signed_pdf_path : signedPath, + stamped: { + customer: !!customerSignatureDataUrl, + admin: !!adminSignatureDataUrl, + }, + }; +} + +/** + * Read the chronological audit trail for a contract from activity_logs. + * Matches every `contract_*` activity_type where metadata.contractId + * equals this contract's id. Ordered oldest → newest so the UI can + * render a vertical timeline. Read-only; used by the admin detail + * page's AuditTrailCard. + */ +async function getAuditTrail(contractId) { + if (!(await db.schema.hasTable('activity_logs'))) return []; + // Push the metadata.contractId filter into SQL instead of fetching + // every contract_* row and filtering in JS. The previous shape + // scanned the entire history every time the detail page loaded — + // O(rows-since-CRM-launch) per request. Both Postgres and SQLite + // store metadata as a JSON-encoded string here, so we match on + // a literal substring that covers either compact or whitespaced + // JSON encodings — `"contractId":` or `"contractId": ` — + // bounded by the activity_type prefix so the search hits the + // contract_* slice of the index. + // + // The substring patterns intentionally don't anchor on word + // boundaries; activity_logs.metadata never contains a contractId + // key collision with another id-shaped value because logActivity + // serialises only what callers pass. + const id = Number(contractId); + if (!Number.isFinite(id)) return []; + const rows = await db('activity_logs') + .where('activity_type', 'like', 'contract_%') + .andWhere(function () { + this.where('metadata', 'like', `%"contractId":${id}%`) + .orWhere('metadata', 'like', `%"contractId": ${id}%`); + }) + .orderBy('created_at', 'asc') + .select('id', 'activity_type', 'actor_type', 'actor_id', 'actor_name', 'metadata', 'created_at'); + + return rows.map((r) => { + let meta = r.metadata; + if (typeof meta === 'string') { + try { meta = JSON.parse(meta); } catch { meta = {}; } + } + return { ...r, metadata: meta || {} }; + }); +} + +/** + * Re-hash the two on-disk PDFs and compare against the stored hashes + * (pdf_sha256 / signed_pdf_sha256 from migration 131). Lets the admin + * confirm that backups, manual moves, or storage corruption haven't + * silently altered the issued document. + * + * Each leg of the response carries: + * - `path`: the stored path string (so the UI can show what was + * checked even when it's missing) + * - `present`: file exists on disk + * - `expected`: the SHA-256 column value (null if never persisted) + * - `actual`: the freshly-computed hash, or null when file missing + * - `match`: true iff both hashes exist AND they're equal + * + * The customer already has both expected hashes via the audit + * certificate the signing flow ships as a second email attachment, so + * they can verify independently with `shasum -a 256`. This endpoint + * is the admin-side equivalent — single click instead of dropping to + * a shell. + */ +async function verifyIntegrity(id) { + const contract = await db('contracts') + .where({ id }) + .select('id', 'pdf_path', 'pdf_sha256', 'signed_pdf_path', 'signed_pdf_sha256') + .first(); + if (!contract) throw new AppError('Contract not found', 404); + + const checkLeg = (filePath, expected) => { + const present = !!filePath && fs.existsSync(filePath); + const actual = present ? sha256OfFile(filePath) : null; + return { + path: filePath || null, + present, + expected: expected || null, + actual, + match: !!(expected && actual && expected === actual), + }; + }; + + return { + unsigned: checkLeg(contract.pdf_path, contract.pdf_sha256), + signed: checkLeg(contract.signed_pdf_path, contract.signed_pdf_sha256), + }; +} +module.exports = { + recordCustomerSignature, + recordAdminCountersignature, + attachSignedPdfUpload, + rerenderAndResend, + restampSignatures, + getAuditTrail, + verifyIntegrity, +}; diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 64534c23..cff6ec02 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -31,2308 +31,28 @@ * client-side previews in the future without pulling the email * processor. */ - -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); -const { db, withRetry, logActivity } = require('../database/db'); -const logger = require('../utils/logger'); -const { getAppSetting } = require('../utils/appSettings'); -const { AppError } = require('../utils/errors'); -const { nextDocumentNumber } = require('../utils/documentSequences'); -const { hasColumnCached } = require('../utils/schemaCache'); -const { formatShortDate } = require('../utils/dateFormatter'); -const businessProfileService = require('./businessProfileService'); -const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); -const pdfService = require('./pdfService'); -const pdfStampService = require('./pdfStampService'); -const emailProcessor = require('./emailProcessor'); -const { ensureContractEmailTemplatesSeeded } = require('./contractEmailTemplates'); -const { ensureSystemBlocksSeeded } = require('./contractBlocksService'); -const { getFrontendBaseUrl } = require('../utils/frontendUrl'); - -const SECTIONS_ORDER = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing']; - -/** - * Build a proper {id, type, name} actor object for logActivity. The - * db.js helper silently downgrades string actors (e.g. 'admin:1') to - * actor_type='system' with null name, so the audit timeline showed - * "system" for every admin-driven event. Fetching the admin's name - * once per service call is a small read cost on a non-hot path. - * - * Pass `customerPublic()` for events triggered by the public token - * (customer signing, customer wet-signed PDF upload). - */ -async function adminActor(adminId) { - if (!adminId) return { type: 'system' }; - try { - // admin_users only carries username + email (no first/last/name - // columns — confirmed from db.js:265). Prefer username for the - // audit timeline because it's the operator-chosen identifier - // shown elsewhere in the admin UI; fall back to email when an - // older install seeded a row without a username. - const row = await db('admin_users') - .where({ id: adminId }) - .select('id', 'username', 'email') - .first(); - if (!row) return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; - const displayName = row.username || row.email || `Admin #${adminId}`; - return { id: adminId, type: 'admin', name: displayName }; - } catch (_) { - return { id: adminId, type: 'admin', name: `Admin #${adminId}` }; - } -} - -function customerPublicActor() { - return { type: 'customer', name: 'Customer (public link)' }; -} - -/** - * Fire a contract lifecycle event for the workflow engine. Best-effort: - * resolves the customer email (so send_email actions have a recipient) and - * never throws into the caller. No-op when the workflows flag is off (emit - * fails closed). Mirrors quoteService.emitQuoteEvent. - */ -async function emitContractEvent(contract, status) { - try { - let customerEmail = null; - if (contract.customer_account_id) { - const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); - customerEmail = c?.email || null; - } - await require('./workflows').emitWorkflowEvent(`contract.${status}`, { - entityType: 'contract', - entityId: contract.id, - payload: { - contractId: contract.id, - contractNumber: contract.contract_number, - customerAccountId: contract.customer_account_id || null, - customerEmail, - eventName: contract.event_name || null, - title: contract.title || null, - }, - }); - } catch (err) { - logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message }); - } -} - -/** - * Privacy gate for the customer/admin IP captured at signing time. - * The `crm_contracts_store_ip` setting (default true) controls - * whether the IP is persisted into the DB. When off, this helper - * returns null regardless of what the route passed in — same shape - * the rest of the code expects, just with no IP data. - * - * Default-true means upgrades preserve current behaviour. Operators - * with strict data-minimisation requirements opt out in Settings → - * CRM-Settings → Contracts. - */ -async function maybeStoreIp(ip) { - if (!ip) return null; - const enabled = await getAppSetting('crm_contracts_store_ip'); - // Default true: only block when EXPLICITLY opted out. The audit - // flagged that `enabled === false` missed legacy installs where - // app_settings stored the toggle as a string ('false', '0') — those - // would slip through and the IP would still get persisted despite - // the operator's intent. Cover string/number/bool variants - // defensively. Anything else (null, undefined, true) preserves - // the default-on behavior. - if (enabled === false) return null; - if (enabled === 0 || enabled === '0') return null; - if (typeof enabled === 'string' && enabled.toLowerCase() === 'false') return null; - return ip; -} - -// --------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------- - -// D.2 — `ensureInt` consolidated into utils/numericHelpers. -const { ensureInt } = require('../utils/numericHelpers'); - -/** - * Gap-free per-year contract number sequence. See - * utils/documentSequences.js for the locking story; migration 132 - * created the underlying table. Atomic against concurrent admin - * creates — the previous SELECT-MAX-then-INSERT raced and could - * emit `C-2026-AB12C3` after 5 retries. - */ -async function nextContractNumber(trx) { - return nextDocumentNumber('contract', 'crm_contracts_number_format', 'C-{YEAR}-{SEQ:04d}', trx); -} - -/** - * Handlebars-lite renderer: - * - `{{#if var}}…{{/if}}` blocks resolved by truthiness of variables[var]. - * - `{{var}}` substituted with the matching variable. Missing - * placeholders are left literally as `{{var}}` so the admin - * notices the unresolved field in preview. - * - * Mirrors safeTemplateReplace in emailProcessor.js (lines 424-461) but - * without HTML escaping — contract bodies are rendered into PDF via - * pdfService.drawText, which doesn't need HTML safety. - */ -function renderTemplatedBody(template, variables) { - if (typeof template !== 'string' || template.length === 0) return template; - const conditionalsResolved = template.replace( - /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g, - (_match, key, inner) => { - const v = variables ? variables[key] : undefined; - const truthy = v !== undefined && v !== null && v !== '' && v !== false && v !== 0; - return truthy ? inner : ''; - } - ); - return conditionalsResolved.replace(/\{\{(\w+)\}\}/g, (match, key) => { - if (!variables || !Object.prototype.hasOwnProperty.call(variables, key)) return match; - return String(variables[key]); - }); -} - -/** - * Build the variable bag used by renderTemplatedBody. Reads the - * customer record, business profile, and (when available) the - * customer's active payment-term defaults so block placeholders for - * net_days / skonto_percent / etc. resolve. Returns plain strings — - * dates formatted DD.MM.YYYY in DE-CH style, numbers as-is. - */ -async function buildPlaceholderContext(contract, customer) { - const profile = (await businessProfileService.getProfile()).profile || {}; - const issuerCompany = profile.company_name || ''; - const issuerAddress = [profile.address_line1, profile.postal_code, profile.city] - .filter(Boolean) - .join(', '); - - // Resolve net_days + skonto from app_settings defaults so the - // payment_terms_reference block has sensible numbers to substitute - // when the admin hasn't tied the contract to a specific quote. - const netDaysDefault = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; - const skontoPercentDefault = await getAppSetting('crm_invoices_skonto_percent_default'); - const skontoWithinDaysDefault = ensureInt(await getAppSetting('crm_invoices_skonto_business_days')) || 5; - - // {{source_quote_number}} placeholder — substituted into the body of - // the `quote_line_items_table` system block (and any admin-authored - // block that wants to reference the quote). Empty string when the - // contract wasn't generated from a quote. - let sourceQuoteNumber = ''; - if (contract.source_quote_id) { - const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) - .select('quote_number').first(); - if (srcQuote) sourceQuoteNumber = srcQuote.quote_number || ''; - } - - const customerName = customer - ? (customer.company_name - || [customer.first_name, customer.last_name].filter(Boolean).join(' ') - || customer.display_name - || customer.email - || '') - : ''; - const customerAddress = customer - ? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city] - .filter(Boolean) - .join(', ') - : ''; - - return { - customer_name: customerName, - customer_address: customerAddress, - event_name: contract.event_name || '', - event_date: formatShortDate(contract.event_date), - issue_date: formatShortDate(contract.issue_date), - contract_number: contract.contract_number || '', - title: contract.title || '', - net_days: String(netDaysDefault), - skonto_percent: skontoPercentDefault == null ? '0' : String(skontoPercentDefault), - skonto_within_days: String(skontoWithinDaysDefault), - cancellation_30d_percent: '25', - currency: (profile.default_currency || 'CHF').toUpperCase(), - issuer_company_name: issuerCompany, - issuer_address: issuerAddress, - source_quote_number: sourceQuoteNumber, - }; -} - -/** - * SHA-256 hex digest of a Buffer or file path. Used at every PDF - * write so we can persist a content hash alongside the path — - * either party can later re-hash the PDF they hold and prove (or - * disprove) it matches what we issued. - */ -function sha256OfBuffer(buffer) { - return crypto.createHash('sha256').update(buffer).digest('hex'); -} -function sha256OfFile(filePath) { - try { - return sha256OfBuffer(fs.readFileSync(filePath)); - } catch (_) { - return null; - } -} - -/** - * Write a contract PDF to disk and return both the path AND the - * SHA-256 hash of the buffer we just wrote. Callers persist BOTH on - * the contracts row so audit defence is single-query: SELECT - * pdf_path, pdf_sha256 FROM contracts WHERE id = ? then re-hash the - * file on disk and compare. - * - * History-preserving (per requirement #6): every write appends a - * deterministic suffix so old versions stay on disk. The contract - * row's `pdf_path` / `signed_pdf_path` always points at the most - * recent one; earlier versions remain available for forensic - * comparison. - */ -async function persistContractPdf(contract, buffer, suffix = '') { - if (!contract.contract_number) return { filePath: null, sha256: null }; - const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); - const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); - fs.mkdirSync(root, { recursive: true }); - // Always append a millisecond timestamp to the filename so writes - // never overwrite an earlier version on disk. Forensic preservation. - // Example filenames: - // C-2026-0001_2026-05-19T1830-22-413.pdf (unsigned) - // C-2026-0001_signed-by-customer_2026-05-19T1845-10-002.pdf - // C-2026-0001_fully-signed_2026-05-19T1912-44-877.pdf - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = suffix - ? `${contract.contract_number}_${suffix}_${stamp}.pdf` - : `${contract.contract_number}_${stamp}.pdf`; - const filePath = path.join(root, fileName); - fs.writeFileSync(filePath, buffer); - return { filePath, sha256: sha256OfBuffer(buffer) }; -} - -// Maximum decoded signature image size. Defends against a customer -// (or attacker holding a captured signing token) POSTing a multi-MB -// signature data URL to fill the disk. A typical signature_pad PNG -// is 10–80 KB; even with retina upscaling we don't expect to see -// 1 MB. The cap is enforced on the BASE64 length before decoding so -// we never allocate the full Buffer for an oversized payload. // -// The frontend (ContractResponsePage) downscales the canvas to a -// fixed max width before exporting via `toDataURL`, so well-behaved -// clients land well under this cap. This server-side check is the -// authoritative guard. -const MAX_SIGNATURE_BASE64_BYTES = 1024 * 1024; // 1 MB of base64 → ~750 KB decoded - -async function persistSignatureImage(contract, role, dataUrl) { - if (!dataUrl || typeof dataUrl !== 'string') return null; - if (dataUrl.length > MAX_SIGNATURE_BASE64_BYTES + 100 /* prefix slack */) { - throw new AppError( - `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, - 413, 'SIGNATURE_TOO_LARGE', - ); - } - const match = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/); - if (!match) { - throw new AppError('Signature must be a base64-encoded PNG or JPEG data URL', 400, 'BAD_SIGNATURE_FORMAT'); - } - if (match[2].length > MAX_SIGNATURE_BASE64_BYTES) { - throw new AppError( - `Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`, - 413, 'SIGNATURE_TOO_LARGE', - ); - } - const ext = match[1] === 'jpeg' ? 'jpg' : 'png'; - const root = path.join( - process.cwd(), - 'storage', - 'business-docs', - 'contract', - 'signatures', - String(contract.id), - ); - fs.mkdirSync(root, { recursive: true }); - // Filename already carries Date.now() so re-stamping a signature - // never overwrites an earlier capture — forensic preservation. - // Per role, the contract row's signed_*_signature_path always - // points at the most recent; older files stay alongside. - const filePath = path.join(root, `${role}-${Date.now()}.${ext}`); - fs.writeFileSync(filePath, Buffer.from(match[2], 'base64')); - return filePath; -} - -/** - * Build the stamp sequence the pdf-lib stamp service expects from a - * single contract row. Customer first, admin second — provenance - * order matches the visual order on the signature page. - * - * Used by the recovery paths (rerenderAndResend, restampSignatures). - * The hot path (recordCustomerSignature / recordAdminCountersignature) - * stamps incrementally so it constructs the stamp inline. - */ -function buildSignatureStamps(contract) { - const locale = contract.language || 'de'; - const nameLabel = 'Name'; - const dateLabel = locale === 'de' ? 'Datum' : 'Date'; - const stamps = []; - if (contract.signed_customer_signature_path) { - stamps.push({ - signaturePngPath: contract.signed_customer_signature_path, - role: 'customer', - caption: { - name: contract.signed_customer_name || '', - signedAt: contract.signed_by_customer_at, - nameLabel, - dateLabel, - }, - }); - } - if (contract.signed_admin_signature_path) { - stamps.push({ - signaturePngPath: contract.signed_admin_signature_path, - role: 'admin', - caption: { - name: contract.signed_admin_name || '', - signedAt: contract.signed_by_admin_at, - nameLabel, - dateLabel, - }, - }); - } - return stamps; -} - -/** - * Build the audit-certificate context expected by - * pdfStampService.renderAuditCertificate from a fully-signed - * contract row. Returns null when the contract isn't signed enough - * to warrant a certificate (no customer + no admin signature data). - */ -function buildAuditCertContext(contract) { - const hasCustomerSig = contract.signed_by_customer_at || contract.signed_customer_name; - const hasAdminSig = contract.signed_by_admin_at || contract.signed_admin_name; - if (!hasCustomerSig && !hasAdminSig) return null; - return { - contract: { - contract_number: contract.contract_number, - sent_at: contract.sent_at, - pdf_sha256: contract.pdf_sha256 || null, - signed_pdf_sha256: contract.signed_pdf_sha256 || null, - }, - customer: hasCustomerSig ? { - name: contract.signed_customer_name, - signedAt: contract.signed_by_customer_at, - ip: contract.signed_customer_ip, - } : null, - admin: hasAdminSig ? { - name: contract.signed_admin_name, - signedAt: contract.signed_by_admin_at, - ip: contract.signed_admin_ip, - } : null, - locale: contract.language || 'de', - }; -} - -/** - * Generate the audit certificate PDF, write it to disk under the same - * year directory as the contract PDFs (suffix `audit`), and return - * its file path. Returns null when there's nothing to certify or when - * rendering fails (the email still goes out without the cert — the - * stamped PDF alone remains delivered). - */ -async function persistAuditCertificate(contract) { - const ctx = buildAuditCertContext(contract); - if (!ctx) return null; - try { - const { buffer } = await pdfStampService.renderAuditCertificate(ctx); - const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear(); - const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year)); - fs.mkdirSync(root, { recursive: true }); - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`); - fs.writeFileSync(filePath, buffer); - return filePath; - } catch (err) { - logger.error('Failed to render audit certificate', { - contractId: contract.id, - contractNumber: contract.contract_number, - message: err.message, - }); - return null; - } -} - -function ensureCustomerActive(customer) { - 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); - } -} - -// --------------------------------------------------------------------- -// Render-context builder + PDF helpers -// --------------------------------------------------------------------- - -/** - * Build the data shape pdfService.renderContractToBuffer expects. - * Sections are emitted in canonical SECTIONS_ORDER; blocks within a - * section are emitted in `position` order. Bodies are run through - * renderTemplatedBody so {{placeholders}} are substituted. - * - * When the contract has been sent, `body_text_snapshot` is used (so - * later edits to the source block don't mutate the rendered document). - * Before send (preview from editor) the live `contract_blocks.body_text` - * is used so the admin can iterate on block bodies and see the result. - */ -async function buildRenderContext(contract, inclusions) { - const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); - const profile = (await businessProfileService.getProfile()).profile || {}; - const placeholders = await buildPlaceholderContext(contract, customer); - - // Pull source-quote line items when this contract was generated from a - // quote. Surfaced on the render context so the renderer can draw a real - // table at the location of the `quote_line_items_table` system block. - // Sub-items keep their parent's position via the LEFT JOIN so the - // renderer can indent them with a `↳` prefix. - let quoteLineItems = []; - let quoteCurrency = null; - let quoteNumber = null; - if (contract.source_quote_id) { - const srcQuote = await db('quotes').where({ id: contract.source_quote_id }) - .select('quote_number', 'currency').first(); - if (srcQuote) { - quoteCurrency = srcQuote.currency; - quoteNumber = srcQuote.quote_number; - quoteLineItems = await db('quote_line_items as li') - .leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id') - .where('li.quote_id', contract.source_quote_id) - .orderBy('li.position', 'asc') - .select('li.*', 'parent.position as parent_position'); - } - } - - const locale = contract.language || customer?.preferred_language || profile.default_locale || 'de'; - - // Group inclusions by section + render each block body. - const blocksBySection = {}; - for (const section of SECTIONS_ORDER) blocksBySection[section] = []; - const sortedInclusions = [...inclusions] - .filter((row) => row.included === true || row.included === 1 || row.included === '1') - .sort((a, b) => { - const sa = SECTIONS_ORDER.indexOf(a.section); - const sb = SECTIONS_ORDER.indexOf(b.section); - if (sa !== sb) return sa - sb; - return (a.position || 0) - (b.position || 0); - }); - - for (const row of sortedInclusions) { - if (!blocksBySection[row.section]) continue; - // The inclusion row carries the JOINED block columns aliased with - // a `block_` prefix (see getContractById). Pre-send drafts have - // null snapshots, so fall through to the live block body. - // Migration 131 added ru/pt/nl/fr columns. The body resolver - // picks the locale-matching column first, falls back through - // DE → EN, so an admin can stage translations one locale at a - // time without breaking contracts in other languages. - const bodyEn = row.body_text_snapshot || row.block_body_text || ''; - const bodyDe = row.body_text_de_snapshot || row.block_body_text_de || ''; - const bodyRu = row.block_body_text_ru || ''; - const bodyPt = row.block_body_text_pt || ''; - const bodyNl = row.block_body_text_nl || ''; - const bodyFr = row.block_body_text_fr || ''; - const localeBody = ({ - de: bodyDe, - ru: bodyRu, - pt: bodyPt, - nl: bodyNl, - fr: bodyFr, - })[locale] || ''; - const sourceBody = localeBody || bodyEn || bodyDe; - // Substitute placeholders, then strip any leading `**Title**\n` - // line — the block's `name` field is already rendered as a bold - // sub-heading by the PDF/public layouts, so a bold first line in - // the body produces a duplicated title. Inline `**bold**` markers - // elsewhere in the body are preserved (the PDF renders them as - // actual bold via renderBodyMarkdown; the public route strips - // them since the React page has no inline-bold UI). - const rendered = renderTemplatedBody(sourceBody, placeholders) - .replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, ''); - blocksBySection[row.section].push({ - slug: row.block_slug || null, - name: row.block_name, - section: row.section, - body: rendered, - }); - } - - // Use the same robust logo resolver quote/invoice use — checks - // business_profile.logo_path → app_settings.branding_logo_path → - // app_settings.branding_logo_url, with ~7 disk-location candidates - // before giving up. - const { resolveLogoFile } = require('../utils/resolveLogoFile'); - const resolvedLogoPath = await resolveLogoFile(profile); - - // Global date format from Settings → General (general_date_format). - 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, - dateFormat, - // Mirror the quote/invoice issuer shape EXACTLY so drawIssuerBlock - // honours the same business-profile toggles (pdf_show_logo, - // pdf_show_company_name, pdf_logo_height, pdf_company_name_inline, - // pdf_folding_marks) across all three document types. Per maintainer: - // contracts reuse the same toggles — no contract-specific knobs. - // Shared issuer + recipient builders. Contracts use the base toggle - // set (no quote-only payment-block fields). The renderer-aware - // recipient gating means contractService's previously-drifted - // local attentionLine logic now matches quote + invoice exactly. - issuer: buildIssuerBlock(profile, resolvedLogoPath), - recipient: buildRecipientBlock(profile, customer), - doc: { - contractNumber: contract.contract_number, - title: contract.title || '', - issueDate: contract.issue_date, - validUntil: contract.valid_until, - introText: contract.intro_text ? renderTemplatedBody(contract.intro_text, placeholders) : null, - outroText: contract.outro_text ? renderTemplatedBody(contract.outro_text, placeholders) : null, - }, - // Blocks grouped + ordered by canonical section order. - sections: SECTIONS_ORDER - .map((section) => ({ section, blocks: blocksBySection[section] })) - .filter((s) => s.blocks.length > 0), - // Source-quote line items, surfaced at the top level so the PDF - // renderer can draw a formatted table where the - // `quote_line_items_table` system block is included. Empty array - // when the contract has no source quote. - quoteLineItems, - quoteCurrency, - quoteSourceNumber: quoteNumber, - // Signature evidence (used by the PDF renderer to stamp signatures - // into the closing section when present). - signatures: { - customer: contract.signed_customer_name ? { - name: contract.signed_customer_name, - signedAt: contract.signed_by_customer_at, - ip: contract.signed_customer_ip, - signaturePath: contract.signed_customer_signature_path, - } : null, - admin: contract.signed_admin_name ? { - name: contract.signed_admin_name, - signedAt: contract.signed_by_admin_at, - ip: contract.signed_admin_ip, - signaturePath: contract.signed_admin_signature_path, - } : null, - }, - // Audit-trail evidence appended to the rendered PDF as a final - // page (issue #3). The renderer skips the page when this is null - // OR when the contract isn't signed yet, so unsigned PDFs stay - // unchanged. Hashes are best-effort: pdfSha256 may be null on - // installs that haven't migrated to the new schema column yet — - // the page still renders the rest of the evidence. - audit: (contract.signed_customer_name || contract.signed_admin_name) ? { - contractNumber: contract.contract_number, - issuedAt: contract.sent_at, - pdfSha256: contract.pdf_sha256 || null, - signedPdfSha256: contract.signed_pdf_sha256 || null, - } : null, - }; -} - -// --------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------- - -async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { - return await withRetry(async () => { - let query = db('contracts') - .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') - .select( - 'contracts.*', - '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', - ); - - if (Array.isArray(filters.status) && filters.status.length > 0) { - query = query.whereIn('contracts.status', filters.status); - } - if (filters.customerAccountId) { - query = query.where('contracts.customer_account_id', filters.customerAccountId); - } - if (filters.q && String(filters.q).trim()) { - const term = `%${String(filters.q).trim()}%`; - query = query.andWhere(function() { - this.where('contracts.contract_number', 'like', term) - .orWhere('contracts.title', 'like', term) - .orWhere('customer_accounts.email', 'like', term) - .orWhere('customer_accounts.company_name', 'like', term); - }); - } - - const countQuery = query.clone().clearSelect().clearOrder().count('contracts.id as total').first(); - const totalRow = await countQuery; - const total = ensureInt(totalRow?.total || 0); - - switch (sort) { - case 'oldest': - query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); - break; - case 'issue_asc': - query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc'); - break; - case 'issue_desc': - query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc'); - break; - case 'customer_asc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') - .orderBy('contracts.id', 'desc'); - break; - case 'customer_desc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') - .orderBy('contracts.id', 'desc'); - break; - case 'newest': - default: - query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.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 getContractById(id) { - return await withRetry(async () => { - const contract = await db('contracts') - .leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id') - .where('contracts.id', id) - .select( - 'contracts.*', - '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', - 'customer_accounts.preferred_language as customer_preferred_language', - ) - .first(); - if (!contract) return null; - - const inclusions = await db('contract_block_inclusions as inc') - .leftJoin('contract_blocks as blk', 'blk.id', 'inc.block_id') - .where('inc.contract_id', id) - .orderByRaw(` - CASE inc.section - WHEN 'basics' THEN 1 - WHEN 'scope' THEN 2 - WHEN 'privacy' THEN 3 - WHEN 'commercial' THEN 4 - WHEN 'nda' THEN 5 - WHEN 'closing' THEN 6 - ELSE 99 - END - `) - .orderBy('inc.position', 'asc') - .select( - 'inc.*', - 'blk.slug as block_slug', - 'blk.name as block_name', - 'blk.description as block_description', - 'blk.body_text as block_body_text', - 'blk.body_text_de as block_body_text_de', - // Migration 131 — locale variants. Pulled with column-existence - // guard so installs that haven't run migration 131 still load - // contracts (just without the new columns). - ...(await hasColumnCached('contract_blocks', 'body_text_ru') - ? ['blk.body_text_ru as block_body_text_ru'] : []), - ...(await hasColumnCached('contract_blocks', 'body_text_pt') - ? ['blk.body_text_pt as block_body_text_pt'] : []), - ...(await hasColumnCached('contract_blocks', 'body_text_nl') - ? ['blk.body_text_nl as block_body_text_nl'] : []), - ...(await hasColumnCached('contract_blocks', 'body_text_fr') - ? ['blk.body_text_fr as block_body_text_fr'] : []), - 'blk.is_system as block_is_system', - ); - return { contract, inclusions }; - }); -} - -/** - * Create a draft contract. Pre-populates `contract_block_inclusions` - * with every active system block toggled ON so the admin sees a - * sensible starting point and just toggles off what they don't need. - * - * Custom (non-system) blocks are NOT auto-included — admin opts in to - * those explicitly so a runaway block library doesn't pollute every - * new contract. - */ -async function createContract(payload, adminId) { - // Self-heal: ensure runtime-seeded system blocks (e.g. the - // quote_line_items_table added after migration 131 was deployed) - // exist before we copy active system blocks into the new contract's - // inclusion list. Idempotent — only fires if rows are missing. - await ensureSystemBlocksSeeded(); - - const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); - ensureCustomerActive(customer); - - const profile = (await businessProfileService.getProfile()).profile; - const language = payload.language || customer.preferred_language || profile?.default_locale || 'de'; - const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; - 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); - - // Schema-drift guard for the event-snapshot columns added as - // in-place migration 130 edits. We only write them when the DB - // actually has them; older dev installs that haven't re-migrated - // simply skip these fields (contract still saves successfully). - const hasEventCols = await hasColumnCached('contracts', 'event_name'); - - return await db.transaction(async (trx) => { - // Pass trx so the sequence claim joins our outer transaction — - // SQLite deadlocks otherwise (1-connection default). - const contractNumber = await nextContractNumber(trx); - const row = { - contract_number: contractNumber, - customer_account_id: payload.customerAccountId, - status: 'draft', - language, - issue_date: issueDate, - valid_until: validUntil, - title: payload.title || null, - intro_text: payload.introText || null, - outro_text: payload.outroText || null, - // Migration 140 — standalone contract is a deal root; mint a - // fresh UUID. The createFromQuote path (line ~1557) sets this - // from the source quote's deal_uuid instead. - deal_uuid: crypto.randomUUID(), - created_by_admin_id: adminId, - created_at: new Date(), - updated_at: new Date(), - }; - if (hasEventCols) { - row.event_name = payload.eventName || null; - row.event_date = payload.eventDate || null; - row.event_time_start = payload.eventTimeStart || null; - row.event_time_end = payload.eventTimeEnd || null; - } - // Migration 121 — optional link to a Project Overview project. - if (payload.projectId !== undefined && await hasColumnCached('contracts', 'project_id')) { - row.project_id = payload.projectId || null; - } - const inserted = await trx('contracts').insert(row).returning('id'); - if (row.project_id && row.deal_uuid) { - await require('./projectService').linkDealToProject(row.deal_uuid, row.project_id, trx); - } - const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - - // Seed with every active system block, toggled on. Per-section - // position = display_order from the source block. - // - // D.3 — batched insert. Previously this loop fired one INSERT per - // block (12+ round-trips inside the transaction on a fresh contract). - // Batched into a single `.insert(rows)` since the row count is - // bounded (system block count) and the inserts are independent. - const systemBlocks = await trx('contract_blocks') - .where({ is_system: true, is_active: true }) - .orderBy(['section', 'display_order']); - const sectionCounters = {}; - const inclusionRows = systemBlocks.map((block) => { - sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; - return { - contract_id: contractId, - block_id: block.id, - section: block.section, - position: sectionCounters[block.section], - body_text_snapshot: null, - body_text_de_snapshot: null, - included: true, - created_at: new Date(), - updated_at: new Date(), - }; - }); - if (inclusionRows.length > 0) { - await trx('contract_block_inclusions').insert(inclusionRows); - } - - try { - await logActivity('contract_created', { contractId, contractNumber, customerAccountId: payload.customerAccountId }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - - logger.info('Contract created', { adminId, contractId, contractNumber }); - return contractId; - }); -} - -/** - * Update a draft contract. Editing a sent contract is refused — admin - * must cancel + create a fresh one (avoids invalidating the customer's - * signed copy). - * - * payload.blocks is an array of `{ blockId, included, position }` - * tuples; the service rewrites the contract_block_inclusions rows - * accordingly. - */ -async function updateContract(id, payload, adminId) { - const existing = await db('contracts').where({ id }).first(); - if (!existing) throw new AppError('Contract not found', 404); - if (existing.status !== 'draft') { - throw new AppError( - `Cannot edit a contract with status '${existing.status}'. Cancel and create a new contract for amendments.`, - 409, - 'CONTRACT_LOCKED', - ); - } - - const hasEventCols = await hasColumnCached('contracts', 'event_name'); - - return await db.transaction(async (trx) => { - const updates = { updated_at: new Date() }; - const map = { - title: 'title', - introText: 'intro_text', - outroText: 'outro_text', - language: 'language', - validUntil: 'valid_until', - issueDate: 'issue_date', - }; - // Event-snapshot fields only flow through when the DB has them - // (in-place migration 130 edit). Guarded so dev installs that - // haven't re-migrated don't crash the update. - if (hasEventCols) { - Object.assign(map, { - eventName: 'event_name', - eventDate: 'event_date', - eventTimeStart: 'event_time_start', - eventTimeEnd: 'event_time_end', - }); - } - for (const [api, col] of Object.entries(map)) { - if (api in payload) updates[col] = payload[api] || null; - } - // Migration 121 — optional Project Overview link. - if ('projectId' in payload && await hasColumnCached('contracts', 'project_id')) { - updates.project_id = payload.projectId || null; - } - await trx('contracts').where({ id }).update(updates); - - // Cascade across the deal lineage (linked quote / event / invoices). - if (updates.project_id) { - const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first(); - await require('./projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx); - } - - // Replace inclusions only when the caller sent an explicit list. - // (Editor's "save" sends every row; an inline "toggle" save could - // send a partial update — current frontend always sends full list.) - if (Array.isArray(payload.blocks)) { - await trx('contract_block_inclusions').where({ contract_id: id }).del(); - // Recompute per-section position so we don't trust caller order - // for ordering integrity; caller controls only the section - // sequence via the order of items in payload.blocks. - // - // Previously this loop did one SELECT per block to look up its - // section. On a contract with 12 included blocks that's 12 - // round-trips inside the transaction — pure N+1. Batch the - // lookup into a single WHERE…IN, build a Map, and read it in - // the loop. The insert itself stays sequential because the - // editor's payload size is bounded (<30 blocks in practice) and - // a single batch insert would lose row-by-row insert ordering - // guarantees we don't actually need. - const blockIds = [ - ...new Set(payload.blocks.map((e) => e.blockId).filter((id) => Number.isFinite(id))), - ]; - const blocksFound = blockIds.length > 0 - ? await trx('contract_blocks').whereIn('id', blockIds).select('id', 'section') - : []; - const sectionByBlockId = new Map(blocksFound.map((b) => [b.id, b.section])); - const sectionCounters = {}; - for (const entry of payload.blocks) { - const section = sectionByBlockId.get(entry.blockId); - if (!section) continue; - sectionCounters[section] = (sectionCounters[section] || 0) + 1; - await trx('contract_block_inclusions').insert({ - contract_id: id, - block_id: entry.blockId, - section, - position: ensureInt(entry.position) || sectionCounters[section], - body_text_snapshot: null, - body_text_de_snapshot: null, - included: entry.included === false ? false : true, - created_at: new Date(), - updated_at: new Date(), - }); - } - } - - try { - await logActivity('contract_updated', { contractId: id }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - return id; - }); -} - -/** - * Render PDF for a saved contract (preview before send, or re-render - * after signing). - */ -async function renderContractPdfBuffer(contractId) { - const data = await getContractById(contractId); - if (!data) throw new AppError('Contract not found', 404); - const ctx = await buildRenderContext(data.contract, data.inclusions); - return await pdfService.renderContractToBuffer(ctx); -} - -/** - * Send the contract: snapshot every included block's body, render PDF, - * persist, mint a signing token, queue the customer email. - */ -async function sendContract(id, adminId) { - // Self-heal: dev installs that ran migration 130 BEFORE we added - // contract_fully_signed to the seed list won't have all three - // contract templates in email_templates. Insert any missing rows - // before we queue the email. Idempotent + module-cached. - await ensureContractEmailTemplatesSeeded(db, logger); - - const data = await getContractById(id); - if (!data) throw new AppError('Contract not found', 404); - const { contract, inclusions } = data; - - if (!['draft'].includes(contract.status)) { - throw new AppError(`Cannot send a contract with status '${contract.status}'`, 409); - } - - const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); - ensureCustomerActive(customer); - - // Snapshot every included block's body into the inclusion row so - // future block edits don't mutate the sent contract. - await db.transaction(async (trx) => { - for (const inc of inclusions) { - if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue; - await trx('contract_block_inclusions').where({ id: inc.id }).update({ - body_text_snapshot: inc.block_body_text || null, - body_text_de_snapshot: inc.block_body_text_de || null, - updated_at: new Date(), - }); - } - }); - - // Re-fetch with snapshots populated so the renderer uses the frozen - // bodies (matches post-send reads). - const refreshed = await getContractById(id); - const ctx = await buildRenderContext(refreshed.contract, refreshed.inclusions); - const buffer = await pdfService.renderContractToBuffer(ctx); - const { filePath: pdfPath, sha256: pdfSha256 } = await persistContractPdf(refreshed.contract, buffer); - - const token = crypto.randomBytes(32).toString('hex'); - const expiresAt = contract.valid_until - ? new Date(new Date(contract.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000) - : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); - - // Schema-drift guard for the new pdf_sha256 column (migration 130 - // in-place edit). Dev installs that haven't re-migrated skip the - // hash write; the send still succeeds. - const hasPdfSha = await hasColumnCached('contracts', 'pdf_sha256'); - - await db.transaction(async (trx) => { - await trx('contract_action_tokens').insert({ - contract_id: id, - token, - expires_at: expiresAt, - created_at: new Date(), - }); - const updates = { - status: 'sent', - sent_at: new Date(), - pdf_path: pdfPath, - updated_at: new Date(), - }; - if (hasPdfSha) updates.pdf_sha256 = pdfSha256; - await trx('contracts').where({ id }).update(updates); - }); - - const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; - const responseUrl = `${frontendUrl}/contract/${token}`; - // Honour the admin's "Attach contract PDF to email" toggle. Default - // ON; an admin who prefers a link-only email turns it off and the - // customer reaches the PDF via the public sign page instead. - const attachPdf = await getAppSetting('crm_contracts_pdf_attachment_enabled'); - await emailProcessor.queueEmail(null, customer.email, 'contract_sent', { - contract_number: contract.contract_number, - customer_name: customer.display_name - || [customer.first_name, customer.last_name].filter(Boolean).join(' ') - || customer.email.split('@')[0], - response_url: responseUrl, - title: contract.title || '', - event_name: contract.event_name || '', - valid_until: formatShortDate(contract.valid_until), - attachments: (attachPdf !== false && pdfPath) ? [{ - filename: `${contract.contract_number}.pdf`, - contentPath: pdfPath, - contentType: 'application/pdf', - }] : undefined, - }); - - try { - await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - - await emitContractEvent(contract, 'sent'); - - logger.info('Contract sent', { adminId, contractId: id }); - return { token, pdfPath }; -} - -/** - * Record a customer's in-browser signature (canvas + typed name + - * "I accept" checkbox). Validates the token, persists the signature - * PNG, re-renders the PDF with the signature stamped, flips status - * to `signed_by_customer`, and queues the admin notification email. - */ -async function recordCustomerSignature({ token, name, ip, signatureDataUrl, accepted }) { - // Self-heal contract email templates. The contract_signed_admin_notification - // email fires from this function — if its row is missing, the admin - // never learns the customer signed. - await ensureContractEmailTemplatesSeeded(db, logger); - - if (accepted !== true) { - throw new AppError('You must confirm that you have read and agree to the terms.', 400, 'TOS_REQUIRED'); - } - if (!name || !String(name).trim()) { - throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); - } - // Server-side guard for the "require drawn signature" admin toggle. - // The public sign page also enforces this client-side, but the - // server is the source of truth — a malicious caller posting - // directly to /sign with a blank signatureDataUrl would otherwise - // bypass the requirement. - const requireDrawn = await getAppSetting('crm_contracts_require_drawn_signature'); - if (requireDrawn === true && (!signatureDataUrl || !String(signatureDataUrl).trim())) { - throw new AppError( - 'A drawn signature is required for this contract — typing your name alone is not sufficient.', - 400, 'SIGNATURE_REQUIRED', - ); - } - const tokenRow = await db('contract_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('This signing link has expired', 410); - } - if (tokenRow.used_at) { - throw new AppError('This contract has already been signed', 410, 'TOKEN_ALREADY_USED'); - } - - const contract = await db('contracts').where({ id: tokenRow.contract_id }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (!['sent'].includes(contract.status)) { - throw new AppError(`Contract cannot be signed in status '${contract.status}'`, 409); - } - - const signaturePath = signatureDataUrl - ? await persistSignatureImage(contract, 'customer', signatureDataUrl) - : null; - - const now = new Date(); - // Resolve the IP gate ONCE before the transaction so both writes - // (contracts row + tokens row) agree. Setting flip mid-transaction - // can't happen anyway, but doing it upfront keeps the data - // consistent and saves a redundant read. - const persistedIp = await maybeStoreIp(ip); - try { - await db.transaction(async (trx) => { - await trx('contracts').where({ id: contract.id }).update({ - status: 'signed_by_customer', - signed_by_customer_at: now, - signed_customer_name: String(name).trim(), - signed_customer_ip: persistedIp, - signed_customer_signature_path: signaturePath, - updated_at: now, - }); - await trx('contract_action_tokens').where({ id: tokenRow.id }).update({ - used_at: now, - used_action: 'signed_by_customer', - used_ip: persistedIp, - }); - }); - } catch (txErr) { - // C.7 — clean up the orphan signature PNG we wrote before the - // transaction. The DB rollback already undid the contract + - // token writes; the file would otherwise sit forever in - // storage/business-docs/contract/.../signatures/. Best-effort - // unlink — if the cleanup itself fails, log and re-throw the - // original transaction error so the caller still sees the real - // failure cause. - if (signaturePath) { - try { - if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); - } catch (cleanupErr) { - logger.warn('Orphan signature PNG cleanup failed', { - path: signaturePath, message: cleanupErr.message, - }); - } - } - throw txErr; - } - - // Stamp the customer's signature onto the UNSIGNED PDF on disk. - // Byte-immutable approach (see pdfStampService): we read pdf_path - // (the immutable as-sent PDF), stamp the customer's signature PNG - // at the fixed coordinates on the signature page, save as a new - // timestamped file, and update signed_pdf_path. Original file - // stays untouched on disk. - const refreshed = await getContractById(contract.id); - try { - if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { - throw new Error(`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}`); - } - const originalPdfBuffer = fs.readFileSync(refreshed.contract.pdf_path); - const stampedBuffer = await pdfStampService.stampSignature({ - pdfBuffer: originalPdfBuffer, - signaturePngPath: signaturePath, - role: 'customer', - caption: { - name: String(name).trim(), - signedAt: now, - nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', - dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', - }, - }); - const { filePath: signedPath, sha256: signedSha256 } = await persistContractPdf( - refreshed.contract, stampedBuffer, 'signed-by-customer', - ); - const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); - const updates = { - signed_pdf_path: signedPath, - updated_at: new Date(), - }; - if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; - // Migration 136 — clear any pre-existing render-failed marker; the - // most recent stamp attempt just succeeded. - if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { - updates.signed_pdf_render_failed_at = null; - updates.signed_pdf_render_error = null; - } - await db('contracts').where({ id: contract.id }).update(updates); - } catch (err) { - // Signature recorded; PDF re-render is best-effort. The admin can - // re-render manually from the detail page if this fails. Logged as - // error (not warn) so persistent failures surface in monitoring. - logger.error('Failed to re-render contract PDF after customer signature', { - contractId: contract.id, - message: err.message, - stack: err.stack, - }); - // Migration 136 — surface the failure on the contract row so the - // admin detail page can render a recovery banner instead of the - // admin only discovering this through monitoring. err.message is - // truncated to 2 KB; the full stack stays in server logs. - try { - if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { - await db('contracts').where({ id: contract.id }).update({ - signed_pdf_render_failed_at: new Date(), - signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), - updated_at: new Date(), - }); - } - } catch (markErr) { - // Marker write itself failed — log + swallow so the customer - // sign response still succeeds. The orphan stays orphan but - // we've at least surfaced both errors. - logger.error('Failed to record signed_pdf_render_failed marker', { - contractId: contract.id, message: markErr.message, - }); - } - } - - // Notify admin. - const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); - const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000'; - try { - await emailProcessor.queueEmail(null, null, 'contract_signed_admin_notification', { - contract_number: contract.contract_number, - customer_email: customer?.email || '', - signed_customer_name: String(name).trim(), - admin_dashboard_url: `${frontendUrl}/admin/clients/contracts/${contract.id}`, - }); - } catch (err) { - logger.warn('Failed to queue admin notification after customer signature', { - contractId: contract.id, error: err.message, - }); - } - - try { - await logActivity('contract_signed_by_customer', { contractId: contract.id, token }, null, customerPublicActor()); - } catch (_) { /* logging is best-effort */ } - - return { status: 'signed_by_customer', signedAt: now }; -} - -/** - * Admin counter-signature. Bumps status to `fully_signed` (or - * `signed_by_admin` if the customer hasn't signed yet — edge case - * where admin signs first, e.g. issuer-side framework agreement). - */ -async function recordAdminCountersignature(contractId, { name, ip, signatureDataUrl }, adminId) { - // Self-heal: ensure the contract_fully_signed template exists - // before we counter-sign. The dual-party send fires from this - // function on the fully_signed transition; without the template - // it silently fails and the customer never receives the PDF. - await ensureContractEmailTemplatesSeeded(db, logger); - - if (!name || !String(name).trim()) { - throw new AppError('Your name is required.', 400, 'NAME_REQUIRED'); - } - const contract = await db('contracts').where({ id: contractId }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (!['signed_by_customer', 'sent'].includes(contract.status)) { - throw new AppError(`Cannot counter-sign a contract with status '${contract.status}'`, 409); - } - - const signaturePath = signatureDataUrl - ? await persistSignatureImage(contract, 'admin', signatureDataUrl) - : null; - - const now = new Date(); - const newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin'; - const persistedAdminIp = await maybeStoreIp(ip); - try { - await db('contracts').where({ id: contract.id }).update({ - status: newStatus, - signed_by_admin_at: now, - signed_admin_name: String(name).trim(), - signed_admin_ip: persistedAdminIp, - signed_admin_signature_path: signaturePath, - updated_at: now, - }); - } catch (updateErr) { - // C.7 — clean up the orphan signature PNG if the contract row - // update threw. Best-effort; log on cleanup failure and re-throw - // the original update error. - if (signaturePath) { - try { - if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath); - } catch (cleanupErr) { - logger.warn('Orphan admin signature PNG cleanup failed', { - path: signaturePath, message: cleanupErr.message, - }); - } - } - throw updateErr; - } - - // Stamp the admin's signature ON TOP of whatever signed_pdf_path - // currently holds (the customer-stamped PDF, in the normal flow) - // — or directly onto the unsigned pdf_path if the admin is the - // first to sign (edge case). Byte-immutable: each prior PDF stays - // on disk; the new file is a fresh timestamped version. - const refreshed = await getContractById(contract.id); - let signedPath = null; - let signedSha256 = null; - try { - const baseFile = (refreshed.contract.signed_pdf_path && fs.existsSync(refreshed.contract.signed_pdf_path)) - ? refreshed.contract.signed_pdf_path - : refreshed.contract.pdf_path; - if (!baseFile || !fs.existsSync(baseFile)) { - throw new Error(`Contract base PDF missing on disk for stamping (signed_pdf_path=${refreshed.contract.signed_pdf_path}, pdf_path=${refreshed.contract.pdf_path})`); - } - const baseBuffer = fs.readFileSync(baseFile); - const stampedBuffer = await pdfStampService.stampSignature({ - pdfBuffer: baseBuffer, - signaturePngPath: signaturePath, - role: 'admin', - caption: { - name: String(name).trim(), - signedAt: now, - nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name', - dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date', - }, - }); - const suffix = newStatus === 'fully_signed' ? 'fully-signed' : 'signed-by-admin'; - const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, suffix); - signedPath = persisted.filePath; - signedSha256 = persisted.sha256; - const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); - const updates = { - signed_pdf_path: signedPath, - updated_at: new Date(), - }; - if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; - if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { - updates.signed_pdf_render_failed_at = null; - updates.signed_pdf_render_error = null; - } - await db('contracts').where({ id: contract.id }).update(updates); - } catch (err) { - logger.error('Failed to stamp contract PDF after admin signature', { - contractId: contract.id, - newStatus, - message: err.message, - stack: err.stack, - }); - // Migration 136 — mirror the customer-sign branch: persist a - // recovery marker so the admin detail page can surface a banner. - try { - if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { - await db('contracts').where({ id: contract.id }).update({ - signed_pdf_render_failed_at: new Date(), - signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048), - updated_at: new Date(), - }); - } - } catch (markErr) { - logger.error('Failed to record signed_pdf_render_failed marker (admin sign)', { - contractId: contract.id, message: markErr.message, - }); - } - } - - // When the admin's signature is what FINALISED the contract (i.e. - // status flipped to fully_signed), email a copy of the freshly - // re-rendered PDF to both parties. We send two separate queueEmail - // calls so each recipient gets the email rendered with their own - // greeting + name. The admin BCC is delivered as "to the issuer" - // so it lands in the same inbox the contract_sent email originated - // from. - if (newStatus === 'fully_signed') { - try { - // Pick the best available PDF as the attachment, in priority - // order: this counter-sign's freshly-rendered signed copy → - // the customer-only signed copy we wrote earlier → the - // original unsigned PDF. Falling all the way through to no - // attachment is acceptable; the email still goes out with the - // contract number so the customer knows it's binding. - const refetched = await db('contracts').where({ id: contract.id }).first(); - const attachmentPath = signedPath - || refetched?.signed_pdf_path - || refetched?.pdf_path - || null; - - const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); - const profile = (await businessProfileService.getProfile()).profile || {}; - const adminRow = await db('admin_users').where({ id: adminId }).first(); - const customerName = customer?.display_name - || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') - || customer?.email?.split('@')[0] - || ''; - // Generate the audit certificate as a SIBLING document (separate - // PDF) and attach it alongside the stamped contract. Audit cert - // captures timestamps, IPs, names, and SHA-256 hashes — the legal - // provenance record. Reproducible from contract data so safe to - // regenerate on demand; we still persist a copy to disk for the - // forensic trail. - const auditCertPath = await persistAuditCertificate(refetched || refreshed.contract); - - const attachments = []; - if (attachmentPath) { - attachments.push({ - filename: `${refreshed.contract.contract_number}-signed.pdf`, - contentPath: attachmentPath, - contentType: 'application/pdf', - }); - } - if (auditCertPath) { - attachments.push({ - filename: `${refreshed.contract.contract_number}-audit.pdf`, - contentPath: auditCertPath, - contentType: 'application/pdf', - }); - } - const attachmentsArg = attachments.length > 0 ? attachments : undefined; - - // 1. Customer copy - if (customer?.email) { - await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { - contract_number: refreshed.contract.contract_number, - customer_name: customerName, - title: refreshed.contract.title || '', - attachments: attachmentsArg, - }); - } - // 2. Admin copy. Prefer business_profile.email (the inbox the - // contract was sent FROM); fall back to the counter-signing - // admin's account email so the audit trail still reaches a - // human even on installs where business_profile.email is blank. - const adminEmail = profile.email || adminRow?.email; - if (adminEmail && adminEmail !== customer?.email) { - await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { - contract_number: refreshed.contract.contract_number, - customer_name: profile.company_name || adminRow?.first_name || 'Team', - title: refreshed.contract.title || '', - attachments: attachmentsArg, - }); - } - } catch (err) { - logger.error('Failed to send contract_fully_signed emails', { - contractId: contract.id, - message: err.message, - stack: err.stack, - }); - } - } - - try { - await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - - // The binding moment — fire contract.signed once the contract is fully signed - // (matches the editor's trigger). Best-effort / fail-closed. - if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed'); - - return { status: newStatus, signedAt: now }; -} - -/** - * Attach a wet-signed PDF as the authoritative signed copy. Either - * party can upload (admin via admin route, customer via public token - * route). When the customer uploads, status flips to `fully_signed` - * because the wet signature is treated as a full agreement (admin - * would normally also sign the wet copy before sending it to the - * customer). - */ -async function attachSignedPdfUpload(contractId, filePath, uploaderRole) { - // Self-heal contract email templates — same reason as the - // sendContract + recordAdminCountersignature paths. - await ensureContractEmailTemplatesSeeded(db, logger); - - if (!filePath) throw new AppError('No file uploaded', 400); - const contract = await db('contracts').where({ id: contractId }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (['cancelled', 'draft'].includes(contract.status)) { - throw new AppError(`Cannot attach a signed PDF to a contract in status '${contract.status}'`, 409); - } - - const now = new Date(); - const updates = { - signed_pdf_path: filePath, - status: 'fully_signed', - updated_at: now, - }; - // Migration 135 — durable wet-upload discriminator. Persists the - // "this row holds an authoritative wet upload, do not auto-overwrite" - // signal as a column rather than inferring from the file path. See - // the migration body for the full rationale. - if (await hasColumnCached('contracts', 'signed_pdf_is_wet_upload')) { - updates.signed_pdf_is_wet_upload = true; - } - // Hash the uploaded PDF on disk so we can later prove it wasn't - // tampered with after upload. Multer wrote the file synchronously - // before this handler runs, so reading it here is safe. - if (await hasColumnCached('contracts', 'signed_pdf_sha256')) { - updates.signed_pdf_sha256 = sha256OfFile(filePath); - } - if (uploaderRole === 'customer' && !contract.signed_by_customer_at) { - updates.signed_by_customer_at = now; - } - if (uploaderRole === 'admin' && !contract.signed_by_admin_at) { - updates.signed_by_admin_at = now; - } - await db('contracts').where({ id: contractId }).update(updates); - - // attachSignedPdfUpload always transitions to fully_signed (see - // updates.status above), so the dual-party send fires here too — - // same pattern as recordAdminCountersignature. The uploaded PDF - // IS the authoritative copy so we attach it directly. - try { - const refreshedContract = await db('contracts').where({ id: contractId }).first(); - const customer = await db('customer_accounts').where({ id: refreshedContract.customer_account_id }).first(); - const profile = (await businessProfileService.getProfile()).profile || {}; - const customerName = customer?.display_name - || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') - || customer?.email?.split('@')[0] - || ''; - const attachments = [{ - filename: `${refreshedContract.contract_number}-signed.pdf`, - contentPath: filePath, - contentType: 'application/pdf', - }]; - // Sibling audit certificate — same legal-provenance record as the - // in-browser sign path. Best-effort; missing cert doesn't block the - // wet-signed PDF from reaching the parties. - const auditCertPath = await persistAuditCertificate(refreshedContract); - if (auditCertPath) { - attachments.push({ - filename: `${refreshedContract.contract_number}-audit.pdf`, - contentPath: auditCertPath, - contentType: 'application/pdf', - }); - } - if (customer?.email) { - await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { - contract_number: refreshedContract.contract_number, - customer_name: customerName, - title: refreshedContract.title || '', - attachments, - }); - } - if (profile.email && profile.email !== customer?.email) { - await emailProcessor.queueEmail(null, profile.email, 'contract_fully_signed', { - contract_number: refreshedContract.contract_number, - customer_name: profile.company_name || 'Team', - title: refreshedContract.title || '', - attachments, - }); - } - } catch (err) { - logger.warn('Failed to send contract_fully_signed emails after PDF upload', { - contractId, error: err.message, - }); - } - - try { - await logActivity('contract_signed_pdf_uploaded', { contractId, uploaderRole }, null, - uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor()); - } catch (_) { /* logging is best-effort */ } - - await emitContractEvent(contract, 'signed'); - - return { status: 'fully_signed', signedPdfPath: filePath }; -} - -/** - * Convert an accepted quote into a fresh draft contract, pre-populating - * the customer, language, title, valid-until window, and source_quote_id - * back-pointer. Idempotent — if the quote already has a linked contract - * (quote.converted_contract_id set), returns that contract's id without - * creating a duplicate. - * - * Does NOT flip quote.status — the quote stays 'accepted' while the - * contract is the active deliverable. The quote→event / quote→invoice - * paths are gated against the converted_contract_id back-pointer so an - * admin can't accidentally double-spend the quote. - */ -async function createFromQuote(quoteId, adminId) { - // Same self-heal as createContract — the quote-conversion path seeds - // the contract with every active system block, and the new - // quote_line_items_table block needs to be present for it to land - // in the default inclusion list. - await ensureSystemBlocksSeeded(); - - const quote = await db('quotes').where({ id: quoteId }).first(); - 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, 'QUOTE_NOT_ACCEPTED'); - } - if (quote.converted_contract_id) { - return { contractId: quote.converted_contract_id, alreadyConverted: true }; - } - if (quote.converted_event_id) { - throw new AppError( - 'This quote was already converted to an event. Create the contract from the event instead.', - 409, 'ALREADY_CONVERTED_TO_EVENT', - ); - } - - const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); - ensureCustomerActive(customer); - - const profile = (await businessProfileService.getProfile()).profile; - const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30; - const issueDate = new Date().toISOString().slice(0, 10); - const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000) - .toISOString().slice(0, 10); - - const title = quote.event_name - ? `Contract — ${quote.event_name}` - : `Contract from quote ${quote.quote_number}`; - - // Schema-drift safety: the lineage columns landed in migration 130 - // as in-place edits. Dev installs that ran 130 BEFORE that edit - // won't have these columns yet. hasColumn() lets us skip the - // affected writes instead of crashing with a generic 500. - const hasContractSourceQuote = await hasColumnCached('contracts', 'source_quote_id'); - const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id'); - const hasContractEventCols = await hasColumnCached('contracts', 'event_name'); - - // Resolve the actor BEFORE opening the transaction — adminActor reads - // admin_users via the global db, which deadlocks the single-connection - // SQLite pool if evaluated inside the trx (prepare_contract runs unattended). - const actor = await adminActor(adminId); - - return await db.transaction(async (trx) => { - // Pass trx so the sequence claim joins our outer transaction — - // SQLite deadlocks otherwise (1-connection default). - const contractNumber = await nextContractNumber(trx); - const contractRow = { - contract_number: contractNumber, - customer_account_id: quote.customer_account_id, - status: 'draft', - language: quote.language || customer.preferred_language || profile?.default_locale || 'de', - issue_date: issueDate, - valid_until: validUntil, - title, - intro_text: quote.intro_text || null, - outro_text: quote.outro_text || null, - created_by_admin_id: adminId, - created_at: new Date(), - updated_at: new Date(), - }; - if (hasContractSourceQuote) contractRow.source_quote_id = quote.id; - // Migration 140 — contract from quote inherits the quote's - // deal_uuid so both documents belong to the same deal chain. - // Falls back to a fresh UUID only if the source quote predates the - // backfill (shouldn't happen on a migrated install, but defensive). - contractRow.deal_uuid = quote.deal_uuid || crypto.randomUUID(); - // Propagate the quote's event snapshot — same fields the quote - // already carries (set by createQuote). Means contract-from-quote - // chains preserve "this contract is for the Wedding Doe / Müller" - // labelling all the way through to the resulting invoice's - // event_name field. - if (hasContractEventCols) { - contractRow.event_name = quote.event_name || null; - contractRow.event_date = quote.event_date || null; - contractRow.event_time_start = quote.event_time_start || null; - contractRow.event_time_end = quote.event_time_end || null; - } - const inserted = await trx('contracts').insert(contractRow).returning('id'); - const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - - // Seed every active system block. Same shape as createContract. - // D.3 — batched insert (one DB round-trip vs N). - const systemBlocks = await trx('contract_blocks') - .where({ is_system: true, is_active: true }) - .orderBy(['section', 'display_order']); - const sectionCounters = {}; - const inclusionRows = systemBlocks.map((block) => { - sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1; - return { - contract_id: contractId, - block_id: block.id, - section: block.section, - position: sectionCounters[block.section], - body_text_snapshot: null, - body_text_de_snapshot: null, - included: true, - created_at: new Date(), - updated_at: new Date(), - }; - }); - if (inclusionRows.length > 0) { - await trx('contract_block_inclusions').insert(inclusionRows); - } - - // Back-pointer so the quote detail page can deep-link to its - // resulting contract and the convert-to-event/invoice paths know - // to refuse double conversion. Skipped silently when the column - // hasn't migrated — the contract is still created cleanly. - if (hasQuoteContractBackPointer) { - await trx('quotes').where({ id: quote.id }).update({ - converted_contract_id: contractId, - updated_at: new Date(), - }); - } - - try { - // Pass `trx` so the audit insert rides the transaction's connection; - // the global db here deadlocks the single-connection SQLite pool. - await logActivity('contract_created_from_quote', - { contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number }, - null, actor, trx); - } catch (_) { /* logging is best-effort */ } - logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id }); - return { contractId, alreadyConverted: false }; - }); -} - -/** - * Convert a fully-signed contract into an event + scheduled invoices. - * Delegates to quoteService.convertToEvent using the contract's - * source_quote_id so the line items + payment plan come from the - * original quote. The quote MUST still be in 'accepted' status (i.e. - * not previously converted) — createFromQuote keeps it that way. - * - * On success the contract's converted_event_id is set (back-pointer) - * and the source quote flips to 'converted'. - */ -async function convertToEvent(contractId, adminId) { - const contract = await db('contracts').where({ id: contractId }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (contract.status !== 'fully_signed') { - throw new AppError( - `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, - 409, 'CONTRACT_NOT_FULLY_SIGNED', - ); - } - if (contract.converted_event_id) { - return { eventId: contract.converted_event_id, alreadyConverted: true }; - } - - const hasContractConvertedEvent = await hasColumnCached('contracts', 'converted_event_id'); - - // Path A: source quote present → delegate to quoteService which - // replays the full installment schedule into invoices alongside - // the event row. - if (contract.source_quote_id) { - const quoteService = require('./quoteService'); - const result = await quoteService.convertToEvent(contract.source_quote_id, adminId, { fromContract: true }); - if (hasContractConvertedEvent) { - await db('contracts').where({ id: contractId }).update({ - converted_event_id: result.eventId, - updated_at: new Date(), - }); - } - try { - await logActivity('contract_converted_to_event', - { contractId, eventId: result.eventId, quoteId: contract.source_quote_id }, - result.eventId, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - return result; - } - - // Path B: standalone contract → mint an empty placeholder event - // row the admin fleshes out from the events admin page. Same - // column-introspection trick quoteService uses so installs with - // old/new host_*/customer_* column variants both work. - const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); - ensureCustomerActive(customer); - const adminRow = await db('admin_users').where({ id: adminId }).first(); - const today = new Date(); - const oneYearFromNow = new Date(today.getTime()); - oneYearFromNow.setFullYear(today.getFullYear() + 1); - - const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ') - || customer.display_name || customer.company_name || contract.contract_number; - const customerEmail = customer.email || `${contract.contract_number.toLowerCase()}@picpeak.local`; - const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local'; - const placeholderHash = crypto.randomBytes(32).toString('hex'); - const shareToken = crypto.randomBytes(32).toString('hex'); - - const eventCols = await db('events').columnInfo(); - const candidate = { - slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`, - // Prefer the contract's event_name snapshot (set on the contract - // editor or inherited from the source quote) over the contract - // title. Falls back to a deterministic placeholder so the event - // row never has a blank name. - event_name: contract.event_name || contract.title || `Event ${contract.contract_number}`, - event_date: contract.event_date || contract.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: placeholderHash, - share_link: shareToken, - share_token: shareToken, - expires_at: oneYearFromNow, - is_active: true, - is_archived: false, - is_draft: true, - created_by: adminId, - quote_id: null, - 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 db('events').insert(eventRow).returning('id'); - const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - - // Link the customer so they see the event on their portal once - // the admin activates it. Best-effort — older installs without - // the junction table still get the event row. - try { - if (await db.schema.hasTable('event_customer_assignments')) { - await db('event_customer_assignments').insert({ - event_id: eventId, - customer_account_id: customer.id, - assigned_by_admin_id: adminId, - assigned_at: new Date(), - }); - } - } catch (_) { /* best-effort */ } - - if (hasContractConvertedEvent) { - await db('contracts').where({ id: contractId }).update({ - converted_event_id: eventId, - updated_at: new Date(), - }); - } - - try { - await logActivity('contract_converted_to_empty_event', - { contractId, eventId }, eventId, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - - return { eventId, alreadyConverted: false }; -} - -/** - * Convert a fully-signed contract directly into invoice(s) without - * creating an event row. Same delegation pattern as convertToEvent. - */ -async function convertToInvoiceOnly(contractId, adminId) { - const contract = await db('contracts').where({ id: contractId }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (contract.status !== 'fully_signed') { - throw new AppError( - `Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`, - 409, 'CONTRACT_NOT_FULLY_SIGNED', - ); - } - - // Schema-drift guard — the lineage columns are in-place edits to - // migration 130. Skip the back-pointer update silently when the - // column hasn't migrated yet. - const hasInvoiceContractBackPointer = await hasColumnCached('invoices', 'source_contract_id'); - - // Path A: contract has a source quote → replay its line items + - // payment plan via quoteService (full installment schedule). - if (contract.source_quote_id) { - const quoteService = require('./quoteService'); - const result = await quoteService.convertToInvoiceOnly(contract.source_quote_id, adminId, { fromContract: true }); - if (hasInvoiceContractBackPointer) { - await db('invoices') - .where({ source_quote_id: contract.source_quote_id }) - .whereNull('source_contract_id') - .update({ source_contract_id: contractId }); - } - try { - await logActivity('contract_converted_to_invoices', - { contractId, quoteId: contract.source_quote_id, installments: result.installmentsCreated }, - null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - return result; - } - - // Path B: standalone contract (no source quote) → direct DB insert - // of an empty draft. We deliberately bypass invoiceService.createInvoice - // because that runs ensureCustomerCanBill, which throws if the - // customer doesn't have feature_bills enabled. Admin clicking - // "Convert to invoice" on the contract detail page IS the - // authorisation; the admin will fill in line items manually before - // sending. - const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); - ensureCustomerActive(customer); - - const invoiceService = require('./invoiceService'); - const profile = (await businessProfileService.getProfile()).profile || {}; - const currency = (profile.default_currency || 'CHF').toUpperCase(); - const language = contract.language || customer.preferred_language || profile.default_locale || 'de'; - const issueDate = new Date().toISOString().slice(0, 10); - const netDays = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30; - const dueDate = new Date(Date.now() + netDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - - // Pre-resolve which event-snapshot columns the invoices table has - // (migration 123) so we can copy contract.event_name etc onto the - // new invoice. Falls back to contract.title when event_name is - // empty — gives standalone contracts a useful label even when - // the admin didn't fill out the event field. - const invoiceHasEventName = await hasColumnCached('invoices', 'event_name'); - const eventNameSnapshot = (contract.event_name || contract.title || null); - - const invoiceNumber = await invoiceService.nextInvoiceNumber(); - const invoiceRow = { - invoice_number: invoiceNumber, - customer_account_id: contract.customer_account_id, - source_quote_id: null, - event_id: null, - language, - currency, - issue_date: issueDate, - due_date: dueDate, - installment_index: 0, - installment_total: 1, - status: 'scheduled', - net_amount_minor: 0, - vat_rate: 0, - vat_amount_minor: 0, - shipping_amount_minor: 0, - total_amount_minor: 0, - paid_amount_minor: 0, - reminder_level: 0, - late_fee_amount_minor: 0, - created_by_admin_id: adminId, - created_at: new Date(), - updated_at: new Date(), - }; - if (hasInvoiceContractBackPointer) invoiceRow.source_contract_id = contractId; - // Migration 140 — invoice inherits the contract's deal_uuid so the - // contract + invoice belong to the same deal chain. Fresh UUID if - // the contract predates the backfill (defensive). - invoiceRow.deal_uuid = contract.deal_uuid || crypto.randomUUID(); - // Snapshot the contract's event fields onto the invoice so the - // BillDetailPage + customer portal show the same "Wedding Doe / - // Müller" label that the contract carries. event_name is also the - // field the dunning emails reference in their templates. - if (invoiceHasEventName) { - invoiceRow.event_name = eventNameSnapshot; - invoiceRow.event_date = contract.event_date || null; - invoiceRow.event_time_start = contract.event_time_start || null; - invoiceRow.event_time_end = contract.event_time_end || null; - } - const inserted = await db('invoices').insert(invoiceRow).returning('id'); - const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - - try { - await logActivity('contract_converted_to_empty_invoice', - { contractId, invoiceId, invoiceNumber }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - - // Match the result shape of the source-quote path so the frontend - // toast can use the same translation key. `installmentsCreated` is - // always 1 here (single empty invoice). - return { installmentsCreated: 1, invoiceId }; -} - -/** - * Recovery helper: re-render the signed PDF + resend the - * contract_fully_signed email to both parties. Used by the admin - * detail page when: - * - a previous render silently failed (signed_pdf_path is empty - * on a fully_signed contract) - * - the customer reports they didn't receive the email - * - the bodies of the seeded blocks were updated post-signing and - * the admin wants the latest text on file - * - * Only available on fully_signed contracts. The wet-signed PDF path - * is preserved: when signed_pdf_path already points at an uploaded - * file (not a re-render path) we DO NOT overwrite — the uploaded PDF - * is the authoritative copy. We still resend the email with that - * uploaded PDF as the attachment. - */ -async function rerenderAndResend(contractId, adminId) { - // Self-heal contract email templates. This is the most likely - // recovery path the admin reaches when a prior dual-party send - // failed silently — including when the failure was caused by the - // template being missing in the first place. - const newlySeeded = await ensureContractEmailTemplatesSeeded(db, logger); - if (newlySeeded.length > 0) { - logger.warn('rerenderAndResend self-healed missing email templates', { - contractId, seeded: newlySeeded, - }); - } - - const contract = await db('contracts').where({ id: contractId }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (contract.status !== 'fully_signed') { - throw new AppError( - `Re-send is only available on fully-signed contracts (status: ${contract.status})`, - 409, 'NOT_FULLY_SIGNED', - ); - } - - let attachmentPath = contract.signed_pdf_path || null; - // Migration 135 — `signed_pdf_is_wet_upload` is the durable - // authoritative-source discriminator. It's set TRUE only by - // attachSignedPdfUpload, so any non-wet path here is a system - // stamp safe to replace. We still null-check the path so missing - // (re-stamp recovery) cases trigger the re-stamp branch below. - const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); - const isWetSignedUpload = hasWetFlagColumn - ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) - // Fallback ONLY for installs where the migration hasn't applied yet: - // preserve the historical substring rule so we don't accidentally - // overwrite uploads on an un-migrated DB. - : !!(attachmentPath && attachmentPath.includes('uploads/contracts/signed')); - if (!attachmentPath || !isWetSignedUpload) { - // Stamp signatures onto the immutable unsigned pdf_path using - // pdf-lib (NOT a full re-render). This preserves the exact bytes - // the customer originally agreed to and side-steps the silent re- - // render failure that left signed_pdf_path NULL on prior contracts. - const refreshed = await getContractById(contract.id); - if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { - throw new AppError( - `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, - 500, 'UNSIGNED_PDF_MISSING', - ); - } - const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); - const stamps = buildSignatureStamps(refreshed.contract); - const { buffer: stampedBuffer, sha256: signedSha256 } = - await pdfStampService.stampSignatures(originalBuffer, stamps); - const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, 'fully-signed'); - attachmentPath = persisted.filePath; - const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); - const updates = { - signed_pdf_path: attachmentPath, - updated_at: new Date(), - }; - if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; - // Migration 136 — this branch is a recovery path; clear any - // existing failed-render marker. - if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { - updates.signed_pdf_render_failed_at = null; - updates.signed_pdf_render_error = null; - } - await db('contracts').where({ id: contract.id }).update(updates); - } - - // Resend the dual-party email with the now-guaranteed attachment. - const refetched = await db('contracts').where({ id: contract.id }).first(); - const customer = await db('customer_accounts').where({ id: refetched.customer_account_id }).first(); - const profile = (await businessProfileService.getProfile()).profile || {}; - const adminRow = await db('admin_users').where({ id: adminId }).first(); - const customerName = customer?.display_name - || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') - || customer?.email?.split('@')[0] - || ''; - // Sibling audit certificate (timestamps + IPs + hashes). Best-effort: - // missing certificate doesn't block the email — the stamped contract - // alone is the primary attachment. - const auditCertPath = await persistAuditCertificate(refetched); - - const attachments = [{ - filename: `${refetched.contract_number}-signed.pdf`, - contentPath: attachmentPath, - contentType: 'application/pdf', - }]; - if (auditCertPath) { - attachments.push({ - filename: `${refetched.contract_number}-audit.pdf`, - contentPath: auditCertPath, - contentType: 'application/pdf', - }); - } - - if (customer?.email) { - await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', { - contract_number: refetched.contract_number, - customer_name: customerName, - title: refetched.title || '', - attachments, - }); - } - const adminEmail = profile.email || adminRow?.email; - if (adminEmail && adminEmail !== customer?.email) { - await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', { - contract_number: refetched.contract_number, - customer_name: profile.company_name || adminRow?.first_name || 'Team', - title: refetched.title || '', - attachments, - }); - } - - try { - await logActivity('contract_resent_signed', { contractId }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - - return { signedPdfPath: attachmentPath, resent: true }; -} - -/** - * Recovery helper: admin re-stamps signatures (customer and/or admin) - * on a contract whose signature_path columns are null/broken because - * the original sign happened before the canvas worked correctly. - * - * The admin draws BOTH signatures on the detail page — the customer's - * signature is admin-attested in this flow (the customer already - * agreed via the original sign; this just makes the PDF show - * something). Original signed_by_*_at + signed_*_name + signed_*_ip - * stay untouched; only the *_signature_path columns + the rendered - * PDF get refreshed. - * - * Available on contracts in status: - * signed_by_customer (re-stamp customer, optionally admin too) - * signed_by_admin (re-stamp admin, optionally customer too) - * fully_signed (re-stamp either or both) - */ -async function restampSignatures(contractId, { customerSignatureDataUrl, adminSignatureDataUrl }, adminId) { - const contract = await db('contracts').where({ id: contractId }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (!['signed_by_customer', 'signed_by_admin', 'fully_signed'].includes(contract.status)) { - throw new AppError( - `Cannot re-stamp signatures on a contract in status '${contract.status}'.`, - 409, 'WRONG_STATUS', - ); - } - if (!customerSignatureDataUrl && !adminSignatureDataUrl) { - throw new AppError('At least one signature data URL must be provided.', 400, 'NO_SIGNATURE'); - } - - const updates = { updated_at: new Date() }; - if (customerSignatureDataUrl) { - updates.signed_customer_signature_path = await persistSignatureImage(contract, 'customer', customerSignatureDataUrl); - } - if (adminSignatureDataUrl) { - updates.signed_admin_signature_path = await persistSignatureImage(contract, 'admin', adminSignatureDataUrl); - } - await db('contracts').where({ id: contract.id }).update(updates); - - // Re-stamp signature images onto the immutable unsigned pdf_path - // using pdf-lib (NOT a full re-render). This is the recovery path - // for contracts where signature images existed on disk but the - // earlier re-render approach failed silently and left signed_pdf_path - // NULL or pointing at a stale file. We always rebuild the stamp from - // pdf_path (the as-sent bytes) so the result is reproducible from - // the audit record. - // - // Wet-signed PDF uploads remain authoritative — if signed_pdf_path - // already points at an uploaded PDF we still produce a stamped copy - // on disk for the audit trail, but signed_pdf_path is not updated. - const refreshed = await getContractById(contract.id); - if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) { - throw new AppError( - `Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`, - 500, 'UNSIGNED_PDF_MISSING', - ); - } - const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path); - const stamps = buildSignatureStamps(refreshed.contract); - const { buffer: stampedBuffer, sha256: signedSha256 } = - await pdfStampService.stampSignatures(originalBuffer, stamps); - const { filePath: signedPath } = await persistContractPdf(refreshed.contract, stampedBuffer, - contract.status === 'fully_signed' ? 'fully-signed' : 'partially-signed'); - - // Migration 135 — read the discriminator column. Fall back to the - // historical substring rule only when the column is absent (un- - // migrated install) so we never accidentally overwrite a wet upload. - const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload'); - const isWetSignedUpload = hasWetFlagColumn - ? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1) - : !!(contract.signed_pdf_path - && contract.signed_pdf_path.includes('uploads/contracts/signed')); - if (!isWetSignedUpload) { - const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256'); - const updates = { - signed_pdf_path: signedPath, - updated_at: new Date(), - }; - if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256; - // Migration 136 — restamp is a recovery path; clear the marker. - if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) { - updates.signed_pdf_render_failed_at = null; - updates.signed_pdf_render_error = null; - } - await db('contracts').where({ id: contract.id }).update(updates); - } - - try { - await logActivity('contract_signatures_restamped', { - contractId, - stamped: { - customer: !!customerSignatureDataUrl, - admin: !!adminSignatureDataUrl, - }, - }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - - return { - signedPdfPath: isWetSignedUpload ? contract.signed_pdf_path : signedPath, - stamped: { - customer: !!customerSignatureDataUrl, - admin: !!adminSignatureDataUrl, - }, - }; -} - -/** - * Read the chronological audit trail for a contract from activity_logs. - * Matches every `contract_*` activity_type where metadata.contractId - * equals this contract's id. Ordered oldest → newest so the UI can - * render a vertical timeline. Read-only; used by the admin detail - * page's AuditTrailCard. - */ -async function getAuditTrail(contractId) { - if (!(await db.schema.hasTable('activity_logs'))) return []; - // Push the metadata.contractId filter into SQL instead of fetching - // every contract_* row and filtering in JS. The previous shape - // scanned the entire history every time the detail page loaded — - // O(rows-since-CRM-launch) per request. Both Postgres and SQLite - // store metadata as a JSON-encoded string here, so we match on - // a literal substring that covers either compact or whitespaced - // JSON encodings — `"contractId":` or `"contractId": ` — - // bounded by the activity_type prefix so the search hits the - // contract_* slice of the index. - // - // The substring patterns intentionally don't anchor on word - // boundaries; activity_logs.metadata never contains a contractId - // key collision with another id-shaped value because logActivity - // serialises only what callers pass. - const id = Number(contractId); - if (!Number.isFinite(id)) return []; - const rows = await db('activity_logs') - .where('activity_type', 'like', 'contract_%') - .andWhere(function () { - this.where('metadata', 'like', `%"contractId":${id}%`) - .orWhere('metadata', 'like', `%"contractId": ${id}%`); - }) - .orderBy('created_at', 'asc') - .select('id', 'activity_type', 'actor_type', 'actor_id', 'actor_name', 'metadata', 'created_at'); - - return rows.map((r) => { - let meta = r.metadata; - if (typeof meta === 'string') { - try { meta = JSON.parse(meta); } catch { meta = {}; } - } - return { ...r, metadata: meta || {} }; - }); -} - -/** - * Re-hash the two on-disk PDFs and compare against the stored hashes - * (pdf_sha256 / signed_pdf_sha256 from migration 131). Lets the admin - * confirm that backups, manual moves, or storage corruption haven't - * silently altered the issued document. - * - * Each leg of the response carries: - * - `path`: the stored path string (so the UI can show what was - * checked even when it's missing) - * - `present`: file exists on disk - * - `expected`: the SHA-256 column value (null if never persisted) - * - `actual`: the freshly-computed hash, or null when file missing - * - `match`: true iff both hashes exist AND they're equal - * - * The customer already has both expected hashes via the audit - * certificate the signing flow ships as a second email attachment, so - * they can verify independently with `shasum -a 256`. This endpoint - * is the admin-side equivalent — single click instead of dropping to - * a shell. - */ -async function verifyIntegrity(id) { - const contract = await db('contracts') - .where({ id }) - .select('id', 'pdf_path', 'pdf_sha256', 'signed_pdf_path', 'signed_pdf_sha256') - .first(); - if (!contract) throw new AppError('Contract not found', 404); - - const checkLeg = (filePath, expected) => { - const present = !!filePath && fs.existsSync(filePath); - const actual = present ? sha256OfFile(filePath) : null; - return { - path: filePath || null, - present, - expected: expected || null, - actual, - match: !!(expected && actual && expected === actual), - }; - }; - - return { - unsigned: checkLeg(contract.pdf_path, contract.pdf_sha256), - signed: checkLeg(contract.signed_pdf_path, contract.signed_pdf_sha256), - }; -} - -async function cancelContract(id, adminId) { - const contract = await db('contracts').where({ id }).first(); - if (!contract) throw new AppError('Contract not found', 404); - if (!['draft', 'sent'].includes(contract.status)) { - throw new AppError(`Cannot cancel a contract with status '${contract.status}'`, 409); - } - await db('contracts').where({ id }).update({ - status: 'cancelled', - updated_at: new Date(), - }); - // Invalidate any outstanding tokens. - await db('contract_action_tokens').where({ contract_id: id, used_at: null }).update({ - expires_at: new Date(), - }); - try { - await logActivity('contract_cancelled', { contractId: id }, null, await adminActor(adminId)); - } catch (_) { /* logging is best-effort */ } - return { status: 'cancelled' }; -} +// Decomposed into ./contract/* modules (move-code refactor). This file is the +// stable public entry point: same require path, same exported names. + +const helpers = require('./contract/helpers'); +const renderContext = require('./contract/renderContext'); +const crud = require('./contract/crud'); +const sending = require('./contract/sending'); +const signatures = require('./contract/signatures'); +const conversions = require('./contract/conversions'); + +const { SECTIONS_ORDER, nextContractNumber } = helpers; +const { renderTemplatedBody, buildPlaceholderContext, buildRenderContext } = renderContext; +const { + listContracts, getContractById, createContract, updateContract, cancelContract, +} = crud; +const { renderContractPdfBuffer, sendContract } = sending; +const { + recordCustomerSignature, recordAdminCountersignature, attachSignedPdfUpload, + rerenderAndResend, restampSignatures, getAuditTrail, verifyIntegrity, +} = signatures; +const { createFromQuote, convertToEvent, convertToInvoiceOnly } = conversions; module.exports = { listContracts, diff --git a/backend/src/services/invoice/create.js b/backend/src/services/invoice/create.js new file mode 100644 index 00000000..eb22be88 --- /dev/null +++ b/backend/src/services/invoice/create.js @@ -0,0 +1,582 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const { db, logActivity } = require('../../database/db'); +const { getAppSetting } = require('../../utils/appSettings'); +const { cleanNetMinor } = require('../../utils/invoiceRounding'); +const { AppError } = require('../../utils/errors'); +const businessProfileService = require('../businessProfileService'); +const { ensureInt, ensureNumber } = require('../../utils/numericHelpers'); +const { hasColumnCached } = require('../../utils/schemaCache'); +const { computeDueDate, computeScheduledSendAt, ensureCustomerCanBill, getHierarchyHelpers, nextInvoiceNumber, resolveDealUuid, resolveNetDays, snapToNextBillingCycle } = require('./helpers'); +const { appendToMonthlyDraft } = require('./drafts'); + + +/** + * Create one invoice. Returns id. Used both manually (admin creates a + * standalone invoice) and by scheduleInvoicesForEvent (one per installment). + */ +async function createInvoice(payload, adminId, trx = db) { + const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first(); + ensureCustomerCanBill(customer); + + // PR #603 review follow-up #1 — when an invoice is attached to an event, + // make sure that event actually belongs to the chosen customer. Without + // this, a typo'd/copy-pasted eventId silently links the invoice to an + // unrelated event, producing misleading reporting links. Only enforced + // when the event HAS customer assignments (an event with none — e.g. a + // legacy import — is allowed through, since we can't prove a mismatch). + if (payload.eventId && await trx.schema.hasTable('event_customer_assignments')) { + const assignments = await trx('event_customer_assignments') + .where({ event_id: payload.eventId }) + .select('customer_account_id'); + if (assignments.length > 0 && + !assignments.some(a => a.customer_account_id === payload.customerAccountId)) { + throw new AppError('The selected event is not assigned to this customer', 422, 'EVENT_CUSTOMER_MISMATCH'); + } + } + + // Accumulator intercept (migration 128). For customers in + // billing_cadence='monthly' OR 'manual' mode every createInvoice call + // APPENDS line items onto a single running draft instead of minting a + // fresh invoice. Admin sees the editor flow exactly as before; the + // returned id is the draft's id so the UI can redirect to the + // accumulator. The two modes differ only in WHEN the draft ships: + // 'monthly' auto-flushes on the cadence day (scheduler), 'manual' + // never auto-flushes (no period_end) and ships only via the admin + // "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape + // hatch used by internal helpers that need to mint a non-draft row + // (e.g. the accumulator itself, or future test fixtures). + if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') + && !payload._skipMonthlyRouting) { + const draft = await appendToMonthlyDraft(payload, customer, adminId, trx); + return { invoiceIds: draft?.id ? [draft.id] : [] }; + } + + 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'; + + // Sequence number is claimed BELOW the installment auto-route so a + // multi-installment save doesn't waste a number. When installments + // are present, spawnInstallmentInvoices claims one number per + // sibling and we never reach the single-row insert that would have + // used `invoiceNumber` here. + const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); + const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null; + // Resolve net_days BEFORE computing the due date so Net 60 / 90 + // selections actually push the due date out. resolveNetDays honors + // the split picker FK the editor sends, the legacy single FK, and + // the crm_payment_default_net_days setting (see helper). The clock + // starts on the SEND date when the invoice is scheduled, otherwise + // the issue date — so a future send pushes the due date out too. + const resolvedNetDays = await resolveNetDays(payload, trx); + const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays) + .toISOString().slice(0, 10); + + // Re-compute totals from line items. Migration 119 — items with a + // non-null `parent_position` are sub-items and their line totals do + // NOT roll into net directly. Parent totals AUTO-RESOLVE from + // priced sub-items: if any sub-item under a parent has unit_price > 0, + // the parent's effective line_total_minor becomes the sum of those + // sub-items, and the parent's own stored unit_price is ignored. + // Mental model matches the editor — pricing on sub-items implies + // "parent is a header, total derives from what's under it". + const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : []; + const items = lineItems.map((li, idx) => { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const discount = ensureNumber(li.discount_percent, 0); + const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); + const isSubItem = li.parent_position != null && li.parent_position !== ''; + return { + position: ensureInt(li.position) || (idx + 1), + quantity: qty, + description: String(li.description || ''), + unit_price_minor: unit, + discount_percent: discount, + line_total_minor: lineTotal, + parent_position: isSubItem ? ensureInt(li.parent_position) : null, + details_text: li.details_text || null, + }; + }); + // Apply the migration-119 hierarchy resolver: rewrites parent + // line_total_minor to sum-of-priced-sub-items where applicable. + // Net is then summed across top-level (resolved) items. + const { resolveParentTotalsFromSubItems } = getHierarchyHelpers(); + resolveParentTotalsFromSubItems(items); + let netMinor = 0; + for (const li of items) { + if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor); + } + // Optional sub-cent reconciliation (crm_invoice_round_total). When on, + // store the full-precision net rounded ONCE so the total matches + // qty × unit arithmetic; the per-line rounding drift is surfaced as a + // "Rundung" row at render time (storedNet − Σ line totals). Off by + // default ⇒ net stays the sum of rounded lines, unchanged behaviour. + const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; + if (roundTotal) { + netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' }); + } + const vatRate = ensureNumber(payload.vatRate, 0); + const vatMinor = Math.round(netMinor * vatRate / 100); + const shippingMinor = ensureInt(payload.shippingAmountMinor); + const totalMinor = netMinor + vatMinor + shippingMinor; + + // Negative line items (Rabatt) are allowed, but the resulting + // invoice total must not go below zero. Credit notes belong in + // the Storno path (createStorno), which mints a separate + // kind='storno' record with cancels_invoice_id set. + if (totalMinor < 0) { + throw new AppError( + 'Invoice total cannot be negative. To issue a credit note, cancel the original invoice with Storno.', + 400, + 'INVOICE_TOTAL_NEGATIVE', + ); + } + + const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); + + // Snapshot the selected payment-term template (net days / Skonto / + // installment plan) onto the invoice itself. Mirrors how the quote + // editor handles this — once snapshotted, edits to the template + // don't retroactively change rendered invoices. Migration 113. + let paymentTermTemplateId = null; + let paymentTermSnapshot = null; + let paymentNetDaysTemplateId = null; + let paymentTimingTemplateId = null; + // Migration 124 — prefer the two split FKs. Compose a snapshot from + // them in the same shape pdfService + scheduler already consume. + // Fall back to the legacy single FK when the caller still uses it. + if (payload.paymentNetDaysTemplateId && payload.paymentTimingTemplateId) { + const [netDays, timing] = await Promise.all([ + trx('payment_net_days_templates').where({ id: payload.paymentNetDaysTemplateId }).first(), + trx('payment_timing_templates').where({ id: payload.paymentTimingTemplateId }).first(), + ]); + if (netDays && timing) { + paymentNetDaysTemplateId = netDays.id; + paymentTimingTemplateId = timing.id; + paymentTermSnapshot = JSON.stringify({ + description: timing.description || netDays.description || null, + net_days: netDays.net_days, + skonto_percent: netDays.skonto_percent, + skonto_within_days: netDays.skonto_within_days, + installments: typeof timing.installments === 'string' + ? (() => { try { return JSON.parse(timing.installments); } catch { return null; } })() + : timing.installments || null, + }); + } + } else if (payload.paymentTermTemplateId) { + const tpl = await trx('payment_term_templates') + .where({ id: payload.paymentTermTemplateId }).first(); + if (tpl) { + paymentTermTemplateId = tpl.id; + paymentTermSnapshot = JSON.stringify({ + description: tpl.description || null, + net_days: tpl.net_days, + skonto_percent: tpl.skonto_percent, + skonto_within_days: tpl.skonto_within_days, + installments: typeof tpl.installments === 'string' + ? (() => { try { return JSON.parse(tpl.installments); } catch { return null; } })() + : tpl.installments || null, + }); + } + } + + // Multi-installment auto-route. Priority: + // 1. payload.installments (explicit override from the ad-hoc + // editor panel — wins over any saved template) + // 2. snapshot.installments (loaded from the picked payment-timing + // template above) + // If either yields ≥2 entries we delegate to spawnInstallmentInvoices + // (the same loop used by quote→invoice conversion) and return the + // array of created IDs. Single-installment plans fall through to + // the single-row insert below. + let installmentsForSpawn = null; + if (Array.isArray(payload.installments) && payload.installments.length > 1) { + installmentsForSpawn = payload.installments; + } else if (paymentTermSnapshot) { + const parsedSnap = typeof paymentTermSnapshot === 'string' + ? (() => { try { return JSON.parse(paymentTermSnapshot); } catch { return null; } })() + : paymentTermSnapshot; + if (parsedSnap && Array.isArray(parsedSnap.installments) && parsedSnap.installments.length > 1) { + installmentsForSpawn = parsedSnap.installments; + } + } + if (installmentsForSpawn) { + return await spawnInstallmentInvoices({ + trx, + eventId: payload.eventId || null, + quoteId: payload.sourceQuoteId || null, + customer, + currency, + language, + lineItems: items, + totals: { + net: netMinor, + vatRate, + vat: vatMinor, + shipping: shippingMinor, + total: totalMinor, + }, + installments: installmentsForSpawn, + eventDate: payload.eventDate || null, + adminId, + ccPdfEmail: payload.ccPdfEmail || null, + netDays: resolvedNetDays, + eventName: payload.eventName || null, + eventTimeStart: payload.eventTimeStart || null, + eventTimeEnd: payload.eventTimeEnd || null, + paymentNetDaysTemplateId, + paymentTimingTemplateId, + paymentTermSnapshot, + dealUuid: await resolveDealUuid(trx, payload), + }); + } + + // Claim the sequence number HERE — after the installment auto-route + // has been ruled out. Previously this was at the top of the function + // which leaked one number per multi-installment save (the spawner + // claims its own numbers and never used this one). + // Pass trx so the sequence claim joins our outer transaction — + // SQLite deadlocks otherwise (1-connection default). + const invoiceNumber = await nextInvoiceNumber(trx); + const row = { + invoice_number: invoiceNumber, + customer_account_id: payload.customerAccountId, + source_quote_id: payload.sourceQuoteId || null, + event_id: payload.eventId || null, + // Inline event snapshot (migration 123). Mirrors quotes — the + // snapshot survives an event rename so an archived invoice keeps + // its original event label for accounting / audit. Optional; + // standalone invoices created without an event will have these + // as null and the renderer simply omits the for-clause. + event_name: payload.eventName || null, + event_date: payload.eventDate || null, + event_time_start: payload.eventTimeStart || null, + event_time_end: payload.eventTimeEnd || null, + language, + currency, + issue_date: issueDate, + due_date: dueDate, + installment_index: ensureInt(payload.installmentIndex), + installment_total: ensureInt(payload.installmentTotal) || 1, + installment_label: payload.installmentLabel || null, + installment_trigger: payload.installmentTrigger || null, + status: scheduledSendAt && scheduledSendAt.getTime() > Date.now() ? 'scheduled' : (payload.sendNow ? 'scheduled' : 'scheduled'), + scheduled_send_at: scheduledSendAt, + net_amount_minor: netMinor, + vat_rate: vatRate, + vat_amount_minor: vatMinor, + shipping_amount_minor: shippingMinor, + total_amount_minor: totalMinor, + cc_pdf_email: payload.ccPdfEmail || null, + business_bank_account_id: bank?.id || null, + qr_format: payload.qrFormat || null, + payment_term_template_id: paymentTermTemplateId, + payment_net_days_template_id: paymentNetDaysTemplateId, + payment_timing_template_id: paymentTimingTemplateId, + payment_term_snapshot: paymentTermSnapshot, + // Per-invoice Skonto opt-out (migration 126). Defaults to false + // — invoice inherits the snapshot/global Skonto config unless + // admin explicitly ticks "Disable Skonto" in the editor. + skonto_disabled: Boolean(payload.skontoDisabled), + // Migration 140 — deal_uuid lineage. Priority: explicit payload + // (used by spawnInstallmentInvoices and Storno/reissue callers to + // force a specific value), source quote, source contract, + // otherwise fresh mint. + deal_uuid: await resolveDealUuid(trx, payload), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + // Migration 130 — snapshot the chosen output VAT code (immutable; the + // accounting export emits exactly this rather than re-deriving from the map). + if (payload.vatCode !== undefined && await hasColumnCached('invoices', 'vat_code')) { + row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; + } + const inserted = await trx('invoices').insert(row).returning('id'); + const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + if (items.length > 0) { + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(items); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items); + } + + try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`); } catch (_) {} + return { invoiceIds: [invoiceId] }; +} + +/** + * Fan-out helper. Creates one invoice row per installment with the + * right `scheduled_send_at`, sequential invoice numbers, and per- + * slice totals. Used by: + * + * - quoteService.convertToEvent / convertToInvoiceOnly — quote + * conversion with multi-installment payment plans. + * - createInvoice (this file) — when the standalone editor path + * submits an installment array. + * + * Expects to be called inside an existing transaction. + * + * Returns `{ invoiceIds: number[] }` — ordered by installment_index + * so callers can navigate to the first or report N IDs. + * + * The legacy export name `scheduleInvoicesForEvent` is preserved as + * an alias for backward compatibility with quoteService callers; new + * code should reach for the clearer `spawnInstallmentInvoices`. + */ +async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, currency, language, + lineItems, totals, installments, eventDate, adminId, + ccPdfEmail, netDays, + eventName, eventTimeStart, eventTimeEnd, + paymentNetDaysTemplateId, paymentTimingTemplateId, + paymentTermSnapshot, dealUuid, hold = false }) { + // Monthly-billing intercept (migration 128). Quote → invoice + // conversion for a monthly-mode customer doesn't fan out N + // installment invoices — the customer pays one consolidated bill + // per period. Append the line items to the running draft (creating + // it if needed) and return early. The installment / cadence math + // below is bypassed; the quote's payment timing is irrelevant once + // items flow into the monthly accumulator. + if (customer && customer.billing_cadence === 'monthly') { + const draft = await appendToMonthlyDraft({ + customerAccountId: customer.id, + lineItems: (lineItems || []).map((li) => ({ + position: li.position, + quantity: li.quantity, + unit_price_minor: li.unit_price_minor, + discount_percent: li.discount_percent, + description: li.description, + parent_position: li.parent_position, + details_text: li.details_text, + })), + vatRate: totals?.vatRate, + }, customer, adminId, trx); + return { invoiceIds: draft?.id ? [draft.id] : [] }; + } + + // netDays drives the due-date offset on every scheduled invoice + // created here. Callers in quoteService pass the converting quote's + // payment-term net_days so Net 60 / 90 templates flow through; when + // absent we fall back to the crm_payment_default_net_days setting + // (then 30) rather than silently using 30, matching createInvoice. + const resolvedNetDays = ensureInt(netDays) + || ensureInt(await getAppSetting('crm_payment_default_net_days', null, trx || db)) + || 30; + const total = installments.length; + const acceptanceTime = new Date(); + const invoiceIds = []; + + for (let i = 0; i < total; i++) { + const inst = installments[i]; + const percent = ensureNumber(inst.percent, 0); + if (percent <= 0) continue; + + // Each installment carries its own slice of the totals. Round to + // minor units; last installment absorbs rounding drift so the + // total exactly equals the quote total. + let netSlice, vatSlice, shippingSlice, totalSlice; + if (i === total - 1) { + // We computed everything so far; remaining slice closes the gap. + const accNet = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.net) * ensureNumber(x.percent, 0) / 100), 0); + const accVat = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.vat) * ensureNumber(x.percent, 0) / 100), 0); + const accShipping = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.shipping) * ensureNumber(x.percent, 0) / 100), 0); + const accTotal = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.total) * ensureNumber(x.percent, 0) / 100), 0); + netSlice = ensureInt(totals.net) - accNet; + vatSlice = ensureInt(totals.vat) - accVat; + shippingSlice = ensureInt(totals.shipping) - accShipping; + totalSlice = ensureInt(totals.total) - accTotal; + } else { + netSlice = Math.round(ensureInt(totals.net) * percent / 100); + vatSlice = Math.round(ensureInt(totals.vat) * percent / 100); + shippingSlice = Math.round(ensureInt(totals.shipping) * percent / 100); + totalSlice = Math.round(ensureInt(totals.total) * percent / 100); + } + + let scheduledSendAt = computeScheduledSendAt(inst.trigger, inst.offset_days, eventDate, acceptanceTime); + // Per-customer billing cadence override: monthly / quarterly + // customers don't pay per-event — snap to the next period boundary. + if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') { + scheduledSendAt = snapToNextBillingCycle(scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day); + } + + // `after_delivery` invoices wait for the admin to confirm photos + // have actually been delivered before they fire — we can't infer + // that automatically from a date. Mark them `pending_delivery` + // with no scheduled_send_at; the scheduler only picks rows in + // status `scheduled`, so they sit idle until the admin clicks + // "Release for delivery" on the invoice detail page. + const isDeliveryTrigger = inst.trigger === 'after_delivery'; + // `hold` (workflow draft-seam): the booking flow's review gate + explicit + // send_document IS the release, so a held invoice is always `scheduled` + // (editable + sendable via sendInvoice) regardless of trigger — never + // `pending_delivery`, which sendInvoice refuses. Without hold, an + // after_delivery invoice stays `pending_delivery` as before. + const rowStatus = (isDeliveryTrigger && !hold) ? 'pending_delivery' : 'scheduled'; + // Held invoices carry no scheduled_send_at so the scheduler never auto-sends + // them — they wait for send_document. after_delivery rows are likewise null + // (the scheduler can't infer a delivery date). + const rowScheduledSendAt = (isDeliveryTrigger || hold) ? null : scheduledSendAt; + + const invoiceNumber = await nextInvoiceNumber(trx); + const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10); + + const row = { + invoice_number: invoiceNumber, + customer_account_id: customer.id, + source_quote_id: quoteId, + event_id: eventId, + // Inline event snapshot carried over from the source quote + // (migration 123). Mirrors how event_date is already carried — + // a converted invoice should keep the event reference even if + // the linked event is later renamed or deleted. + event_name: eventName || null, + event_date: eventDate || null, + event_time_start: eventTimeStart || null, + event_time_end: eventTimeEnd || null, + language, + currency, + issue_date: scheduledSendAt.toISOString().slice(0, 10), + due_date: dueDate, + installment_index: i, + installment_total: total, + installment_label: inst.label || `Installment ${i + 1}/${total}`, + installment_trigger: inst.trigger, + status: rowStatus, + scheduled_send_at: rowScheduledSendAt, + net_amount_minor: netSlice, + vat_rate: ensureNumber(totals.vatRate, 0), + vat_amount_minor: vatSlice, + shipping_amount_minor: shippingSlice, + total_amount_minor: totalSlice, + cc_pdf_email: ccPdfEmail || null, + // Migration 124 — carry the split payment-term FKs over from + // the source quote so the converted invoice is editable (when + // it eventually unlocks) with the same orthogonal split. The + // snapshot itself is the legal record; the FKs are convenience. + payment_net_days_template_id: paymentNetDaysTemplateId || null, + payment_timing_template_id: paymentTimingTemplateId || null, + payment_term_snapshot: paymentTermSnapshot + ? (typeof paymentTermSnapshot === 'string' + ? paymentTermSnapshot + : JSON.stringify(paymentTermSnapshot)) + : null, + // Migration 140 — every installment sibling shares one deal_uuid + // (passed in from the converting caller, ultimately the source + // quote's value). Defensive fallback to a fresh UUID if the + // caller didn't pass one — shouldn't happen on a migrated + // install but keeps the column non-null. + deal_uuid: dealUuid || crypto.randomUUID(), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + + const inserted = await trx('invoices').insert(row).returning('id'); + const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + // Line items: copy from the quote so the customer sees what they + // actually agreed to, not a generic "Gesamtbetrag" placeholder. + // Two modes: + // - Single-installment (100%): clone every quote line item + // verbatim. The invoice totals already match the quote's. + // - Multi-installment (split payment): clone the quote lines + // but mark the invoice with the installment context. We pro- + // rate by inserting one extra line at the bottom that adjusts + // to the installment slice — keeps the per-line description + // visible while the total still equals the pro-rata amount. + const sourceLines = Array.isArray(lineItems) ? lineItems : []; + if (sourceLines.length === 0) { + // Fallback for the (rare) case where the quote has no line + // items — fall back to the legacy "Installment N/M" line so + // we still produce a sensible invoice. + await trx('invoice_line_items').insert({ + invoice_id: invoiceId, + position: 1, + quantity: 1, + description: inst.label || `Installment ${i + 1}/${total}`, + unit_price_minor: netSlice, + discount_percent: 0, + line_total_minor: netSlice, + created_at: new Date(), + updated_at: new Date(), + }); + } else { + // Clone each quote line as-is, preserving its original `position` + // so the sub-item hierarchy carries over. Source lines already + // have `parent_position` populated by getQuoteById's self-join, + // so the same value reused on the new invoice points at the + // correct (also-cloned) parent. insertLineItemsHierarchical + // resolves position → new parent_line_item_id during the + // two-phase insert. Migration 119. + const cloned = sourceLines.map((li) => ({ + position: ensureInt(li.position), + quantity: li.quantity, + description: li.description, + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: ensureInt(li.line_total_minor), + parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), + details_text: li.details_text || null, + })); + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(cloned); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, cloned); + + // For split payments add an explicit "Installment X/Y (Z%)" + // adjustment line that reconciles the cloned line totals to + // the actual invoice net (which is the pro-rata slice). The + // line carries the difference as a negative if the slice is + // less than the quote total (typical), or positive on the + // final installment if rounding nudged the other way. + // + // The adjustment ONLY considers top-level cloned lines — + // sub-items don't contribute to net so they can't appear in + // the reconciliation sum. + if (total > 1) { + const clonedSum = cloned + .filter((x) => x.parent_position == null) + .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); + const adjustment = netSlice - clonedSum; + if (adjustment !== 0) { + const installmentLabel = inst.label || `Installment ${i + 1}/${total}`; + const maxPosition = cloned.reduce((m, x) => Math.max(m, x.position), 0); + await trx('invoice_line_items').insert({ + invoice_id: invoiceId, + position: maxPosition + 1, + quantity: 1, + description: `${installmentLabel} (${percent}% — ${i + 1}/${total})`, + unit_price_minor: adjustment, + discount_percent: 0, + line_total_minor: adjustment, + parent_line_item_id: null, + details_text: null, + created_at: new Date(), + updated_at: new Date(), + }); + } + } + } + + try { + // Pass `trx` so the audit insert rides the transaction's connection — + // logging via the global db here deadlocks the single-connection SQLite + // pool (this runs unattended from the booking flow's prepare_invoice). + await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt }, + eventId, `admin:${adminId}`, trx); + } catch (_) {} + invoiceIds.push(invoiceId); + } + return { invoiceIds }; +} + +// Backward-compat alias — older callers reference this name. +const scheduleInvoicesForEvent = spawnInstallmentInvoices; +module.exports = { + createInvoice, + spawnInstallmentInvoices, + scheduleInvoicesForEvent, +}; diff --git a/backend/src/services/invoice/drafts.js b/backend/src/services/invoice/drafts.js new file mode 100644 index 00000000..7bd4bd5c --- /dev/null +++ b/backend/src/services/invoice/drafts.js @@ -0,0 +1,334 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const { db, logActivity } = require('../../database/db'); +const { AppError } = require('../../utils/errors'); +const businessProfileService = require('../businessProfileService'); +const { ensureInt, ensureNumber } = require('../../utils/numericHelpers'); +const { computeMonthlyCadenceDate, getHierarchyHelpers, nextInvoiceNumber } = require('./helpers'); + + +/** + * Find or create the running "monthly draft" invoice for a customer. + * One draft per customer per current billing period (`monthly_period_end >= today`). + * Subsequent saves through createInvoice for the same monthly-mode + * customer append line items onto this draft instead of minting fresh + * invoices. + * + * Returns `{ id, row }` for the draft so the caller can append items + * + recompute totals without a second query. + * + * Period bounds: + * start = first calendar day of the month that contains today + * end = computeMonthlyCadenceDate(year, month, cycle_day) where + * year/month are picked so that the resolved date is in the + * future. If today is already PAST the cadence day for the + * current month, the period rolls to next month — admin + * authoring items after the cadence is "starting the next + * bill", not "appending to one that already fired". + */ +async function getOrCreateMonthlyDraft(customer, adminId, trx) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Manual cadence has no billing cycle: the draft accumulates + // indefinitely and ships ONLY via the admin "Trigger invoice now" + // gesture, so it carries NO period_end. The scheduler's auto-flush + // filter is `monthly_period_end <= today`, which a NULL period_end + // can never satisfy — keeping manual drafts out of the cron path. + const isManual = customer.billing_cadence === 'manual'; + + // Resolve period_end: prefer the cadence in the current month, but + // if it has already passed, roll to next month so the new draft + // gathers items toward the NEXT bill. + const cycleDay = ensureInt(customer.billing_cycle_day) || 1; + let target = computeMonthlyCadenceDate(today.getFullYear(), today.getMonth(), cycleDay); + if (target.getTime() < today.getTime()) { + const nextMonth = today.getMonth() + 1; + target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay); + } + const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1); + const periodEnd = isManual ? null : target; + // Placeholder issue/due date for the empty draft row — recomputed at + // issuance time. Manual drafts have no period_end, so fall back to today. + const placeholderDate = (periodEnd || today).toISOString().slice(0, 10); + + // Look up any existing open draft for this customer. We deliberately + // do NOT filter by monthly_period_end here — only one draft can be + // open per customer at a time (enforced by the partial unique index + // created in migration 133). If the scheduler hasn't yet promoted an + // expired draft, it's still the canonical landing spot for any new + // items the admin queues; promoting it is the scheduler's job, not + // ours. forUpdate() locks the row on Postgres so concurrent appenders + // serialize on totals recomputation; SQLite's transaction write-lock + // gives us the same guarantee implicitly. + const existing = await trx('invoices') + .where({ + customer_account_id: customer.id, + is_monthly_draft: true, + }) + .orderBy('id', 'desc') + .forUpdate() + .first(); + if (existing) { + return { id: existing.id, row: existing, created: false }; + } + + // None yet — mint one with zero line items + zero totals. The + // caller appends items + recomputes immediately after. + const profile = (await businessProfileService.getProfile()).profile; + const currency = (customer.preferred_currency || profile?.default_currency || 'CHF').toUpperCase(); + const language = customer.preferred_language || profile?.default_locale || 'de'; + const invoiceNumber = await nextInvoiceNumber(trx); + const bank = await businessProfileService.resolveBankAccountForCurrency(currency, null); + + const row = { + invoice_number: invoiceNumber, + customer_account_id: customer.id, + source_quote_id: null, + event_id: null, + language, + currency, + issue_date: placeholderDate, + due_date: placeholderDate, // recomputed at issuance time + installment_index: 0, + installment_total: 1, + status: 'scheduled', + scheduled_send_at: null, // monthly pass sets this on cadence day + net_amount_minor: 0, + vat_rate: 0, + vat_amount_minor: 0, + shipping_amount_minor: 0, + total_amount_minor: 0, + business_bank_account_id: bank?.id || null, + qr_format: null, + is_monthly_draft: true, + monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null, + monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null, + // Migration 140 — each monthly-draft cycle is its own deal (no + // quote/contract chain). Fresh UUID at creation; subsequent line + // appends just mutate this same row, so the uuid sticks. + deal_uuid: crypto.randomUUID(), + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + try { + const inserted = await trx('invoices').insert(row).returning('id'); + const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + return { id, row: { ...row, id }, created: true }; + } catch (err) { + // Partial-unique-index violation: another transaction snuck a draft + // in between our SELECT and INSERT. Re-SELECT the winner and return + // it — concurrent callers converge on the same draft row instead + // of double-billing the customer. The error string varies by + // driver: Postgres → SQLSTATE 23505; better-sqlite3 → 'UNIQUE + // constraint failed'; node-sqlite3 → 'SQLITE_CONSTRAINT'. + const msg = String(err && err.message || ''); + const isUniqueViolation = + err && err.code === '23505' || + /unique/i.test(msg) || + /sqlite_constraint/i.test(msg); + if (!isUniqueViolation) throw err; + const winner = await trx('invoices') + .where({ customer_account_id: customer.id, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!winner) { + // No row to return despite the unique-violation — this would + // mean the winning transaction rolled back after we lost the + // race. Surface the original error so the caller can retry. + throw err; + } + return { id: winner.id, row: winner, created: false }; + } +} + +/** + * Append line items from a `createInvoice`-shaped payload onto the + * customer's running monthly-draft (migration 128). Used when the + * customer is billing_cadence='monthly': the admin's editor save + * lands here instead of minting a new invoice. + * + * Pulls the existing draft (or creates a fresh one for the current + * period), appends the new line items continuing the position + * sequence, recomputes totals across the merged set, and returns the + * draft's id so the route layer can fetch + return it. + */ +async function appendToMonthlyDraft(payload, customer, adminId, trx) { + const draft = await getOrCreateMonthlyDraft(customer, adminId, trx); + + // Load existing line items so we can compute the next `position` and + // re-sum totals across the merged set. The migration-119 hierarchy + // helpers operate on the merged array so parent_position pointers + // remain consistent. + const existing = await trx('invoice_line_items') + .where({ invoice_id: draft.id }) + .orderBy('position', 'asc'); + const nextPosition = existing.length + ? Math.max(...existing.map((li) => ensureInt(li.position))) + 1 + : 1; + + const incoming = Array.isArray(payload.lineItems) ? payload.lineItems : []; + const newItems = incoming.map((li, idx) => { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const discount = ensureNumber(li.discount_percent, 0); + const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); + const isSubItem = li.parent_position != null && li.parent_position !== ''; + return { + position: nextPosition + idx, + quantity: qty, + description: String(li.description || ''), + unit_price_minor: unit, + discount_percent: discount, + line_total_minor: lineTotal, + parent_position: isSubItem ? ensureInt(li.parent_position) : null, + details_text: li.details_text || null, + }; + }); + + if (newItems.length > 0) { + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(newItems); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', draft.id, newItems); + } + + // Recompute totals across the entire draft so the running figures + // shown on the customer-detail "Monthly queue" card stay accurate + // as items accumulate. Mirrors createInvoice's totals path. + const allItems = await trx('invoice_line_items') + .where({ invoice_id: draft.id }); + let netMinor = 0; + for (const li of allItems) { + if (li.parent_line_item_id == null) netMinor += ensureInt(li.line_total_minor); + } + const vatRate = ensureNumber(payload.vatRate, draft.row.vat_rate || 0); + const vatMinor = Math.round(netMinor * Number(vatRate) / 100); + const shippingMinor = ensureInt(draft.row.shipping_amount_minor); + const totalMinor = netMinor + vatMinor + shippingMinor; + + await trx('invoices').where({ id: draft.id }).update({ + net_amount_minor: netMinor, + vat_rate: vatRate, + vat_amount_minor: vatMinor, + total_amount_minor: totalMinor, + updated_at: new Date(), + }); + + try { + await logActivity('monthly_billing_items_queued', + { invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length }, + null, `admin:${adminId}`); + } catch (_) {} + + return draft.id; +} + +/** + * Append a single, fully-formed line item to the customer's running + * monthly draft (migration 128 + 129). Used by customerHoursService + * when an hour entry is logged for a monthly-mode customer — we want + * the inserted `invoice_line_items.id` back so the entry can be + * stamped with the cross-reference. + * + * `lineItem` is the shape consumed by appendToMonthlyDraft's internal + * insertLineItemsHierarchical helper (description, quantity, + * unit_price_minor, discount_percent, line_total_minor, etc.). The + * `position` field is set internally — caller-supplied positions are + * ignored to keep the accumulator's sequence intact. + * + * Returns { invoiceId, lineItemId } — the draft id plus the id of the + * newly-appended row. + */ +async function appendOneLineItemToMonthlyDraft(customer, lineItem, adminId, trx) { + // Reuse the accumulator path — it handles get-or-create + totals + // recompute + activity log. We pass a single-item array. + await appendToMonthlyDraft({ + customerAccountId: customer.id, + lineItems: [lineItem], + vatRate: 0, // hours logging doesn't ship with VAT today + }, customer, adminId, trx); + + // Look up the draft we just appended onto + its tail line item. + // Newest insert wins by id desc; we filter by position match so + // concurrent appends in another tx don't return the wrong row. + const draft = await trx('invoices') + .where({ customer_account_id: customer.id, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!draft) { + // Defensive — appendToMonthlyDraft would have created one. + throw new AppError('Monthly draft missing after append', 500); + } + const tail = await trx('invoice_line_items') + .where({ invoice_id: draft.id }) + .orderBy('position', 'desc') + .first(); + return { invoiceId: draft.id, lineItemId: tail?.id || null }; +} + +/** + * Admin override — issue the customer's running monthly draft NOW, + * bypassing the cadence-day wait. Mirrors the scheduler's monthly + * pass (migration 128): clears is_monthly_draft, sets the issue date + * + scheduled_send_at to now, and fires sendInvoice inline so the + * email goes out on the next email-queue tick (~60s) instead of + * waiting for the next scheduler iteration. + * + * Refuses when: + * - no draft exists (admin hasn't queued anything yet) + * - the draft has zero line items (nothing to send — same as the + * scheduler's empty-month skip path) + * + * Returns { invoiceId, invoiceNumber } so the route can surface the + * resulting invoice on the response toast. + */ +/** + * Read the customer's running monthly draft + its line items so the + * customer-detail page can preview what will ship on the next cycle + * day. Returns null when no open draft exists (admin hasn't queued + * anything yet for the current period). Used by GET + * /admin/customers/:id/monthly-draft. + */ +async function getMonthlyDraft(customerId) { + const draft = await db('invoices') + .where({ customer_account_id: customerId, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!draft) return null; + const lineItems = await db('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', draft.id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + return { + id: draft.id, + invoiceNumber: draft.invoice_number, + currency: draft.currency, + periodStart: draft.monthly_period_start, + periodEnd: draft.monthly_period_end, + netAmountMinor: draft.net_amount_minor, + vatRate: draft.vat_rate == null ? null : Number(draft.vat_rate), + vatAmountMinor: draft.vat_amount_minor, + totalAmountMinor: draft.total_amount_minor, + lineItems: lineItems.map((li) => ({ + id: li.id, + position: li.position, + quantity: Number(li.quantity), + description: li.description, + unitPriceMinor: ensureInt(li.unit_price_minor), + discountPercent: Number(li.discount_percent || 0), + lineTotalMinor: ensureInt(li.line_total_minor), + parentPosition: li.parent_position == null ? null : ensureInt(li.parent_position), + detailsText: li.details_text || '', + })), + }; +} +module.exports = { + getOrCreateMonthlyDraft, + appendToMonthlyDraft, + appendOneLineItemToMonthlyDraft, + getMonthlyDraft, +}; diff --git a/backend/src/services/invoice/helpers.js b/backend/src/services/invoice/helpers.js new file mode 100644 index 00000000..22fa656c --- /dev/null +++ b/backend/src/services/invoice/helpers.js @@ -0,0 +1,301 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const { db } = require('../../database/db'); +const { getAppSetting } = require('../../utils/appSettings'); +const { AppError } = require('../../utils/errors'); +const { nextDocumentNumber } = require('../../utils/documentSequences'); +const { ensureInt } = require('../../utils/numericHelpers'); + +// Migration 119 line-item hierarchy helpers, shared with quoteService. +// We import lazily inside the functions that use them to avoid a +// require-cycle warning (quoteService also imports invoiceService for +// the quote→invoice conversion path). +function getHierarchyHelpers() { + // eslint-disable-next-line global-require + return require('../quoteService')._internal; +} + +// Atomic gap-free invoice 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 emitted a random `R-2026-AB12C3` after 5 retries, +// breaking the §14 UStG single-sequence requirement. +async function nextInvoiceNumber(trx) { + return nextDocumentNumber('invoice', 'crm_invoices_number_format', 'R-{YEAR}-{SEQ:04d}', trx); +} + +function ensureCustomerCanBill(customer) { + 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); + } + if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') { + throw new AppError('This customer has bills disabled', 409, 'CUSTOMER_FEATURE_DISABLED'); + } +} + +/** + * Resolve a trigger ('quote_accepted' | 'before_event' | ...) + + * offset_days into a concrete date relative to the event. + */ +function computeScheduledSendAt(trigger, offsetDays, eventDate, baseDate = new Date()) { + const ms = 24 * 60 * 60 * 1000; + const offset = ensureInt(offsetDays) * ms; + const eventTs = eventDate ? new Date(eventDate).getTime() : null; + switch (trigger) { + case 'quote_accepted': + return new Date(baseDate.getTime() + offset); + case 'before_event': + case 'after_event': + if (!eventTs) return new Date(baseDate.getTime() + offset); + return new Date(eventTs + offset); + case 'after_delivery': + // Treat as event_date + 14 days as a sensible default; admin can + // edit the scheduled_send_at on the invoice later. + if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset); + return new Date(eventTs + 14 * ms + offset); + case 'fixed_date': + default: + return new Date(baseDate.getTime() + offset); + } +} + +function computeDueDate(scheduledSendAt, netDays = 30) { + return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000); +} + +/** + * Resolve the net-days a new invoice's due date should be anchored to. + * Single source of truth so the editor (split picker), legacy callers, + * and quote→invoice conversion all land on the same number. Priority: + * + * 1. `payload.netDays` — explicit caller override (installment spawn + * passes the snapshot's net_days here). + * 2. Split picker (migration 124): payment_net_days_templates.net_days + * via `payload.paymentNetDaysTemplateId`. This is what the bill + * editor actually sends; the old code only read the legacy FK and + * so silently ignored Net 60 / 90 selections. + * 3. Legacy single FK: payment_term_templates.net_days via + * `payload.paymentTermTemplateId`. + * 4. The `crm_payment_default_net_days` setting (admin-configured). + * 5. 30 — historical hard default. + */ +async function resolveNetDays(payload, trx = db) { + if (payload && payload.netDays != null && payload.netDays !== '') { + const n = ensureInt(payload.netDays); + if (n) return n; + } + if (payload && payload.paymentNetDaysTemplateId) { + const probe = await trx('payment_net_days_templates') + .where({ id: payload.paymentNetDaysTemplateId }) + .select('net_days') + .first(); + if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; + } + if (payload && payload.paymentTermTemplateId) { + const probe = await trx('payment_term_templates') + .where({ id: payload.paymentTermTemplateId }) + .select('net_days') + .first(); + if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; + } + const setting = ensureInt(await getAppSetting('crm_payment_default_net_days')); + if (setting) return setting; + return 30; +} + +/** + * Net-days for an already-persisted invoice row (no payload). Reads the + * snapshot's net_days, then the crm_payment_default_net_days setting, + * then 30. Used at send time to re-anchor the due date when the issue + * date is stamped. Mirrors resolveNetDays' tail. + */ +async function resolveNetDaysForRow(invoice) { + const snap = typeof invoice.payment_term_snapshot === 'string' + ? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })() + : invoice.payment_term_snapshot; + if (snap && snap.net_days != null) { + const n = ensureInt(snap.net_days); + if (n) return n; + } + const setting = ensureInt(await getAppSetting('crm_payment_default_net_days')); + if (setting) return setting; + return 30; +} + +/** + * Resolve the deal_uuid for a new invoice row (migration 140). Priority: + * + * 1. `payload.dealUuid` — explicit caller override. Used by + * spawnInstallmentInvoices (all siblings share one uuid), + * Storno (inherits from cancelled invoice), and reissue + * (inherits from the cancelled original). + * 2. The source quote's deal_uuid, if `payload.sourceQuoteId` is set. + * 3. The source contract's deal_uuid, if `payload.sourceContractId` + * is set. + * 4. Fresh mint — standalone invoices that aren't part of any chain. + * + * Returns a UUID string. Never returns null. + */ +async function resolveDealUuid(trx, payload) { + if (payload?.dealUuid) return payload.dealUuid; + if (payload?.sourceQuoteId) { + const q = await trx('quotes').where({ id: payload.sourceQuoteId }).first('deal_uuid'); + if (q?.deal_uuid) return q.deal_uuid; + } + if (payload?.sourceContractId) { + const c = await trx('contracts').where({ id: payload.sourceContractId }).first('deal_uuid'); + if (c?.deal_uuid) return c.deal_uuid; + } + return crypto.randomUUID(); +} + +/** + * Snap a baseline date to the next billing-cycle boundary for a + * customer on a fixed cadence. Used by scheduleInvoicesForEvent so + * monthly / quarterly customers don't get billed immediately on quote + * acceptance — instead the invoice fires on `billing_cycle_day` of the + * next period. + * + * `cycleDay` honours the sign-as-discriminator convention from + * migration 128: positive 1..28 = that day of the month; negative + * -1..-15 = that many days before end of month. Resolution is + * delegated to `computeMonthlyCadenceDate` so the two helpers can't + * disagree about what "-3 cycle day" means. + * + * Day numbers beyond the destination month's length are clamped + * (e.g. day 31 in February rolls back to Feb 28/29). Negative days + * are clamped to day 1 minimum (extreme values like -40 don't blow + * past the start of the month). + * + * History: a prior version of this function did + * `Math.max(1, Math.min(31, ensureInt(cycleDay) || 1))`, silently + * clamping every negative value to 1 — so a customer configured + * with cycle_day=-3 (last 3 days of month) got billed on day 1 + * instead. Audit finding: monthly cycle sign convention bug. + */ +function snapToNextBillingCycle(baseDate, cadence, cycleDay) { + if (!cadence || cadence === 'per_event') return baseDate; + const day = Number.isFinite(ensureInt(cycleDay)) ? ensureInt(cycleDay) : 1; + const d = new Date(baseDate.getTime()); + + if (cadence === 'monthly') { + // Move to the cycleDay in the next calendar month. If we're already + // before cycleDay this month and the base date is in the same month, + // we still move forward to NEXT month so accepting a quote on + // Jan 5 (cycleDay=1) fires on Feb 1, not Jan 5. + const nextMonth = d.getMonth() + 1; + return computeMonthlyCadenceDate(d.getFullYear(), nextMonth, day); + } + + if (cadence === 'quarterly') { + // First month of the next quarter. Quarter starts: Jan, Apr, Jul, Oct. + const month = d.getMonth(); + const nextQuarterMonth = (Math.floor(month / 3) + 1) * 3; // 0,3,6,9 + return computeMonthlyCadenceDate(d.getFullYear(), nextQuarterMonth, day); + } + + return baseDate; +} + +/** + * Compute the canonical "cadence day" for a given (year, month) using + * the customer's `billing_cycle_day`. Migration 128 introduced the + * sign-as-discriminator convention: + * positive 1..28 → that day of the month, clamped to month length + * negative -1..-15 → that many days before end of month + * Zero falls back to 1 (matches the service-layer clamp). + * + * Returns a JS Date at local-midnight on the resolved day. Callers + * compare against today's date with day-resolution math; the time + * component never matters for monthly-bill issuance. + */ +function computeMonthlyCadenceDate(year, month /* 0-based */, cycleDay) { + const day = Number.isFinite(cycleDay) ? Math.trunc(cycleDay) : 1; + const monthLen = new Date(year, month + 1, 0).getDate(); + let target; + if (day > 0) { + target = Math.min(day, monthLen); + } else if (day < 0) { + // Sign-as-discriminator: -N = N days before month end. Documented + // in the admin UI hint as "Use negative -1..-15 for 'N days before + // month end' (so -3 fires on the 28th of a 31-day month)". + // Formula: monthLen + day → -3 + 31 = 28 ✓. + // Clamped to day 1 minimum so extreme values (-40) don't blow + // past the start of the month. + target = Math.max(1, monthLen + day); + } else { + target = 1; + } + return new Date(year, month, target); +} + +// ---------------------------------------------------------------------- +// updateInstallmentPlan — atomic post-spawn plan edit +// ---------------------------------------------------------------------- + +// Statuses that are still pre-customer (no PDF has gone out the door). +// Both `scheduled` and `pending_delivery` are reshapable; anything else +// belongs to the audit trail and can't be silently mutated. +const EDITABLE_INSTALLMENT_STATUSES = new Set(['scheduled', 'pending_delivery']); + +const VALID_INSTALLMENT_TRIGGERS = new Set([ + 'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date', +]); + +// Module-cached issuer country code — refreshed on every business +// profile save by listening to the same query React-Query revalidates. +// For backend purposes we read it lazily once per process and cache +// the resolved Intl locale; admins changing the country in Settings +// take effect after the next backend restart, which is acceptable +// (this isn't on a hot path). +let _cachedIntlLocale = null; +async function resolveIntlLocale(docLocale) { + if (_cachedIntlLocale) return _cachedIntlLocale; + try { + const businessProfileService = require('../businessProfileService'); + const profile = (await businessProfileService.getProfile()).profile || {}; + const cc = (profile.country_code || '').toUpperCase(); + if (['CH', 'LI', 'DE', 'AT'].includes(cc)) { + _cachedIntlLocale = 'de-CH'; + return _cachedIntlLocale; + } + } catch (_) { /* fall through to per-locale default */ } + return docLocale === 'de' ? 'de-CH' : 'en-GB'; +} + +function formatMajor(minor, currency, locale) { + // Sync version — keeps the existing call-sites working. Reads the + // module cache populated by the async warm-up on first send. When + // the cache hasn't filled yet (first invocation in a process) + // fall through to the legacy de-vs-en split; the cache fills after + // the first send and every subsequent send uses the correct locale. + const cached = _cachedIntlLocale; + const intlLocale = cached || (locale === 'de' ? 'de-CH' : 'en-GB'); + // Best-effort warm-up — fire and forget; the next call hits cache. + if (!cached) { + resolveIntlLocale(locale).catch(() => { /* tolerate */ }); + } + return new Intl.NumberFormat(intlLocale, { + style: 'currency', currency: (currency || 'CHF').toUpperCase(), + }).format(Number(minor || 0) / 100); +} + +module.exports = { + getHierarchyHelpers, + nextInvoiceNumber, + ensureCustomerCanBill, + computeScheduledSendAt, + computeDueDate, + resolveNetDays, + resolveNetDaysForRow, + resolveDealUuid, + snapToNextBillingCycle, + computeMonthlyCadenceDate, + EDITABLE_INSTALLMENT_STATUSES, + VALID_INSTALLMENT_TRIGGERS, + resolveIntlLocale, + formatMajor, +}; diff --git a/backend/src/services/invoice/installmentPlan.js b/backend/src/services/invoice/installmentPlan.js new file mode 100644 index 00000000..65f4c771 --- /dev/null +++ b/backend/src/services/invoice/installmentPlan.js @@ -0,0 +1,381 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const { logActivity } = require('../../database/db'); +const { AppError } = require('../../utils/errors'); +const { ensureInt, ensureNumber } = require('../../utils/numericHelpers'); +const { computeDueDate, computeScheduledSendAt, EDITABLE_INSTALLMENT_STATUSES, getHierarchyHelpers, nextInvoiceNumber, snapToNextBillingCycle, VALID_INSTALLMENT_TRIGGERS } = require('./helpers'); + + +/** + * Compute one slice of a plan total. Matches the rounding rule used by + * spawnInstallmentInvoices — every slice except the last is a rounded + * percent share; the last slice absorbs rounding drift so the per-slice + * sums exactly equal the plan total. + */ +function computeSliceTotals(installments, totals, i) { + const lastIndex = installments.length - 1; + const pct = ensureNumber(installments[i].percent, 0); + if (i < lastIndex) { + return { + net: Math.round(ensureInt(totals.net) * pct / 100), + vat: Math.round(ensureInt(totals.vat) * pct / 100), + shipping: Math.round(ensureInt(totals.shipping) * pct / 100), + total: Math.round(ensureInt(totals.total) * pct / 100), + }; + } + const acc = installments.slice(0, i).reduce((s, x) => { + const p = ensureNumber(x.percent, 0); + return { + net: s.net + Math.round(ensureInt(totals.net) * p / 100), + vat: s.vat + Math.round(ensureInt(totals.vat) * p / 100), + shipping: s.shipping + Math.round(ensureInt(totals.shipping) * p / 100), + total: s.total + Math.round(ensureInt(totals.total) * p / 100), + }; + }, { net: 0, vat: 0, shipping: 0, total: 0 }); + return { + net: ensureInt(totals.net) - acc.net, + vat: ensureInt(totals.vat) - acc.vat, + shipping: ensureInt(totals.shipping) - acc.shipping, + total: ensureInt(totals.total) - acc.total, + }; +} + +/** + * Throws AppError on invalid input. Exposed for the route layer to + * surface as 400 before opening a transaction. + */ +function validateInstallmentPlanInput(installments) { + if (!Array.isArray(installments) || installments.length === 0) { + throw new AppError('installments must be a non-empty array', 400); + } + let sum = 0; + for (let i = 0; i < installments.length; i++) { + const inst = installments[i] || {}; + const pct = ensureNumber(inst.percent, NaN); + if (!Number.isFinite(pct) || pct < 0 || pct > 100) { + throw new AppError(`Row ${i + 1}: percent must be between 0 and 100`, 400); + } + if (!VALID_INSTALLMENT_TRIGGERS.has(inst.trigger)) { + throw new AppError(`Row ${i + 1}: invalid trigger '${inst.trigger}'`, 400); + } + const off = ensureInt(inst.offset_days); + if (!Number.isFinite(off)) { + throw new AppError(`Row ${i + 1}: offset_days must be an integer`, 400); + } + sum += pct; + } + if (Math.abs(sum - 100) > 0.001) { + throw new AppError( + `Installment percents must sum to 100 (got ${sum})`, + 400, + 'PERCENT_SUM_INVALID', + ); + } +} + +/** + * Heuristic — spawnInstallmentInvoices appends a reconciliation line + * with a stable description shape like "Anzahlung (30% — 1/3)". The + * em-dash is U+2014 so the regex won't match plain hyphens used in + * admin-authored line descriptions. + * + * We could harden this with an `is_reconciliation_line` column, but + * the cost of a schema change isn't worth the residual edge (admins + * don't edit reconciliation lines today). + */ +function isReconciliationLineItem(li) { + if (!li || typeof li.description !== 'string') return false; + return / \(\d+(?:\.\d+)?% — \d+\/\d+\)$/.test(li.description); +} + +/** + * Replace (or insert) the reconciliation line on an invoice so its + * description matches the new label/percent and the line's amount + * closes the gap between the cloned-quote-line subtotal and the + * sibling's net slice. Symmetric with the inline logic in spawn. + * + * `topLineSubtotal` is the sum of non-reconciliation, top-level line + * items already on the invoice — passed in so callers reading the row + * once don't have to re-query. + */ +async function replaceReconciliationLine( + trx, invoiceId, { label, percent, index, total, netSlice, topLineSubtotal }, +) { + const all = await trx('invoice_line_items') + .where({ invoice_id: invoiceId }) + .orderBy('position', 'asc'); + for (const li of all) { + if (isReconciliationLineItem(li)) { + await trx('invoice_line_items').where({ id: li.id }).del(); + } + } + if (total <= 1) return; + + const nonRecon = all.filter((x) => !isReconciliationLineItem(x)); + const subtotal = topLineSubtotal != null + ? topLineSubtotal + : nonRecon.filter((x) => x.parent_position == null) + .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); + const adjustment = netSlice - subtotal; + if (adjustment === 0) return; + + const maxPosition = nonRecon.reduce( + (m, x) => Math.max(m, ensureInt(x.position)), 0, + ); + await trx('invoice_line_items').insert({ + invoice_id: invoiceId, + position: maxPosition + 1, + quantity: 1, + description: `${label} (${percent}% — ${index + 1}/${total})`, + unit_price_minor: adjustment, + discount_percent: 0, + line_total_minor: adjustment, + parent_line_item_id: null, + details_text: null, + created_at: new Date(), + updated_at: new Date(), + }); +} + +/** + * Atomically reshape an installment plan after siblings have spawned. + * The plan is the unit of edit: percents / count / triggers all change + * together in one transaction. Mutating individual siblings stays on + * the existing PUT /admin/invoices/:id path. + * + * Guards: + * - dealUuid must exist + own ≥1 invoice (else 404) + * - all siblings must be in EDITABLE_INSTALLMENT_STATUSES (else 409 + * `INVOICE_LOCKED`) + * - no Storno on the deal (else 409 `PLAN_HAS_STORNO`) + * - new plan validated by validateInstallmentPlanInput + * + * Algorithm: + * - Plan total = sum of existing siblings' totals (captures any + * per-sibling edits since spawn). + * - Reused siblings (i < min(old, new)): UPDATE in place — preserves + * id + invoice_number, so sequence numbers aren't burned. + * - Extra new rows (new > old): INSERT — claims a fresh invoice_number + * per row; clones canonical (non-reconciliation) line items from + * existing[0] so each new sibling carries the quote lines. + * - Trim rows (new < old): DELETE — claimed sequence numbers ARE lost + * (document_sequences has no release path, and that's intentional + * for §14 UStG continuity). + * + * Returns `{ invoiceIds, kept, created, deleted }`. + */ +async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) { + if (!dealUuid) throw new AppError('dealUuid is required', 400); + validateInstallmentPlanInput(installments); + + const existing = await trx('invoices') + .where({ deal_uuid: dealUuid }) + .orderBy('installment_index', 'asc'); + + if (existing.length === 0) { + throw new AppError('No invoices found for this deal', 404); + } + const isMultiInstallment = existing.some((r) => ensureInt(r.installment_total) > 1); + if (!isMultiInstallment) { + throw new AppError( + 'This deal is not an installment plan', + 400, + 'NOT_INSTALLMENT_PLAN', + ); + } + for (const row of existing) { + if (row.kind === 'storno') { + throw new AppError( + `Plan contains a Storno (${row.invoice_number}) — reshape refused`, + 409, + 'PLAN_HAS_STORNO', + ); + } + if (!EDITABLE_INSTALLMENT_STATUSES.has(row.status)) { + throw new AppError( + `Cannot reshape — invoice ${row.invoice_number} is '${row.status}'`, + 409, + 'INVOICE_LOCKED', + ); + } + } + + const totals = existing.reduce((acc, r) => ({ + net: acc.net + ensureInt(r.net_amount_minor), + vat: acc.vat + ensureInt(r.vat_amount_minor), + shipping: acc.shipping + ensureInt(r.shipping_amount_minor), + total: acc.total + ensureInt(r.total_amount_minor), + vatRate: ensureNumber(r.vat_rate, acc.vatRate), + }), { net: 0, vat: 0, shipping: 0, total: 0, vatRate: 0 }); + + const sample = existing[0]; // canonical event + customer + payment-term shape + + // netDays inferred from sample's issue → due gap so the new rows + // honour the same payment-term the customer agreed to. Falls back + // to 30 when either column is missing. + const inferredNetDays = sample.due_date && sample.issue_date + ? Math.round((new Date(sample.due_date) - new Date(sample.issue_date)) / (24 * 60 * 60 * 1000)) + : 30; + const netDays = Number.isFinite(inferredNetDays) && inferredNetDays > 0 ? inferredNetDays : 30; + + const eventDate = sample.event_date || null; + const customer = sample.customer_account_id + ? await trx('customer_accounts').where({ id: sample.customer_account_id }).first() + : null; + + // Cache canonical (non-reconciliation) line items from existing[0] + // for cloning into any newly-created siblings. + let canonicalLineItems = null; + const acceptanceTime = new Date(); + const newCount = installments.length; + const reusableCount = Math.min(existing.length, newCount); + + const kept = []; + const created = []; + const deleted = []; + + for (let i = 0; i < newCount; i++) { + const inst = installments[i]; + const slice = computeSliceTotals(installments, totals, i); + + let scheduledSendAt = computeScheduledSendAt( + inst.trigger, inst.offset_days, eventDate, acceptanceTime, + ); + if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') { + scheduledSendAt = snapToNextBillingCycle( + scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day, + ); + } + const isDeliveryTrigger = inst.trigger === 'after_delivery'; + const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; + const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; + const dueDate = computeDueDate(scheduledSendAt, netDays).toISOString().slice(0, 10); + const label = inst.label || `Installment ${i + 1}/${newCount}`; + + if (i < reusableCount) { + const existingRow = existing[i]; + await trx('invoices').where({ id: existingRow.id }).update({ + installment_index: i, + installment_total: newCount, + installment_label: label, + installment_trigger: inst.trigger, + status: rowStatus, + scheduled_send_at: rowScheduledSendAt, + issue_date: scheduledSendAt.toISOString().slice(0, 10), + due_date: dueDate, + net_amount_minor: slice.net, + vat_amount_minor: slice.vat, + shipping_amount_minor: slice.shipping, + total_amount_minor: slice.total, + updated_at: new Date(), + }); + await replaceReconciliationLine(trx, existingRow.id, { + label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net, + }); + kept.push(existingRow.id); + continue; + } + + // New sibling — clone canonical lines from existing[0] on first + // use, then reuse the cached copy for any further new siblings. + if (canonicalLineItems === null) { + const sourceLines = await trx('invoice_line_items') + .where({ invoice_id: existing[0].id }) + .orderBy('position', 'asc'); + canonicalLineItems = sourceLines.filter((li) => !isReconciliationLineItem(li)); + } + + const invoiceNumber = await nextInvoiceNumber(trx); + const row = { + invoice_number: invoiceNumber, + customer_account_id: sample.customer_account_id, + source_quote_id: sample.source_quote_id, + event_id: sample.event_id, + event_name: sample.event_name, + event_date: sample.event_date, + event_time_start: sample.event_time_start, + event_time_end: sample.event_time_end, + language: sample.language, + currency: sample.currency, + issue_date: scheduledSendAt.toISOString().slice(0, 10), + due_date: dueDate, + installment_index: i, + installment_total: newCount, + installment_label: label, + installment_trigger: inst.trigger, + status: rowStatus, + scheduled_send_at: rowScheduledSendAt, + net_amount_minor: slice.net, + vat_rate: ensureNumber(sample.vat_rate, 0), + vat_amount_minor: slice.vat, + shipping_amount_minor: slice.shipping, + total_amount_minor: slice.total, + cc_pdf_email: sample.cc_pdf_email || null, + payment_net_days_template_id: sample.payment_net_days_template_id || null, + payment_timing_template_id: sample.payment_timing_template_id || null, + payment_term_snapshot: sample.payment_term_snapshot || null, + deal_uuid: dealUuid, + created_by_admin_id: adminId, + created_at: new Date(), + updated_at: new Date(), + }; + const inserted = await trx('invoices').insert(row).returning('id'); + const newId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; + + if (canonicalLineItems.length > 0) { + const cloned = canonicalLineItems.map((li) => ({ + position: ensureInt(li.position), + quantity: li.quantity, + description: li.description, + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: ensureInt(li.line_total_minor), + parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), + details_text: li.details_text || null, + })); + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(cloned); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', newId, cloned); + } + + await replaceReconciliationLine(trx, newId, { + label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net, + }); + + try { + await logActivity('invoice_scheduled', { + invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape', + }, sample.event_id, `admin:${adminId}`); + } catch (_) {} + + created.push(newId); + } + + // Trim extras (only fires when newCount < existing.length). + for (let i = newCount; i < existing.length; i++) { + const oldRow = existing[i]; + await trx('invoice_line_items').where({ invoice_id: oldRow.id }).del(); + await trx('invoices').where({ id: oldRow.id }).del(); + deleted.push(oldRow.id); + } + + try { + await logActivity('installment_plan_updated', { + dealUuid, newCount, + kept: kept.length, created: created.length, deleted: deleted.length, + }, sample.event_id, `admin:${adminId}`); + } catch (_) {} + + return { + invoiceIds: [...kept, ...created], + kept, created, deleted, + }; +} +module.exports = { + computeSliceTotals, + validateInstallmentPlanInput, + isReconciliationLineItem, + replaceReconciliationLine, + updateInstallmentPlan, +}; diff --git a/backend/src/services/invoice/payments.js b/backend/src/services/invoice/payments.js new file mode 100644 index 00000000..bc745684 --- /dev/null +++ b/backend/src/services/invoice/payments.js @@ -0,0 +1,525 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const { db, logActivity } = require('../../database/db'); +const logger = require('../../utils/logger'); +const { getAppSetting } = require('../../utils/appSettings'); +const { AppError } = require('../../utils/errors'); +const { formatShortDate } = require('../../utils/dateFormatter'); +const emailProcessor = require('../emailProcessor'); +const { ensureInt } = require('../../utils/numericHelpers'); +const { formatMajor } = require('./helpers'); +const { applyReminder, resolveAdminEmailForInvoice, resolvePerReminderFeeMinor, resolveSkontoPercentForInvoice } = require('./reminders'); + + +/** + * Record a payment against an invoice. Supports partial payments + * (multiple rows accumulate into `paid_amount_minor`). Status flips + * to `paid` once the running total meets or exceeds total_amount_minor. + */ +async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, notes, skontoApplied }, adminId) { + const invoice = await db('invoices').where({ id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + if (invoice.status === 'cancelled') { + throw new AppError('Cannot mark a cancelled invoice as paid', 409); + } + const amount = ensureInt(amountMinor); + if (amount <= 0) { + throw new AppError('amount must be > 0', 400); + } + // Skonto bookkeeping (migration 126). When the admin ticks "Paid + // with Skonto" we store both the flag AND the absolute discount + // in minor units. Computing the discount here (instead of in the + // renderer at report time) means the value is frozen against + // later template/percentage edits — the tax-report row stays + // accurate for years. + const skontoFlag = Boolean(skontoApplied); + const skontoAmountMinor = skontoFlag + ? Math.max(0, ensureInt(invoice.total_amount_minor) - amount) + : null; + + const markResult = await db.transaction(async (trx) => { + await trx('invoice_payment_log').insert({ + invoice_id: id, + amount_minor: amount, + paid_at: paidAt ? new Date(paidAt) : new Date(), + payment_method: paymentMethod || null, + reference: reference || null, + notes: notes || null, + recorded_by_admin_id: adminId, + skonto_applied: skontoFlag, + skonto_amount_minor: skontoAmountMinor, + created_at: new Date(), + }); + const sumRow = await trx('invoice_payment_log').where({ invoice_id: id }).sum('amount_minor as total').first(); + const total = ensureInt(sumRow?.total || 0); + // Consider the invoice paid when the recorded payments cover the + // invoice total. The late fee is NOT added to the threshold here + // — admins frequently waive it once the customer actually pays + // (and chasing the extra 25 CHF after a 1500 CHF invoice clears + // makes nobody happy). Admin can record a separate payment_log + // row if they did collect the fee; status flips to paid the + // moment the principal is covered. + // + // Skonto path (migration 126): when the admin flagged this + // payment as Skonto-applied, the discounted amount equals the + // expected payment — flip to 'paid' even though paid_amount_minor + // is strictly less than total_amount_minor. Without this branch + // the invoice would sit in 'sent' or 'overdue' forever despite + // being legitimately settled. + const skontoEffectiveTotal = skontoFlag + ? ensureInt(invoice.total_amount_minor) - (skontoAmountMinor || 0) + : ensureInt(invoice.total_amount_minor); + const isFull = total >= skontoEffectiveTotal; + + const update = { + paid_amount_minor: total, + payment_method: paymentMethod || invoice.payment_method, + payment_reference: reference || invoice.payment_reference, + updated_at: new Date(), + }; + if (isFull) { + update.status = 'paid'; + update.paid_at = paidAt ? new Date(paidAt) : new Date(); + } + await trx('invoices').where({ id }).update(update); + + try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment', + { invoiceId: id, amountMinor: amount, totalPaidMinor: total }, + invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + + // Migration 127 — admin payment-received notification. Fires only + // on the transition into 'paid' so admins don't get duplicate + // emails when additional payment-log rows are recorded after the + // invoice already cleared (rare but possible — e.g. late-fee + // top-up). Queued after the transaction so a failed email never + // rolls back a recorded payment. Carried Skonto context lets the + // template show the discount line conditionally. + if (isFull && invoice.status !== 'paid') { + try { + await queueInvoicePaidAdminNotification({ + invoice, + paidTotalMinor: total, + paymentMethod: paymentMethod || invoice.payment_method || null, + paymentReference: reference || invoice.payment_reference || null, + paidAt: paidAt ? new Date(paidAt) : new Date(), + skontoApplied: skontoFlag, + skontoAmountMinor: skontoAmountMinor || 0, + }); + } catch (err) { + // Notification is best-effort — don't surface a 500 to the + // admin when the recorded payment itself succeeded. + logger.warn('invoice_paid admin notification failed to queue', { invoiceId: id, err: err.message }); + } + } + + return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status }; + }); + + // Fire invoice.paid for the workflow engine ONLY on the transition into + // 'paid' (mirrors the admin-notification guard above). After the commit so a + // workflow side effect can never roll back the recorded payment. + if (markResult.status === 'paid' && invoice.status !== 'paid') { + try { + await require('../workflows').emitWorkflowEvent('invoice.paid', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + paidTotalMinor: markResult.paidTotalMinor, + }, + }); + } catch (_) {} + } + return markResult; +} + +/** + * Generate a fresh payment-check token for an invoice and queue the + * admin email with three signed action buttons. Throttled to once + * per 24h per invoice via invoices.last_payment_check_at. + * + * Returns { token, sent: bool, reason? } so callers can log / + * surface the outcome. + */ +/** + * Queue the admin "payment received" notification (migration 127). + * Called from markPaid the first time an invoice transitions into + * `status='paid'`. Resolves the admin's address via the same chain + * the payment-check email uses (created_by_admin_id → business + * profile fallback). Silently no-ops when no admin email can be + * resolved — caller logs the warn line. + */ +async function queueInvoicePaidAdminNotification({ + invoice, paidTotalMinor, paymentMethod, paymentReference, + paidAt, skontoApplied, skontoAmountMinor, +}) { + const adminContact = await resolveAdminEmailForInvoice(invoice); + if (!adminContact?.email) { + logger.warn('invoice_paid notification skipped — no admin email resolved', + { invoiceId: invoice.id }); + return; + } + + const profile = await db('business_profile').where({ id: 1 }).first(); + const locale = invoice.language || profile?.default_locale || 'de'; + + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + // Resolve the Skonto percentage at notification time so the + // template can render "Paid with Skonto X%" without a second query. + // Same resolver the rest of the Skonto surfaces use — null when + // skonto_disabled is true or no Skonto is configured. + const skontoPercent = skontoApplied + ? await resolveSkontoPercentForInvoice(invoice) + : null; + + await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email, + 'invoice_paid_admin_notification', { + invoice_number: invoice.invoice_number, + customer_name: customer?.company_name + || customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email || '', + event_name: invoice.event_name || '', + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), + paid_amount: formatMajor(paidTotalMinor, invoice.currency, locale), + payment_method: paymentMethod || '', + payment_reference: paymentReference || '', + paid_at: formatShortDate(paidAt), + skonto_applied: !!skontoApplied, + skonto_percent: skontoApplied && skontoPercent ? skontoPercent : '', + skonto_discount_amount: skontoApplied + ? formatMajor(skontoAmountMinor, invoice.currency, locale) + : '', + }); + + try { + await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id }, + invoice.event_id || null, 'system'); + } catch (_) {} +} + +async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) { + const invoice = await db('invoices').where({ id: invoiceId }).first(); + if (!invoice) return { sent: false, reason: 'not_found' }; + if (!['sent', 'overdue'].includes(invoice.status)) { + return { sent: false, reason: `wrong_status_${invoice.status}` }; + } + const now = new Date(); + if (!skipThrottle && invoice.last_payment_check_at) { + const last = new Date(invoice.last_payment_check_at).getTime(); + if (now.getTime() - last < 24 * 60 * 60 * 1000) { + return { sent: false, reason: 'throttled_24h' }; + } + } + + const adminContact = await resolveAdminEmailForInvoice(invoice); + if (!adminContact?.email) { + logger.warn('Payment-check email skipped — no admin email resolved', { invoiceId }); + return { sent: false, reason: 'no_admin_email' }; + } + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + await db('invoice_payment_check_tokens').insert({ + invoice_id: invoiceId, + token, + expires_at: expiresAt, + created_at: now, + }); + await db('invoices').where({ id: invoiceId }).update({ + last_payment_check_at: now, + updated_at: now, + }); + + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + const profile = await db('business_profile').where({ id: 1 }).first(); + const locale = invoice.language || profile?.default_locale || 'de'; + + // Determine whether the customer reminder will include a Mahngebühr + // if the admin selects "Not paid" / "Partial" — surfaced to the + // email so the admin sees the consequence before clicking. + const reminderFeeMinor = await resolvePerReminderFeeMinor(invoice); + const nextLevel = (invoice.reminder_level || 0) + 1; + const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2; + + const baseUrl = process.env.FRONTEND_URL + || (await getAppSetting('app_frontend_url')) + || 'https://app.example.com'; + const buildUrl = (action) => + `${baseUrl.replace(/\/$/, '')}/payment-check/${token}?action=${action}`; + + // Outstanding = gross total + late fee − already paid. The admin + // is being asked about what's STILL OWED, not the original gross + // figure — so surface outstanding + paid in the email context. + // Partial payments logged earlier (e.g. via a previous admin + // payment-check click) are reflected, so the admin doesn't get + // asked "did the customer pay CHF 234?" when they already paid + // CHF 134 of it. + const paidMinor = Number(invoice.paid_amount_minor || 0); + const lateFeeAlreadyMinor = Number(invoice.late_fee_amount_minor || 0); + const outstandingMinor = Math.max(0, + Number(invoice.total_amount_minor || 0) + lateFeeAlreadyMinor - paidMinor); + const hasPartial = paidMinor > 0; + + // Resolve Skonto for the optional 4th button (migration 126). Only + // surface the button when (a) Skonto is configured for this invoice + // AND (b) the customer paid within the Skonto window — past the + // window the discount is moot. Both checks are visible to the + // template so the email can hide the button conditionally. + const skontoPercent = await resolveSkontoPercentForInvoice(invoice); + const hasSkonto = !!skontoPercent && skontoPercent > 0; + const skontoDiscountedTotalMinor = hasSkonto + ? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100)) + : null; + + await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email, + 'invoice_payment_check_admin', { + invoice_number: invoice.invoice_number, + customer_name: customer?.company_name + || customer?.display_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.email || '', + event_name: invoice.event_name || '', + due_date: formatShortDate(invoice.due_date), + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), + paid_amount: formatMajor(paidMinor, invoice.currency, locale), + outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale), + has_partial_payment: hasPartial, + paid_url: buildUrl('paid_full'), + partial_url: buildUrl('partial'), + unpaid_url: buildUrl('unpaid'), + // Skonto button — template uses {{#if has_skonto}} to render the + // fourth button only when the invoice qualifies. + has_skonto: hasSkonto, + skonto_percent: hasSkonto ? skontoPercent : '', + skonto_amount: hasSkonto + ? formatMajor(skontoDiscountedTotalMinor, invoice.currency, locale) + : '', + skonto_url: hasSkonto ? buildUrl('paid_with_skonto') : '', + late_fee_due: willChargeFee, + late_fee_amount: formatMajor(reminderFeeMinor, invoice.currency, locale), + }); + + try { + await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) }, + invoice.event_id || null, 'scheduler'); + } catch (_) {} + + return { token, sent: true }; +} + +/** + * Validate a payment-check token and return the invoice context + * the public page needs. Token must exist, not be expired, not + * already used. + */ +async function getPaymentCheckByToken(token) { + const row = await db('invoice_payment_check_tokens').where({ token }).first(); + if (!row) throw new AppError('Token not found', 404); + if (row.used_at) { + const err = new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); + err.usedAt = row.used_at; + err.usedAction = row.used_action; + throw err; + } + if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) { + throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED'); + } + const invoice = await db('invoices').where({ id: row.invoice_id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + + const outstandingMinor = Math.max(0, + Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0) + - Number(invoice.paid_amount_minor || 0)); + + // Surface the Skonto state so the public page can decide whether to + // render the "Paid with Skonto" action card (migration 126). Only + // applies when the invoice's payment terms actually carry a Skonto + // percentage — admin shouldn't see the option on an invoice that + // never offered the discount. + const skontoPercent = await resolveSkontoPercentForInvoice(invoice); + const hasSkonto = !!skontoPercent && skontoPercent > 0; + const skontoDiscountedTotalMinor = hasSkonto + ? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100)) + : null; + + return { + invoiceNumber: invoice.invoice_number, + customer: { + label: customer?.company_name + || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') + || customer?.display_name || customer?.email || '', + email: customer?.email, + }, + issueDate: invoice.issue_date, + dueDate: invoice.due_date, + totalMinor: invoice.total_amount_minor, + paidMinor: invoice.paid_amount_minor, + lateFeeMinor: invoice.late_fee_amount_minor, + outstandingMinor, + currency: invoice.currency, + status: invoice.status, + reminderLevel: invoice.reminder_level, + expiresAt: row.expires_at, + hasSkonto, + skontoPercent: hasSkonto ? skontoPercent : null, + skontoDiscountedTotalMinor, + }; +} + +/** + * Record the admin's payment-check action and fire the downstream + * consequences: + * - 'paid_full' → markPaid for the outstanding amount, no reminder. + * - 'partial' → markPaid for the amount supplied, then fire the + * next reminder for the remainder. + * - 'unpaid' → fire the next reminder (level 1 or 2) with the + * existing Mahngebühr logic in applyReminder. + * + * Atomic: token consumption + invoice status update happen in one + * transaction. The reminder email is queued AFTER the txn commits + * to avoid emailing a customer about a payment that never + * actually committed. + */ +async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminId }) { + // 'paid_with_skonto' (migration 126) is a fourth admin action — the + // customer settled the bill within the early-payment-discount window, + // so the recorded payment equals total minus the configured Skonto %. + // Same token-consumption semantics as 'paid_full'. + if (!['paid_full', 'paid_with_skonto', 'partial', 'unpaid'].includes(action)) { + throw new AppError('Invalid action', 400); + } + + const row = await db('invoice_payment_check_tokens').where({ token }).first(); + if (!row) throw new AppError('Token not found', 404); + if (row.used_at) { + throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); + } + if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) { + throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED'); + } + const invoice = await db('invoices').where({ id: row.invoice_id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + + const outstandingMinor = Math.max(0, + Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0) + - Number(invoice.paid_amount_minor || 0)); + + if (action === 'partial') { + const amt = ensureInt(amountMinor); + if (amt <= 0) throw new AppError('partial amount must be > 0', 400); + if (amt > outstandingMinor) throw new AppError('partial amount exceeds outstanding', 400); + } + + // Consume the token first — atomic with status update so a + // double-click can't fire the action twice. + const now = new Date(); + const updated = await db('invoice_payment_check_tokens') + .where({ id: row.id }) + .whereNull('used_at') + .update({ + used_at: now, + used_action: action, + used_amount_minor: action === 'partial' ? ensureInt(amountMinor) : null, + used_ip: ip || null, + }); + if (updated === 0) { + // Lost a race with another consumer. + throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); + } + + try { + await logActivity('invoice_payment_check_recorded', + { invoiceId: invoice.id, action, amountMinor: amountMinor || null }, + invoice.event_id || null, + adminId ? `admin:${adminId}` : 'public:payment-check'); + } catch (_) {} + + // --- Apply the action ----------------------------------------- + if (action === 'paid_full') { + await markPaid(invoice.id, { + amountMinor: outstandingMinor, + paymentMethod: invoice.payment_method || 'bank_transfer', + reference: invoice.payment_reference || null, + notes: 'Confirmed via admin payment-check link', + }, adminId || invoice.created_by_admin_id); + return { applied: 'paid_full' }; + } + + if (action === 'paid_with_skonto') { + // Resolve the Skonto percentage at click time so admins can't + // accidentally double-discount after the template changed. Same + // resolution chain pdfService uses: invoice snapshot → source + // quote snapshot → global crm_invoices_skonto_percent_default. + const skontoPercent = await resolveSkontoPercentForInvoice(invoice); + if (!skontoPercent || skontoPercent <= 0) { + throw new AppError('No Skonto configured on this invoice', 409, 'SKONTO_NOT_CONFIGURED'); + } + const discountedTotalMinor = Math.round( + Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100), + ); + // Outstanding-aware: if the customer already paid part of the + // bill (rare on the Skonto path, but possible after a partial), + // record only the remaining slice up to the discounted total. + const paidMinor = Number(invoice.paid_amount_minor || 0); + const remainingMinor = Math.max(0, discountedTotalMinor - paidMinor); + if (remainingMinor <= 0) { + throw new AppError('Invoice already paid past the Skonto threshold', 409); + } + await markPaid(invoice.id, { + amountMinor: remainingMinor, + paymentMethod: invoice.payment_method || 'bank_transfer', + reference: invoice.payment_reference || null, + notes: `Confirmed via admin payment-check link (Skonto ${skontoPercent}% applied)`, + skontoApplied: true, + }, adminId || invoice.created_by_admin_id); + return { applied: 'paid_with_skonto', skontoPercent }; + } + + if (action === 'partial') { + const amt = ensureInt(amountMinor); + await markPaid(invoice.id, { + amountMinor: amt, + paymentMethod: invoice.payment_method || 'bank_transfer', + reference: invoice.payment_reference || null, + notes: 'Partial payment confirmed via admin payment-check link', + }, adminId || invoice.created_by_admin_id); + // Then fire the customer reminder for the remainder, unless + // markPaid flipped the invoice to paid (i.e. the partial + // amount equalled the outstanding). + const refreshed = await db('invoices').where({ id: invoice.id }).first(); + if (refreshed.status !== 'paid') { + const nextLevel = (refreshed.reminder_level || 0) + 1; + if (nextLevel <= 3) { + const lineItems = await db('invoice_line_items') + .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); + await applyReminder(refreshed, lineItems, nextLevel, adminId); + } + } + return { applied: 'partial' }; + } + + // 'unpaid' + const nextLevel = (invoice.reminder_level || 0) + 1; + if (nextLevel > 3) { + // Already at max reminder — admin has to take this offline. + return { applied: 'unpaid', reminderSkipped: 'max_level_reached' }; + } + const lineItems = await db('invoice_line_items') + .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); + await applyReminder(invoice, lineItems, nextLevel, adminId); + return { applied: 'unpaid', reminderLevel: nextLevel }; +} +module.exports = { + markPaid, + queueInvoicePaidAdminNotification, + queuePaymentCheckEmail, + getPaymentCheckByToken, + recordPaymentCheckAction, +}; diff --git a/backend/src/services/invoice/queries.js b/backend/src/services/invoice/queries.js new file mode 100644 index 00000000..dafe13fb --- /dev/null +++ b/backend/src/services/invoice/queries.js @@ -0,0 +1,166 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const { db, withRetry } = require('../../database/db'); +const { ensureInt } = require('../../utils/numericHelpers'); + + +// --------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------- + +async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { + return await withRetry(async () => { + let query = db('invoices') + .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') + // Surface the source contract's human contract_number (mirror of + // the src_quote JOIN in getInvoiceById) so list rows + detail + // page can render "From contract LBM-C-2026-0010" instead of + // the bare DB id "#10". LEFT join — most invoices have no + // source contract. + .leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id') + .select( + 'invoices.*', + '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', + // Same isPassive-source as getInvoiceById — surfaced so list + // rows can render the Passive badge inline without an N+1 + // round-trip. + 'customer_accounts.password_hash as customer_password_hash', + 'customer_accounts.company_name as customer_company_name', + 'src_contract.contract_number as source_contract_number', + ); + + if (Array.isArray(filters.status) && filters.status.length > 0) { + query = query.whereIn('invoices.status', filters.status); + } + if (filters.customerAccountId) { + query = query.where('invoices.customer_account_id', filters.customerAccountId); + } + // Hide monthly drafts (migration 128) from the default list — they + // live on the customer detail page's "Monthly billing queue" card. + // Callers that explicitly want them (the customer-detail summary + // fetch) pass `includeMonthlyDrafts: true`. + if (!filters.includeMonthlyDrafts) { + query = query.where(function () { + this.where('invoices.is_monthly_draft', false) + .orWhereNull('invoices.is_monthly_draft'); + }); + } + if (filters.sourceQuoteId) { + query = query.where('invoices.source_quote_id', filters.sourceQuoteId); + } + if (filters.unpaidOnly) { + query = query.whereIn('invoices.status', ['scheduled', 'sent', 'overdue']); + } + if (filters.q && String(filters.q).trim()) { + const term = `%${String(filters.q).trim()}%`; + query = query.andWhere(function() { + this.where('invoices.invoice_number', 'like', term) + .orWhere('customer_accounts.email', 'like', term) + .orWhere('customer_accounts.company_name', 'like', term); + }); + } + const countRow = await query.clone().clearSelect().clearOrder().count('invoices.id as total').first(); + const total = ensureInt(countRow?.total || 0); + + switch (sort) { + // "Newest" / "Oldest" means newest/oldest by CREATION time, not + // by issue_date. Issue_date is admin-controlled (used for tax + // accruals, retro-dating, future-dating) so it can drift from + // actual chronology — sorting by it makes a just-created invoice + // disappear into the middle of the list whenever its issue_date + // is set to something other than today. created_at always + // reflects when the row landed in the DB. id is the tiebreaker + // for rows that share a created_at second. + case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break; + case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break; + case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break; + case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break; + case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break; + case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break; + case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('invoices.id', 'desc'); + break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('invoices.id', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.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 getInvoiceById(id) { + return await withRetry(async () => { + // LEFT JOIN customer_accounts so transformInvoice has populated + // customer_email / company etc. — mirrors getQuoteById. + const invoice = await db('invoices') + .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') + // Join the source quote so the detail view can display its + // human-readable number ("LBM-Q-2026-0006") instead of just + // the numeric id ("#6"). LEFT join — most invoices come from + // a quote conversion but standalone invoices don't have one. + .leftJoin('quotes as src_quote', 'invoices.source_quote_id', 'src_quote.id') + // Migration 130 lineage: source contract's human contract_number + // so the detail view shows "From contract LBM-C-2026-0010" + // instead of "#10". Same LEFT-join shape as src_quote. + .leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id') + // Self-joins for Storno lineage so the detail view can render + // "Cancelled by Stornorechnung S-XXXX" / "This Stornorechnung + // cancels invoice R-XXXX" using the human invoice_number rather + // than the bare DB row id. Same pattern as source_quote_number. + .leftJoin('invoices as cancels_inv', 'invoices.cancels_invoice_id', 'cancels_inv.id') + .leftJoin('invoices as cancellation_storno', 'invoices.cancellation_storno_id', 'cancellation_storno.id') + .where('invoices.id', id) + .select( + 'invoices.*', + '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 transformInvoice can compute the + // customer.isPassive flag (passwordHash == null). The hash + // itself never leaves the API — transformInvoice drops it + // and only exposes the boolean. + 'customer_accounts.password_hash as customer_password_hash', + 'src_quote.quote_number as source_quote_number', + 'src_contract.contract_number as source_contract_number', + 'cancels_inv.invoice_number as cancels_invoice_number', + 'cancellation_storno.invoice_number as cancellation_storno_number', + ) + .first(); + if (!invoice) return null; + // Self-join so each row also carries `parent_position` (the position + // of its parent line item, when it's a sub-item). The editor needs + // position-based references to rebuild the hierarchy in the UI; + // parent_line_item_id is the DB-level relationship but isn't + // stable in the payload the editor sends back. Migration 119. + const lineItems = await db('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + const payments = await db('invoice_payment_log').where({ invoice_id: id }).orderBy('paid_at', 'asc'); + return { invoice, lineItems, payments }; + }); +} +module.exports = { + listInvoices, + getInvoiceById, +}; diff --git a/backend/src/services/invoice/reminders.js b/backend/src/services/invoice/reminders.js new file mode 100644 index 00000000..c8fecef4 --- /dev/null +++ b/backend/src/services/invoice/reminders.js @@ -0,0 +1,264 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const { db, logActivity } = require('../../database/db'); +const { getAppSetting } = require('../../utils/appSettings'); +const { AppError } = require('../../utils/errors'); +const { formatShortDate } = require('../../utils/dateFormatter'); +const { resolveBillingRecipients } = require('../_billingRecipients'); +const pdfService = require('../pdfService'); +const emailProcessor = require('../emailProcessor'); +const { ensureInt } = require('../../utils/numericHelpers'); +const { hasColumnCached } = require('../../utils/schemaCache'); +const { formatMajor } = require('./helpers'); +const { getInvoiceById } = require('./queries'); +const { buildInvoiceRenderContext } = require('./render'); + + +/** + * Manually trigger a reminder email. The scheduler does this + * automatically; this is the "Send reminder now" button on the + * invoice detail page. + */ +async function sendReminder(id, levelOverride, adminId) { + const data = await getInvoiceById(id); + if (!data) throw new AppError('Invoice not found', 404); + const { invoice, lineItems } = data; + if (invoice.status !== 'sent' && invoice.status !== 'overdue') { + throw new AppError(`Cannot remind on status '${invoice.status}'`, 409); + } + const newLevel = levelOverride || (invoice.reminder_level + 1); + if (newLevel > 3) { + throw new AppError('Reminder level exhausted', 409); + } + return await applyReminder(invoice, lineItems, newLevel, adminId); +} + +// Per-reminder Mahngebühr in minor units (0 when disabled). Flat amount OR a +// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from +// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete +// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so). +// Net per-reminder Mahngebühr (flat amount or % of invoice gross), 0 disabled. +async function resolveLateFeeNetMinor(invoice) { + if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0; + const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat'; + let fee; + if (type === 'percent') { + const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0; + fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100); + } else { + fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; + } + return Math.max(0, fee); +} + +// VAT rate on the fee — jurisdiction-dependent (CH: yes; DE/AT: no), so +// toggle-gated AND org-VAT-gated: 0 when the org has no default VAT rate, so +// enabling the toggle on a non-VAT org adds nothing. +async function resolveLateFeeVatRate() { + if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) !== true) return 0; + const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default'); + return Number(profile?.vat_rate_default) || 0; +} + +// Gross per-reminder fee (net + VAT) — for the admin payment-check preview. +async function resolvePerReminderFeeMinor(invoice) { + const net = await resolveLateFeeNetMinor(invoice); + if (net <= 0) return 0; + const rate = await resolveLateFeeVatRate(); + return rate > 0 ? net + Math.round(net * rate / 100) : net; +} + +async function applyReminder(invoice, lineItems, level, adminId) { + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + + // Per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×, computed + // from `level` so re-applying the same level never stacks. The fee is dunning + // STATE on the row (gross + the VAT portion) — it is NOT shown on the + // immutable invoice; it appears on the separate Mahnung document below. + let lateFeeGross = invoice.late_fee_amount_minor || 0; + let lateFeeVat = invoice.late_fee_vat_minor || 0; + if (level >= 2) { + const net = await resolveLateFeeNetMinor(invoice); + const rate = await resolveLateFeeVatRate(); + const vatPer = rate > 0 ? Math.round(net * rate / 100) : 0; + lateFeeGross = (level - 1) * (net + vatPer); + lateFeeVat = (level - 1) * vatPer; + } + const newTotal = Number(invoice.total_amount_minor || 0) + lateFeeGross; + + const update = { + status: 'overdue', + reminder_level: level, + last_reminder_sent_at: new Date(), + late_fee_amount_minor: lateFeeGross, + updated_at: new Date(), + }; + if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat; + await db('invoices').where({ id: invoice.id }).update(update); + + // Fire invoice.overdue at the status→overdue flip. Deduped per (workflow, + // invoice), so across the reminder ladder it triggers a flow at most once. + // Best-effort / fail-closed. + try { + await require('../workflows').emitWorkflowEvent('invoice.overdue', { + entityType: 'invoice', + entityId: invoice.id, + payload: { + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + customerEmail: customer?.email || null, + dueDate: invoice.due_date, + reminderLevel: level, + totalMinor: invoice.total_amount_minor, + currency: invoice.currency, + }, + }); + } catch (_) {} + + // Render the MAHNUNG (reminder letter). The original invoice PDF is left + // UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a + // 'mahnung' kind: same line items + the Mahngebühr row + the new total, with + // a "Mahnung" title and no QR (it would encode the old amount). + const fresh = await db('invoices').where({ id: invoice.id }).first(); + const ctx = await buildInvoiceRenderContext(fresh, lineItems); + ctx.doc.kind = 'mahnung'; + ctx.doc.reminderLevel = level; + ctx.doc.lateFeeMinor = lateFeeGross; + ctx.totals.lateFeeAmountMinor = lateFeeGross; + const buffer = await pdfService.renderInvoiceToBuffer(ctx); + const fs = require('fs'); + const path = require('path'); + const year = new Date(fresh.issue_date).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year)); + fs.mkdirSync(root, { recursive: true }); + const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`); + fs.writeFileSync(mahnungPath, buffer); + + // days_overdue floors at 1 (a "0 days overdue" reminder reads as broken). + const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000); + const daysOverdue = Math.max(1, rawDaysOverdue); + const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second'; + const locale = ctx.locale || invoice.language || 'de'; + const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0)); + + // Attach the (unchanged) original invoice PDF + the new Mahnung. + const attachments = []; + if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) { + attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' }); + } + attachments.push({ filename: `${fresh.invoice_number}_Mahnung.pdf`, contentPath: mahnungPath, contentType: 'application/pdf' }); + + const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email); + try { + await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, { + invoice_number: invoice.invoice_number, + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), + new_total_amount: formatMajor(newTotal, invoice.currency, locale), + outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale), + paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, locale), + late_fee_amount: formatMajor(lateFeeGross, invoice.currency, locale), + due_date: formatShortDate(invoice.due_date), + days_overdue: daysOverdue, + cc: reminderCc, + attachments, + // Dunning reminders are relationship mail — hold to business hours. + }, { respectBusinessHours: true }); + } catch (err) { + // Don't leave the just-rendered Mahnung PDF orphaned on disk if queueing the + // email failed — it would only be reachable via the next reminder anyway. + try { fs.unlinkSync(mahnungPath); } catch (_) { /* best-effort cleanup */ } + throw err; + } + + try { + await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross }, + invoice.event_id || null, `admin:${adminId || 'system'}`); + } catch (_) {} + + return { level, lateFeeMinor: lateFeeGross }; +} + +// --------------------------------------------------------------------- +// Payment-check workflow (admin-confirmed reminders) +// --------------------------------------------------------------------- + +/** + * Resolve the admin email address that should receive the payment- + * check prompt. Priority: + * 1. created_by_admin_id's email (the admin who issued the invoice) + * 2. First admin user with bills.manage permission + * 3. business_profile.email as a last resort + * Returns null when nothing usable is found — caller logs + skips. + */ +/** + * Resolve the effective Skonto percentage for an invoice at the + * current moment. Resolution chain (matches pdfService rendering): + * 1. invoice.payment_term_snapshot.skonto_percent + * 2. source quote's payment_term_snapshot.skonto_percent + * 3. global crm_invoices_skonto_percent_default + * Returns null when nothing is configured. + * + * Lifted into a helper so the payment-check action and the email + * template (which both need to know "does this invoice qualify for a + * Paid-with-Skonto button?") share one source of truth. + */ +async function resolveSkontoPercentForInvoice(invoice) { + // Per-invoice opt-out (migration 126) wins over every other source. + // Admin sets this on Storni / replacement invoices / payment-plan + // installments that shouldn't qualify for the discount even when + // the global default offers it. + if (invoice.skonto_disabled) return null; + // Per-customer opt-out (migration 112) — a customer that negotiated + // "no Skonto" as a contract term never qualifies, so the admin + // doesn't have to tick the per-invoice toggle on every invoice. + // Falls through customer → invoice → snapshot → quote → global. + if (invoice.customer_account_id) { + const cust = await db('customer_accounts') + .where({ id: invoice.customer_account_id }) + .select('skonto_disabled') + .first(); + if (cust && cust.skonto_disabled) return null; + } + const parseSnap = (raw) => { + if (!raw) return null; + if (typeof raw === 'object') return raw; + try { return JSON.parse(raw); } catch { return null; } + }; + const invSnap = parseSnap(invoice.payment_term_snapshot); + if (invSnap?.skonto_percent != null && Number(invSnap.skonto_percent) > 0) { + return Number(invSnap.skonto_percent); + } + if (invoice.source_quote_id) { + const q = await db('quotes').where({ id: invoice.source_quote_id }).select('payment_term_snapshot').first(); + const qSnap = parseSnap(q?.payment_term_snapshot); + if (qSnap?.skonto_percent != null && Number(qSnap.skonto_percent) > 0) { + return Number(qSnap.skonto_percent); + } + } + const defaultPct = Number(await getAppSetting('crm_invoices_skonto_percent_default')); + return Number.isFinite(defaultPct) && defaultPct > 0 ? defaultPct : null; +} + +async function resolveAdminEmailForInvoice(invoice) { + if (invoice.created_by_admin_id) { + const admin = await db('admin_users').where({ id: invoice.created_by_admin_id }).first(); + if (admin?.email) return { email: admin.email, name: admin.username || admin.email }; + } + // Fallback: business_profile.email. + const profile = await db('business_profile').where({ id: 1 }).first(); + if (profile?.email) return { email: profile.email, name: profile.company_name || profile.email }; + return null; +} +module.exports = { + sendReminder, + resolveLateFeeNetMinor, + resolveLateFeeVatRate, + resolvePerReminderFeeMinor, + applyReminder, + resolveSkontoPercentForInvoice, + resolveAdminEmailForInvoice, +}; diff --git a/backend/src/services/invoice/render.js b/backend/src/services/invoice/render.js new file mode 100644 index 00000000..b397fd04 --- /dev/null +++ b/backend/src/services/invoice/render.js @@ -0,0 +1,354 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const { db } = require('../../database/db'); +const { getAppSetting } = require('../../utils/appSettings'); +const { cleanNetMinor } = require('../../utils/invoiceRounding'); +const { AppError } = require('../../utils/errors'); +const businessProfileService = require('../businessProfileService'); +const { buildIssuerBlock, buildRecipientBlock } = require('../_renderContext'); +const pdfService = require('../pdfService'); +const { ensureInt, ensureNumber } = require('../../utils/numericHelpers'); +const { getHierarchyHelpers } = require('./helpers'); +const { getInvoiceById } = require('./queries'); + + +async function buildInvoiceRenderContext(invoice, lineItems) { + const { profile } = await businessProfileService.getProfile(); + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + const bank = invoice.business_bank_account_id + ? await db('business_bank_accounts').where({ id: invoice.business_bank_account_id }).first() + : await businessProfileService.resolveBankAccountForCurrency(invoice.currency); + + // 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); + + // QR format resolution order (per-invoice override → profile + // default → none) gated by the global enable toggle. The earlier + // version had an operator-precedence bug that effectively dropped + // the profile default; this rewrites it as plain if/else for + // readability + correctness. + const qrGloballyEnabled = (await getAppSetting('crm_invoices_qr_enabled')) !== false; + let resolvedQrFormat = 'none'; + if (qrGloballyEnabled) { + resolvedQrFormat = invoice.qr_format || profile?.default_qr_format || 'none'; + } + + // Resolve the payment-term snapshot to thread Skonto + net-days into + // the PDF's "Zahlungsbedingungen" block. Three sources, in priority + // order: + // 1. The invoice's OWN snapshot (migration 113 — set when admin + // picks a template directly in the New Invoice form). + // 2. The originating quote's snapshot, if this invoice was + // created from one. + // 3. The global CRM defaults (settings tab) — `crm_invoices_*`. + // Both layers above are wrapped in `paymentTerm` exactly as + // quoteService builds it so pdfService.drawPaymentBlock renders + // the same block on both document types. + let paymentTerm = null; + + // Invoice-level snapshot wins when set. + if (invoice.payment_term_snapshot) { + const snapshot = typeof invoice.payment_term_snapshot === 'string' + ? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })() + : invoice.payment_term_snapshot; + if (snapshot) { + paymentTerm = { + description: snapshot.description, + netDays: snapshot.net_days, + skontoPercent: snapshot.skonto_percent, + skontoWithinDays: snapshot.skonto_within_days, + }; + } + } + + // Load the source quote once — used for the payment-term snapshot + // fallback AND for the "Bezug: Angebot Q-..." reference line on + // the invoice PDF. We deliberately keep invoice numbers on a + // strict monotonic sequence (tax compliance) and surface the link + // as a text reference rather than mirroring the number. + let sourceQuote = null; + if (invoice.source_quote_id) { + sourceQuote = await db('quotes').where({ id: invoice.source_quote_id }).first(); + if (!paymentTerm && sourceQuote?.payment_term_snapshot) { + const snapshot = typeof sourceQuote.payment_term_snapshot === 'string' + ? (() => { try { return JSON.parse(sourceQuote.payment_term_snapshot); } catch { return null; } })() + : sourceQuote.payment_term_snapshot; + if (snapshot) { + paymentTerm = { + description: snapshot.description, + netDays: snapshot.net_days, + skontoPercent: snapshot.skonto_percent, + skontoWithinDays: snapshot.skonto_within_days, + }; + } + } + } + // Globally-default Skonto values, always loaded. Used either to + // FILL a partial source-quote snapshot OR to seed the whole + // paymentTerm when there's no source quote. Both reads survive + // missing rows (returns null), unset values (NaN guarded), and + // string-encoded numbers from app_settings. + const defaultSkontoPercentRaw = await getAppSetting('crm_invoices_skonto_percent_default'); + const defaultSkontoDaysRaw = await getAppSetting('crm_invoices_skonto_business_days'); + const defaultSkontoPercent = Number.isFinite(Number(defaultSkontoPercentRaw)) && Number(defaultSkontoPercentRaw) > 0 + ? Number(defaultSkontoPercentRaw) : null; + const defaultSkontoDays = Number.isFinite(Number(defaultSkontoDaysRaw)) && Number(defaultSkontoDaysRaw) > 0 + ? parseInt(defaultSkontoDaysRaw, 10) : null; + + if (paymentTerm) { + // The source quote's snapshot may carry only some of the Skonto + // fields (e.g. when the template predates Skonto support); fill + // missing parts from the global defaults so the PDF still shows + // the row whenever there's enough info to render it. + if (paymentTerm.skontoPercent == null && defaultSkontoPercent != null) { + paymentTerm.skontoPercent = defaultSkontoPercent; + } + if (paymentTerm.skontoWithinDays == null && defaultSkontoDays != null) { + paymentTerm.skontoWithinDays = defaultSkontoDays; + } + } else { + // Ad-hoc invoice (no source quote). Build the paymentTerm from + // the global defaults. Renders only when BOTH percent + days are + // set + > 0 (pdfService.drawPaymentBlock guards on that). + paymentTerm = { + description: null, + netDays: 30, + skontoPercent: defaultSkontoPercent, + skontoWithinDays: defaultSkontoDays, + }; + } + + // Per-invoice Skonto opt-out (migration 126). The + // `resolveSkontoPercentForInvoice` helper above already respects + // this for payment-tracking surfaces, but the PDF render path was + // assembling `paymentTerm.skontoPercent/Days` from the snapshot or + // global defaults and ignoring the flag — so ticking "Disable + // Skonto" on the invoice cleared it from "Paid with Skonto" buttons + // but still printed the discount row on the PDF. Zero out both + // fields here so pdfService.drawPaymentBlock's + // `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays` + // guard suppresses the row. The per-customer opt-out (migration 112) + // is honoured here too — a customer flagged skonto_disabled never + // prints the discount row, mirroring resolveSkontoPercentForInvoice. + if (invoice.skonto_disabled || customer?.skonto_disabled) { + paymentTerm.skontoPercent = null; + paymentTerm.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 */ } + + // Sub-cent reconciliation (crm_invoice_round_total). "Betrag Netto" + // shows the sum of the visible line totals so it foots with the items; + // the stored net may be the clean (rounded-once) value, and the gap is + // shown as a "Rundung" row. Legacy/unrounded invoices have equal + // values ⇒ adjustment 0, no row. Suppressed on Storno/Mahnung: those + // negate the stored net and flip line-total signs at render, so the + // forward "storedNet − Σ lines" derivation doesn't apply. + const isReversalDoc = invoice.kind === 'storno' || invoice.kind === 'mahnung'; + const displayedNetMinor = isReversalDoc + ? ensureInt(invoice.net_amount_minor) + : lineItems.reduce( + (s, li) => (li.parent_line_item_id == null && (li.parent_position == null || li.parent_position === '') + ? s + ensureInt(li.line_total_minor) : s), + 0, + ); + const roundingAdjustmentMinor = isReversalDoc + ? 0 + : ensureInt(invoice.net_amount_minor) - displayedNetMinor; + + return { + locale: invoice.language || profile?.default_locale || 'de', + currency: invoice.currency, + qrFormat: resolvedQrFormat, + dateFormat, + // Shared issuer + recipient builders. Invoices skip the quote-only + // payment-block toggles; the invoice PDF always shows the payment + // block. See backend/src/services/_renderContext.js. + issuer: buildIssuerBlock(profile, resolvedLogoPath), + recipient: buildRecipientBlock(profile, customer), + bank: bank ? { + accountHolder: bank.account_holder || profile?.company_name, + iban: bank.iban, bic: bank.bic, currency: bank.currency, + } : null, + paymentTerm, + 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 + notes flow through to PDF. + parentLineItemId: li.parent_line_item_id || null, + parentPosition: li.parent_position == null ? null : Number(li.parent_position), + detailsText: li.details_text || null, + })), + totals: { + netAmountMinor: displayedNetMinor, + roundingAdjustmentMinor, + vatRate: invoice.vat_rate, + // Migration 130 — VAT-code snapshot (so re-editing preserves it). + vatCode: invoice.vat_code ?? null, + vatAmountMinor: invoice.vat_amount_minor, + shippingAmountMinor: invoice.shipping_amount_minor, + totalAmountMinor: invoice.total_amount_minor, + // The Mahngebühr is shown on the separate Mahnung document, NEVER on + // the (immutable) invoice — so the invoice render always reports 0. The + // Mahnung render path (applyReminder) overrides this with the tracked fee. + lateFeeAmountMinor: 0, + }, + doc: { + // Document type discriminator. `'invoice'` (default) renders + // the standard invoice layout. `'storno'` switches the title + // to "Stornorechnung", forces the mandatory "Storno zu …" + // reference line, displays signed totals, and suppresses the + // payment terms / IBAN / QR-bill sections (cancellation + // documents aren't payment instruments). + kind: invoice.kind || 'invoice', + invoiceNumber: invoice.invoice_number, + issueDate: invoice.issue_date, + dueDate: invoice.due_date, + totalAmountMinor: invoice.total_amount_minor, + lateFeeMinor: 0, + // Reminder level — drives Skonto suppression on second + // reminders (no early-payment discount once the customer + // is in dunning). + reminderLevel: invoice.reminder_level || 0, + // PDF renderer draws "Bezug: Angebot Q-..." under the title + // when set. Empty/null suppresses the line (standalone invoice). + sourceQuoteNumber: sourceQuote?.quote_number || null, + // When this invoice replaces a previously-cancelled one + // (migration 114, reissue workflow), the renderer stamps a + // second reference line: "Bezug: Ersetzt Rechnung R-XXXX vom + // DATE". + replacesInvoice: await (async () => { + if (!invoice.replaces_invoice_id) return null; + const prior = await db('invoices') + .where({ id: invoice.replaces_invoice_id }) + .select('invoice_number', 'issue_date').first(); + return prior + ? { number: prior.invoice_number, issueDate: prior.issue_date } + : null; + })(), + // Storno reference — populated only on `kind='storno'` rows. + // The renderer turns it into the mandatory "Storno zu Rechnung + // R-XXXX vom DATE" line under the title. Drives §14c-defensible + // traceability: the customer sees explicitly what was reversed. + cancelsInvoice: await (async () => { + if (!invoice.cancels_invoice_id) return null; + const prior = await db('invoices') + .where({ id: invoice.cancels_invoice_id }) + .select('invoice_number', 'issue_date').first(); + return prior + ? { number: prior.invoice_number, issueDate: prior.issue_date } + : null; + })(), + }, + }; +} + +async function renderInvoicePdfBuffer(invoiceId) { + const data = await getInvoiceById(invoiceId); + if (!data) throw new AppError('Invoice not found', 404); + // Imported (historical) invoices store the original PDF on disk + // — short-circuit the renderer and stream the file untouched so + // legal documents stay byte-identical to the source. Path is + // stored relative to STORAGE_PATH but we accept absolute too. + if (data.invoice.imported_pdf_path) { + const fs = require('fs'); + const path = require('path'); + const { getStoragePath } = require('../../config/storage'); + const raw = String(data.invoice.imported_pdf_path).trim(); + const candidates = [ + path.isAbsolute(raw) ? raw : null, + path.join(getStoragePath(), raw.replace(/^\/+/, '')), + ].filter(Boolean); + const found = candidates.find((p) => { + try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } + }); + if (!found) { + throw new AppError('Imported invoice PDF is missing on disk', 410); + } + return fs.readFileSync(found); + } + const ctx = await buildInvoiceRenderContext(data.invoice, data.lineItems); + return await pdfService.renderInvoiceToBuffer(ctx); +} + +async function renderInvoicePdfFromPayload(payload) { + const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); + const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : []; + // Migration 119 — preview must match the saved-invoice math: + // - Compute every row's raw line_total_minor (qty × unit × discount). + // - Then resolveParentTotalsFromSubItems rewrites each parent's + // line_total to the sum of its priced sub-items (parent's own + // unit_price is ignored when any sub-item has a price). + // - Net sums TOP-LEVEL items only (parent_position == null). + // Without these two steps, the preview shows the parent at 0 and + // double-counts sub-items into net, neither of which matches the + // values the renderer would produce for the persisted invoice. + const items = lineItems.map((li, idx) => { + const qty = ensureNumber(li.quantity, 1); + const unit = ensureInt(li.unit_price_minor); + const discount = ensureNumber(li.discount_percent, 0); + const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); + return { ...li, position: li.position || idx + 1, line_total_minor: lineTotal }; + }); + const { resolveParentTotalsFromSubItems } = getHierarchyHelpers(); + resolveParentTotalsFromSubItems(items); + let netMinor = 0; + for (const it of items) { + if (it.parent_position == null || it.parent_position === '') { + netMinor += ensureInt(it.line_total_minor); + } + } + // Match the saved-invoice math: clean-net reconciliation when the + // crm_invoice_round_total setting is on (see createInvoice). + const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; + if (roundTotal) { + netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' }); + } + const vatRate = ensureNumber(payload.vatRate, 0); + const vatMinor = Math.round(netMinor * vatRate / 100); + const shippingMinor = ensureInt(payload.shippingAmountMinor); + const totalMinor = netMinor + vatMinor + shippingMinor; + const fakeInvoice = { + invoice_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), + due_date: payload.dueDate || new Date(Date.now() + 30 * 86400e3).toISOString().slice(0, 10), + business_bank_account_id: payload.businessBankAccountId, + qr_format: payload.qrFormat, + net_amount_minor: netMinor, + vat_rate: vatRate, + vat_amount_minor: vatMinor, + shipping_amount_minor: shippingMinor, + total_amount_minor: totalMinor, + }; + const ctx = await buildInvoiceRenderContext(fakeInvoice, items); + return await pdfService.renderInvoiceToBuffer(ctx); +} +module.exports = { + buildInvoiceRenderContext, + renderInvoicePdfBuffer, + renderInvoicePdfFromPayload, +}; diff --git a/backend/src/services/invoice/scheduler.js b/backend/src/services/invoice/scheduler.js new file mode 100644 index 00000000..581c08a9 --- /dev/null +++ b/backend/src/services/invoice/scheduler.js @@ -0,0 +1,173 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const { db, logActivity } = require('../../database/db'); +const logger = require('../../utils/logger'); +const { getAppSetting } = require('../../utils/appSettings'); +const { ensureInt } = require('../../utils/numericHelpers'); +const { queuePaymentCheckEmail } = require('./payments'); +const { sendInvoice } = require('./sending'); + + +/** + * Cron tick — find scheduled invoices ready to send + invoices past + * due date that need a reminder. Called by invoiceSchedulerService. + */ +async function runScheduledTasks() { + const now = new Date(); + + // 1. Flush scheduled invoices. + const ready = await db('invoices') + .where({ status: 'scheduled' }) + .andWhere(function() { + this.whereNotNull('scheduled_send_at').andWhere('scheduled_send_at', '<=', now); + }) + .limit(20); + for (const inv of ready) { + try { + await sendInvoice(inv.id, null); + } catch (err) { + logger.error('Scheduled invoice send failed', { invoiceId: inv.id, err: err.message }); + } + } + + // 2. Monthly-bill issuance (migration 128). + // + // Walk every monthly draft whose period_end is today-or-earlier. + // - If the draft has zero line items, skip silently (empty month + // per user spec — no invoice issued, no email, just a log). + // - Otherwise flip is_monthly_draft=false and arm scheduled_send_at + // to `now` so the next flush-pass picks it up and runs the + // standard sendInvoice path. Keeping the issuance one tick away + // from this pass means email queueing + activity log + dunning + // schedule all stay on the existing well-trodden code paths + // instead of duplicating logic here. + const monthlyToday = new Date(now); + monthlyToday.setHours(0, 0, 0, 0); + const dueDrafts = await db('invoices') + .where({ is_monthly_draft: true }) + .andWhere('monthly_period_end', '<=', monthlyToday.toISOString().slice(0, 10)) + .limit(50); + for (const draft of dueDrafts) { + try { + const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1); + if (items.length === 0) { + // Empty month — leave the draft alone (admin may still add + // items between now and end-of-day) OR mark it consumed so + // the next save creates a fresh period draft. We pick the + // latter: clear is_monthly_draft so the next createInvoice + // for this customer mints a new period. + // + // Status is 'skipped', not 'cancelled': the latter implies + // an admin (or Storno) deliberately voided a real invoice; + // an empty monthly period is a "nothing happened" non-event + // that we still record for audit-trail continuity. Listing + // queries that aggregate cancelled rows (e.g. the Bills list + // cancellation footnote) should not pull skipped rows in. + await db('invoices').where({ id: draft.id }).update({ + is_monthly_draft: false, + status: 'skipped', + updated_at: new Date(), + }); + logger.info('Monthly bill skipped — no items queued', { + invoiceId: draft.id, customerId: draft.customer_account_id, + }); + try { + await logActivity('monthly_bill_skipped_empty', + { invoiceId: draft.id, customerId: draft.customer_account_id }, + null, 'scheduler'); + } catch (_) {} + continue; + } + // Arm for the flush pass: clear the draft flag, set the send + // time to now, recompute due_date from issue_date + the global + // crm_invoices_net_days_default (best-effort; admin can override + // by editing the draft before the cadence day). + const issueDate = monthlyToday.toISOString().slice(0, 10); + await db('invoices').where({ id: draft.id }).update({ + is_monthly_draft: false, + issue_date: issueDate, + scheduled_send_at: new Date(), + updated_at: new Date(), + }); + try { + await logActivity('monthly_bill_issued', + { invoiceId: draft.id, customerId: draft.customer_account_id, + periodEnd: draft.monthly_period_end }, + null, 'scheduler'); + } catch (_) {} + } catch (err) { + logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message }); + } + } + + // 3. Overdue payment-check prompts (if reminders enabled). + // + // NEW behavior (migration 115/116): instead of auto-firing the + // customer reminder when an invoice goes overdue, we email the + // ADMIN with three signed-token action buttons: + // - Paid in full → markPaid for the outstanding amount + // - Partial → admin enters amount; partial + reminder + // - Not paid yet → reminder fires (with Mahngebühr at level 2) + // + // The reminder thresholds still gate when the prompt fires: + // - level 0 invoice past firstCutoff → prompt for level-1 path + // - level 1 invoice past secondCutoff → prompt for level-2 path + // Throttled to one email per 24h per invoice via + // invoices.last_payment_check_at. + const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled'); + // Mutual exclusion with the workflow engine: the hardcoded ladder stands down + // only when the invoice_dunning built-in is ENABLED (then the engine fires the + // payment-check emails). A disabled built-in leaves this ladder running — so + // the flow can ship disabled without dunning going dark, and disabling the + // flow reverts to the ladder. Fails closed → ladder stays on if the subsystem + // is down. + let engineDrivesDunning = false; + try { + engineDrivesDunning = await require('../workflows').isBuiltinFlowActive('invoice_dunning'); + } catch (_) { /* workflows tables absent / flag system down → ladder stays on */ } + if (remindersEnabled !== false && !engineDrivesDunning) { + const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14; + const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30; + + const firstCutoff = new Date(now.getTime() - firstDays * 86400000); + const secondCutoff = new Date(now.getTime() - secondDays * 86400000); + + // Pre-reminder check (would-be-level-1). + // `kind='invoice'` filter keeps Stornorechnungen out of the + // dunning ladder — they have no due_date and no payment + // expectation; reminding on them would be a customer-facing + // bug. + const firstBatch = await db('invoices') + .where('kind', 'invoice') + .whereIn('status', ['sent', 'overdue']) + .where('reminder_level', 0) + .where('due_date', '<=', firstCutoff) + .limit(20); + for (const inv of firstBatch) { + try { + await queuePaymentCheckEmail(inv.id); + } catch (err) { + logger.error('Payment-check email failed', { invoiceId: inv.id, err: err.message }); + } + } + + // Pre-reminder check (would-be-level-2, including Mahngebühr). + const secondBatch = await db('invoices') + .where('kind', 'invoice') + .whereIn('status', ['sent', 'overdue']) + .where('reminder_level', 1) + .where('due_date', '<=', secondCutoff) + .limit(20); + for (const inv of secondBatch) { + try { + await queuePaymentCheckEmail(inv.id); + } catch (err) { + logger.error('Payment-check email (level 2) failed', { invoiceId: inv.id, err: err.message }); + } + } + } +} +module.exports = { + runScheduledTasks, +}; diff --git a/backend/src/services/invoice/sending.js b/backend/src/services/invoice/sending.js new file mode 100644 index 00000000..1645d4f1 --- /dev/null +++ b/backend/src/services/invoice/sending.js @@ -0,0 +1,654 @@ +// Extracted verbatim from invoiceService.js — see ../invoiceService.js for the +// module-level overview. Do not add behavior here without updating the entry re-exports. + +const crypto = require('crypto'); +const { db, logActivity } = require('../../database/db'); +const logger = require('../../utils/logger'); +const { AppError } = require('../../utils/errors'); +const { formatShortDate } = require('../../utils/dateFormatter'); +const { resolveBillingRecipients } = require('../_billingRecipients'); +const pdfService = require('../pdfService'); +const emailProcessor = require('../emailProcessor'); +const { ensureInt, ensureNumber } = require('../../utils/numericHelpers'); +const { computeDueDate, ensureCustomerCanBill, formatMajor, getHierarchyHelpers, nextInvoiceNumber, resolveNetDaysForRow } = require('./helpers'); +const { getInvoiceById } = require('./queries'); +const { createInvoice } = require('./create'); +const { buildInvoiceRenderContext } = require('./render'); + + +/** + * Send an invoice email + PDF. Flips status scheduled → sent. + */ +async function sendInvoice(id, adminId) { + const data = await getInvoiceById(id); + if (!data) throw new AppError('Invoice not found', 404); + const { invoice, lineItems } = data; + // Stornorechnungen go through their own send path — different + // email template, different variables, different PDF render + // branch. The scheduler's flush loop hits this entry point for + // every row in status='scheduled', so the dispatch lives here. + if (invoice.kind === 'storno') { + return await sendStorno(id, adminId); + } + if (!['scheduled', 'sent', 'overdue'].includes(invoice.status)) { + throw new AppError(`Cannot send invoice with status '${invoice.status}'`, 409); + } + // Monthly-draft guard (migration 128). Rows flagged + // is_monthly_draft=true accumulate line items across the period + // and must ONLY be issued via triggerMonthlyBillNow / the scheduled + // monthly flush — both clear the flag before re-entering this + // function. Without this guard, admin clicks on a draft's Send + // button would ship the running accumulator early AND leave the + // flag set, so subsequent createInvoice calls would silently + // append onto the same already-sent row. + if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) { + throw new AppError( + 'This invoice is a monthly draft — use "Trigger invoice now" on the customer detail page, or wait for the scheduled cycle day.', + 409, 'MONTHLY_DRAFT_NOT_SENDABLE', + ); + } + const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); + ensureCustomerCanBill(customer); + + // Re-sync the invoice's language from the customer's current + // preferred_language at send time when the invoice has never been + // sent. Picks up admin language changes made between create and + // send (notable for monthly drafts that accumulate for ~30 days, + // and for any standalone scheduled invoice where admin updated the + // customer record after authoring). Sent / overdue invoices keep + // their existing language because they're legal records — the + // rendered PDF is the source of truth from the moment it ships. + if (invoice.status === 'scheduled' && customer.preferred_language + && customer.preferred_language !== invoice.language) { + await db('invoices').where({ id }).update({ + language: customer.preferred_language, + updated_at: new Date(), + }); + invoice.language = customer.preferred_language; + } + + // Stamp the issue date at the moment the invoice actually goes out. + // A scheduled invoice's issue_date is provisional — set to the + // authoring day at creation — but the legal issue date is when it + // ships. Anchoring it here keeps the printed invoice date, the Skonto + // window (a relative "pay within N working days" counted from that + // date) and the net-days due date all consistent with the send date. + // Only on the first send (status 'scheduled'); 'sent' / 'overdue' + // rows are immutable legal records and keep their stamped date. + if (invoice.status === 'scheduled') { + const sendDateIso = new Date().toISOString().slice(0, 10); + const netDays = await resolveNetDaysForRow(invoice); + // Re-anchor the due date too, but only when it was machine-set: if + // the stored due_date still equals the auto formula off the OLD + // base (scheduled_send_at, else the old issue_date), the admin never + // hand-edited it and we slide it to the new issue date. A divergent + // value means a manual override (the editor's "Override due date" + // toggle) — leave it untouched. + const oldBase = invoice.scheduled_send_at + ? new Date(invoice.scheduled_send_at) + : new Date(invoice.issue_date); + const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10); + const storedDue = invoice.due_date + ? new Date(invoice.due_date).toISOString().slice(0, 10) + : null; + const updates = { issue_date: sendDateIso, updated_at: new Date() }; + if (storedDue && storedDue === oldAutoDue) { + updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10); + } + await db('invoices').where({ id }).update(updates); + invoice.issue_date = updates.issue_date; + if (updates.due_date) invoice.due_date = updates.due_date; + } + + const ctx = await buildInvoiceRenderContext(invoice, lineItems); + const buffer = await pdfService.renderInvoiceToBuffer(ctx); + + // Persist PDF snapshot. + const fs = require('fs'); + const path = require('path'); + const year = new Date(invoice.issue_date).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); + fs.mkdirSync(root, { recursive: true }); + const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`); + fs.writeFileSync(pdfPath, buffer); + + const newStatus = invoice.status === 'overdue' ? 'overdue' : 'sent'; + await db('invoices').where({ id }).update({ + status: newStatus, sent_at: new Date(), pdf_path: pdfPath, updated_at: new Date(), + }); + + const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email); + await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', { + invoice_number: invoice.invoice_number, + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + event_name: invoice.event_name || '', + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale), + due_date: formatShortDate(invoice.due_date), + installment_label: invoice.installment_label || '', + installment_index: invoice.installment_index + 1, + installment_total: invoice.installment_total, + cc: invoiceCc, + attachments: [{ + filename: `${invoice.invoice_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }], + }); + + try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + + // Fire the workflow engine's invoice.sent trigger (after the row is updated + + // the email queued). Idempotent per invoice id; no-op when the workflows flag + // is off. Never throws into the send path. + try { + await require('../workflows').emitWorkflowEvent('invoice.sent', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + customerEmail: invoiceTo, + dueDate: invoice.due_date, + issueDate: invoice.issue_date, + totalMinor: invoice.total_amount_minor, + currency: invoice.currency, + }, + }); + } catch (_) {} + + return { sent: true, pdfPath }; +} + +/** + * Materialise a Stornorechnung (cancellation invoice) for an already- + * issued original. Atomic: + * 1. Insert a new `invoices` row with `kind='storno'`, totals + * negated, no due_date / payment terms / bank account / QR, + * and `cancels_invoice_id` pointing at the original. + * 2. Snapshot the original's line items at full positive amounts + * (the sign is carried by the row-level totals; the renderer + * flips line totals visually for `kind='storno'`). Preserves + * the migration-119 sub-item hierarchy via parent_position → + * parent_line_item_id resolution in `insertLineItemsHierarchical`. + * 3. Flip the original to `status='cancelled'` and pin its + * `cancellation_storno_id` so the admin detail view can render + * a "Cancelled by Storno S-XXXX" banner. + * + * Returns the Storno's id. The caller is responsible for actually + * sending it (sendStorno) — splitting the create/send seam means + * a failed PDF render or email queue doesn't roll back the + * cancellation itself; the storno sits in `status='scheduled'` + * and the cron picks it up. + */ +async function createStorno(originalId, adminId, trx = db) { + const original = await trx('invoices').where({ id: originalId }).first(); + if (!original) throw new AppError('Invoice not found', 404); + if (original.kind === 'storno') { + throw new AppError('Cannot Storno a Storno', 409, 'IS_STORNO'); + } + if (original.status === 'scheduled') { + throw new AppError( + 'This invoice has not been sent yet — Storno only applies to issued documents.', + 409, + 'USE_EDIT_INSTEAD', + ); + } + if (original.status === 'cancelled') { + throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED'); + } + + // Generate the Storno's sequence number from the same gap-free + // series as regular invoices (single sequence — decision locked + // with the maintainer; satisfies §14 (4) Nr. 4 UStG). + // Pass trx so the sequence claim joins the caller's transaction — + // SQLite deadlocks otherwise (1-connection default). + const stornoNumber = await nextInvoiceNumber(trx); + const now = new Date(); + const issueDate = now.toISOString().slice(0, 10); + + // Insert the Storno row. Totals negated for accounting integrity + // (tax report aggregates by row-level totals, so a Storno + // contributes correctly without the renderer needing to flip + // signs at report time). Line items below stay positive — the + // renderer applies the sign at presentation time. + const insertedRow = await trx('invoices').insert({ + kind: 'storno', + invoice_number: stornoNumber, + customer_account_id: original.customer_account_id, + event_id: original.event_id, + // Inline event snapshot — copy so the Storno carries the same + // event label as the invoice it reverses (migration 123). The + // bookkeeper expects to see both documents under the same event. + event_name: original.event_name || null, + event_date: original.event_date || null, + event_time_start: original.event_time_start || null, + event_time_end: original.event_time_end || null, + source_quote_id: null, + // Migration 124 — carry the split FKs through onto the Storno row + // so the lineage stays consistent if anyone audits the + // cancellation document and checks the picker state. + payment_net_days_template_id: original.payment_net_days_template_id || null, + payment_timing_template_id: original.payment_timing_template_id || null, + currency: original.currency, + language: original.language, + vat_rate: original.vat_rate, + // Migration 130 — carry the original's VAT-code snapshot onto the Storno so + // both documents export the same code. Conditional spread = safe on pre-130 + // DBs (undefined → omitted). + ...(original.vat_code ? { vat_code: original.vat_code } : {}), + shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0), + net_amount_minor: -ensureInt(original.net_amount_minor), + vat_amount_minor: -ensureInt(original.vat_amount_minor), + total_amount_minor: -ensureInt(original.total_amount_minor), + late_fee_amount_minor: 0, + paid_amount_minor: 0, + status: 'scheduled', + scheduled_send_at: now, + issue_date: issueDate, + // Storni have no payment due — mirror issue_date to satisfy the + // schema's NOT NULL constraint on due_date. The field is dead data + // for kind='storno' rows: the PDF renderer suppresses the due-date + // line, and the dunning scheduler filters kind='invoice'. + due_date: issueDate, + reminder_level: 0, + cc_pdf_email: original.cc_pdf_email, + // No payment block on a Storno — it's not a payment instrument. + business_bank_account_id: null, + qr_format: null, + payment_term_template_id: null, + // Lineage. + cancels_invoice_id: original.id, + replaces_invoice_id: null, + cancellation_storno_id: null, + // Migration 140 — Storno belongs to the same deal as the invoice + // it cancels; both render together in the lineage view. + deal_uuid: original.deal_uuid || crypto.randomUUID(), + created_at: now, + updated_at: now, + }).returning('id'); + const stornoId = Array.isArray(insertedRow) + ? (insertedRow[0]?.id ?? insertedRow[0]) + : insertedRow; + + // Snapshot the original's line items (positive amounts — the + // Storno's sign convention lives on the row-level totals + the + // renderer flip). + const lineItems = await trx('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', originalId) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + if (lineItems.length > 0) { + const cloned = lineItems.map((li) => ({ + position: ensureInt(li.position), + quantity: li.quantity, + description: li.description, + unit_price_minor: ensureInt(li.unit_price_minor), + discount_percent: ensureNumber(li.discount_percent, 0), + line_total_minor: ensureInt(li.line_total_minor), + parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), + details_text: li.details_text || null, + })); + const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); + validateLineItemHierarchy(cloned); + await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', stornoId, cloned); + } + + // Flip the original to cancelled + link the Storno. + await trx('invoices').where({ id: originalId }).update({ + status: 'cancelled', + cancellation_storno_id: stornoId, + updated_at: now, + }); + + try { + await logActivity('invoice_cancelled_via_storno', + { invoiceId: originalId, stornoId, stornoNumber }, + original.event_id || null, `admin:${adminId}`); + } catch (_) {} + + return stornoId; +} + +/** + * Send a Stornorechnung — renders the PDF, persists it on disk, + * flips the row to `status='sent'`, and queues the `storno_issued` + * email to the customer with the PDF attached. + * + * Mirrors sendInvoice's shape so the scheduler's flush loop can + * delegate uniformly. The email template ships in Phase 3 + * (renames the dormant `invoice_cancelled` seed); if the worker + * picks up the job before the template lands it logs the missing + * template — the row stays in `sent` either way. + */ +async function sendStorno(stornoId, adminId) { + const data = await getInvoiceById(stornoId); + if (!data) throw new AppError('Storno not found', 404); + const { invoice: storno, lineItems } = data; + if (storno.kind !== 'storno') { + throw new AppError(`Expected kind='storno', got '${storno.kind}'`, 409); + } + if (storno.status === 'sent') return { status: 'sent' }; + + const customer = await db('customer_accounts').where({ id: storno.customer_account_id }).first(); + ensureCustomerCanBill(customer); + + const ctx = await buildInvoiceRenderContext(storno, lineItems); + const buffer = await pdfService.renderInvoiceToBuffer(ctx); + + // Persist PDF snapshot alongside regular invoices. + const fs = require('fs'); + const path = require('path'); + const year = new Date(storno.issue_date).getFullYear(); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); + fs.mkdirSync(root, { recursive: true }); + const pdfPath = path.join(root, `${storno.invoice_number}.pdf`); + fs.writeFileSync(pdfPath, buffer); + + await db('invoices').where({ id: stornoId }).update({ + status: 'sent', + sent_at: new Date(), + pdf_path: pdfPath, + updated_at: new Date(), + }); + + // Look up the original so we can include both numbers in the + // email body — customers' bookkeepers expect to see the pair. + const originalRow = storno.cancels_invoice_id + ? await db('invoices').where({ id: storno.cancels_invoice_id }) + .select('invoice_number', 'issue_date').first() + : null; + + const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email); + await emailProcessor.queueEmail(storno.event_id || null, stornoTo, 'storno_issued', { + storno_number: storno.invoice_number, + original_invoice_number: originalRow?.invoice_number || '', + original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '', + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale), + cc: stornoCc, + attachments: [{ + filename: `${storno.invoice_number}.pdf`, + contentPath: pdfPath, + contentType: 'application/pdf', + }], + }); + + try { + await logActivity('storno_sent', + { stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null }, + storno.event_id || null, `admin:${adminId || 'system'}`); + } catch (_) {} + + return { status: 'sent', stornoId }; +} + +/** + * Reissue an invoice — the legally-correct alternative to post-send + * editing. + * 1. If the original is still live (sent / overdue / paid), + * generate a Stornorechnung for it via `createStorno` and + * immediately send it to the customer (sendStorno). The + * original flips to `status='cancelled'` and its + * `cancellation_storno_id` is pinned. + * 2. Create a fresh `scheduled` invoice with a new sequence + * number, line items snapshotted from the original, and + * `replaces_invoice_id` pointing at the original so the + * renderer can stamp "Bezug: Ersetzt Rechnung R-XXXX". + * + * If the original is ALREADY cancelled (admin previously cancelled + * it via Storno on its own), the cancel step is skipped — only the + * replacement is created. `scheduled` originals are rejected + * (USE_EDIT_INSTEAD) since drafts don't need legal cancellation. + */ +async function reissueInvoice(id, adminId) { + const original = await db('invoices').where({ id }).first(); + if (!original) throw new AppError('Invoice not found', 404); + if (original.kind === 'storno') { + throw new AppError('Cannot reissue a Storno document', 409, 'IS_STORNO'); + } + if (original.status === 'scheduled') { + throw new AppError( + 'This invoice has not been sent yet — use Edit instead of Cancel & reissue.', + 409, + 'USE_EDIT_INSTEAD', + ); + } + + // Cancel via Storno first if still live. We deliberately commit + // the Storno BEFORE creating the replacement so a failed sendStorno + // doesn't roll back the cancellation; the storno sits in + // status='scheduled' and the cron picks it up. Same resiliency + // contract as cancelInvoice. + let stornoId = null; + if (original.status !== 'cancelled') { + stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx)); + try { await sendStorno(stornoId, adminId); } catch (err) { + logger.warn('sendStorno during reissue failed — scheduler will retry', { stornoId, err: err.message }); + } + } + + // Build the replacement. Same shape as the original — re-uses + // createInvoice so totals are recomputed authoritatively from + // line items (any rounding drift gets normalised). Self-join + // carries parent_position so migration-119 sub-items survive. + return await db.transaction(async (trx) => { + const lineItems = await trx('invoice_line_items as li') + .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') + .where('li.invoice_id', id) + .orderBy('li.position', 'asc') + .select('li.*', 'parent.position as parent_position'); + const liPayload = lineItems.map((li) => ({ + position: li.position, + quantity: Number(li.quantity), + description: li.description, + unit_price_minor: Number(li.unit_price_minor), + discount_percent: Number(li.discount_percent || 0), + parent_position: li.parent_position == null ? null : Number(li.parent_position), + details_text: li.details_text || null, + })); + + const { invoiceIds: reissuedIds } = await createInvoice({ + customerAccountId: original.customer_account_id, + sourceQuoteId: original.source_quote_id || null, + eventId: original.event_id || null, + language: original.language, + currency: original.currency, + vatRate: original.vat_rate, + shippingAmountMinor: original.shipping_amount_minor, + ccPdfEmail: original.cc_pdf_email, + businessBankAccountId: original.business_bank_account_id, + qrFormat: original.qr_format, + paymentTermTemplateId: original.payment_term_template_id, + // Reissue always produces a standalone invoice even when the + // customer is on monthly billing — folding the reissued items + // into the current period's running draft would conflate two + // unrelated billing periods. The escape hatch keeps the + // standard createInvoice flow. + _skipMonthlyRouting: true, + // Carry the split picker (migration 124) + event snapshot + // (migration 123) onto the reissued draft so the admin doesn't + // have to re-set them after a Cancel & reissue. createInvoice + // already accepts these on both code paths. + paymentNetDaysTemplateId: original.payment_net_days_template_id || null, + paymentTimingTemplateId: original.payment_timing_template_id || null, + eventName: original.event_name || null, + eventDate: original.event_date || null, + eventTimeStart: original.event_time_start || null, + eventTimeEnd: original.event_time_end || null, + // No installment metadata — reissue defaults to a single + // standalone invoice. If the admin needs the same split they + // can run the original conversion again from the quote. + lineItems: liPayload, + // Migration 140 — reissue inherits the cancelled original's + // deal_uuid so Storno + replacement + cancelled all group + // under one deal lineage view. + dealUuid: original.deal_uuid || null, + }, adminId, trx); + // Reissue always produces a single invoice (no installments + // forced), so the array length is 1. + const newId = reissuedIds[0]; + + await trx('invoices').where({ id: newId }).update({ + replaces_invoice_id: id, + updated_at: new Date(), + }); + + try { + await logActivity('invoice_reissued', + { originalInvoiceId: id, newInvoiceId: newId, stornoId }, + original.event_id || null, `admin:${adminId}`); + } catch (_) {} + + return { id: newId, replaces: id, stornoId }; + }); +} + +/** + * Release a `pending_delivery` invoice for sending. Used when the + * photographer has actually delivered the photos and is ready to + * collect the final installment — flips the status to `scheduled` + * with `scheduled_send_at = now`, then immediately calls sendInvoice + * so the email goes out without waiting for the next scheduler tick. + * + * Refuses to act on rows that aren't pending — admins should use + * sendInvoice / sendReminder for the normal `scheduled`/`sent` flow. + */ +async function releaseForDelivery(id, adminId) { + const invoice = await db('invoices').where({ id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + if (invoice.status !== 'pending_delivery') { + throw new AppError( + `Invoice is not awaiting delivery (status: '${invoice.status}')`, + 409, + 'NOT_PENDING_DELIVERY', + ); + } + const now = new Date(); + await db('invoices').where({ id }).update({ + status: 'scheduled', + scheduled_send_at: now, + updated_at: now, + }); + try { + await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); + } catch (_) {} + // Fire immediately rather than waiting for the next scheduler + // tick — admin clicked the button because they want it out now. + return await sendInvoice(id, adminId); +} + +/** + * Cancel an invoice. The behaviour depends on whether the document + * was ever issued: + * + * - `scheduled` (draft, no PDF emitted): soft cancel — status + * flips to 'cancelled', nothing leaves the system. No Storno is + * generated because no document exists for the customer to + * reverse. + * + * - `sent` / `overdue` / `paid` (issued): generate a + * Stornorechnung (cancellation invoice) with its own sequence + * number, attach a signed PDF, and email it to the customer. + * Original flips to 'cancelled' and pins its + * `cancellation_storno_id` for the admin lineage view. This is + * the only §14c-defensible cancellation path under DACH tax law + * once an invoice has been delivered to the recipient. + * + * Note we allow `paid` here on purpose — bookkeepers cancel + * paid invoices when issuing refunds. The actual money + * movement (refund, carry-forward as Anzahlung) is handled + * separately; the Storno is the document leg. + * + * - `cancelled` (already): 409, `ALREADY_CANCELLED`. + * + * Returns `{ cancelled: true, stornoId? }` so the caller can + * surface "Storno S-XXXX wurde erzeugt" feedback when applicable. + */ +async function cancelInvoice(id, adminId) { + const invoice = await db('invoices').where({ id }).first(); + if (!invoice) throw new AppError('Invoice not found', 404); + if (invoice.kind === 'storno') { + throw new AppError('Cannot cancel a Storno document', 409, 'IS_STORNO'); + } + if (invoice.status === 'cancelled') { + throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED'); + } + + // Draft path: nothing was issued, soft cancel and we're done. + if (invoice.status === 'scheduled') { + await db('invoices').where({ id }).update({ + status: 'cancelled', updated_at: new Date(), + }); + try { + await logActivity('invoice_cancelled', + { invoiceId: id, viaStorno: false }, + invoice.event_id || null, `admin:${adminId}`); + } catch (_) {} + return { cancelled: true, stornoId: null }; + } + + // Issued path: Storno required. Commit createStorno in its own + // transaction so a failed sendStorno doesn't roll back the + // cancellation; the scheduler picks up an unsent Storno on the + // next tick. + const stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx)); + try { await sendStorno(stornoId, adminId); } catch (err) { + logger.warn('sendStorno after cancelInvoice failed — scheduler will retry', { stornoId, err: err.message }); + } + return { cancelled: true, stornoId }; +} + +async function triggerMonthlyBillNow(customerId, adminId) { + const draft = await db('invoices') + .where({ customer_account_id: customerId, is_monthly_draft: true }) + .orderBy('id', 'desc') + .first(); + if (!draft) { + throw new AppError('No pending monthly bill for this customer', 409, 'NO_MONTHLY_DRAFT'); + } + const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1); + if (items.length === 0) { + throw new AppError('Monthly draft is empty — nothing to bill', 409, 'EMPTY_DRAFT'); + } + + // Arm the draft: clear the discriminator, pin issue_date to today, + // and set scheduled_send_at to now so the flush pass + sendInvoice + // path treats it like any other ready-to-send invoice. Logged as a + // distinct activity so the audit trail shows admin override vs the + // scheduler's automatic fire. + const issueDate = new Date().toISOString().slice(0, 10); + await db('invoices').where({ id: draft.id }).update({ + is_monthly_draft: false, + issue_date: issueDate, + scheduled_send_at: new Date(), + updated_at: new Date(), + }); + try { + await logActivity('monthly_bill_triggered_manually', + { invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end }, + null, `admin:${adminId}`); + } catch (_) {} + + // Inline send so admin gets immediate feedback (PDF stored, status + // flipped to 'sent', email queued). A failure here doesn't roll + // back the arming — the scheduler will pick it up on the next tick. + try { + await sendInvoice(draft.id, adminId); + } catch (err) { + logger.warn('triggerMonthlyBillNow: inline send failed — scheduler will retry', + { invoiceId: draft.id, err: err.message }); + } + return { invoiceId: draft.id, invoiceNumber: draft.invoice_number }; +} +module.exports = { + sendInvoice, + createStorno, + sendStorno, + reissueInvoice, + releaseForDelivery, + cancelInvoice, + triggerMonthlyBillNow, +}; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 63e43484..f4c85e49 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -19,3567 +19,43 @@ * that customer. */ -const crypto = require('crypto'); -const { db, withRetry, logActivity } = require('../database/db'); -const logger = require('../utils/logger'); -const { getAppSetting } = require('../utils/appSettings'); -const { cleanNetMinor } = require('../utils/invoiceRounding'); -const { AppError } = require('../utils/errors'); -const { formatBoolean } = require('../utils/dbCompat'); -const { nextDocumentNumber } = require('../utils/documentSequences'); -const { formatShortDate } = require('../utils/dateFormatter'); -const businessProfileService = require('./businessProfileService'); -const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext'); -const { resolveBillingRecipients } = require('./_billingRecipients'); -const pdfService = require('./pdfService'); -const emailProcessor = require('./emailProcessor'); -// Migration 119 line-item hierarchy helpers, shared with quoteService. -// We import lazily inside the functions that use them to avoid a -// require-cycle warning (quoteService also imports invoiceService for -// the quote→invoice conversion path). -function getHierarchyHelpers() { - // eslint-disable-next-line global-require - return require('./quoteService')._internal; -} - -// D.2 — `ensureInt` + `ensureNumber` consolidated into utils/numericHelpers. -const { ensureInt, ensureNumber } = require('../utils/numericHelpers'); -const { hasColumnCached } = require('../utils/schemaCache'); - -// Atomic gap-free invoice 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 emitted a random `R-2026-AB12C3` after 5 retries, -// breaking the §14 UStG single-sequence requirement. -async function nextInvoiceNumber(trx) { - return nextDocumentNumber('invoice', 'crm_invoices_number_format', 'R-{YEAR}-{SEQ:04d}', trx); -} - -function ensureCustomerCanBill(customer) { - 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); - } - if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') { - throw new AppError('This customer has bills disabled', 409, 'CUSTOMER_FEATURE_DISABLED'); - } -} - -/** - * Resolve a trigger ('quote_accepted' | 'before_event' | ...) + - * offset_days into a concrete date relative to the event. - */ -function computeScheduledSendAt(trigger, offsetDays, eventDate, baseDate = new Date()) { - const ms = 24 * 60 * 60 * 1000; - const offset = ensureInt(offsetDays) * ms; - const eventTs = eventDate ? new Date(eventDate).getTime() : null; - switch (trigger) { - case 'quote_accepted': - return new Date(baseDate.getTime() + offset); - case 'before_event': - case 'after_event': - if (!eventTs) return new Date(baseDate.getTime() + offset); - return new Date(eventTs + offset); - case 'after_delivery': - // Treat as event_date + 14 days as a sensible default; admin can - // edit the scheduled_send_at on the invoice later. - if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset); - return new Date(eventTs + 14 * ms + offset); - case 'fixed_date': - default: - return new Date(baseDate.getTime() + offset); - } -} - -function computeDueDate(scheduledSendAt, netDays = 30) { - return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000); -} - -/** - * Resolve the net-days a new invoice's due date should be anchored to. - * Single source of truth so the editor (split picker), legacy callers, - * and quote→invoice conversion all land on the same number. Priority: - * - * 1. `payload.netDays` — explicit caller override (installment spawn - * passes the snapshot's net_days here). - * 2. Split picker (migration 124): payment_net_days_templates.net_days - * via `payload.paymentNetDaysTemplateId`. This is what the bill - * editor actually sends; the old code only read the legacy FK and - * so silently ignored Net 60 / 90 selections. - * 3. Legacy single FK: payment_term_templates.net_days via - * `payload.paymentTermTemplateId`. - * 4. The `crm_payment_default_net_days` setting (admin-configured). - * 5. 30 — historical hard default. - */ -async function resolveNetDays(payload, trx = db) { - if (payload && payload.netDays != null && payload.netDays !== '') { - const n = ensureInt(payload.netDays); - if (n) return n; - } - if (payload && payload.paymentNetDaysTemplateId) { - const probe = await trx('payment_net_days_templates') - .where({ id: payload.paymentNetDaysTemplateId }) - .select('net_days') - .first(); - if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; - } - if (payload && payload.paymentTermTemplateId) { - const probe = await trx('payment_term_templates') - .where({ id: payload.paymentTermTemplateId }) - .select('net_days') - .first(); - if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30; - } - const setting = ensureInt(await getAppSetting('crm_payment_default_net_days')); - if (setting) return setting; - return 30; -} - -/** - * Net-days for an already-persisted invoice row (no payload). Reads the - * snapshot's net_days, then the crm_payment_default_net_days setting, - * then 30. Used at send time to re-anchor the due date when the issue - * date is stamped. Mirrors resolveNetDays' tail. - */ -async function resolveNetDaysForRow(invoice) { - const snap = typeof invoice.payment_term_snapshot === 'string' - ? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })() - : invoice.payment_term_snapshot; - if (snap && snap.net_days != null) { - const n = ensureInt(snap.net_days); - if (n) return n; - } - const setting = ensureInt(await getAppSetting('crm_payment_default_net_days')); - if (setting) return setting; - return 30; -} - -/** - * Resolve the deal_uuid for a new invoice row (migration 140). Priority: - * - * 1. `payload.dealUuid` — explicit caller override. Used by - * spawnInstallmentInvoices (all siblings share one uuid), - * Storno (inherits from cancelled invoice), and reissue - * (inherits from the cancelled original). - * 2. The source quote's deal_uuid, if `payload.sourceQuoteId` is set. - * 3. The source contract's deal_uuid, if `payload.sourceContractId` - * is set. - * 4. Fresh mint — standalone invoices that aren't part of any chain. - * - * Returns a UUID string. Never returns null. - */ -async function resolveDealUuid(trx, payload) { - if (payload?.dealUuid) return payload.dealUuid; - if (payload?.sourceQuoteId) { - const q = await trx('quotes').where({ id: payload.sourceQuoteId }).first('deal_uuid'); - if (q?.deal_uuid) return q.deal_uuid; - } - if (payload?.sourceContractId) { - const c = await trx('contracts').where({ id: payload.sourceContractId }).first('deal_uuid'); - if (c?.deal_uuid) return c.deal_uuid; - } - return crypto.randomUUID(); -} - -/** - * Snap a baseline date to the next billing-cycle boundary for a - * customer on a fixed cadence. Used by scheduleInvoicesForEvent so - * monthly / quarterly customers don't get billed immediately on quote - * acceptance — instead the invoice fires on `billing_cycle_day` of the - * next period. - * - * `cycleDay` honours the sign-as-discriminator convention from - * migration 128: positive 1..28 = that day of the month; negative - * -1..-15 = that many days before end of month. Resolution is - * delegated to `computeMonthlyCadenceDate` so the two helpers can't - * disagree about what "-3 cycle day" means. - * - * Day numbers beyond the destination month's length are clamped - * (e.g. day 31 in February rolls back to Feb 28/29). Negative days - * are clamped to day 1 minimum (extreme values like -40 don't blow - * past the start of the month). - * - * History: a prior version of this function did - * `Math.max(1, Math.min(31, ensureInt(cycleDay) || 1))`, silently - * clamping every negative value to 1 — so a customer configured - * with cycle_day=-3 (last 3 days of month) got billed on day 1 - * instead. Audit finding: monthly cycle sign convention bug. - */ -function snapToNextBillingCycle(baseDate, cadence, cycleDay) { - if (!cadence || cadence === 'per_event') return baseDate; - const day = Number.isFinite(ensureInt(cycleDay)) ? ensureInt(cycleDay) : 1; - const d = new Date(baseDate.getTime()); - - if (cadence === 'monthly') { - // Move to the cycleDay in the next calendar month. If we're already - // before cycleDay this month and the base date is in the same month, - // we still move forward to NEXT month so accepting a quote on - // Jan 5 (cycleDay=1) fires on Feb 1, not Jan 5. - const nextMonth = d.getMonth() + 1; - return computeMonthlyCadenceDate(d.getFullYear(), nextMonth, day); - } - - if (cadence === 'quarterly') { - // First month of the next quarter. Quarter starts: Jan, Apr, Jul, Oct. - const month = d.getMonth(); - const nextQuarterMonth = (Math.floor(month / 3) + 1) * 3; // 0,3,6,9 - return computeMonthlyCadenceDate(d.getFullYear(), nextQuarterMonth, day); - } - - return baseDate; -} - -/** - * Compute the canonical "cadence day" for a given (year, month) using - * the customer's `billing_cycle_day`. Migration 128 introduced the - * sign-as-discriminator convention: - * positive 1..28 → that day of the month, clamped to month length - * negative -1..-15 → that many days before end of month - * Zero falls back to 1 (matches the service-layer clamp). - * - * Returns a JS Date at local-midnight on the resolved day. Callers - * compare against today's date with day-resolution math; the time - * component never matters for monthly-bill issuance. - */ -function computeMonthlyCadenceDate(year, month /* 0-based */, cycleDay) { - const day = Number.isFinite(cycleDay) ? Math.trunc(cycleDay) : 1; - const monthLen = new Date(year, month + 1, 0).getDate(); - let target; - if (day > 0) { - target = Math.min(day, monthLen); - } else if (day < 0) { - // Sign-as-discriminator: -N = N days before month end. Documented - // in the admin UI hint as "Use negative -1..-15 for 'N days before - // month end' (so -3 fires on the 28th of a 31-day month)". - // Formula: monthLen + day → -3 + 31 = 28 ✓. - // Clamped to day 1 minimum so extreme values (-40) don't blow - // past the start of the month. - target = Math.max(1, monthLen + day); - } else { - target = 1; - } - return new Date(year, month, target); -} - -/** - * Find or create the running "monthly draft" invoice for a customer. - * One draft per customer per current billing period (`monthly_period_end >= today`). - * Subsequent saves through createInvoice for the same monthly-mode - * customer append line items onto this draft instead of minting fresh - * invoices. - * - * Returns `{ id, row }` for the draft so the caller can append items - * + recompute totals without a second query. - * - * Period bounds: - * start = first calendar day of the month that contains today - * end = computeMonthlyCadenceDate(year, month, cycle_day) where - * year/month are picked so that the resolved date is in the - * future. If today is already PAST the cadence day for the - * current month, the period rolls to next month — admin - * authoring items after the cadence is "starting the next - * bill", not "appending to one that already fired". - */ -async function getOrCreateMonthlyDraft(customer, adminId, trx) { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - // Manual cadence has no billing cycle: the draft accumulates - // indefinitely and ships ONLY via the admin "Trigger invoice now" - // gesture, so it carries NO period_end. The scheduler's auto-flush - // filter is `monthly_period_end <= today`, which a NULL period_end - // can never satisfy — keeping manual drafts out of the cron path. - const isManual = customer.billing_cadence === 'manual'; - - // Resolve period_end: prefer the cadence in the current month, but - // if it has already passed, roll to next month so the new draft - // gathers items toward the NEXT bill. - const cycleDay = ensureInt(customer.billing_cycle_day) || 1; - let target = computeMonthlyCadenceDate(today.getFullYear(), today.getMonth(), cycleDay); - if (target.getTime() < today.getTime()) { - const nextMonth = today.getMonth() + 1; - target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay); - } - const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1); - const periodEnd = isManual ? null : target; - // Placeholder issue/due date for the empty draft row — recomputed at - // issuance time. Manual drafts have no period_end, so fall back to today. - const placeholderDate = (periodEnd || today).toISOString().slice(0, 10); - - // Look up any existing open draft for this customer. We deliberately - // do NOT filter by monthly_period_end here — only one draft can be - // open per customer at a time (enforced by the partial unique index - // created in migration 133). If the scheduler hasn't yet promoted an - // expired draft, it's still the canonical landing spot for any new - // items the admin queues; promoting it is the scheduler's job, not - // ours. forUpdate() locks the row on Postgres so concurrent appenders - // serialize on totals recomputation; SQLite's transaction write-lock - // gives us the same guarantee implicitly. - const existing = await trx('invoices') - .where({ - customer_account_id: customer.id, - is_monthly_draft: true, - }) - .orderBy('id', 'desc') - .forUpdate() - .first(); - if (existing) { - return { id: existing.id, row: existing, created: false }; - } - - // None yet — mint one with zero line items + zero totals. The - // caller appends items + recomputes immediately after. - const profile = (await businessProfileService.getProfile()).profile; - const currency = (customer.preferred_currency || profile?.default_currency || 'CHF').toUpperCase(); - const language = customer.preferred_language || profile?.default_locale || 'de'; - const invoiceNumber = await nextInvoiceNumber(trx); - const bank = await businessProfileService.resolveBankAccountForCurrency(currency, null); - - const row = { - invoice_number: invoiceNumber, - customer_account_id: customer.id, - source_quote_id: null, - event_id: null, - language, - currency, - issue_date: placeholderDate, - due_date: placeholderDate, // recomputed at issuance time - installment_index: 0, - installment_total: 1, - status: 'scheduled', - scheduled_send_at: null, // monthly pass sets this on cadence day - net_amount_minor: 0, - vat_rate: 0, - vat_amount_minor: 0, - shipping_amount_minor: 0, - total_amount_minor: 0, - business_bank_account_id: bank?.id || null, - qr_format: null, - is_monthly_draft: true, - monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null, - monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null, - // Migration 140 — each monthly-draft cycle is its own deal (no - // quote/contract chain). Fresh UUID at creation; subsequent line - // appends just mutate this same row, so the uuid sticks. - deal_uuid: crypto.randomUUID(), - created_by_admin_id: adminId, - created_at: new Date(), - updated_at: new Date(), - }; - try { - const inserted = await trx('invoices').insert(row).returning('id'); - const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - return { id, row: { ...row, id }, created: true }; - } catch (err) { - // Partial-unique-index violation: another transaction snuck a draft - // in between our SELECT and INSERT. Re-SELECT the winner and return - // it — concurrent callers converge on the same draft row instead - // of double-billing the customer. The error string varies by - // driver: Postgres → SQLSTATE 23505; better-sqlite3 → 'UNIQUE - // constraint failed'; node-sqlite3 → 'SQLITE_CONSTRAINT'. - const msg = String(err && err.message || ''); - const isUniqueViolation = - err && err.code === '23505' || - /unique/i.test(msg) || - /sqlite_constraint/i.test(msg); - if (!isUniqueViolation) throw err; - const winner = await trx('invoices') - .where({ customer_account_id: customer.id, is_monthly_draft: true }) - .orderBy('id', 'desc') - .first(); - if (!winner) { - // No row to return despite the unique-violation — this would - // mean the winning transaction rolled back after we lost the - // race. Surface the original error so the caller can retry. - throw err; - } - return { id: winner.id, row: winner, created: false }; - } -} - -// --------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------- - -async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) { - return await withRetry(async () => { - let query = db('invoices') - .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') - // Surface the source contract's human contract_number (mirror of - // the src_quote JOIN in getInvoiceById) so list rows + detail - // page can render "From contract LBM-C-2026-0010" instead of - // the bare DB id "#10". LEFT join — most invoices have no - // source contract. - .leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id') - .select( - 'invoices.*', - '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', - // Same isPassive-source as getInvoiceById — surfaced so list - // rows can render the Passive badge inline without an N+1 - // round-trip. - 'customer_accounts.password_hash as customer_password_hash', - 'customer_accounts.company_name as customer_company_name', - 'src_contract.contract_number as source_contract_number', - ); - - if (Array.isArray(filters.status) && filters.status.length > 0) { - query = query.whereIn('invoices.status', filters.status); - } - if (filters.customerAccountId) { - query = query.where('invoices.customer_account_id', filters.customerAccountId); - } - // Hide monthly drafts (migration 128) from the default list — they - // live on the customer detail page's "Monthly billing queue" card. - // Callers that explicitly want them (the customer-detail summary - // fetch) pass `includeMonthlyDrafts: true`. - if (!filters.includeMonthlyDrafts) { - query = query.where(function () { - this.where('invoices.is_monthly_draft', false) - .orWhereNull('invoices.is_monthly_draft'); - }); - } - if (filters.sourceQuoteId) { - query = query.where('invoices.source_quote_id', filters.sourceQuoteId); - } - if (filters.unpaidOnly) { - query = query.whereIn('invoices.status', ['scheduled', 'sent', 'overdue']); - } - if (filters.q && String(filters.q).trim()) { - const term = `%${String(filters.q).trim()}%`; - query = query.andWhere(function() { - this.where('invoices.invoice_number', 'like', term) - .orWhere('customer_accounts.email', 'like', term) - .orWhere('customer_accounts.company_name', 'like', term); - }); - } - const countRow = await query.clone().clearSelect().clearOrder().count('invoices.id as total').first(); - const total = ensureInt(countRow?.total || 0); - - switch (sort) { - // "Newest" / "Oldest" means newest/oldest by CREATION time, not - // by issue_date. Issue_date is admin-controlled (used for tax - // accruals, retro-dating, future-dating) so it can drift from - // actual chronology — sorting by it makes a just-created invoice - // disappear into the middle of the list whenever its issue_date - // is set to something other than today. created_at always - // reflects when the row landed in the DB. id is the tiebreaker - // for rows that share a created_at second. - case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break; - case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break; - case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break; - case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break; - case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break; - case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break; - case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break; - case 'customer_asc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') - .orderBy('invoices.id', 'desc'); - break; - case 'customer_desc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') - .orderBy('invoices.id', 'desc'); - break; - case 'newest': - default: - query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.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 getInvoiceById(id) { - return await withRetry(async () => { - // LEFT JOIN customer_accounts so transformInvoice has populated - // customer_email / company etc. — mirrors getQuoteById. - const invoice = await db('invoices') - .leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id') - // Join the source quote so the detail view can display its - // human-readable number ("LBM-Q-2026-0006") instead of just - // the numeric id ("#6"). LEFT join — most invoices come from - // a quote conversion but standalone invoices don't have one. - .leftJoin('quotes as src_quote', 'invoices.source_quote_id', 'src_quote.id') - // Migration 130 lineage: source contract's human contract_number - // so the detail view shows "From contract LBM-C-2026-0010" - // instead of "#10". Same LEFT-join shape as src_quote. - .leftJoin('contracts as src_contract', 'invoices.source_contract_id', 'src_contract.id') - // Self-joins for Storno lineage so the detail view can render - // "Cancelled by Stornorechnung S-XXXX" / "This Stornorechnung - // cancels invoice R-XXXX" using the human invoice_number rather - // than the bare DB row id. Same pattern as source_quote_number. - .leftJoin('invoices as cancels_inv', 'invoices.cancels_invoice_id', 'cancels_inv.id') - .leftJoin('invoices as cancellation_storno', 'invoices.cancellation_storno_id', 'cancellation_storno.id') - .where('invoices.id', id) - .select( - 'invoices.*', - '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 transformInvoice can compute the - // customer.isPassive flag (passwordHash == null). The hash - // itself never leaves the API — transformInvoice drops it - // and only exposes the boolean. - 'customer_accounts.password_hash as customer_password_hash', - 'src_quote.quote_number as source_quote_number', - 'src_contract.contract_number as source_contract_number', - 'cancels_inv.invoice_number as cancels_invoice_number', - 'cancellation_storno.invoice_number as cancellation_storno_number', - ) - .first(); - if (!invoice) return null; - // Self-join so each row also carries `parent_position` (the position - // of its parent line item, when it's a sub-item). The editor needs - // position-based references to rebuild the hierarchy in the UI; - // parent_line_item_id is the DB-level relationship but isn't - // stable in the payload the editor sends back. Migration 119. - const lineItems = await db('invoice_line_items as li') - .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') - .where('li.invoice_id', id) - .orderBy('li.position', 'asc') - .select('li.*', 'parent.position as parent_position'); - const payments = await db('invoice_payment_log').where({ invoice_id: id }).orderBy('paid_at', 'asc'); - return { invoice, lineItems, payments }; - }); -} - -/** - * Append line items from a `createInvoice`-shaped payload onto the - * customer's running monthly-draft (migration 128). Used when the - * customer is billing_cadence='monthly': the admin's editor save - * lands here instead of minting a new invoice. - * - * Pulls the existing draft (or creates a fresh one for the current - * period), appends the new line items continuing the position - * sequence, recomputes totals across the merged set, and returns the - * draft's id so the route layer can fetch + return it. - */ -async function appendToMonthlyDraft(payload, customer, adminId, trx) { - const draft = await getOrCreateMonthlyDraft(customer, adminId, trx); - - // Load existing line items so we can compute the next `position` and - // re-sum totals across the merged set. The migration-119 hierarchy - // helpers operate on the merged array so parent_position pointers - // remain consistent. - const existing = await trx('invoice_line_items') - .where({ invoice_id: draft.id }) - .orderBy('position', 'asc'); - const nextPosition = existing.length - ? Math.max(...existing.map((li) => ensureInt(li.position))) + 1 - : 1; - - const incoming = Array.isArray(payload.lineItems) ? payload.lineItems : []; - const newItems = incoming.map((li, idx) => { - const qty = ensureNumber(li.quantity, 1); - const unit = ensureInt(li.unit_price_minor); - const discount = ensureNumber(li.discount_percent, 0); - const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); - const isSubItem = li.parent_position != null && li.parent_position !== ''; - return { - position: nextPosition + idx, - quantity: qty, - description: String(li.description || ''), - unit_price_minor: unit, - discount_percent: discount, - line_total_minor: lineTotal, - parent_position: isSubItem ? ensureInt(li.parent_position) : null, - details_text: li.details_text || null, - }; - }); - - if (newItems.length > 0) { - const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); - validateLineItemHierarchy(newItems); - await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', draft.id, newItems); - } - - // Recompute totals across the entire draft so the running figures - // shown on the customer-detail "Monthly queue" card stay accurate - // as items accumulate. Mirrors createInvoice's totals path. - const allItems = await trx('invoice_line_items') - .where({ invoice_id: draft.id }); - let netMinor = 0; - for (const li of allItems) { - if (li.parent_line_item_id == null) netMinor += ensureInt(li.line_total_minor); - } - const vatRate = ensureNumber(payload.vatRate, draft.row.vat_rate || 0); - const vatMinor = Math.round(netMinor * Number(vatRate) / 100); - const shippingMinor = ensureInt(draft.row.shipping_amount_minor); - const totalMinor = netMinor + vatMinor + shippingMinor; - - await trx('invoices').where({ id: draft.id }).update({ - net_amount_minor: netMinor, - vat_rate: vatRate, - vat_amount_minor: vatMinor, - total_amount_minor: totalMinor, - updated_at: new Date(), - }); - - try { - await logActivity('monthly_billing_items_queued', - { invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length }, - null, `admin:${adminId}`); - } catch (_) {} - - return draft.id; -} - -/** - * Append a single, fully-formed line item to the customer's running - * monthly draft (migration 128 + 129). Used by customerHoursService - * when an hour entry is logged for a monthly-mode customer — we want - * the inserted `invoice_line_items.id` back so the entry can be - * stamped with the cross-reference. - * - * `lineItem` is the shape consumed by appendToMonthlyDraft's internal - * insertLineItemsHierarchical helper (description, quantity, - * unit_price_minor, discount_percent, line_total_minor, etc.). The - * `position` field is set internally — caller-supplied positions are - * ignored to keep the accumulator's sequence intact. - * - * Returns { invoiceId, lineItemId } — the draft id plus the id of the - * newly-appended row. - */ -async function appendOneLineItemToMonthlyDraft(customer, lineItem, adminId, trx) { - // Reuse the accumulator path — it handles get-or-create + totals - // recompute + activity log. We pass a single-item array. - await appendToMonthlyDraft({ - customerAccountId: customer.id, - lineItems: [lineItem], - vatRate: 0, // hours logging doesn't ship with VAT today - }, customer, adminId, trx); - - // Look up the draft we just appended onto + its tail line item. - // Newest insert wins by id desc; we filter by position match so - // concurrent appends in another tx don't return the wrong row. - const draft = await trx('invoices') - .where({ customer_account_id: customer.id, is_monthly_draft: true }) - .orderBy('id', 'desc') - .first(); - if (!draft) { - // Defensive — appendToMonthlyDraft would have created one. - throw new AppError('Monthly draft missing after append', 500); - } - const tail = await trx('invoice_line_items') - .where({ invoice_id: draft.id }) - .orderBy('position', 'desc') - .first(); - return { invoiceId: draft.id, lineItemId: tail?.id || null }; -} - -/** - * Create one invoice. Returns id. Used both manually (admin creates a - * standalone invoice) and by scheduleInvoicesForEvent (one per installment). - */ -async function createInvoice(payload, adminId, trx = db) { - const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first(); - ensureCustomerCanBill(customer); - - // PR #603 review follow-up #1 — when an invoice is attached to an event, - // make sure that event actually belongs to the chosen customer. Without - // this, a typo'd/copy-pasted eventId silently links the invoice to an - // unrelated event, producing misleading reporting links. Only enforced - // when the event HAS customer assignments (an event with none — e.g. a - // legacy import — is allowed through, since we can't prove a mismatch). - if (payload.eventId && await trx.schema.hasTable('event_customer_assignments')) { - const assignments = await trx('event_customer_assignments') - .where({ event_id: payload.eventId }) - .select('customer_account_id'); - if (assignments.length > 0 && - !assignments.some(a => a.customer_account_id === payload.customerAccountId)) { - throw new AppError('The selected event is not assigned to this customer', 422, 'EVENT_CUSTOMER_MISMATCH'); - } - } - - // Accumulator intercept (migration 128). For customers in - // billing_cadence='monthly' OR 'manual' mode every createInvoice call - // APPENDS line items onto a single running draft instead of minting a - // fresh invoice. Admin sees the editor flow exactly as before; the - // returned id is the draft's id so the UI can redirect to the - // accumulator. The two modes differ only in WHEN the draft ships: - // 'monthly' auto-flushes on the cadence day (scheduler), 'manual' - // never auto-flushes (no period_end) and ships only via the admin - // "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape - // hatch used by internal helpers that need to mint a non-draft row - // (e.g. the accumulator itself, or future test fixtures). - if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') - && !payload._skipMonthlyRouting) { - const draft = await appendToMonthlyDraft(payload, customer, adminId, trx); - return { invoiceIds: draft?.id ? [draft.id] : [] }; - } - - 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'; - - // Sequence number is claimed BELOW the installment auto-route so a - // multi-installment save doesn't waste a number. When installments - // are present, spawnInstallmentInvoices claims one number per - // sibling and we never reach the single-row insert that would have - // used `invoiceNumber` here. - const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10); - const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null; - // Resolve net_days BEFORE computing the due date so Net 60 / 90 - // selections actually push the due date out. resolveNetDays honors - // the split picker FK the editor sends, the legacy single FK, and - // the crm_payment_default_net_days setting (see helper). The clock - // starts on the SEND date when the invoice is scheduled, otherwise - // the issue date — so a future send pushes the due date out too. - const resolvedNetDays = await resolveNetDays(payload, trx); - const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays) - .toISOString().slice(0, 10); - - // Re-compute totals from line items. Migration 119 — items with a - // non-null `parent_position` are sub-items and their line totals do - // NOT roll into net directly. Parent totals AUTO-RESOLVE from - // priced sub-items: if any sub-item under a parent has unit_price > 0, - // the parent's effective line_total_minor becomes the sum of those - // sub-items, and the parent's own stored unit_price is ignored. - // Mental model matches the editor — pricing on sub-items implies - // "parent is a header, total derives from what's under it". - const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : []; - const items = lineItems.map((li, idx) => { - const qty = ensureNumber(li.quantity, 1); - const unit = ensureInt(li.unit_price_minor); - const discount = ensureNumber(li.discount_percent, 0); - const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); - const isSubItem = li.parent_position != null && li.parent_position !== ''; - return { - position: ensureInt(li.position) || (idx + 1), - quantity: qty, - description: String(li.description || ''), - unit_price_minor: unit, - discount_percent: discount, - line_total_minor: lineTotal, - parent_position: isSubItem ? ensureInt(li.parent_position) : null, - details_text: li.details_text || null, - }; - }); - // Apply the migration-119 hierarchy resolver: rewrites parent - // line_total_minor to sum-of-priced-sub-items where applicable. - // Net is then summed across top-level (resolved) items. - const { resolveParentTotalsFromSubItems } = getHierarchyHelpers(); - resolveParentTotalsFromSubItems(items); - let netMinor = 0; - for (const li of items) { - if (li.parent_position == null) netMinor += ensureInt(li.line_total_minor); - } - // Optional sub-cent reconciliation (crm_invoice_round_total). When on, - // store the full-precision net rounded ONCE so the total matches - // qty × unit arithmetic; the per-line rounding drift is surfaced as a - // "Rundung" row at render time (storedNet − Σ line totals). Off by - // default ⇒ net stays the sum of rounded lines, unchanged behaviour. - const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; - if (roundTotal) { - netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' }); - } - const vatRate = ensureNumber(payload.vatRate, 0); - const vatMinor = Math.round(netMinor * vatRate / 100); - const shippingMinor = ensureInt(payload.shippingAmountMinor); - const totalMinor = netMinor + vatMinor + shippingMinor; - - // Negative line items (Rabatt) are allowed, but the resulting - // invoice total must not go below zero. Credit notes belong in - // the Storno path (createStorno), which mints a separate - // kind='storno' record with cancels_invoice_id set. - if (totalMinor < 0) { - throw new AppError( - 'Invoice total cannot be negative. To issue a credit note, cancel the original invoice with Storno.', - 400, - 'INVOICE_TOTAL_NEGATIVE', - ); - } - - const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); - - // Snapshot the selected payment-term template (net days / Skonto / - // installment plan) onto the invoice itself. Mirrors how the quote - // editor handles this — once snapshotted, edits to the template - // don't retroactively change rendered invoices. Migration 113. - let paymentTermTemplateId = null; - let paymentTermSnapshot = null; - let paymentNetDaysTemplateId = null; - let paymentTimingTemplateId = null; - // Migration 124 — prefer the two split FKs. Compose a snapshot from - // them in the same shape pdfService + scheduler already consume. - // Fall back to the legacy single FK when the caller still uses it. - if (payload.paymentNetDaysTemplateId && payload.paymentTimingTemplateId) { - const [netDays, timing] = await Promise.all([ - trx('payment_net_days_templates').where({ id: payload.paymentNetDaysTemplateId }).first(), - trx('payment_timing_templates').where({ id: payload.paymentTimingTemplateId }).first(), - ]); - if (netDays && timing) { - paymentNetDaysTemplateId = netDays.id; - paymentTimingTemplateId = timing.id; - paymentTermSnapshot = JSON.stringify({ - description: timing.description || netDays.description || null, - net_days: netDays.net_days, - skonto_percent: netDays.skonto_percent, - skonto_within_days: netDays.skonto_within_days, - installments: typeof timing.installments === 'string' - ? (() => { try { return JSON.parse(timing.installments); } catch { return null; } })() - : timing.installments || null, - }); - } - } else if (payload.paymentTermTemplateId) { - const tpl = await trx('payment_term_templates') - .where({ id: payload.paymentTermTemplateId }).first(); - if (tpl) { - paymentTermTemplateId = tpl.id; - paymentTermSnapshot = JSON.stringify({ - description: tpl.description || null, - net_days: tpl.net_days, - skonto_percent: tpl.skonto_percent, - skonto_within_days: tpl.skonto_within_days, - installments: typeof tpl.installments === 'string' - ? (() => { try { return JSON.parse(tpl.installments); } catch { return null; } })() - : tpl.installments || null, - }); - } - } - - // Multi-installment auto-route. Priority: - // 1. payload.installments (explicit override from the ad-hoc - // editor panel — wins over any saved template) - // 2. snapshot.installments (loaded from the picked payment-timing - // template above) - // If either yields ≥2 entries we delegate to spawnInstallmentInvoices - // (the same loop used by quote→invoice conversion) and return the - // array of created IDs. Single-installment plans fall through to - // the single-row insert below. - let installmentsForSpawn = null; - if (Array.isArray(payload.installments) && payload.installments.length > 1) { - installmentsForSpawn = payload.installments; - } else if (paymentTermSnapshot) { - const parsedSnap = typeof paymentTermSnapshot === 'string' - ? (() => { try { return JSON.parse(paymentTermSnapshot); } catch { return null; } })() - : paymentTermSnapshot; - if (parsedSnap && Array.isArray(parsedSnap.installments) && parsedSnap.installments.length > 1) { - installmentsForSpawn = parsedSnap.installments; - } - } - if (installmentsForSpawn) { - return await spawnInstallmentInvoices({ - trx, - eventId: payload.eventId || null, - quoteId: payload.sourceQuoteId || null, - customer, - currency, - language, - lineItems: items, - totals: { - net: netMinor, - vatRate, - vat: vatMinor, - shipping: shippingMinor, - total: totalMinor, - }, - installments: installmentsForSpawn, - eventDate: payload.eventDate || null, - adminId, - ccPdfEmail: payload.ccPdfEmail || null, - netDays: resolvedNetDays, - eventName: payload.eventName || null, - eventTimeStart: payload.eventTimeStart || null, - eventTimeEnd: payload.eventTimeEnd || null, - paymentNetDaysTemplateId, - paymentTimingTemplateId, - paymentTermSnapshot, - dealUuid: await resolveDealUuid(trx, payload), - }); - } - - // Claim the sequence number HERE — after the installment auto-route - // has been ruled out. Previously this was at the top of the function - // which leaked one number per multi-installment save (the spawner - // claims its own numbers and never used this one). - // Pass trx so the sequence claim joins our outer transaction — - // SQLite deadlocks otherwise (1-connection default). - const invoiceNumber = await nextInvoiceNumber(trx); - const row = { - invoice_number: invoiceNumber, - customer_account_id: payload.customerAccountId, - source_quote_id: payload.sourceQuoteId || null, - event_id: payload.eventId || null, - // Inline event snapshot (migration 123). Mirrors quotes — the - // snapshot survives an event rename so an archived invoice keeps - // its original event label for accounting / audit. Optional; - // standalone invoices created without an event will have these - // as null and the renderer simply omits the for-clause. - event_name: payload.eventName || null, - event_date: payload.eventDate || null, - event_time_start: payload.eventTimeStart || null, - event_time_end: payload.eventTimeEnd || null, - language, - currency, - issue_date: issueDate, - due_date: dueDate, - installment_index: ensureInt(payload.installmentIndex), - installment_total: ensureInt(payload.installmentTotal) || 1, - installment_label: payload.installmentLabel || null, - installment_trigger: payload.installmentTrigger || null, - status: scheduledSendAt && scheduledSendAt.getTime() > Date.now() ? 'scheduled' : (payload.sendNow ? 'scheduled' : 'scheduled'), - scheduled_send_at: scheduledSendAt, - net_amount_minor: netMinor, - vat_rate: vatRate, - vat_amount_minor: vatMinor, - shipping_amount_minor: shippingMinor, - total_amount_minor: totalMinor, - cc_pdf_email: payload.ccPdfEmail || null, - business_bank_account_id: bank?.id || null, - qr_format: payload.qrFormat || null, - payment_term_template_id: paymentTermTemplateId, - payment_net_days_template_id: paymentNetDaysTemplateId, - payment_timing_template_id: paymentTimingTemplateId, - payment_term_snapshot: paymentTermSnapshot, - // Per-invoice Skonto opt-out (migration 126). Defaults to false - // — invoice inherits the snapshot/global Skonto config unless - // admin explicitly ticks "Disable Skonto" in the editor. - skonto_disabled: Boolean(payload.skontoDisabled), - // Migration 140 — deal_uuid lineage. Priority: explicit payload - // (used by spawnInstallmentInvoices and Storno/reissue callers to - // force a specific value), source quote, source contract, - // otherwise fresh mint. - deal_uuid: await resolveDealUuid(trx, payload), - created_by_admin_id: adminId, - created_at: new Date(), - updated_at: new Date(), - }; - // Migration 130 — snapshot the chosen output VAT code (immutable; the - // accounting export emits exactly this rather than re-deriving from the map). - if (payload.vatCode !== undefined && await hasColumnCached('invoices', 'vat_code')) { - row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; - } - const inserted = await trx('invoices').insert(row).returning('id'); - const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - - if (items.length > 0) { - const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); - validateLineItemHierarchy(items); - await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items); - } - - try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`); } catch (_) {} - return { invoiceIds: [invoiceId] }; -} - -/** - * Fan-out helper. Creates one invoice row per installment with the - * right `scheduled_send_at`, sequential invoice numbers, and per- - * slice totals. Used by: - * - * - quoteService.convertToEvent / convertToInvoiceOnly — quote - * conversion with multi-installment payment plans. - * - createInvoice (this file) — when the standalone editor path - * submits an installment array. - * - * Expects to be called inside an existing transaction. - * - * Returns `{ invoiceIds: number[] }` — ordered by installment_index - * so callers can navigate to the first or report N IDs. - * - * The legacy export name `scheduleInvoicesForEvent` is preserved as - * an alias for backward compatibility with quoteService callers; new - * code should reach for the clearer `spawnInstallmentInvoices`. - */ -async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, currency, language, - lineItems, totals, installments, eventDate, adminId, - ccPdfEmail, netDays, - eventName, eventTimeStart, eventTimeEnd, - paymentNetDaysTemplateId, paymentTimingTemplateId, - paymentTermSnapshot, dealUuid, hold = false }) { - // Monthly-billing intercept (migration 128). Quote → invoice - // conversion for a monthly-mode customer doesn't fan out N - // installment invoices — the customer pays one consolidated bill - // per period. Append the line items to the running draft (creating - // it if needed) and return early. The installment / cadence math - // below is bypassed; the quote's payment timing is irrelevant once - // items flow into the monthly accumulator. - if (customer && customer.billing_cadence === 'monthly') { - const draft = await appendToMonthlyDraft({ - customerAccountId: customer.id, - lineItems: (lineItems || []).map((li) => ({ - position: li.position, - quantity: li.quantity, - unit_price_minor: li.unit_price_minor, - discount_percent: li.discount_percent, - description: li.description, - parent_position: li.parent_position, - details_text: li.details_text, - })), - vatRate: totals?.vatRate, - }, customer, adminId, trx); - return { invoiceIds: draft?.id ? [draft.id] : [] }; - } - - // netDays drives the due-date offset on every scheduled invoice - // created here. Callers in quoteService pass the converting quote's - // payment-term net_days so Net 60 / 90 templates flow through; when - // absent we fall back to the crm_payment_default_net_days setting - // (then 30) rather than silently using 30, matching createInvoice. - const resolvedNetDays = ensureInt(netDays) - || ensureInt(await getAppSetting('crm_payment_default_net_days', null, trx || db)) - || 30; - const total = installments.length; - const acceptanceTime = new Date(); - const invoiceIds = []; - - for (let i = 0; i < total; i++) { - const inst = installments[i]; - const percent = ensureNumber(inst.percent, 0); - if (percent <= 0) continue; - - // Each installment carries its own slice of the totals. Round to - // minor units; last installment absorbs rounding drift so the - // total exactly equals the quote total. - let netSlice, vatSlice, shippingSlice, totalSlice; - if (i === total - 1) { - // We computed everything so far; remaining slice closes the gap. - const accNet = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.net) * ensureNumber(x.percent, 0) / 100), 0); - const accVat = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.vat) * ensureNumber(x.percent, 0) / 100), 0); - const accShipping = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.shipping) * ensureNumber(x.percent, 0) / 100), 0); - const accTotal = installments.slice(0, i).reduce((s, x) => s + Math.round(ensureInt(totals.total) * ensureNumber(x.percent, 0) / 100), 0); - netSlice = ensureInt(totals.net) - accNet; - vatSlice = ensureInt(totals.vat) - accVat; - shippingSlice = ensureInt(totals.shipping) - accShipping; - totalSlice = ensureInt(totals.total) - accTotal; - } else { - netSlice = Math.round(ensureInt(totals.net) * percent / 100); - vatSlice = Math.round(ensureInt(totals.vat) * percent / 100); - shippingSlice = Math.round(ensureInt(totals.shipping) * percent / 100); - totalSlice = Math.round(ensureInt(totals.total) * percent / 100); - } - - let scheduledSendAt = computeScheduledSendAt(inst.trigger, inst.offset_days, eventDate, acceptanceTime); - // Per-customer billing cadence override: monthly / quarterly - // customers don't pay per-event — snap to the next period boundary. - if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') { - scheduledSendAt = snapToNextBillingCycle(scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day); - } - - // `after_delivery` invoices wait for the admin to confirm photos - // have actually been delivered before they fire — we can't infer - // that automatically from a date. Mark them `pending_delivery` - // with no scheduled_send_at; the scheduler only picks rows in - // status `scheduled`, so they sit idle until the admin clicks - // "Release for delivery" on the invoice detail page. - const isDeliveryTrigger = inst.trigger === 'after_delivery'; - // `hold` (workflow draft-seam): the booking flow's review gate + explicit - // send_document IS the release, so a held invoice is always `scheduled` - // (editable + sendable via sendInvoice) regardless of trigger — never - // `pending_delivery`, which sendInvoice refuses. Without hold, an - // after_delivery invoice stays `pending_delivery` as before. - const rowStatus = (isDeliveryTrigger && !hold) ? 'pending_delivery' : 'scheduled'; - // Held invoices carry no scheduled_send_at so the scheduler never auto-sends - // them — they wait for send_document. after_delivery rows are likewise null - // (the scheduler can't infer a delivery date). - const rowScheduledSendAt = (isDeliveryTrigger || hold) ? null : scheduledSendAt; - - const invoiceNumber = await nextInvoiceNumber(trx); - const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10); - - const row = { - invoice_number: invoiceNumber, - customer_account_id: customer.id, - source_quote_id: quoteId, - event_id: eventId, - // Inline event snapshot carried over from the source quote - // (migration 123). Mirrors how event_date is already carried — - // a converted invoice should keep the event reference even if - // the linked event is later renamed or deleted. - event_name: eventName || null, - event_date: eventDate || null, - event_time_start: eventTimeStart || null, - event_time_end: eventTimeEnd || null, - language, - currency, - issue_date: scheduledSendAt.toISOString().slice(0, 10), - due_date: dueDate, - installment_index: i, - installment_total: total, - installment_label: inst.label || `Installment ${i + 1}/${total}`, - installment_trigger: inst.trigger, - status: rowStatus, - scheduled_send_at: rowScheduledSendAt, - net_amount_minor: netSlice, - vat_rate: ensureNumber(totals.vatRate, 0), - vat_amount_minor: vatSlice, - shipping_amount_minor: shippingSlice, - total_amount_minor: totalSlice, - cc_pdf_email: ccPdfEmail || null, - // Migration 124 — carry the split payment-term FKs over from - // the source quote so the converted invoice is editable (when - // it eventually unlocks) with the same orthogonal split. The - // snapshot itself is the legal record; the FKs are convenience. - payment_net_days_template_id: paymentNetDaysTemplateId || null, - payment_timing_template_id: paymentTimingTemplateId || null, - payment_term_snapshot: paymentTermSnapshot - ? (typeof paymentTermSnapshot === 'string' - ? paymentTermSnapshot - : JSON.stringify(paymentTermSnapshot)) - : null, - // Migration 140 — every installment sibling shares one deal_uuid - // (passed in from the converting caller, ultimately the source - // quote's value). Defensive fallback to a fresh UUID if the - // caller didn't pass one — shouldn't happen on a migrated - // install but keeps the column non-null. - deal_uuid: dealUuid || crypto.randomUUID(), - created_by_admin_id: adminId, - created_at: new Date(), - updated_at: new Date(), - }; - - const inserted = await trx('invoices').insert(row).returning('id'); - const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - - // Line items: copy from the quote so the customer sees what they - // actually agreed to, not a generic "Gesamtbetrag" placeholder. - // Two modes: - // - Single-installment (100%): clone every quote line item - // verbatim. The invoice totals already match the quote's. - // - Multi-installment (split payment): clone the quote lines - // but mark the invoice with the installment context. We pro- - // rate by inserting one extra line at the bottom that adjusts - // to the installment slice — keeps the per-line description - // visible while the total still equals the pro-rata amount. - const sourceLines = Array.isArray(lineItems) ? lineItems : []; - if (sourceLines.length === 0) { - // Fallback for the (rare) case where the quote has no line - // items — fall back to the legacy "Installment N/M" line so - // we still produce a sensible invoice. - await trx('invoice_line_items').insert({ - invoice_id: invoiceId, - position: 1, - quantity: 1, - description: inst.label || `Installment ${i + 1}/${total}`, - unit_price_minor: netSlice, - discount_percent: 0, - line_total_minor: netSlice, - created_at: new Date(), - updated_at: new Date(), - }); - } else { - // Clone each quote line as-is, preserving its original `position` - // so the sub-item hierarchy carries over. Source lines already - // have `parent_position` populated by getQuoteById's self-join, - // so the same value reused on the new invoice points at the - // correct (also-cloned) parent. insertLineItemsHierarchical - // resolves position → new parent_line_item_id during the - // two-phase insert. Migration 119. - const cloned = sourceLines.map((li) => ({ - position: ensureInt(li.position), - quantity: li.quantity, - description: li.description, - unit_price_minor: ensureInt(li.unit_price_minor), - discount_percent: ensureNumber(li.discount_percent, 0), - line_total_minor: ensureInt(li.line_total_minor), - parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), - details_text: li.details_text || null, - })); - const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); - validateLineItemHierarchy(cloned); - await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, cloned); - - // For split payments add an explicit "Installment X/Y (Z%)" - // adjustment line that reconciles the cloned line totals to - // the actual invoice net (which is the pro-rata slice). The - // line carries the difference as a negative if the slice is - // less than the quote total (typical), or positive on the - // final installment if rounding nudged the other way. - // - // The adjustment ONLY considers top-level cloned lines — - // sub-items don't contribute to net so they can't appear in - // the reconciliation sum. - if (total > 1) { - const clonedSum = cloned - .filter((x) => x.parent_position == null) - .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); - const adjustment = netSlice - clonedSum; - if (adjustment !== 0) { - const installmentLabel = inst.label || `Installment ${i + 1}/${total}`; - const maxPosition = cloned.reduce((m, x) => Math.max(m, x.position), 0); - await trx('invoice_line_items').insert({ - invoice_id: invoiceId, - position: maxPosition + 1, - quantity: 1, - description: `${installmentLabel} (${percent}% — ${i + 1}/${total})`, - unit_price_minor: adjustment, - discount_percent: 0, - line_total_minor: adjustment, - parent_line_item_id: null, - details_text: null, - created_at: new Date(), - updated_at: new Date(), - }); - } - } - } - - try { - // Pass `trx` so the audit insert rides the transaction's connection — - // logging via the global db here deadlocks the single-connection SQLite - // pool (this runs unattended from the booking flow's prepare_invoice). - await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt }, - eventId, `admin:${adminId}`, trx); - } catch (_) {} - invoiceIds.push(invoiceId); - } - return { invoiceIds }; -} - -// Backward-compat alias — older callers reference this name. -const scheduleInvoicesForEvent = spawnInstallmentInvoices; - -// ---------------------------------------------------------------------- -// updateInstallmentPlan — atomic post-spawn plan edit -// ---------------------------------------------------------------------- - -// Statuses that are still pre-customer (no PDF has gone out the door). -// Both `scheduled` and `pending_delivery` are reshapable; anything else -// belongs to the audit trail and can't be silently mutated. -const EDITABLE_INSTALLMENT_STATUSES = new Set(['scheduled', 'pending_delivery']); - -const VALID_INSTALLMENT_TRIGGERS = new Set([ - 'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date', -]); - -/** - * Compute one slice of a plan total. Matches the rounding rule used by - * spawnInstallmentInvoices — every slice except the last is a rounded - * percent share; the last slice absorbs rounding drift so the per-slice - * sums exactly equal the plan total. - */ -function computeSliceTotals(installments, totals, i) { - const lastIndex = installments.length - 1; - const pct = ensureNumber(installments[i].percent, 0); - if (i < lastIndex) { - return { - net: Math.round(ensureInt(totals.net) * pct / 100), - vat: Math.round(ensureInt(totals.vat) * pct / 100), - shipping: Math.round(ensureInt(totals.shipping) * pct / 100), - total: Math.round(ensureInt(totals.total) * pct / 100), - }; - } - const acc = installments.slice(0, i).reduce((s, x) => { - const p = ensureNumber(x.percent, 0); - return { - net: s.net + Math.round(ensureInt(totals.net) * p / 100), - vat: s.vat + Math.round(ensureInt(totals.vat) * p / 100), - shipping: s.shipping + Math.round(ensureInt(totals.shipping) * p / 100), - total: s.total + Math.round(ensureInt(totals.total) * p / 100), - }; - }, { net: 0, vat: 0, shipping: 0, total: 0 }); - return { - net: ensureInt(totals.net) - acc.net, - vat: ensureInt(totals.vat) - acc.vat, - shipping: ensureInt(totals.shipping) - acc.shipping, - total: ensureInt(totals.total) - acc.total, - }; -} - -/** - * Throws AppError on invalid input. Exposed for the route layer to - * surface as 400 before opening a transaction. - */ -function validateInstallmentPlanInput(installments) { - if (!Array.isArray(installments) || installments.length === 0) { - throw new AppError('installments must be a non-empty array', 400); - } - let sum = 0; - for (let i = 0; i < installments.length; i++) { - const inst = installments[i] || {}; - const pct = ensureNumber(inst.percent, NaN); - if (!Number.isFinite(pct) || pct < 0 || pct > 100) { - throw new AppError(`Row ${i + 1}: percent must be between 0 and 100`, 400); - } - if (!VALID_INSTALLMENT_TRIGGERS.has(inst.trigger)) { - throw new AppError(`Row ${i + 1}: invalid trigger '${inst.trigger}'`, 400); - } - const off = ensureInt(inst.offset_days); - if (!Number.isFinite(off)) { - throw new AppError(`Row ${i + 1}: offset_days must be an integer`, 400); - } - sum += pct; - } - if (Math.abs(sum - 100) > 0.001) { - throw new AppError( - `Installment percents must sum to 100 (got ${sum})`, - 400, - 'PERCENT_SUM_INVALID', - ); - } -} - -/** - * Heuristic — spawnInstallmentInvoices appends a reconciliation line - * with a stable description shape like "Anzahlung (30% — 1/3)". The - * em-dash is U+2014 so the regex won't match plain hyphens used in - * admin-authored line descriptions. - * - * We could harden this with an `is_reconciliation_line` column, but - * the cost of a schema change isn't worth the residual edge (admins - * don't edit reconciliation lines today). - */ -function isReconciliationLineItem(li) { - if (!li || typeof li.description !== 'string') return false; - return / \(\d+(?:\.\d+)?% — \d+\/\d+\)$/.test(li.description); -} - -/** - * Replace (or insert) the reconciliation line on an invoice so its - * description matches the new label/percent and the line's amount - * closes the gap between the cloned-quote-line subtotal and the - * sibling's net slice. Symmetric with the inline logic in spawn. - * - * `topLineSubtotal` is the sum of non-reconciliation, top-level line - * items already on the invoice — passed in so callers reading the row - * once don't have to re-query. - */ -async function replaceReconciliationLine( - trx, invoiceId, { label, percent, index, total, netSlice, topLineSubtotal }, -) { - const all = await trx('invoice_line_items') - .where({ invoice_id: invoiceId }) - .orderBy('position', 'asc'); - for (const li of all) { - if (isReconciliationLineItem(li)) { - await trx('invoice_line_items').where({ id: li.id }).del(); - } - } - if (total <= 1) return; - - const nonRecon = all.filter((x) => !isReconciliationLineItem(x)); - const subtotal = topLineSubtotal != null - ? topLineSubtotal - : nonRecon.filter((x) => x.parent_position == null) - .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); - const adjustment = netSlice - subtotal; - if (adjustment === 0) return; - - const maxPosition = nonRecon.reduce( - (m, x) => Math.max(m, ensureInt(x.position)), 0, - ); - await trx('invoice_line_items').insert({ - invoice_id: invoiceId, - position: maxPosition + 1, - quantity: 1, - description: `${label} (${percent}% — ${index + 1}/${total})`, - unit_price_minor: adjustment, - discount_percent: 0, - line_total_minor: adjustment, - parent_line_item_id: null, - details_text: null, - created_at: new Date(), - updated_at: new Date(), - }); -} - -/** - * Atomically reshape an installment plan after siblings have spawned. - * The plan is the unit of edit: percents / count / triggers all change - * together in one transaction. Mutating individual siblings stays on - * the existing PUT /admin/invoices/:id path. - * - * Guards: - * - dealUuid must exist + own ≥1 invoice (else 404) - * - all siblings must be in EDITABLE_INSTALLMENT_STATUSES (else 409 - * `INVOICE_LOCKED`) - * - no Storno on the deal (else 409 `PLAN_HAS_STORNO`) - * - new plan validated by validateInstallmentPlanInput - * - * Algorithm: - * - Plan total = sum of existing siblings' totals (captures any - * per-sibling edits since spawn). - * - Reused siblings (i < min(old, new)): UPDATE in place — preserves - * id + invoice_number, so sequence numbers aren't burned. - * - Extra new rows (new > old): INSERT — claims a fresh invoice_number - * per row; clones canonical (non-reconciliation) line items from - * existing[0] so each new sibling carries the quote lines. - * - Trim rows (new < old): DELETE — claimed sequence numbers ARE lost - * (document_sequences has no release path, and that's intentional - * for §14 UStG continuity). - * - * Returns `{ invoiceIds, kept, created, deleted }`. - */ -async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) { - if (!dealUuid) throw new AppError('dealUuid is required', 400); - validateInstallmentPlanInput(installments); - - const existing = await trx('invoices') - .where({ deal_uuid: dealUuid }) - .orderBy('installment_index', 'asc'); - - if (existing.length === 0) { - throw new AppError('No invoices found for this deal', 404); - } - const isMultiInstallment = existing.some((r) => ensureInt(r.installment_total) > 1); - if (!isMultiInstallment) { - throw new AppError( - 'This deal is not an installment plan', - 400, - 'NOT_INSTALLMENT_PLAN', - ); - } - for (const row of existing) { - if (row.kind === 'storno') { - throw new AppError( - `Plan contains a Storno (${row.invoice_number}) — reshape refused`, - 409, - 'PLAN_HAS_STORNO', - ); - } - if (!EDITABLE_INSTALLMENT_STATUSES.has(row.status)) { - throw new AppError( - `Cannot reshape — invoice ${row.invoice_number} is '${row.status}'`, - 409, - 'INVOICE_LOCKED', - ); - } - } - - const totals = existing.reduce((acc, r) => ({ - net: acc.net + ensureInt(r.net_amount_minor), - vat: acc.vat + ensureInt(r.vat_amount_minor), - shipping: acc.shipping + ensureInt(r.shipping_amount_minor), - total: acc.total + ensureInt(r.total_amount_minor), - vatRate: ensureNumber(r.vat_rate, acc.vatRate), - }), { net: 0, vat: 0, shipping: 0, total: 0, vatRate: 0 }); - - const sample = existing[0]; // canonical event + customer + payment-term shape - - // netDays inferred from sample's issue → due gap so the new rows - // honour the same payment-term the customer agreed to. Falls back - // to 30 when either column is missing. - const inferredNetDays = sample.due_date && sample.issue_date - ? Math.round((new Date(sample.due_date) - new Date(sample.issue_date)) / (24 * 60 * 60 * 1000)) - : 30; - const netDays = Number.isFinite(inferredNetDays) && inferredNetDays > 0 ? inferredNetDays : 30; - - const eventDate = sample.event_date || null; - const customer = sample.customer_account_id - ? await trx('customer_accounts').where({ id: sample.customer_account_id }).first() - : null; - - // Cache canonical (non-reconciliation) line items from existing[0] - // for cloning into any newly-created siblings. - let canonicalLineItems = null; - const acceptanceTime = new Date(); - const newCount = installments.length; - const reusableCount = Math.min(existing.length, newCount); - - const kept = []; - const created = []; - const deleted = []; - - for (let i = 0; i < newCount; i++) { - const inst = installments[i]; - const slice = computeSliceTotals(installments, totals, i); - - let scheduledSendAt = computeScheduledSendAt( - inst.trigger, inst.offset_days, eventDate, acceptanceTime, - ); - if (customer && customer.billing_cadence && customer.billing_cadence !== 'per_event') { - scheduledSendAt = snapToNextBillingCycle( - scheduledSendAt, customer.billing_cadence, customer.billing_cycle_day, - ); - } - const isDeliveryTrigger = inst.trigger === 'after_delivery'; - const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; - const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; - const dueDate = computeDueDate(scheduledSendAt, netDays).toISOString().slice(0, 10); - const label = inst.label || `Installment ${i + 1}/${newCount}`; - - if (i < reusableCount) { - const existingRow = existing[i]; - await trx('invoices').where({ id: existingRow.id }).update({ - installment_index: i, - installment_total: newCount, - installment_label: label, - installment_trigger: inst.trigger, - status: rowStatus, - scheduled_send_at: rowScheduledSendAt, - issue_date: scheduledSendAt.toISOString().slice(0, 10), - due_date: dueDate, - net_amount_minor: slice.net, - vat_amount_minor: slice.vat, - shipping_amount_minor: slice.shipping, - total_amount_minor: slice.total, - updated_at: new Date(), - }); - await replaceReconciliationLine(trx, existingRow.id, { - label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net, - }); - kept.push(existingRow.id); - continue; - } - - // New sibling — clone canonical lines from existing[0] on first - // use, then reuse the cached copy for any further new siblings. - if (canonicalLineItems === null) { - const sourceLines = await trx('invoice_line_items') - .where({ invoice_id: existing[0].id }) - .orderBy('position', 'asc'); - canonicalLineItems = sourceLines.filter((li) => !isReconciliationLineItem(li)); - } - - const invoiceNumber = await nextInvoiceNumber(trx); - const row = { - invoice_number: invoiceNumber, - customer_account_id: sample.customer_account_id, - source_quote_id: sample.source_quote_id, - event_id: sample.event_id, - event_name: sample.event_name, - event_date: sample.event_date, - event_time_start: sample.event_time_start, - event_time_end: sample.event_time_end, - language: sample.language, - currency: sample.currency, - issue_date: scheduledSendAt.toISOString().slice(0, 10), - due_date: dueDate, - installment_index: i, - installment_total: newCount, - installment_label: label, - installment_trigger: inst.trigger, - status: rowStatus, - scheduled_send_at: rowScheduledSendAt, - net_amount_minor: slice.net, - vat_rate: ensureNumber(sample.vat_rate, 0), - vat_amount_minor: slice.vat, - shipping_amount_minor: slice.shipping, - total_amount_minor: slice.total, - cc_pdf_email: sample.cc_pdf_email || null, - payment_net_days_template_id: sample.payment_net_days_template_id || null, - payment_timing_template_id: sample.payment_timing_template_id || null, - payment_term_snapshot: sample.payment_term_snapshot || null, - deal_uuid: dealUuid, - created_by_admin_id: adminId, - created_at: new Date(), - updated_at: new Date(), - }; - const inserted = await trx('invoices').insert(row).returning('id'); - const newId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; - - if (canonicalLineItems.length > 0) { - const cloned = canonicalLineItems.map((li) => ({ - position: ensureInt(li.position), - quantity: li.quantity, - description: li.description, - unit_price_minor: ensureInt(li.unit_price_minor), - discount_percent: ensureNumber(li.discount_percent, 0), - line_total_minor: ensureInt(li.line_total_minor), - parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), - details_text: li.details_text || null, - })); - const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); - validateLineItemHierarchy(cloned); - await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', newId, cloned); - } - - await replaceReconciliationLine(trx, newId, { - label, percent: inst.percent, index: i, total: newCount, netSlice: slice.net, - }); - - try { - await logActivity('invoice_scheduled', { - invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape', - }, sample.event_id, `admin:${adminId}`); - } catch (_) {} - - created.push(newId); - } - - // Trim extras (only fires when newCount < existing.length). - for (let i = newCount; i < existing.length; i++) { - const oldRow = existing[i]; - await trx('invoice_line_items').where({ invoice_id: oldRow.id }).del(); - await trx('invoices').where({ id: oldRow.id }).del(); - deleted.push(oldRow.id); - } - - try { - await logActivity('installment_plan_updated', { - dealUuid, newCount, - kept: kept.length, created: created.length, deleted: deleted.length, - }, sample.event_id, `admin:${adminId}`); - } catch (_) {} - - return { - invoiceIds: [...kept, ...created], - kept, created, deleted, - }; -} - -async function buildInvoiceRenderContext(invoice, lineItems) { - const { profile } = await businessProfileService.getProfile(); - const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); - const bank = invoice.business_bank_account_id - ? await db('business_bank_accounts').where({ id: invoice.business_bank_account_id }).first() - : await businessProfileService.resolveBankAccountForCurrency(invoice.currency); - - // 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); - - // QR format resolution order (per-invoice override → profile - // default → none) gated by the global enable toggle. The earlier - // version had an operator-precedence bug that effectively dropped - // the profile default; this rewrites it as plain if/else for - // readability + correctness. - const qrGloballyEnabled = (await getAppSetting('crm_invoices_qr_enabled')) !== false; - let resolvedQrFormat = 'none'; - if (qrGloballyEnabled) { - resolvedQrFormat = invoice.qr_format || profile?.default_qr_format || 'none'; - } - - // Resolve the payment-term snapshot to thread Skonto + net-days into - // the PDF's "Zahlungsbedingungen" block. Three sources, in priority - // order: - // 1. The invoice's OWN snapshot (migration 113 — set when admin - // picks a template directly in the New Invoice form). - // 2. The originating quote's snapshot, if this invoice was - // created from one. - // 3. The global CRM defaults (settings tab) — `crm_invoices_*`. - // Both layers above are wrapped in `paymentTerm` exactly as - // quoteService builds it so pdfService.drawPaymentBlock renders - // the same block on both document types. - let paymentTerm = null; - - // Invoice-level snapshot wins when set. - if (invoice.payment_term_snapshot) { - const snapshot = typeof invoice.payment_term_snapshot === 'string' - ? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })() - : invoice.payment_term_snapshot; - if (snapshot) { - paymentTerm = { - description: snapshot.description, - netDays: snapshot.net_days, - skontoPercent: snapshot.skonto_percent, - skontoWithinDays: snapshot.skonto_within_days, - }; - } - } - - // Load the source quote once — used for the payment-term snapshot - // fallback AND for the "Bezug: Angebot Q-..." reference line on - // the invoice PDF. We deliberately keep invoice numbers on a - // strict monotonic sequence (tax compliance) and surface the link - // as a text reference rather than mirroring the number. - let sourceQuote = null; - if (invoice.source_quote_id) { - sourceQuote = await db('quotes').where({ id: invoice.source_quote_id }).first(); - if (!paymentTerm && sourceQuote?.payment_term_snapshot) { - const snapshot = typeof sourceQuote.payment_term_snapshot === 'string' - ? (() => { try { return JSON.parse(sourceQuote.payment_term_snapshot); } catch { return null; } })() - : sourceQuote.payment_term_snapshot; - if (snapshot) { - paymentTerm = { - description: snapshot.description, - netDays: snapshot.net_days, - skontoPercent: snapshot.skonto_percent, - skontoWithinDays: snapshot.skonto_within_days, - }; - } - } - } - // Globally-default Skonto values, always loaded. Used either to - // FILL a partial source-quote snapshot OR to seed the whole - // paymentTerm when there's no source quote. Both reads survive - // missing rows (returns null), unset values (NaN guarded), and - // string-encoded numbers from app_settings. - const defaultSkontoPercentRaw = await getAppSetting('crm_invoices_skonto_percent_default'); - const defaultSkontoDaysRaw = await getAppSetting('crm_invoices_skonto_business_days'); - const defaultSkontoPercent = Number.isFinite(Number(defaultSkontoPercentRaw)) && Number(defaultSkontoPercentRaw) > 0 - ? Number(defaultSkontoPercentRaw) : null; - const defaultSkontoDays = Number.isFinite(Number(defaultSkontoDaysRaw)) && Number(defaultSkontoDaysRaw) > 0 - ? parseInt(defaultSkontoDaysRaw, 10) : null; - - if (paymentTerm) { - // The source quote's snapshot may carry only some of the Skonto - // fields (e.g. when the template predates Skonto support); fill - // missing parts from the global defaults so the PDF still shows - // the row whenever there's enough info to render it. - if (paymentTerm.skontoPercent == null && defaultSkontoPercent != null) { - paymentTerm.skontoPercent = defaultSkontoPercent; - } - if (paymentTerm.skontoWithinDays == null && defaultSkontoDays != null) { - paymentTerm.skontoWithinDays = defaultSkontoDays; - } - } else { - // Ad-hoc invoice (no source quote). Build the paymentTerm from - // the global defaults. Renders only when BOTH percent + days are - // set + > 0 (pdfService.drawPaymentBlock guards on that). - paymentTerm = { - description: null, - netDays: 30, - skontoPercent: defaultSkontoPercent, - skontoWithinDays: defaultSkontoDays, - }; - } - - // Per-invoice Skonto opt-out (migration 126). The - // `resolveSkontoPercentForInvoice` helper above already respects - // this for payment-tracking surfaces, but the PDF render path was - // assembling `paymentTerm.skontoPercent/Days` from the snapshot or - // global defaults and ignoring the flag — so ticking "Disable - // Skonto" on the invoice cleared it from "Paid with Skonto" buttons - // but still printed the discount row on the PDF. Zero out both - // fields here so pdfService.drawPaymentBlock's - // `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays` - // guard suppresses the row. The per-customer opt-out (migration 112) - // is honoured here too — a customer flagged skonto_disabled never - // prints the discount row, mirroring resolveSkontoPercentForInvoice. - if (invoice.skonto_disabled || customer?.skonto_disabled) { - paymentTerm.skontoPercent = null; - paymentTerm.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 */ } - - // Sub-cent reconciliation (crm_invoice_round_total). "Betrag Netto" - // shows the sum of the visible line totals so it foots with the items; - // the stored net may be the clean (rounded-once) value, and the gap is - // shown as a "Rundung" row. Legacy/unrounded invoices have equal - // values ⇒ adjustment 0, no row. Suppressed on Storno/Mahnung: those - // negate the stored net and flip line-total signs at render, so the - // forward "storedNet − Σ lines" derivation doesn't apply. - const isReversalDoc = invoice.kind === 'storno' || invoice.kind === 'mahnung'; - const displayedNetMinor = isReversalDoc - ? ensureInt(invoice.net_amount_minor) - : lineItems.reduce( - (s, li) => (li.parent_line_item_id == null && (li.parent_position == null || li.parent_position === '') - ? s + ensureInt(li.line_total_minor) : s), - 0, - ); - const roundingAdjustmentMinor = isReversalDoc - ? 0 - : ensureInt(invoice.net_amount_minor) - displayedNetMinor; - - return { - locale: invoice.language || profile?.default_locale || 'de', - currency: invoice.currency, - qrFormat: resolvedQrFormat, - dateFormat, - // Shared issuer + recipient builders. Invoices skip the quote-only - // payment-block toggles; the invoice PDF always shows the payment - // block. See backend/src/services/_renderContext.js. - issuer: buildIssuerBlock(profile, resolvedLogoPath), - recipient: buildRecipientBlock(profile, customer), - bank: bank ? { - accountHolder: bank.account_holder || profile?.company_name, - iban: bank.iban, bic: bank.bic, currency: bank.currency, - } : null, - paymentTerm, - 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 + notes flow through to PDF. - parentLineItemId: li.parent_line_item_id || null, - parentPosition: li.parent_position == null ? null : Number(li.parent_position), - detailsText: li.details_text || null, - })), - totals: { - netAmountMinor: displayedNetMinor, - roundingAdjustmentMinor, - vatRate: invoice.vat_rate, - // Migration 130 — VAT-code snapshot (so re-editing preserves it). - vatCode: invoice.vat_code ?? null, - vatAmountMinor: invoice.vat_amount_minor, - shippingAmountMinor: invoice.shipping_amount_minor, - totalAmountMinor: invoice.total_amount_minor, - // The Mahngebühr is shown on the separate Mahnung document, NEVER on - // the (immutable) invoice — so the invoice render always reports 0. The - // Mahnung render path (applyReminder) overrides this with the tracked fee. - lateFeeAmountMinor: 0, - }, - doc: { - // Document type discriminator. `'invoice'` (default) renders - // the standard invoice layout. `'storno'` switches the title - // to "Stornorechnung", forces the mandatory "Storno zu …" - // reference line, displays signed totals, and suppresses the - // payment terms / IBAN / QR-bill sections (cancellation - // documents aren't payment instruments). - kind: invoice.kind || 'invoice', - invoiceNumber: invoice.invoice_number, - issueDate: invoice.issue_date, - dueDate: invoice.due_date, - totalAmountMinor: invoice.total_amount_minor, - lateFeeMinor: 0, - // Reminder level — drives Skonto suppression on second - // reminders (no early-payment discount once the customer - // is in dunning). - reminderLevel: invoice.reminder_level || 0, - // PDF renderer draws "Bezug: Angebot Q-..." under the title - // when set. Empty/null suppresses the line (standalone invoice). - sourceQuoteNumber: sourceQuote?.quote_number || null, - // When this invoice replaces a previously-cancelled one - // (migration 114, reissue workflow), the renderer stamps a - // second reference line: "Bezug: Ersetzt Rechnung R-XXXX vom - // DATE". - replacesInvoice: await (async () => { - if (!invoice.replaces_invoice_id) return null; - const prior = await db('invoices') - .where({ id: invoice.replaces_invoice_id }) - .select('invoice_number', 'issue_date').first(); - return prior - ? { number: prior.invoice_number, issueDate: prior.issue_date } - : null; - })(), - // Storno reference — populated only on `kind='storno'` rows. - // The renderer turns it into the mandatory "Storno zu Rechnung - // R-XXXX vom DATE" line under the title. Drives §14c-defensible - // traceability: the customer sees explicitly what was reversed. - cancelsInvoice: await (async () => { - if (!invoice.cancels_invoice_id) return null; - const prior = await db('invoices') - .where({ id: invoice.cancels_invoice_id }) - .select('invoice_number', 'issue_date').first(); - return prior - ? { number: prior.invoice_number, issueDate: prior.issue_date } - : null; - })(), - }, - }; -} - -async function renderInvoicePdfBuffer(invoiceId) { - const data = await getInvoiceById(invoiceId); - if (!data) throw new AppError('Invoice not found', 404); - // Imported (historical) invoices store the original PDF on disk - // — short-circuit the renderer and stream the file untouched so - // legal documents stay byte-identical to the source. Path is - // stored relative to STORAGE_PATH but we accept absolute too. - if (data.invoice.imported_pdf_path) { - const fs = require('fs'); - const path = require('path'); - const { getStoragePath } = require('../config/storage'); - const raw = String(data.invoice.imported_pdf_path).trim(); - const candidates = [ - path.isAbsolute(raw) ? raw : null, - path.join(getStoragePath(), raw.replace(/^\/+/, '')), - ].filter(Boolean); - const found = candidates.find((p) => { - try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } - }); - if (!found) { - throw new AppError('Imported invoice PDF is missing on disk', 410); - } - return fs.readFileSync(found); - } - const ctx = await buildInvoiceRenderContext(data.invoice, data.lineItems); - return await pdfService.renderInvoiceToBuffer(ctx); -} - -async function renderInvoicePdfFromPayload(payload) { - const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first(); - const lineItems = Array.isArray(payload.lineItems) ? payload.lineItems : []; - // Migration 119 — preview must match the saved-invoice math: - // - Compute every row's raw line_total_minor (qty × unit × discount). - // - Then resolveParentTotalsFromSubItems rewrites each parent's - // line_total to the sum of its priced sub-items (parent's own - // unit_price is ignored when any sub-item has a price). - // - Net sums TOP-LEVEL items only (parent_position == null). - // Without these two steps, the preview shows the parent at 0 and - // double-counts sub-items into net, neither of which matches the - // values the renderer would produce for the persisted invoice. - const items = lineItems.map((li, idx) => { - const qty = ensureNumber(li.quantity, 1); - const unit = ensureInt(li.unit_price_minor); - const discount = ensureNumber(li.discount_percent, 0); - const lineTotal = Math.round(Math.round(qty * unit) * (1 - discount / 100)); - return { ...li, position: li.position || idx + 1, line_total_minor: lineTotal }; - }); - const { resolveParentTotalsFromSubItems } = getHierarchyHelpers(); - resolveParentTotalsFromSubItems(items); - let netMinor = 0; - for (const it of items) { - if (it.parent_position == null || it.parent_position === '') { - netMinor += ensureInt(it.line_total_minor); - } - } - // Match the saved-invoice math: clean-net reconciliation when the - // crm_invoice_round_total setting is on (see createInvoice). - const roundTotal = (await getAppSetting('crm_invoice_round_total', false)) === true; - if (roundTotal) { - netMinor = cleanNetMinor(items, { parentKey: 'parent_position', positionKey: 'position' }); - } - const vatRate = ensureNumber(payload.vatRate, 0); - const vatMinor = Math.round(netMinor * vatRate / 100); - const shippingMinor = ensureInt(payload.shippingAmountMinor); - const totalMinor = netMinor + vatMinor + shippingMinor; - const fakeInvoice = { - invoice_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), - due_date: payload.dueDate || new Date(Date.now() + 30 * 86400e3).toISOString().slice(0, 10), - business_bank_account_id: payload.businessBankAccountId, - qr_format: payload.qrFormat, - net_amount_minor: netMinor, - vat_rate: vatRate, - vat_amount_minor: vatMinor, - shipping_amount_minor: shippingMinor, - total_amount_minor: totalMinor, - }; - const ctx = await buildInvoiceRenderContext(fakeInvoice, items); - return await pdfService.renderInvoiceToBuffer(ctx); -} - -/** - * Send an invoice email + PDF. Flips status scheduled → sent. - */ -async function sendInvoice(id, adminId) { - const data = await getInvoiceById(id); - if (!data) throw new AppError('Invoice not found', 404); - const { invoice, lineItems } = data; - // Stornorechnungen go through their own send path — different - // email template, different variables, different PDF render - // branch. The scheduler's flush loop hits this entry point for - // every row in status='scheduled', so the dispatch lives here. - if (invoice.kind === 'storno') { - return await sendStorno(id, adminId); - } - if (!['scheduled', 'sent', 'overdue'].includes(invoice.status)) { - throw new AppError(`Cannot send invoice with status '${invoice.status}'`, 409); - } - // Monthly-draft guard (migration 128). Rows flagged - // is_monthly_draft=true accumulate line items across the period - // and must ONLY be issued via triggerMonthlyBillNow / the scheduled - // monthly flush — both clear the flag before re-entering this - // function. Without this guard, admin clicks on a draft's Send - // button would ship the running accumulator early AND leave the - // flag set, so subsequent createInvoice calls would silently - // append onto the same already-sent row. - if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) { - throw new AppError( - 'This invoice is a monthly draft — use "Trigger invoice now" on the customer detail page, or wait for the scheduled cycle day.', - 409, 'MONTHLY_DRAFT_NOT_SENDABLE', - ); - } - const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); - ensureCustomerCanBill(customer); - - // Re-sync the invoice's language from the customer's current - // preferred_language at send time when the invoice has never been - // sent. Picks up admin language changes made between create and - // send (notable for monthly drafts that accumulate for ~30 days, - // and for any standalone scheduled invoice where admin updated the - // customer record after authoring). Sent / overdue invoices keep - // their existing language because they're legal records — the - // rendered PDF is the source of truth from the moment it ships. - if (invoice.status === 'scheduled' && customer.preferred_language - && customer.preferred_language !== invoice.language) { - await db('invoices').where({ id }).update({ - language: customer.preferred_language, - updated_at: new Date(), - }); - invoice.language = customer.preferred_language; - } - - // Stamp the issue date at the moment the invoice actually goes out. - // A scheduled invoice's issue_date is provisional — set to the - // authoring day at creation — but the legal issue date is when it - // ships. Anchoring it here keeps the printed invoice date, the Skonto - // window (a relative "pay within N working days" counted from that - // date) and the net-days due date all consistent with the send date. - // Only on the first send (status 'scheduled'); 'sent' / 'overdue' - // rows are immutable legal records and keep their stamped date. - if (invoice.status === 'scheduled') { - const sendDateIso = new Date().toISOString().slice(0, 10); - const netDays = await resolveNetDaysForRow(invoice); - // Re-anchor the due date too, but only when it was machine-set: if - // the stored due_date still equals the auto formula off the OLD - // base (scheduled_send_at, else the old issue_date), the admin never - // hand-edited it and we slide it to the new issue date. A divergent - // value means a manual override (the editor's "Override due date" - // toggle) — leave it untouched. - const oldBase = invoice.scheduled_send_at - ? new Date(invoice.scheduled_send_at) - : new Date(invoice.issue_date); - const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10); - const storedDue = invoice.due_date - ? new Date(invoice.due_date).toISOString().slice(0, 10) - : null; - const updates = { issue_date: sendDateIso, updated_at: new Date() }; - if (storedDue && storedDue === oldAutoDue) { - updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10); - } - await db('invoices').where({ id }).update(updates); - invoice.issue_date = updates.issue_date; - if (updates.due_date) invoice.due_date = updates.due_date; - } - - const ctx = await buildInvoiceRenderContext(invoice, lineItems); - const buffer = await pdfService.renderInvoiceToBuffer(ctx); - - // Persist PDF snapshot. - const fs = require('fs'); - const path = require('path'); - const year = new Date(invoice.issue_date).getFullYear(); - const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); - fs.mkdirSync(root, { recursive: true }); - const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`); - fs.writeFileSync(pdfPath, buffer); - - const newStatus = invoice.status === 'overdue' ? 'overdue' : 'sent'; - await db('invoices').where({ id }).update({ - status: newStatus, sent_at: new Date(), pdf_path: pdfPath, updated_at: new Date(), - }); - - const { to: invoiceTo, cc: invoiceCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email); - await emailProcessor.queueEmail(invoice.event_id || null, invoiceTo, 'invoice_sent', { - invoice_number: invoice.invoice_number, - customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], - event_name: invoice.event_name || '', - total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale), - due_date: formatShortDate(invoice.due_date), - installment_label: invoice.installment_label || '', - installment_index: invoice.installment_index + 1, - installment_total: invoice.installment_total, - cc: invoiceCc, - attachments: [{ - filename: `${invoice.invoice_number}.pdf`, - contentPath: pdfPath, - contentType: 'application/pdf', - }], - }); - - try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} - - // Fire the workflow engine's invoice.sent trigger (after the row is updated + - // the email queued). Idempotent per invoice id; no-op when the workflows flag - // is off. Never throws into the send path. - try { - await require('./workflows').emitWorkflowEvent('invoice.sent', { - entityType: 'invoice', - entityId: id, - payload: { - invoiceId: id, - invoiceNumber: invoice.invoice_number, - eventId: invoice.event_id || null, - customerAccountId: invoice.customer_account_id, - customerEmail: invoiceTo, - dueDate: invoice.due_date, - issueDate: invoice.issue_date, - totalMinor: invoice.total_amount_minor, - currency: invoice.currency, - }, - }); - } catch (_) {} - - return { sent: true, pdfPath }; -} - -/** - * Record a payment against an invoice. Supports partial payments - * (multiple rows accumulate into `paid_amount_minor`). Status flips - * to `paid` once the running total meets or exceeds total_amount_minor. - */ -async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, notes, skontoApplied }, adminId) { - const invoice = await db('invoices').where({ id }).first(); - if (!invoice) throw new AppError('Invoice not found', 404); - if (invoice.status === 'cancelled') { - throw new AppError('Cannot mark a cancelled invoice as paid', 409); - } - const amount = ensureInt(amountMinor); - if (amount <= 0) { - throw new AppError('amount must be > 0', 400); - } - // Skonto bookkeeping (migration 126). When the admin ticks "Paid - // with Skonto" we store both the flag AND the absolute discount - // in minor units. Computing the discount here (instead of in the - // renderer at report time) means the value is frozen against - // later template/percentage edits — the tax-report row stays - // accurate for years. - const skontoFlag = Boolean(skontoApplied); - const skontoAmountMinor = skontoFlag - ? Math.max(0, ensureInt(invoice.total_amount_minor) - amount) - : null; - - const markResult = await db.transaction(async (trx) => { - await trx('invoice_payment_log').insert({ - invoice_id: id, - amount_minor: amount, - paid_at: paidAt ? new Date(paidAt) : new Date(), - payment_method: paymentMethod || null, - reference: reference || null, - notes: notes || null, - recorded_by_admin_id: adminId, - skonto_applied: skontoFlag, - skonto_amount_minor: skontoAmountMinor, - created_at: new Date(), - }); - const sumRow = await trx('invoice_payment_log').where({ invoice_id: id }).sum('amount_minor as total').first(); - const total = ensureInt(sumRow?.total || 0); - // Consider the invoice paid when the recorded payments cover the - // invoice total. The late fee is NOT added to the threshold here - // — admins frequently waive it once the customer actually pays - // (and chasing the extra 25 CHF after a 1500 CHF invoice clears - // makes nobody happy). Admin can record a separate payment_log - // row if they did collect the fee; status flips to paid the - // moment the principal is covered. - // - // Skonto path (migration 126): when the admin flagged this - // payment as Skonto-applied, the discounted amount equals the - // expected payment — flip to 'paid' even though paid_amount_minor - // is strictly less than total_amount_minor. Without this branch - // the invoice would sit in 'sent' or 'overdue' forever despite - // being legitimately settled. - const skontoEffectiveTotal = skontoFlag - ? ensureInt(invoice.total_amount_minor) - (skontoAmountMinor || 0) - : ensureInt(invoice.total_amount_minor); - const isFull = total >= skontoEffectiveTotal; - - const update = { - paid_amount_minor: total, - payment_method: paymentMethod || invoice.payment_method, - payment_reference: reference || invoice.payment_reference, - updated_at: new Date(), - }; - if (isFull) { - update.status = 'paid'; - update.paid_at = paidAt ? new Date(paidAt) : new Date(); - } - await trx('invoices').where({ id }).update(update); - - try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment', - { invoiceId: id, amountMinor: amount, totalPaidMinor: total }, - invoice.event_id || null, `admin:${adminId}`); } catch (_) {} - - // Migration 127 — admin payment-received notification. Fires only - // on the transition into 'paid' so admins don't get duplicate - // emails when additional payment-log rows are recorded after the - // invoice already cleared (rare but possible — e.g. late-fee - // top-up). Queued after the transaction so a failed email never - // rolls back a recorded payment. Carried Skonto context lets the - // template show the discount line conditionally. - if (isFull && invoice.status !== 'paid') { - try { - await queueInvoicePaidAdminNotification({ - invoice, - paidTotalMinor: total, - paymentMethod: paymentMethod || invoice.payment_method || null, - paymentReference: reference || invoice.payment_reference || null, - paidAt: paidAt ? new Date(paidAt) : new Date(), - skontoApplied: skontoFlag, - skontoAmountMinor: skontoAmountMinor || 0, - }); - } catch (err) { - // Notification is best-effort — don't surface a 500 to the - // admin when the recorded payment itself succeeded. - logger.warn('invoice_paid admin notification failed to queue', { invoiceId: id, err: err.message }); - } - } - - return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status }; - }); - - // Fire invoice.paid for the workflow engine ONLY on the transition into - // 'paid' (mirrors the admin-notification guard above). After the commit so a - // workflow side effect can never roll back the recorded payment. - if (markResult.status === 'paid' && invoice.status !== 'paid') { - try { - await require('./workflows').emitWorkflowEvent('invoice.paid', { - entityType: 'invoice', - entityId: id, - payload: { - invoiceId: id, - invoiceNumber: invoice.invoice_number, - eventId: invoice.event_id || null, - customerAccountId: invoice.customer_account_id, - paidTotalMinor: markResult.paidTotalMinor, - }, - }); - } catch (_) {} - } - return markResult; -} - -/** - * Materialise a Stornorechnung (cancellation invoice) for an already- - * issued original. Atomic: - * 1. Insert a new `invoices` row with `kind='storno'`, totals - * negated, no due_date / payment terms / bank account / QR, - * and `cancels_invoice_id` pointing at the original. - * 2. Snapshot the original's line items at full positive amounts - * (the sign is carried by the row-level totals; the renderer - * flips line totals visually for `kind='storno'`). Preserves - * the migration-119 sub-item hierarchy via parent_position → - * parent_line_item_id resolution in `insertLineItemsHierarchical`. - * 3. Flip the original to `status='cancelled'` and pin its - * `cancellation_storno_id` so the admin detail view can render - * a "Cancelled by Storno S-XXXX" banner. - * - * Returns the Storno's id. The caller is responsible for actually - * sending it (sendStorno) — splitting the create/send seam means - * a failed PDF render or email queue doesn't roll back the - * cancellation itself; the storno sits in `status='scheduled'` - * and the cron picks it up. - */ -async function createStorno(originalId, adminId, trx = db) { - const original = await trx('invoices').where({ id: originalId }).first(); - if (!original) throw new AppError('Invoice not found', 404); - if (original.kind === 'storno') { - throw new AppError('Cannot Storno a Storno', 409, 'IS_STORNO'); - } - if (original.status === 'scheduled') { - throw new AppError( - 'This invoice has not been sent yet — Storno only applies to issued documents.', - 409, - 'USE_EDIT_INSTEAD', - ); - } - if (original.status === 'cancelled') { - throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED'); - } - - // Generate the Storno's sequence number from the same gap-free - // series as regular invoices (single sequence — decision locked - // with the maintainer; satisfies §14 (4) Nr. 4 UStG). - // Pass trx so the sequence claim joins the caller's transaction — - // SQLite deadlocks otherwise (1-connection default). - const stornoNumber = await nextInvoiceNumber(trx); - const now = new Date(); - const issueDate = now.toISOString().slice(0, 10); - - // Insert the Storno row. Totals negated for accounting integrity - // (tax report aggregates by row-level totals, so a Storno - // contributes correctly without the renderer needing to flip - // signs at report time). Line items below stay positive — the - // renderer applies the sign at presentation time. - const insertedRow = await trx('invoices').insert({ - kind: 'storno', - invoice_number: stornoNumber, - customer_account_id: original.customer_account_id, - event_id: original.event_id, - // Inline event snapshot — copy so the Storno carries the same - // event label as the invoice it reverses (migration 123). The - // bookkeeper expects to see both documents under the same event. - event_name: original.event_name || null, - event_date: original.event_date || null, - event_time_start: original.event_time_start || null, - event_time_end: original.event_time_end || null, - source_quote_id: null, - // Migration 124 — carry the split FKs through onto the Storno row - // so the lineage stays consistent if anyone audits the - // cancellation document and checks the picker state. - payment_net_days_template_id: original.payment_net_days_template_id || null, - payment_timing_template_id: original.payment_timing_template_id || null, - currency: original.currency, - language: original.language, - vat_rate: original.vat_rate, - // Migration 130 — carry the original's VAT-code snapshot onto the Storno so - // both documents export the same code. Conditional spread = safe on pre-130 - // DBs (undefined → omitted). - ...(original.vat_code ? { vat_code: original.vat_code } : {}), - shipping_amount_minor: -ensureInt(original.shipping_amount_minor || 0), - net_amount_minor: -ensureInt(original.net_amount_minor), - vat_amount_minor: -ensureInt(original.vat_amount_minor), - total_amount_minor: -ensureInt(original.total_amount_minor), - late_fee_amount_minor: 0, - paid_amount_minor: 0, - status: 'scheduled', - scheduled_send_at: now, - issue_date: issueDate, - // Storni have no payment due — mirror issue_date to satisfy the - // schema's NOT NULL constraint on due_date. The field is dead data - // for kind='storno' rows: the PDF renderer suppresses the due-date - // line, and the dunning scheduler filters kind='invoice'. - due_date: issueDate, - reminder_level: 0, - cc_pdf_email: original.cc_pdf_email, - // No payment block on a Storno — it's not a payment instrument. - business_bank_account_id: null, - qr_format: null, - payment_term_template_id: null, - // Lineage. - cancels_invoice_id: original.id, - replaces_invoice_id: null, - cancellation_storno_id: null, - // Migration 140 — Storno belongs to the same deal as the invoice - // it cancels; both render together in the lineage view. - deal_uuid: original.deal_uuid || crypto.randomUUID(), - created_at: now, - updated_at: now, - }).returning('id'); - const stornoId = Array.isArray(insertedRow) - ? (insertedRow[0]?.id ?? insertedRow[0]) - : insertedRow; - - // Snapshot the original's line items (positive amounts — the - // Storno's sign convention lives on the row-level totals + the - // renderer flip). - const lineItems = await trx('invoice_line_items as li') - .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') - .where('li.invoice_id', originalId) - .orderBy('li.position', 'asc') - .select('li.*', 'parent.position as parent_position'); - if (lineItems.length > 0) { - const cloned = lineItems.map((li) => ({ - position: ensureInt(li.position), - quantity: li.quantity, - description: li.description, - unit_price_minor: ensureInt(li.unit_price_minor), - discount_percent: ensureNumber(li.discount_percent, 0), - line_total_minor: ensureInt(li.line_total_minor), - parent_position: li.parent_position == null ? null : ensureInt(li.parent_position), - details_text: li.details_text || null, - })); - const { validateLineItemHierarchy, insertLineItemsHierarchical } = getHierarchyHelpers(); - validateLineItemHierarchy(cloned); - await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', stornoId, cloned); - } - - // Flip the original to cancelled + link the Storno. - await trx('invoices').where({ id: originalId }).update({ - status: 'cancelled', - cancellation_storno_id: stornoId, - updated_at: now, - }); - - try { - await logActivity('invoice_cancelled_via_storno', - { invoiceId: originalId, stornoId, stornoNumber }, - original.event_id || null, `admin:${adminId}`); - } catch (_) {} - - return stornoId; -} - -/** - * Send a Stornorechnung — renders the PDF, persists it on disk, - * flips the row to `status='sent'`, and queues the `storno_issued` - * email to the customer with the PDF attached. - * - * Mirrors sendInvoice's shape so the scheduler's flush loop can - * delegate uniformly. The email template ships in Phase 3 - * (renames the dormant `invoice_cancelled` seed); if the worker - * picks up the job before the template lands it logs the missing - * template — the row stays in `sent` either way. - */ -async function sendStorno(stornoId, adminId) { - const data = await getInvoiceById(stornoId); - if (!data) throw new AppError('Storno not found', 404); - const { invoice: storno, lineItems } = data; - if (storno.kind !== 'storno') { - throw new AppError(`Expected kind='storno', got '${storno.kind}'`, 409); - } - if (storno.status === 'sent') return { status: 'sent' }; - - const customer = await db('customer_accounts').where({ id: storno.customer_account_id }).first(); - ensureCustomerCanBill(customer); - - const ctx = await buildInvoiceRenderContext(storno, lineItems); - const buffer = await pdfService.renderInvoiceToBuffer(ctx); - - // Persist PDF snapshot alongside regular invoices. - const fs = require('fs'); - const path = require('path'); - const year = new Date(storno.issue_date).getFullYear(); - const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); - fs.mkdirSync(root, { recursive: true }); - const pdfPath = path.join(root, `${storno.invoice_number}.pdf`); - fs.writeFileSync(pdfPath, buffer); - - await db('invoices').where({ id: stornoId }).update({ - status: 'sent', - sent_at: new Date(), - pdf_path: pdfPath, - updated_at: new Date(), - }); - - // Look up the original so we can include both numbers in the - // email body — customers' bookkeepers expect to see the pair. - const originalRow = storno.cancels_invoice_id - ? await db('invoices').where({ id: storno.cancels_invoice_id }) - .select('invoice_number', 'issue_date').first() - : null; - - const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email); - await emailProcessor.queueEmail(storno.event_id || null, stornoTo, 'storno_issued', { - storno_number: storno.invoice_number, - original_invoice_number: originalRow?.invoice_number || '', - original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '', - customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], - total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale), - cc: stornoCc, - attachments: [{ - filename: `${storno.invoice_number}.pdf`, - contentPath: pdfPath, - contentType: 'application/pdf', - }], - }); - - try { - await logActivity('storno_sent', - { stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null }, - storno.event_id || null, `admin:${adminId || 'system'}`); - } catch (_) {} - - return { status: 'sent', stornoId }; -} - -/** - * Reissue an invoice — the legally-correct alternative to post-send - * editing. - * 1. If the original is still live (sent / overdue / paid), - * generate a Stornorechnung for it via `createStorno` and - * immediately send it to the customer (sendStorno). The - * original flips to `status='cancelled'` and its - * `cancellation_storno_id` is pinned. - * 2. Create a fresh `scheduled` invoice with a new sequence - * number, line items snapshotted from the original, and - * `replaces_invoice_id` pointing at the original so the - * renderer can stamp "Bezug: Ersetzt Rechnung R-XXXX". - * - * If the original is ALREADY cancelled (admin previously cancelled - * it via Storno on its own), the cancel step is skipped — only the - * replacement is created. `scheduled` originals are rejected - * (USE_EDIT_INSTEAD) since drafts don't need legal cancellation. - */ -async function reissueInvoice(id, adminId) { - const original = await db('invoices').where({ id }).first(); - if (!original) throw new AppError('Invoice not found', 404); - if (original.kind === 'storno') { - throw new AppError('Cannot reissue a Storno document', 409, 'IS_STORNO'); - } - if (original.status === 'scheduled') { - throw new AppError( - 'This invoice has not been sent yet — use Edit instead of Cancel & reissue.', - 409, - 'USE_EDIT_INSTEAD', - ); - } - - // Cancel via Storno first if still live. We deliberately commit - // the Storno BEFORE creating the replacement so a failed sendStorno - // doesn't roll back the cancellation; the storno sits in - // status='scheduled' and the cron picks it up. Same resiliency - // contract as cancelInvoice. - let stornoId = null; - if (original.status !== 'cancelled') { - stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx)); - try { await sendStorno(stornoId, adminId); } catch (err) { - logger.warn('sendStorno during reissue failed — scheduler will retry', { stornoId, err: err.message }); - } - } - - // Build the replacement. Same shape as the original — re-uses - // createInvoice so totals are recomputed authoritatively from - // line items (any rounding drift gets normalised). Self-join - // carries parent_position so migration-119 sub-items survive. - return await db.transaction(async (trx) => { - const lineItems = await trx('invoice_line_items as li') - .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') - .where('li.invoice_id', id) - .orderBy('li.position', 'asc') - .select('li.*', 'parent.position as parent_position'); - const liPayload = lineItems.map((li) => ({ - position: li.position, - quantity: Number(li.quantity), - description: li.description, - unit_price_minor: Number(li.unit_price_minor), - discount_percent: Number(li.discount_percent || 0), - parent_position: li.parent_position == null ? null : Number(li.parent_position), - details_text: li.details_text || null, - })); - - const { invoiceIds: reissuedIds } = await createInvoice({ - customerAccountId: original.customer_account_id, - sourceQuoteId: original.source_quote_id || null, - eventId: original.event_id || null, - language: original.language, - currency: original.currency, - vatRate: original.vat_rate, - shippingAmountMinor: original.shipping_amount_minor, - ccPdfEmail: original.cc_pdf_email, - businessBankAccountId: original.business_bank_account_id, - qrFormat: original.qr_format, - paymentTermTemplateId: original.payment_term_template_id, - // Reissue always produces a standalone invoice even when the - // customer is on monthly billing — folding the reissued items - // into the current period's running draft would conflate two - // unrelated billing periods. The escape hatch keeps the - // standard createInvoice flow. - _skipMonthlyRouting: true, - // Carry the split picker (migration 124) + event snapshot - // (migration 123) onto the reissued draft so the admin doesn't - // have to re-set them after a Cancel & reissue. createInvoice - // already accepts these on both code paths. - paymentNetDaysTemplateId: original.payment_net_days_template_id || null, - paymentTimingTemplateId: original.payment_timing_template_id || null, - eventName: original.event_name || null, - eventDate: original.event_date || null, - eventTimeStart: original.event_time_start || null, - eventTimeEnd: original.event_time_end || null, - // No installment metadata — reissue defaults to a single - // standalone invoice. If the admin needs the same split they - // can run the original conversion again from the quote. - lineItems: liPayload, - // Migration 140 — reissue inherits the cancelled original's - // deal_uuid so Storno + replacement + cancelled all group - // under one deal lineage view. - dealUuid: original.deal_uuid || null, - }, adminId, trx); - // Reissue always produces a single invoice (no installments - // forced), so the array length is 1. - const newId = reissuedIds[0]; - - await trx('invoices').where({ id: newId }).update({ - replaces_invoice_id: id, - updated_at: new Date(), - }); - - try { - await logActivity('invoice_reissued', - { originalInvoiceId: id, newInvoiceId: newId, stornoId }, - original.event_id || null, `admin:${adminId}`); - } catch (_) {} - - return { id: newId, replaces: id, stornoId }; - }); -} - -/** - * Release a `pending_delivery` invoice for sending. Used when the - * photographer has actually delivered the photos and is ready to - * collect the final installment — flips the status to `scheduled` - * with `scheduled_send_at = now`, then immediately calls sendInvoice - * so the email goes out without waiting for the next scheduler tick. - * - * Refuses to act on rows that aren't pending — admins should use - * sendInvoice / sendReminder for the normal `scheduled`/`sent` flow. - */ -async function releaseForDelivery(id, adminId) { - const invoice = await db('invoices').where({ id }).first(); - if (!invoice) throw new AppError('Invoice not found', 404); - if (invoice.status !== 'pending_delivery') { - throw new AppError( - `Invoice is not awaiting delivery (status: '${invoice.status}')`, - 409, - 'NOT_PENDING_DELIVERY', - ); - } - const now = new Date(); - await db('invoices').where({ id }).update({ - status: 'scheduled', - scheduled_send_at: now, - updated_at: now, - }); - try { - await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); - } catch (_) {} - // Fire immediately rather than waiting for the next scheduler - // tick — admin clicked the button because they want it out now. - return await sendInvoice(id, adminId); -} - -/** - * Cancel an invoice. The behaviour depends on whether the document - * was ever issued: - * - * - `scheduled` (draft, no PDF emitted): soft cancel — status - * flips to 'cancelled', nothing leaves the system. No Storno is - * generated because no document exists for the customer to - * reverse. - * - * - `sent` / `overdue` / `paid` (issued): generate a - * Stornorechnung (cancellation invoice) with its own sequence - * number, attach a signed PDF, and email it to the customer. - * Original flips to 'cancelled' and pins its - * `cancellation_storno_id` for the admin lineage view. This is - * the only §14c-defensible cancellation path under DACH tax law - * once an invoice has been delivered to the recipient. - * - * Note we allow `paid` here on purpose — bookkeepers cancel - * paid invoices when issuing refunds. The actual money - * movement (refund, carry-forward as Anzahlung) is handled - * separately; the Storno is the document leg. - * - * - `cancelled` (already): 409, `ALREADY_CANCELLED`. - * - * Returns `{ cancelled: true, stornoId? }` so the caller can - * surface "Storno S-XXXX wurde erzeugt" feedback when applicable. - */ -async function cancelInvoice(id, adminId) { - const invoice = await db('invoices').where({ id }).first(); - if (!invoice) throw new AppError('Invoice not found', 404); - if (invoice.kind === 'storno') { - throw new AppError('Cannot cancel a Storno document', 409, 'IS_STORNO'); - } - if (invoice.status === 'cancelled') { - throw new AppError('Invoice already cancelled', 409, 'ALREADY_CANCELLED'); - } - - // Draft path: nothing was issued, soft cancel and we're done. - if (invoice.status === 'scheduled') { - await db('invoices').where({ id }).update({ - status: 'cancelled', updated_at: new Date(), - }); - try { - await logActivity('invoice_cancelled', - { invoiceId: id, viaStorno: false }, - invoice.event_id || null, `admin:${adminId}`); - } catch (_) {} - return { cancelled: true, stornoId: null }; - } - - // Issued path: Storno required. Commit createStorno in its own - // transaction so a failed sendStorno doesn't roll back the - // cancellation; the scheduler picks up an unsent Storno on the - // next tick. - const stornoId = await db.transaction(async (trx) => createStorno(id, adminId, trx)); - try { await sendStorno(stornoId, adminId); } catch (err) { - logger.warn('sendStorno after cancelInvoice failed — scheduler will retry', { stornoId, err: err.message }); - } - return { cancelled: true, stornoId }; -} - -/** - * Manually trigger a reminder email. The scheduler does this - * automatically; this is the "Send reminder now" button on the - * invoice detail page. - */ -async function sendReminder(id, levelOverride, adminId) { - const data = await getInvoiceById(id); - if (!data) throw new AppError('Invoice not found', 404); - const { invoice, lineItems } = data; - if (invoice.status !== 'sent' && invoice.status !== 'overdue') { - throw new AppError(`Cannot remind on status '${invoice.status}'`, 409); - } - const newLevel = levelOverride || (invoice.reminder_level + 1); - if (newLevel > 3) { - throw new AppError('Reminder level exhausted', 409); - } - return await applyReminder(invoice, lineItems, newLevel, adminId); -} - -// Per-reminder Mahngebühr in minor units (0 when disabled). Flat amount OR a -// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from -// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete -// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so). -// Net per-reminder Mahngebühr (flat amount or % of invoice gross), 0 disabled. -async function resolveLateFeeNetMinor(invoice) { - if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0; - const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat'; - let fee; - if (type === 'percent') { - const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0; - fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100); - } else { - fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; - } - return Math.max(0, fee); -} - -// VAT rate on the fee — jurisdiction-dependent (CH: yes; DE/AT: no), so -// toggle-gated AND org-VAT-gated: 0 when the org has no default VAT rate, so -// enabling the toggle on a non-VAT org adds nothing. -async function resolveLateFeeVatRate() { - if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) !== true) return 0; - const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default'); - return Number(profile?.vat_rate_default) || 0; -} - -// Gross per-reminder fee (net + VAT) — for the admin payment-check preview. -async function resolvePerReminderFeeMinor(invoice) { - const net = await resolveLateFeeNetMinor(invoice); - if (net <= 0) return 0; - const rate = await resolveLateFeeVatRate(); - return rate > 0 ? net + Math.round(net * rate / 100) : net; -} - -async function applyReminder(invoice, lineItems, level, adminId) { - const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); - - // Per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×, computed - // from `level` so re-applying the same level never stacks. The fee is dunning - // STATE on the row (gross + the VAT portion) — it is NOT shown on the - // immutable invoice; it appears on the separate Mahnung document below. - let lateFeeGross = invoice.late_fee_amount_minor || 0; - let lateFeeVat = invoice.late_fee_vat_minor || 0; - if (level >= 2) { - const net = await resolveLateFeeNetMinor(invoice); - const rate = await resolveLateFeeVatRate(); - const vatPer = rate > 0 ? Math.round(net * rate / 100) : 0; - lateFeeGross = (level - 1) * (net + vatPer); - lateFeeVat = (level - 1) * vatPer; - } - const newTotal = Number(invoice.total_amount_minor || 0) + lateFeeGross; - - const update = { - status: 'overdue', - reminder_level: level, - last_reminder_sent_at: new Date(), - late_fee_amount_minor: lateFeeGross, - updated_at: new Date(), - }; - if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat; - await db('invoices').where({ id: invoice.id }).update(update); - - // Fire invoice.overdue at the status→overdue flip. Deduped per (workflow, - // invoice), so across the reminder ladder it triggers a flow at most once. - // Best-effort / fail-closed. - try { - await require('./workflows').emitWorkflowEvent('invoice.overdue', { - entityType: 'invoice', - entityId: invoice.id, - payload: { - invoiceId: invoice.id, - invoiceNumber: invoice.invoice_number, - eventId: invoice.event_id || null, - customerAccountId: invoice.customer_account_id, - customerEmail: customer?.email || null, - dueDate: invoice.due_date, - reminderLevel: level, - totalMinor: invoice.total_amount_minor, - currency: invoice.currency, - }, - }); - } catch (_) {} - - // Render the MAHNUNG (reminder letter). The original invoice PDF is left - // UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a - // 'mahnung' kind: same line items + the Mahngebühr row + the new total, with - // a "Mahnung" title and no QR (it would encode the old amount). - const fresh = await db('invoices').where({ id: invoice.id }).first(); - const ctx = await buildInvoiceRenderContext(fresh, lineItems); - ctx.doc.kind = 'mahnung'; - ctx.doc.reminderLevel = level; - ctx.doc.lateFeeMinor = lateFeeGross; - ctx.totals.lateFeeAmountMinor = lateFeeGross; - const buffer = await pdfService.renderInvoiceToBuffer(ctx); - const fs = require('fs'); - const path = require('path'); - const year = new Date(fresh.issue_date).getFullYear(); - const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year)); - fs.mkdirSync(root, { recursive: true }); - const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`); - fs.writeFileSync(mahnungPath, buffer); - - // days_overdue floors at 1 (a "0 days overdue" reminder reads as broken). - const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000); - const daysOverdue = Math.max(1, rawDaysOverdue); - const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second'; - const locale = ctx.locale || invoice.language || 'de'; - const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0)); - - // Attach the (unchanged) original invoice PDF + the new Mahnung. - const attachments = []; - if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) { - attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' }); - } - attachments.push({ filename: `${fresh.invoice_number}_Mahnung.pdf`, contentPath: mahnungPath, contentType: 'application/pdf' }); - - const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email); - try { - await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, { - invoice_number: invoice.invoice_number, - customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], - total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), - new_total_amount: formatMajor(newTotal, invoice.currency, locale), - outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale), - paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, locale), - late_fee_amount: formatMajor(lateFeeGross, invoice.currency, locale), - due_date: formatShortDate(invoice.due_date), - days_overdue: daysOverdue, - cc: reminderCc, - attachments, - // Dunning reminders are relationship mail — hold to business hours. - }, { respectBusinessHours: true }); - } catch (err) { - // Don't leave the just-rendered Mahnung PDF orphaned on disk if queueing the - // email failed — it would only be reachable via the next reminder anyway. - try { fs.unlinkSync(mahnungPath); } catch (_) { /* best-effort cleanup */ } - throw err; - } - - try { - await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross }, - invoice.event_id || null, `admin:${adminId || 'system'}`); - } catch (_) {} - - return { level, lateFeeMinor: lateFeeGross }; -} - -// --------------------------------------------------------------------- -// Payment-check workflow (admin-confirmed reminders) -// --------------------------------------------------------------------- - -/** - * Resolve the admin email address that should receive the payment- - * check prompt. Priority: - * 1. created_by_admin_id's email (the admin who issued the invoice) - * 2. First admin user with bills.manage permission - * 3. business_profile.email as a last resort - * Returns null when nothing usable is found — caller logs + skips. - */ -/** - * Resolve the effective Skonto percentage for an invoice at the - * current moment. Resolution chain (matches pdfService rendering): - * 1. invoice.payment_term_snapshot.skonto_percent - * 2. source quote's payment_term_snapshot.skonto_percent - * 3. global crm_invoices_skonto_percent_default - * Returns null when nothing is configured. - * - * Lifted into a helper so the payment-check action and the email - * template (which both need to know "does this invoice qualify for a - * Paid-with-Skonto button?") share one source of truth. - */ -async function resolveSkontoPercentForInvoice(invoice) { - // Per-invoice opt-out (migration 126) wins over every other source. - // Admin sets this on Storni / replacement invoices / payment-plan - // installments that shouldn't qualify for the discount even when - // the global default offers it. - if (invoice.skonto_disabled) return null; - // Per-customer opt-out (migration 112) — a customer that negotiated - // "no Skonto" as a contract term never qualifies, so the admin - // doesn't have to tick the per-invoice toggle on every invoice. - // Falls through customer → invoice → snapshot → quote → global. - if (invoice.customer_account_id) { - const cust = await db('customer_accounts') - .where({ id: invoice.customer_account_id }) - .select('skonto_disabled') - .first(); - if (cust && cust.skonto_disabled) return null; - } - const parseSnap = (raw) => { - if (!raw) return null; - if (typeof raw === 'object') return raw; - try { return JSON.parse(raw); } catch { return null; } - }; - const invSnap = parseSnap(invoice.payment_term_snapshot); - if (invSnap?.skonto_percent != null && Number(invSnap.skonto_percent) > 0) { - return Number(invSnap.skonto_percent); - } - if (invoice.source_quote_id) { - const q = await db('quotes').where({ id: invoice.source_quote_id }).select('payment_term_snapshot').first(); - const qSnap = parseSnap(q?.payment_term_snapshot); - if (qSnap?.skonto_percent != null && Number(qSnap.skonto_percent) > 0) { - return Number(qSnap.skonto_percent); - } - } - const defaultPct = Number(await getAppSetting('crm_invoices_skonto_percent_default')); - return Number.isFinite(defaultPct) && defaultPct > 0 ? defaultPct : null; -} - -async function resolveAdminEmailForInvoice(invoice) { - if (invoice.created_by_admin_id) { - const admin = await db('admin_users').where({ id: invoice.created_by_admin_id }).first(); - if (admin?.email) return { email: admin.email, name: admin.username || admin.email }; - } - // Fallback: business_profile.email. - const profile = await db('business_profile').where({ id: 1 }).first(); - if (profile?.email) return { email: profile.email, name: profile.company_name || profile.email }; - return null; -} - -/** - * Generate a fresh payment-check token for an invoice and queue the - * admin email with three signed action buttons. Throttled to once - * per 24h per invoice via invoices.last_payment_check_at. - * - * Returns { token, sent: bool, reason? } so callers can log / - * surface the outcome. - */ -/** - * Queue the admin "payment received" notification (migration 127). - * Called from markPaid the first time an invoice transitions into - * `status='paid'`. Resolves the admin's address via the same chain - * the payment-check email uses (created_by_admin_id → business - * profile fallback). Silently no-ops when no admin email can be - * resolved — caller logs the warn line. - */ -async function queueInvoicePaidAdminNotification({ - invoice, paidTotalMinor, paymentMethod, paymentReference, - paidAt, skontoApplied, skontoAmountMinor, -}) { - const adminContact = await resolveAdminEmailForInvoice(invoice); - if (!adminContact?.email) { - logger.warn('invoice_paid notification skipped — no admin email resolved', - { invoiceId: invoice.id }); - return; - } - - const profile = await db('business_profile').where({ id: 1 }).first(); - const locale = invoice.language || profile?.default_locale || 'de'; - - const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); - // Resolve the Skonto percentage at notification time so the - // template can render "Paid with Skonto X%" without a second query. - // Same resolver the rest of the Skonto surfaces use — null when - // skonto_disabled is true or no Skonto is configured. - const skontoPercent = skontoApplied - ? await resolveSkontoPercentForInvoice(invoice) - : null; - - await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email, - 'invoice_paid_admin_notification', { - invoice_number: invoice.invoice_number, - customer_name: customer?.company_name - || customer?.display_name - || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') - || customer?.email || '', - event_name: invoice.event_name || '', - total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), - paid_amount: formatMajor(paidTotalMinor, invoice.currency, locale), - payment_method: paymentMethod || '', - payment_reference: paymentReference || '', - paid_at: formatShortDate(paidAt), - skonto_applied: !!skontoApplied, - skonto_percent: skontoApplied && skontoPercent ? skontoPercent : '', - skonto_discount_amount: skontoApplied - ? formatMajor(skontoAmountMinor, invoice.currency, locale) - : '', - }); - - try { - await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id }, - invoice.event_id || null, 'system'); - } catch (_) {} -} - -async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) { - const invoice = await db('invoices').where({ id: invoiceId }).first(); - if (!invoice) return { sent: false, reason: 'not_found' }; - if (!['sent', 'overdue'].includes(invoice.status)) { - return { sent: false, reason: `wrong_status_${invoice.status}` }; - } - const now = new Date(); - if (!skipThrottle && invoice.last_payment_check_at) { - const last = new Date(invoice.last_payment_check_at).getTime(); - if (now.getTime() - last < 24 * 60 * 60 * 1000) { - return { sent: false, reason: 'throttled_24h' }; - } - } - - const adminContact = await resolveAdminEmailForInvoice(invoice); - if (!adminContact?.email) { - logger.warn('Payment-check email skipped — no admin email resolved', { invoiceId }); - return { sent: false, reason: 'no_admin_email' }; - } - - const token = crypto.randomBytes(32).toString('hex'); - const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); - await db('invoice_payment_check_tokens').insert({ - invoice_id: invoiceId, - token, - expires_at: expiresAt, - created_at: now, - }); - await db('invoices').where({ id: invoiceId }).update({ - last_payment_check_at: now, - updated_at: now, - }); - - const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); - const profile = await db('business_profile').where({ id: 1 }).first(); - const locale = invoice.language || profile?.default_locale || 'de'; - - // Determine whether the customer reminder will include a Mahngebühr - // if the admin selects "Not paid" / "Partial" — surfaced to the - // email so the admin sees the consequence before clicking. - const reminderFeeMinor = await resolvePerReminderFeeMinor(invoice); - const nextLevel = (invoice.reminder_level || 0) + 1; - const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2; - - const baseUrl = process.env.FRONTEND_URL - || (await getAppSetting('app_frontend_url')) - || 'https://app.example.com'; - const buildUrl = (action) => - `${baseUrl.replace(/\/$/, '')}/payment-check/${token}?action=${action}`; - - // Outstanding = gross total + late fee − already paid. The admin - // is being asked about what's STILL OWED, not the original gross - // figure — so surface outstanding + paid in the email context. - // Partial payments logged earlier (e.g. via a previous admin - // payment-check click) are reflected, so the admin doesn't get - // asked "did the customer pay CHF 234?" when they already paid - // CHF 134 of it. - const paidMinor = Number(invoice.paid_amount_minor || 0); - const lateFeeAlreadyMinor = Number(invoice.late_fee_amount_minor || 0); - const outstandingMinor = Math.max(0, - Number(invoice.total_amount_minor || 0) + lateFeeAlreadyMinor - paidMinor); - const hasPartial = paidMinor > 0; - - // Resolve Skonto for the optional 4th button (migration 126). Only - // surface the button when (a) Skonto is configured for this invoice - // AND (b) the customer paid within the Skonto window — past the - // window the discount is moot. Both checks are visible to the - // template so the email can hide the button conditionally. - const skontoPercent = await resolveSkontoPercentForInvoice(invoice); - const hasSkonto = !!skontoPercent && skontoPercent > 0; - const skontoDiscountedTotalMinor = hasSkonto - ? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100)) - : null; - - await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email, - 'invoice_payment_check_admin', { - invoice_number: invoice.invoice_number, - customer_name: customer?.company_name - || customer?.display_name - || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') - || customer?.email || '', - event_name: invoice.event_name || '', - due_date: formatShortDate(invoice.due_date), - total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), - paid_amount: formatMajor(paidMinor, invoice.currency, locale), - outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale), - has_partial_payment: hasPartial, - paid_url: buildUrl('paid_full'), - partial_url: buildUrl('partial'), - unpaid_url: buildUrl('unpaid'), - // Skonto button — template uses {{#if has_skonto}} to render the - // fourth button only when the invoice qualifies. - has_skonto: hasSkonto, - skonto_percent: hasSkonto ? skontoPercent : '', - skonto_amount: hasSkonto - ? formatMajor(skontoDiscountedTotalMinor, invoice.currency, locale) - : '', - skonto_url: hasSkonto ? buildUrl('paid_with_skonto') : '', - late_fee_due: willChargeFee, - late_fee_amount: formatMajor(reminderFeeMinor, invoice.currency, locale), - }); - - try { - await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) }, - invoice.event_id || null, 'scheduler'); - } catch (_) {} - - return { token, sent: true }; -} - -/** - * Validate a payment-check token and return the invoice context - * the public page needs. Token must exist, not be expired, not - * already used. - */ -async function getPaymentCheckByToken(token) { - const row = await db('invoice_payment_check_tokens').where({ token }).first(); - if (!row) throw new AppError('Token not found', 404); - if (row.used_at) { - const err = new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); - err.usedAt = row.used_at; - err.usedAction = row.used_action; - throw err; - } - if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) { - throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED'); - } - const invoice = await db('invoices').where({ id: row.invoice_id }).first(); - if (!invoice) throw new AppError('Invoice not found', 404); - const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); - - const outstandingMinor = Math.max(0, - Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0) - - Number(invoice.paid_amount_minor || 0)); - - // Surface the Skonto state so the public page can decide whether to - // render the "Paid with Skonto" action card (migration 126). Only - // applies when the invoice's payment terms actually carry a Skonto - // percentage — admin shouldn't see the option on an invoice that - // never offered the discount. - const skontoPercent = await resolveSkontoPercentForInvoice(invoice); - const hasSkonto = !!skontoPercent && skontoPercent > 0; - const skontoDiscountedTotalMinor = hasSkonto - ? Math.round(Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100)) - : null; - - return { - invoiceNumber: invoice.invoice_number, - customer: { - label: customer?.company_name - || [customer?.first_name, customer?.last_name].filter(Boolean).join(' ') - || customer?.display_name || customer?.email || '', - email: customer?.email, - }, - issueDate: invoice.issue_date, - dueDate: invoice.due_date, - totalMinor: invoice.total_amount_minor, - paidMinor: invoice.paid_amount_minor, - lateFeeMinor: invoice.late_fee_amount_minor, - outstandingMinor, - currency: invoice.currency, - status: invoice.status, - reminderLevel: invoice.reminder_level, - expiresAt: row.expires_at, - hasSkonto, - skontoPercent: hasSkonto ? skontoPercent : null, - skontoDiscountedTotalMinor, - }; -} - -/** - * Record the admin's payment-check action and fire the downstream - * consequences: - * - 'paid_full' → markPaid for the outstanding amount, no reminder. - * - 'partial' → markPaid for the amount supplied, then fire the - * next reminder for the remainder. - * - 'unpaid' → fire the next reminder (level 1 or 2) with the - * existing Mahngebühr logic in applyReminder. - * - * Atomic: token consumption + invoice status update happen in one - * transaction. The reminder email is queued AFTER the txn commits - * to avoid emailing a customer about a payment that never - * actually committed. - */ -async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminId }) { - // 'paid_with_skonto' (migration 126) is a fourth admin action — the - // customer settled the bill within the early-payment-discount window, - // so the recorded payment equals total minus the configured Skonto %. - // Same token-consumption semantics as 'paid_full'. - if (!['paid_full', 'paid_with_skonto', 'partial', 'unpaid'].includes(action)) { - throw new AppError('Invalid action', 400); - } - - const row = await db('invoice_payment_check_tokens').where({ token }).first(); - if (!row) throw new AppError('Token not found', 404); - if (row.used_at) { - throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); - } - if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) { - throw new AppError('This link has expired', 410, 'TOKEN_EXPIRED'); - } - const invoice = await db('invoices').where({ id: row.invoice_id }).first(); - if (!invoice) throw new AppError('Invoice not found', 404); - - const outstandingMinor = Math.max(0, - Number(invoice.total_amount_minor || 0) + Number(invoice.late_fee_amount_minor || 0) - - Number(invoice.paid_amount_minor || 0)); - - if (action === 'partial') { - const amt = ensureInt(amountMinor); - if (amt <= 0) throw new AppError('partial amount must be > 0', 400); - if (amt > outstandingMinor) throw new AppError('partial amount exceeds outstanding', 400); - } - - // Consume the token first — atomic with status update so a - // double-click can't fire the action twice. - const now = new Date(); - const updated = await db('invoice_payment_check_tokens') - .where({ id: row.id }) - .whereNull('used_at') - .update({ - used_at: now, - used_action: action, - used_amount_minor: action === 'partial' ? ensureInt(amountMinor) : null, - used_ip: ip || null, - }); - if (updated === 0) { - // Lost a race with another consumer. - throw new AppError('This link has already been used', 410, 'TOKEN_ALREADY_USED'); - } - - try { - await logActivity('invoice_payment_check_recorded', - { invoiceId: invoice.id, action, amountMinor: amountMinor || null }, - invoice.event_id || null, - adminId ? `admin:${adminId}` : 'public:payment-check'); - } catch (_) {} - - // --- Apply the action ----------------------------------------- - if (action === 'paid_full') { - await markPaid(invoice.id, { - amountMinor: outstandingMinor, - paymentMethod: invoice.payment_method || 'bank_transfer', - reference: invoice.payment_reference || null, - notes: 'Confirmed via admin payment-check link', - }, adminId || invoice.created_by_admin_id); - return { applied: 'paid_full' }; - } - - if (action === 'paid_with_skonto') { - // Resolve the Skonto percentage at click time so admins can't - // accidentally double-discount after the template changed. Same - // resolution chain pdfService uses: invoice snapshot → source - // quote snapshot → global crm_invoices_skonto_percent_default. - const skontoPercent = await resolveSkontoPercentForInvoice(invoice); - if (!skontoPercent || skontoPercent <= 0) { - throw new AppError('No Skonto configured on this invoice', 409, 'SKONTO_NOT_CONFIGURED'); - } - const discountedTotalMinor = Math.round( - Number(invoice.total_amount_minor) * (1 - Number(skontoPercent) / 100), - ); - // Outstanding-aware: if the customer already paid part of the - // bill (rare on the Skonto path, but possible after a partial), - // record only the remaining slice up to the discounted total. - const paidMinor = Number(invoice.paid_amount_minor || 0); - const remainingMinor = Math.max(0, discountedTotalMinor - paidMinor); - if (remainingMinor <= 0) { - throw new AppError('Invoice already paid past the Skonto threshold', 409); - } - await markPaid(invoice.id, { - amountMinor: remainingMinor, - paymentMethod: invoice.payment_method || 'bank_transfer', - reference: invoice.payment_reference || null, - notes: `Confirmed via admin payment-check link (Skonto ${skontoPercent}% applied)`, - skontoApplied: true, - }, adminId || invoice.created_by_admin_id); - return { applied: 'paid_with_skonto', skontoPercent }; - } - - if (action === 'partial') { - const amt = ensureInt(amountMinor); - await markPaid(invoice.id, { - amountMinor: amt, - paymentMethod: invoice.payment_method || 'bank_transfer', - reference: invoice.payment_reference || null, - notes: 'Partial payment confirmed via admin payment-check link', - }, adminId || invoice.created_by_admin_id); - // Then fire the customer reminder for the remainder, unless - // markPaid flipped the invoice to paid (i.e. the partial - // amount equalled the outstanding). - const refreshed = await db('invoices').where({ id: invoice.id }).first(); - if (refreshed.status !== 'paid') { - const nextLevel = (refreshed.reminder_level || 0) + 1; - if (nextLevel <= 3) { - const lineItems = await db('invoice_line_items') - .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); - await applyReminder(refreshed, lineItems, nextLevel, adminId); - } - } - return { applied: 'partial' }; - } - - // 'unpaid' - const nextLevel = (invoice.reminder_level || 0) + 1; - if (nextLevel > 3) { - // Already at max reminder — admin has to take this offline. - return { applied: 'unpaid', reminderSkipped: 'max_level_reached' }; - } - const lineItems = await db('invoice_line_items') - .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); - await applyReminder(invoice, lineItems, nextLevel, adminId); - return { applied: 'unpaid', reminderLevel: nextLevel }; -} - -/** - * Admin override — issue the customer's running monthly draft NOW, - * bypassing the cadence-day wait. Mirrors the scheduler's monthly - * pass (migration 128): clears is_monthly_draft, sets the issue date - * + scheduled_send_at to now, and fires sendInvoice inline so the - * email goes out on the next email-queue tick (~60s) instead of - * waiting for the next scheduler iteration. - * - * Refuses when: - * - no draft exists (admin hasn't queued anything yet) - * - the draft has zero line items (nothing to send — same as the - * scheduler's empty-month skip path) - * - * Returns { invoiceId, invoiceNumber } so the route can surface the - * resulting invoice on the response toast. - */ -/** - * Read the customer's running monthly draft + its line items so the - * customer-detail page can preview what will ship on the next cycle - * day. Returns null when no open draft exists (admin hasn't queued - * anything yet for the current period). Used by GET - * /admin/customers/:id/monthly-draft. - */ -async function getMonthlyDraft(customerId) { - const draft = await db('invoices') - .where({ customer_account_id: customerId, is_monthly_draft: true }) - .orderBy('id', 'desc') - .first(); - if (!draft) return null; - const lineItems = await db('invoice_line_items as li') - .leftJoin('invoice_line_items as parent', 'parent.id', 'li.parent_line_item_id') - .where('li.invoice_id', draft.id) - .orderBy('li.position', 'asc') - .select('li.*', 'parent.position as parent_position'); - return { - id: draft.id, - invoiceNumber: draft.invoice_number, - currency: draft.currency, - periodStart: draft.monthly_period_start, - periodEnd: draft.monthly_period_end, - netAmountMinor: draft.net_amount_minor, - vatRate: draft.vat_rate == null ? null : Number(draft.vat_rate), - vatAmountMinor: draft.vat_amount_minor, - totalAmountMinor: draft.total_amount_minor, - lineItems: lineItems.map((li) => ({ - id: li.id, - position: li.position, - quantity: Number(li.quantity), - description: li.description, - unitPriceMinor: ensureInt(li.unit_price_minor), - discountPercent: Number(li.discount_percent || 0), - lineTotalMinor: ensureInt(li.line_total_minor), - parentPosition: li.parent_position == null ? null : ensureInt(li.parent_position), - detailsText: li.details_text || '', - })), - }; -} - -async function triggerMonthlyBillNow(customerId, adminId) { - const draft = await db('invoices') - .where({ customer_account_id: customerId, is_monthly_draft: true }) - .orderBy('id', 'desc') - .first(); - if (!draft) { - throw new AppError('No pending monthly bill for this customer', 409, 'NO_MONTHLY_DRAFT'); - } - const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1); - if (items.length === 0) { - throw new AppError('Monthly draft is empty — nothing to bill', 409, 'EMPTY_DRAFT'); - } - - // Arm the draft: clear the discriminator, pin issue_date to today, - // and set scheduled_send_at to now so the flush pass + sendInvoice - // path treats it like any other ready-to-send invoice. Logged as a - // distinct activity so the audit trail shows admin override vs the - // scheduler's automatic fire. - const issueDate = new Date().toISOString().slice(0, 10); - await db('invoices').where({ id: draft.id }).update({ - is_monthly_draft: false, - issue_date: issueDate, - scheduled_send_at: new Date(), - updated_at: new Date(), - }); - try { - await logActivity('monthly_bill_triggered_manually', - { invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end }, - null, `admin:${adminId}`); - } catch (_) {} - - // Inline send so admin gets immediate feedback (PDF stored, status - // flipped to 'sent', email queued). A failure here doesn't roll - // back the arming — the scheduler will pick it up on the next tick. - try { - await sendInvoice(draft.id, adminId); - } catch (err) { - logger.warn('triggerMonthlyBillNow: inline send failed — scheduler will retry', - { invoiceId: draft.id, err: err.message }); - } - return { invoiceId: draft.id, invoiceNumber: draft.invoice_number }; -} - -/** - * Cron tick — find scheduled invoices ready to send + invoices past - * due date that need a reminder. Called by invoiceSchedulerService. - */ -async function runScheduledTasks() { - const now = new Date(); - - // 1. Flush scheduled invoices. - const ready = await db('invoices') - .where({ status: 'scheduled' }) - .andWhere(function() { - this.whereNotNull('scheduled_send_at').andWhere('scheduled_send_at', '<=', now); - }) - .limit(20); - for (const inv of ready) { - try { - await sendInvoice(inv.id, null); - } catch (err) { - logger.error('Scheduled invoice send failed', { invoiceId: inv.id, err: err.message }); - } - } - - // 2. Monthly-bill issuance (migration 128). - // - // Walk every monthly draft whose period_end is today-or-earlier. - // - If the draft has zero line items, skip silently (empty month - // per user spec — no invoice issued, no email, just a log). - // - Otherwise flip is_monthly_draft=false and arm scheduled_send_at - // to `now` so the next flush-pass picks it up and runs the - // standard sendInvoice path. Keeping the issuance one tick away - // from this pass means email queueing + activity log + dunning - // schedule all stay on the existing well-trodden code paths - // instead of duplicating logic here. - const monthlyToday = new Date(now); - monthlyToday.setHours(0, 0, 0, 0); - const dueDrafts = await db('invoices') - .where({ is_monthly_draft: true }) - .andWhere('monthly_period_end', '<=', monthlyToday.toISOString().slice(0, 10)) - .limit(50); - for (const draft of dueDrafts) { - try { - const items = await db('invoice_line_items').where({ invoice_id: draft.id }).limit(1); - if (items.length === 0) { - // Empty month — leave the draft alone (admin may still add - // items between now and end-of-day) OR mark it consumed so - // the next save creates a fresh period draft. We pick the - // latter: clear is_monthly_draft so the next createInvoice - // for this customer mints a new period. - // - // Status is 'skipped', not 'cancelled': the latter implies - // an admin (or Storno) deliberately voided a real invoice; - // an empty monthly period is a "nothing happened" non-event - // that we still record for audit-trail continuity. Listing - // queries that aggregate cancelled rows (e.g. the Bills list - // cancellation footnote) should not pull skipped rows in. - await db('invoices').where({ id: draft.id }).update({ - is_monthly_draft: false, - status: 'skipped', - updated_at: new Date(), - }); - logger.info('Monthly bill skipped — no items queued', { - invoiceId: draft.id, customerId: draft.customer_account_id, - }); - try { - await logActivity('monthly_bill_skipped_empty', - { invoiceId: draft.id, customerId: draft.customer_account_id }, - null, 'scheduler'); - } catch (_) {} - continue; - } - // Arm for the flush pass: clear the draft flag, set the send - // time to now, recompute due_date from issue_date + the global - // crm_invoices_net_days_default (best-effort; admin can override - // by editing the draft before the cadence day). - const issueDate = monthlyToday.toISOString().slice(0, 10); - await db('invoices').where({ id: draft.id }).update({ - is_monthly_draft: false, - issue_date: issueDate, - scheduled_send_at: new Date(), - updated_at: new Date(), - }); - try { - await logActivity('monthly_bill_issued', - { invoiceId: draft.id, customerId: draft.customer_account_id, - periodEnd: draft.monthly_period_end }, - null, 'scheduler'); - } catch (_) {} - } catch (err) { - logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message }); - } - } - - // 3. Overdue payment-check prompts (if reminders enabled). - // - // NEW behavior (migration 115/116): instead of auto-firing the - // customer reminder when an invoice goes overdue, we email the - // ADMIN with three signed-token action buttons: - // - Paid in full → markPaid for the outstanding amount - // - Partial → admin enters amount; partial + reminder - // - Not paid yet → reminder fires (with Mahngebühr at level 2) - // - // The reminder thresholds still gate when the prompt fires: - // - level 0 invoice past firstCutoff → prompt for level-1 path - // - level 1 invoice past secondCutoff → prompt for level-2 path - // Throttled to one email per 24h per invoice via - // invoices.last_payment_check_at. - const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled'); - // Mutual exclusion with the workflow engine: the hardcoded ladder stands down - // only when the invoice_dunning built-in is ENABLED (then the engine fires the - // payment-check emails). A disabled built-in leaves this ladder running — so - // the flow can ship disabled without dunning going dark, and disabling the - // flow reverts to the ladder. Fails closed → ladder stays on if the subsystem - // is down. - let engineDrivesDunning = false; - try { - engineDrivesDunning = await require('./workflows').isBuiltinFlowActive('invoice_dunning'); - } catch (_) { /* workflows tables absent / flag system down → ladder stays on */ } - if (remindersEnabled !== false && !engineDrivesDunning) { - const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14; - const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30; - - const firstCutoff = new Date(now.getTime() - firstDays * 86400000); - const secondCutoff = new Date(now.getTime() - secondDays * 86400000); - - // Pre-reminder check (would-be-level-1). - // `kind='invoice'` filter keeps Stornorechnungen out of the - // dunning ladder — they have no due_date and no payment - // expectation; reminding on them would be a customer-facing - // bug. - const firstBatch = await db('invoices') - .where('kind', 'invoice') - .whereIn('status', ['sent', 'overdue']) - .where('reminder_level', 0) - .where('due_date', '<=', firstCutoff) - .limit(20); - for (const inv of firstBatch) { - try { - await queuePaymentCheckEmail(inv.id); - } catch (err) { - logger.error('Payment-check email failed', { invoiceId: inv.id, err: err.message }); - } - } - - // Pre-reminder check (would-be-level-2, including Mahngebühr). - const secondBatch = await db('invoices') - .where('kind', 'invoice') - .whereIn('status', ['sent', 'overdue']) - .where('reminder_level', 1) - .where('due_date', '<=', secondCutoff) - .limit(20); - for (const inv of secondBatch) { - try { - await queuePaymentCheckEmail(inv.id); - } catch (err) { - logger.error('Payment-check email (level 2) failed', { invoiceId: inv.id, err: err.message }); - } - } - } -} - -// Module-cached issuer country code — refreshed on every business -// profile save by listening to the same query React-Query revalidates. -// For backend purposes we read it lazily once per process and cache -// the resolved Intl locale; admins changing the country in Settings -// take effect after the next backend restart, which is acceptable -// (this isn't on a hot path). -let _cachedIntlLocale = null; -async function resolveIntlLocale(docLocale) { - if (_cachedIntlLocale) return _cachedIntlLocale; - try { - const businessProfileService = require('./businessProfileService'); - const profile = (await businessProfileService.getProfile()).profile || {}; - const cc = (profile.country_code || '').toUpperCase(); - if (['CH', 'LI', 'DE', 'AT'].includes(cc)) { - _cachedIntlLocale = 'de-CH'; - return _cachedIntlLocale; - } - } catch (_) { /* fall through to per-locale default */ } - return docLocale === 'de' ? 'de-CH' : 'en-GB'; -} - -function formatMajor(minor, currency, locale) { - // Sync version — keeps the existing call-sites working. Reads the - // module cache populated by the async warm-up on first send. When - // the cache hasn't filled yet (first invocation in a process) - // fall through to the legacy de-vs-en split; the cache fills after - // the first send and every subsequent send uses the correct locale. - const cached = _cachedIntlLocale; - const intlLocale = cached || (locale === 'de' ? 'de-CH' : 'en-GB'); - // Best-effort warm-up — fire and forget; the next call hits cache. - if (!cached) { - resolveIntlLocale(locale).catch(() => { /* tolerate */ }); - } - return new Intl.NumberFormat(intlLocale, { - style: 'currency', currency: (currency || 'CHF').toUpperCase(), - }).format(Number(minor || 0) / 100); -} +// +// Decomposed into ./invoice/* modules (move-code refactor). This file is the +// stable public entry point: same require path, same exported names. + +const helpers = require('./invoice/helpers'); +const queries = require('./invoice/queries'); +const drafts = require('./invoice/drafts'); +const create = require('./invoice/create'); +const installmentPlan = require('./invoice/installmentPlan'); +const render = require('./invoice/render'); +const reminders = require('./invoice/reminders'); +const payments = require('./invoice/payments'); +const sending = require('./invoice/sending'); +const scheduler = require('./invoice/scheduler'); + +const { + listInvoices, getInvoiceById, +} = queries; +const { + getOrCreateMonthlyDraft, getMonthlyDraft, appendToMonthlyDraft, appendOneLineItemToMonthlyDraft, +} = drafts; +const { createInvoice, spawnInstallmentInvoices, scheduleInvoicesForEvent } = create; +const { updateInstallmentPlan, validateInstallmentPlanInput } = installmentPlan; +const { renderInvoicePdfBuffer, renderInvoicePdfFromPayload } = render; +const { + sendReminder, applyReminder, resolveLateFeeNetMinor, resolveLateFeeVatRate, + resolvePerReminderFeeMinor, resolveSkontoPercentForInvoice, +} = reminders; +const { + markPaid, queuePaymentCheckEmail, getPaymentCheckByToken, recordPaymentCheckAction, +} = payments; +const { + sendInvoice, cancelInvoice, releaseForDelivery, reissueInvoice, createStorno, sendStorno, + triggerMonthlyBillNow, +} = sending; +const { runScheduledTasks } = scheduler; +const { nextInvoiceNumber } = helpers; module.exports = { listInvoices,