refactor(backend): decompose invoiceService, contractService, adminEvents

Move-code split, public entry points unchanged:
- services/invoiceService.js (3,623 -> 99) + services/invoice/* (10 modules)
- services/contractService.js (2,363 -> 83) + services/contract/* (6 modules)
- routes/adminEvents.js -> routes/adminEvents/* (crud, slideshow, resets,
  archive/bulk, logo); route registration order verified identical
Lazy cross-service requires preserved to keep the module graph acyclic.
This commit is contained in:
Paul Nothaft
2026-07-03 08:10:25 +02:00
parent 51f827774a
commit fdf62dafef
26 changed files with 7284 additions and 6803 deletions
@@ -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');
}
});
};
File diff suppressed because it is too large Load Diff
+326
View File
@@ -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,
};
+17
View File
@@ -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;
+145
View File
@@ -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');
}
});
};
+193
View File
@@ -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/<slug>/<token>`).
// 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');
}
});
};
+151
View File
@@ -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');
}
});
};
@@ -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,
};
+384
View File
@@ -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,
};
+134
View File
@@ -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,
};
@@ -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,
};
+135
View File
@@ -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,
};
@@ -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 1080 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,
};
+861
View File
@@ -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":<n>` or `"contractId": <n>` —
// 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,
};
File diff suppressed because it is too large Load Diff
+582
View File
@@ -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,
};
+334
View File
@@ -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,
};
+301
View File
@@ -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,
};
@@ -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,
};
+525
View File
@@ -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,
};
+166
View File
@@ -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,
};
+264
View File
@@ -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,
};
+354
View File
@@ -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,
};
+173
View File
@@ -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,
};
+654
View File
@@ -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,
};
File diff suppressed because it is too large Load Diff