feat(crm): backend code — services + routes + utilities + tests

Brings in the full backend CRM stack on top of the consolidated
migration (60abe8c).

Services (CRM)
  - quoteService — full lifecycle (draft → sent → accepted → converted
    to event/invoice), Skonto + Storno + reissue paths
  - invoiceService — spawnInstallmentInvoices, updateInstallmentPlan,
    monthly-billing accumulator, payment-check tokens, dunning ladder
  - contractService — block-composable contract editor, in-browser
    signature flow, wet-PDF upload path, integrity check, audit trail
  - customerHoursService — per-entry locking, billing integration
  - dealsService — cross-document lineage (deal_uuid)
  - taxReportService — quarterly aggregates + CSV/PDF export
  - eventReminderService — pre-event customer reminder cron pass
  - _renderContext — shared issuer/recipient blocks across PDF types
  - pdfService extensions — custom-font registration, font picker

Routes (admin + public)
  - adminQuotes, adminInvoices, adminContracts, adminCalendar,
    adminDeals, adminTaxReport, adminDev, adminBusinessProfile
  - publicQuotes (accept/decline), publicContracts (sign),
    publicPaymentCheck
  - Extensions on adminEvents, adminCustomers, adminSettings,
    adminEmail, adminFeatureFlags, adminThumbnails, adminPhotos,
    adminCategories, adminUsers, adminArchives, adminDashboard
  - server.js wires the new mounts (kept upstream's noStoreCache on
    customer routes per 3-way merge)

Utilities
  - schemaCache (cached hasColumn lookups across services)
  - documentSequences (atomic gap-free numbering — §14 UStG)
  - safePath (path-containment guards at fs stream boundaries)
  - clientIp (sanctioned XFF reader for audit logs)
  - publicTokenGuards (pre-multer token validation + attempt counters)
  - numericHelpers (ensureInt / ensureNumber consolidation)
  - dateFormatter (formatShortDate + dateInputLang)
  - dbCompat extensions, iban + pdfFilename helpers, resolveLogoFile

Infrastructure
  - Bundled PDF fonts (Comic-Neue / IBM-Plex-Sans / Inter / Jost /
    Montserrat / Noto-Sans / Playfair-Display / Poppins)
  - Backend package.json + lock updates (pdfkit, signature_pad,
    qrcode, et al.)
  - Sample storage layout under storage/business-docs/quote/

Tests
  - 14 new test files covering quote/invoice/contract lifecycle,
    installment plan reshape, line-item hierarchy, customer hours,
    payment check, tax report PDF, IBAN parsing, filename sanitiser
This commit is contained in:
Luca
2026-05-26 18:18:51 +02:00
parent 60abe8c76d
commit d543949188
91 changed files with 23578 additions and 123 deletions
+516
View File
@@ -0,0 +1,516 @@
/**
* Admin → Business Profile Routes
*
* Endpoint mounted at /api/admin/business-profile (see server.js wiring).
* Issuer block + bank-account roster that every quote/invoice PDF pulls
* from. Gated by the existing `settings.edit` permission so any admin
* who can edit Settings can edit this too — no separate CRM permission
* required at this layer.
*
* Logo upload is delegated to the shared branding-upload helper at
* /api/admin/branding/upload-logo and we just store the returned URL on
* business_profile.logo_path; that route already has the multer +
* resize stack we'd otherwise duplicate.
*/
const express = require('express');
const { body, param } = require('express-validator');
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { getStoragePath } = require('../config/storage');
const businessProfileService = require('../services/businessProfileService');
const { db } = require('../database/db');
const { validateIban } = require('../utils/iban');
const { validationResult } = require('express-validator');
const { ValidationError } = require('../utils/errors');
/**
* Same shape as utils/routeHelpers.validateRequest BUT surfaces the
* FIRST field-level error message as the top-level `error` string —
* so a user typing a bad IBAN sees "IBAN checksum is invalid — please
* check for typos" in the toast, not the generic "Validation failed".
*
* Scoped to this route file because business-profile is the only
* surface where field-specific copy is worth the extra wiring;
* other routes keep the shared helper's behaviour.
*/
function validateRequestWithFieldMessage(req) {
const errors = validationResult(req);
if (errors.isEmpty()) return;
const details = errors.array().map((err) => ({
field: err.path || err.param,
message: err.msg,
}));
// Use the first field-level message as the top-level message so
// generic toast UIs that only read `error` still get the precise
// reason. Falls back to "Validation failed" only when no message
// was supplied (shouldn't happen with our validators).
const primary = details[0]?.message || 'Validation failed';
throw new ValidationError(primary, details);
}
/**
* express-validator custom rule that runs the ISO 13616 IBAN check
* AND normalises the value on the request body so the service-layer
* insert/update stores the canonical spaceless uppercase form. Lets
* admins paste IBANs with spaces ("CH93 0076 ...") without the
* uniqueness/render code having to re-normalise downstream.
*
* Used by both POST and PUT /bank-accounts. Pass `required: true` on
* POST (IBAN is mandatory there) and `required: false` on PUT (admin
* may be patching other fields without touching the IBAN).
*/
function ibanValidator({ required }) {
return (value, { req }) => {
if (value == null || value === '') {
if (required) throw new Error('IBAN is required');
return true;
}
const result = validateIban(value);
if (!result.valid) {
const reasonText = {
EMPTY: 'IBAN is required',
FORMAT: 'IBAN format is invalid (expected country code + check digits + account)',
LENGTH: 'IBAN has the wrong length for this country',
CHECKSUM: 'IBAN checksum is invalid — please check for typos',
}[result.reason] || 'IBAN is invalid';
throw new Error(reasonText);
}
// Persist the normalised value so the DB never sees a
// user-typed space.
req.body.iban = result.normalized;
return true;
};
}
const router = express.Router();
// Multer config for the dedicated PDF letterhead logo. Same target
// directory as the global branding upload (storage/uploads/logos)
// but accepts SVG in addition to PNG / JPEG — the PDF renderer
// rasterises SVGs to PNG on the fly via resolveLogoFile() so the
// admin can drop a vector logo here and have it work in print.
const pdfLogoStorage = multer.diskStorage({
destination: async (_req, _file, cb) => {
const dir = path.join(getStoragePath(), 'uploads/logos');
await fs.mkdir(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.png';
cb(null, `pdf-logo-${Date.now()}${ext}`);
},
});
const pdfLogoUpload = multer({
storage: pdfLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/png', 'image/jpeg', 'image/svg+xml'];
if (allowed.includes(file.mimetype)) cb(null, true);
else cb(new Error('Only PNG, JPEG and SVG logos are allowed'));
},
});
/**
* DB-shape → API shape. Keep narrow so adding new DB columns doesn't
* silently leak through the API contract.
*/
function transformProfile(p) {
if (!p) return null;
return {
id: p.id,
companyName: p.company_name || '',
addressLine1: p.address_line1 || '',
addressLine2: p.address_line2 || '',
postalCode: p.postal_code || '',
city: p.city || '',
state: p.state || '',
countryCode: p.country_code || '',
countryName: p.country_name || '',
phone: p.phone || '',
mobile: p.mobile || '',
email: p.email || '',
website: p.website || '',
vatId: p.vat_id || '',
// Steuernummer (migration 139). Distinct from VAT-ID; both can
// appear on the invoice issuer block to satisfy §14 UStG.
taxId: p.tax_id || '',
vatLabel: p.vat_label || 'MwSt.',
vatRateDefault: p.vat_rate_default == null ? null : Number(p.vat_rate_default),
defaultCurrency: p.default_currency || 'CHF',
defaultLocale: p.default_locale || 'de',
defaultQrFormat: p.default_qr_format || 'none',
footerLine: p.footer_line || '',
logoPath: p.logo_path || '',
pdfFontTtfPath: p.pdf_font_ttf_path || '',
// Bundled-fonts dropdown (migration 121). NULL = no preference,
// Helvetica fallback. Surfaces the on-disk directory name (e.g.
// "Inter", "Playfair-Display"); pdfService maps it to the
// bundled TTFs at render time.
pdfFontFamily: p.pdf_font_family || null,
pdfShowLogo: p.pdf_show_logo == null ? true : (p.pdf_show_logo === true || p.pdf_show_logo === 1 || p.pdf_show_logo === '1'),
pdfShowCompanyName: p.pdf_show_company_name == null ? true : (p.pdf_show_company_name === true || p.pdf_show_company_name === 1 || p.pdf_show_company_name === '1'),
pdfFoldingMarks: p.pdf_folding_marks || 'none',
pdfLogoHeight: p.pdf_logo_height == null ? 56 : Number(p.pdf_logo_height),
pdfCompanyNameInline: p.pdf_company_name_inline === true || p.pdf_company_name_inline === 1 || p.pdf_company_name_inline === '1',
pdfQuoteShowNetDays: p.pdf_quote_show_net_days === true || p.pdf_quote_show_net_days === 1 || p.pdf_quote_show_net_days === '1',
pdfQuoteShowSkonto: p.pdf_quote_show_skonto === true || p.pdf_quote_show_skonto === 1 || p.pdf_quote_show_skonto === '1',
// Migration 137 — IANA timezone for the admin calendar. Null when
// the admin hasn't picked one; frontend falls back to the browser.
timezone: p.timezone || null,
createdAt: p.created_at,
updatedAt: p.updated_at,
};
}
function transformBank(b) {
if (!b) return null;
return {
id: b.id,
label: b.label || '',
accountHolder: b.account_holder || '',
iban: b.iban,
bic: b.bic || '',
currency: b.currency || '',
isDefault: b.is_default === 1 || b.is_default === true || b.is_default === '1',
displayOrder: b.display_order || 0,
createdAt: b.created_at,
updatedAt: b.updated_at,
};
}
router.use(adminAuth);
// ---- GET / ------------------------------------------------------------
router.get(
'/',
requirePermission('settings.view'),
handleAsync(async (req, res) => {
const { profile, bankAccounts } = await businessProfileService.getProfile();
return successResponse(res, {
profile: transformProfile(profile),
bankAccounts: bankAccounts.map(transformBank),
});
})
);
// ---- GET /logo-diagnostic ---------------------------------------------
// Diagnostic for "logo doesn't appear on PDF" tickets. Returns the
// configured logo sources (business_profile.logo_path,
// app_settings.branding_logo_path, app_settings.branding_logo_url),
// the storage root the renderer would use, the candidate paths the
// resolver would try, and which one (if any) currently resolves to
// an existing file. Read-only — never modifies anything.
router.get(
'/logo-diagnostic',
requirePermission('settings.view'),
handleAsync(async (req, res) => {
const fs = require('fs');
const path = require('path');
const { getStoragePath } = require('../config/storage');
const { getAppSetting } = require('../utils/appSettings');
const { resolveLogoFile } = require('../utils/resolveLogoFile');
const { profile } = await businessProfileService.getProfile();
const storageRoot = getStoragePath();
const brandingDiskPath = await getAppSetting('branding_logo_path');
const brandingLogoUrl = await getAppSetting('branding_logo_url');
const resolved = await resolveLogoFile(profile);
const inspect = (label, raw) => {
const value = (raw || '').toString().trim();
if (!value) return { label, value: null, candidates: [] };
const stripped = value.replace(/^\/+/, '');
const baseName = path.basename(value);
const candidates = [
path.isAbsolute(value) ? value : null,
path.join(storageRoot, stripped),
path.join(storageRoot, 'uploads', 'logos', baseName),
path.join(storageRoot, 'branding', baseName),
path.join(process.cwd(), 'storage', stripped),
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
path.join(process.cwd(), 'storage', 'branding', baseName),
].filter(Boolean);
return {
label, value,
candidates: [...new Set(candidates)].map((p) => ({
path: p,
exists: (() => { try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } })(),
})),
};
};
return successResponse(res, {
storageRoot,
cwd: process.cwd(),
resolvedTo: resolved,
sources: [
inspect('business_profile.logo_path', profile?.logo_path),
inspect('app_settings.branding_logo_path', brandingDiskPath),
inspect('app_settings.branding_logo_url', brandingLogoUrl),
],
});
})
);
// ---- POST /logo, DELETE /logo -----------------------------------------
// Dedicated PDF letterhead logo upload (separate from the global
// Settings → Branding logo). PNG, JPEG, and SVG accepted; the PDF
// renderer rasterises SVG to PNG via resolveLogoFile() so vector
// uploads work in print. The relative path is stored in
// business_profile.logo_path; the existing fallback to
// branding_logo_path still applies when this is unset.
router.post(
'/logo',
requirePermission('settings.edit'),
pdfLogoUpload.single('logo'),
handleAsync(async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No logo file uploaded' });
}
// Clean up the previous PDF logo on disk if it was uploaded via
// this same endpoint (matches the pdf-logo-* prefix). We leave
// anything else untouched — the admin may have set logo_path to
// a path managed by a different system.
try {
const previous = await db('business_profile').where({ id: 1 }).first();
const prev = previous?.logo_path;
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
const stripped = prev.replace(/^\/+/, '');
const prevDisk = path.isAbsolute(prev)
? prev
: path.join(getStoragePath(), stripped);
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
}
} catch (_) { /* ignore */ }
const relative = `/uploads/logos/${req.file.filename}`;
await businessProfileService.updateProfile(
{ logo_path: relative },
req.admin.id
);
return successResponse(res, { logoPath: relative }, 200, 'PDF logo uploaded');
})
);
router.delete(
'/logo',
requirePermission('settings.edit'),
handleAsync(async (req, res) => {
const existing = await db('business_profile').where({ id: 1 }).first();
const prev = existing?.logo_path;
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
const stripped = prev.replace(/^\/+/, '');
const prevDisk = path.isAbsolute(prev)
? prev
: path.join(getStoragePath(), stripped);
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
}
await businessProfileService.updateProfile(
{ logo_path: '' },
req.admin.id
);
return successResponse(res, { cleared: true }, 200, 'PDF logo cleared');
})
);
// ---- PUT / ------------------------------------------------------------
router.put(
'/',
requirePermission('settings.edit'),
[
// All fields optional — partial update is fine. We only run shallow
// shape validation on the types that absolutely must be sane;
// service layer does the trimming + currency/country normalisation.
body('companyName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('addressLine1').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('addressLine2').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('postalCode').optional({ values: 'falsy' }).isString().isLength({ max: 20 }),
body('city').optional({ values: 'falsy' }).isString().isLength({ max: 120 }),
body('state').optional({ values: 'falsy' }).isString().isLength({ max: 120 }),
body('countryCode').optional({ values: 'falsy' }).isString().isLength({ min: 2, max: 2 }),
body('countryName').optional({ values: 'falsy' }).isString().isLength({ max: 120 }),
body('phone').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('mobile').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('email').optional({ values: 'falsy' }).isEmail().withMessage('Invalid issuer email'),
body('website').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('vatId').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
// Migration 139 — Steuernummer (DE/AT). Free-text up to 64 chars.
body('taxId').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('vatLabel').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('vatRateDefault').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('defaultCurrency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('defaultLocale').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('defaultQrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
body('footerLine').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('logoPath').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
// Bundled-fonts dropdown (migration 121). Free-text upload field
// (pdfFontTtfPath, migration 103) was retired from the UI in
// favour of this dropdown; the column stays in the DB so any
// legacy value continues to be honoured by pdfService.
body('pdfFontFamily').optional({ nullable: true, values: 'falsy' }).isString().isLength({ max: 128 }),
// Visibility toggles use the explicit-undefined check pattern so
// `false` actually reaches the service layer. `optional({ values:
// 'falsy' })` would drop `false` and the toggle could never be
// disabled.
body('pdfShowLogo').optional().isBoolean(),
body('pdfShowCompanyName').optional().isBoolean(),
body('pdfCompanyNameInline').optional().isBoolean(),
body('pdfFoldingMarks').optional({ values: 'falsy' }).isIn(['none', 'half', 'third', 'both']),
body('pdfLogoHeight').optional({ values: 'falsy' }).isInt({ min: 24, max: 200 }),
body('pdfQuoteShowNetDays').optional().isBoolean(),
body('pdfQuoteShowSkonto').optional().isBoolean(),
// Migration 137 — admin calendar timezone (IANA string e.g.
// "Europe/Zurich"). Free-text; backend stores up to 64 chars.
// Frontend falls back to browser Intl when this is blank.
body('timezone').optional({ values: 'falsy', nullable: true }).isString().isLength({ max: 64 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
// Convert camelCase → snake_case for the service layer.
const payload = {};
const map = {
companyName: 'company_name',
addressLine1: 'address_line1',
addressLine2: 'address_line2',
postalCode: 'postal_code',
city: 'city',
state: 'state',
countryCode: 'country_code',
countryName: 'country_name',
phone: 'phone',
mobile: 'mobile',
email: 'email',
website: 'website',
vatId: 'vat_id',
taxId: 'tax_id',
vatLabel: 'vat_label',
vatRateDefault: 'vat_rate_default',
defaultCurrency: 'default_currency',
defaultLocale: 'default_locale',
defaultQrFormat: 'default_qr_format',
footerLine: 'footer_line',
logoPath: 'logo_path',
pdfFontFamily: 'pdf_font_family',
pdfShowLogo: 'pdf_show_logo',
pdfShowCompanyName: 'pdf_show_company_name',
pdfCompanyNameInline: 'pdf_company_name_inline',
pdfFoldingMarks: 'pdf_folding_marks',
pdfLogoHeight: 'pdf_logo_height',
pdfQuoteShowNetDays: 'pdf_quote_show_net_days',
pdfQuoteShowSkonto: 'pdf_quote_show_skonto',
// Migration 137 — admin calendar timezone.
timezone: 'timezone',
};
for (const [api, db] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
payload[db] = req.body[api];
}
}
const { profile, bankAccounts } = await businessProfileService.updateProfile(
payload,
req.admin.id
);
return successResponse(res, {
profile: transformProfile(profile),
bankAccounts: bankAccounts.map(transformBank),
}, 200, 'Business profile updated');
})
);
// ---- bank accounts ----------------------------------------------------
router.get(
'/bank-accounts',
requirePermission('settings.view'),
handleAsync(async (req, res) => {
const { bankAccounts } = await businessProfileService.getProfile();
return successResponse(res, { bankAccounts: bankAccounts.map(transformBank) });
})
);
router.post(
'/bank-accounts',
requirePermission('settings.edit'),
[
body('iban').isString().isLength({ min: 5, max: 64 }).withMessage('IBAN is required')
.bail().custom(ibanValidator({ required: true })),
body('label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('accountHolder').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('bic').optional({ values: 'falsy' }).isString().isLength({ max: 16 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('isDefault').optional({ values: 'falsy' }).isBoolean(),
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
],
handleAsync(async (req, res) => {
validateRequestWithFieldMessage(req);
const bank = await businessProfileService.createBankAccount({
iban: req.body.iban,
label: req.body.label,
account_holder: req.body.accountHolder,
bic: req.body.bic,
currency: req.body.currency,
is_default: req.body.isDefault,
display_order: req.body.displayOrder,
}, req.admin.id);
return successResponse(res, { bankAccount: transformBank(bank) }, 201, 'Bank account created');
})
);
router.put(
'/bank-accounts/:id',
requirePermission('settings.edit'),
[
param('id').isInt({ min: 1 }),
body('iban').optional({ values: 'falsy' }).isString().isLength({ min: 5, max: 64 })
.bail().custom(ibanValidator({ required: false })),
body('label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('accountHolder').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('bic').optional({ values: 'falsy' }).isString().isLength({ max: 16 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('isDefault').optional({ values: 'falsy' }).isBoolean(),
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
],
handleAsync(async (req, res) => {
validateRequestWithFieldMessage(req);
const id = parseInt(req.params.id, 10);
const payload = {};
const map = {
iban: 'iban',
label: 'label',
accountHolder: 'account_holder',
bic: 'bic',
currency: 'currency',
isDefault: 'is_default',
displayOrder: 'display_order',
};
for (const [api, db] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
payload[db] = req.body[api];
}
}
const bank = await businessProfileService.updateBankAccount(id, payload, req.admin.id);
return successResponse(res, { bankAccount: transformBank(bank) }, 200, 'Bank account updated');
})
);
router.delete(
'/bank-accounts/:id',
requirePermission('settings.edit'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
await businessProfileService.deleteBankAccount(id, req.admin.id);
return successResponse(res, { deleted: true }, 200, 'Bank account deleted');
})
);
module.exports = router;
+312
View File
@@ -0,0 +1,312 @@
/**
* Admin calendar aggregate endpoint.
*
* One read returns four layers the frontend renders together on the
* admin calendar surface (`/admin/clients/calendar`):
*
* 1. events — galleries from the `events` table. Blue solid.
* 2. hours — customer_hour_entries. Green solid; greyed when
* locked (entry's invoice is past send/draft state).
* 3. quotes — quotes that haven't been converted to an event yet,
* `status IN ('sent','accepted')`. Amber dashed.
* 4. contracts — contracts that haven't been converted to an event
* yet, `status IN ('signed_by_customer','fully_signed')`.
* Purple dashed.
*
* Each item carries a `kind` discriminator so the frontend can union-type
* the response.
*
* **Access**
*
* Behind the `calendar` master feature flag (admin can disable globally
* via Settings → Features). Read permission is `customers.view` —
* mirrors the existing hour-entry list permission, since the calendar's
* primary mutation surface is hour entries and we want the same audience
* for read.
*
* **Range guard**
*
* `from` / `to` are required ISO date strings. We cap `to-from` at
* **90 days** so a misconfigured client (e.g. an infinite scroll that
* keeps expanding the range) can't trigger a multi-year scan. FullCalendar
* fetches month-by-month by default, so 90 days is a comfortable margin.
*
* **Drift guards**
*
* The `events.event_time_start / event_time_end / is_full_day` columns
* (migration 137) are read through `hasColumnCached` so un-migrated
* installs default to all-day rendering without 500-ing.
*
* **No mutations here**
*
* Hour-entry CRUD stays on the existing `/api/admin/customers/:id/
* hour-entries` routes (migration 129 + B.6 permission split). The
* calendar's drag-create / inline-edit modals call those directly.
*/
const express = require('express');
const { query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { hasColumnCached } = require('../utils/schemaCache');
const { db } = require('../database/db');
const customerHoursService = require('../services/customerHoursService');
const router = express.Router();
// ----- feature flag gate (admin global) ----------------------------------
async function requireCalendarFlag(req, res, next) {
try {
const row = await db('feature_flags').where({ key: 'calendar' }).first();
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
if (!enabled) {
return res.status(403).json({ error: 'Calendar feature is disabled', code: 'CALENDAR_DISABLED' });
}
next();
} catch (err) {
next(err);
}
}
router.use(adminAuth);
router.use(requireCalendarFlag);
const MAX_RANGE_DAYS = 90;
const PENDING_QUOTE_STATUSES = ['sent', 'accepted'];
const PENDING_CONTRACT_STATUSES = ['signed_by_customer', 'fully_signed'];
/**
* Normalise a date column value to the `YYYY-MM-DD` string the
* frontend mapper expects.
*
* Different drivers return the value differently:
* - SQLite (dev) returns a string like "2026-05-18" — slice it.
* - node-postgres (prod) returns a JS Date set to UTC midnight of
* the stored day — extract the UTC components.
*
* The previous shape kept the Date object as-is in the JSON response
* ("2026-05-18T00:00:00.000Z"), which the frontend then concatenated
* with the entry time as `${dateStr}T${time}` to feed FullCalendar.
* The resulting `"2026-05-18T00:00:00.000ZT09:00"` was invalid ISO,
* FC parsed it to NaN, and the entry silently failed to render —
* making logged hours "disappear" on every hard refresh (entries
* created in-session still appeared because the imperative addEvent
* received a clean YYYY-MM-DD from the modal).
*/
function toIsoDateString(value) {
if (!value) return null;
if (typeof value === 'string') return value.slice(0, 10);
if (value instanceof Date && !Number.isNaN(value.getTime())) {
const y = value.getUTCFullYear();
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
const d = String(value.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return null;
}
/**
* GET /api/admin/calendar/items?from=YYYY-MM-DD&to=YYYY-MM-DD
*
* Returns `{ items: [...], range: { from, to } }`.
*
* Items are concatenated across the four layers. Order is NOT guaranteed —
* FullCalendar sorts by start time client-side. Each item shape:
*
* - { kind: 'event', id, slug, eventName, eventDate, eventTimeStart, eventTimeEnd, isFullDay, customerName }
* - { kind: 'hours', id, customerAccountId, entryDate, startTime, endTime, description, locked, invoiceId, invoiceStatus, customerName }
* - { kind: 'quote', id, quoteNumber, eventName, eventDate, eventTimeStart, eventTimeEnd, status, customerName }
* - { kind: 'contract', id, contractNumber, eventName, eventDate, eventTimeStart, eventTimeEnd, status, customerName }
*/
router.get(
'/items',
requirePermission('customers.view'),
[
query('from').isISO8601().withMessage('from must be ISO date'),
query('to').isISO8601().withMessage('to must be ISO date'),
],
handleAsync(async (req, res) => {
validateRequest(req);
const from = String(req.query.from).slice(0, 10);
const to = String(req.query.to).slice(0, 10);
if (from > to) {
return res.status(400).json({ error: 'from must be <= to', code: 'INVALID_RANGE' });
}
// Day-span guard. Date-string lex compare doesn't give a day count
// directly; subtract via Date so DST + month boundaries are handled.
const fromDate = new Date(from + 'T00:00:00Z');
const toDate = new Date(to + 'T00:00:00Z');
const daysSpan = Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000);
if (daysSpan > MAX_RANGE_DAYS) {
return res.status(400).json({
error: `Range too wide (max ${MAX_RANGE_DAYS} days)`,
code: 'RANGE_TOO_WIDE',
});
}
const hasEventCalendarCols = await hasColumnCached('events', 'is_full_day');
// -- 1. Events ---------------------------------------------------------
const eventsQ = db('events')
.whereBetween('event_date', [from, to])
.where('is_active', true)
.where('is_archived', false)
.orderBy('event_date', 'asc');
// Project columns. The new time columns are guarded so older installs
// that ran the service before migration 137 still get sane defaults.
const eventsRows = await eventsQ.select(
'id', 'slug', 'event_name', 'event_date', 'customer_name',
...(hasEventCalendarCols
? ['event_time_start', 'event_time_end', 'is_full_day']
: []),
);
const events = eventsRows.map((r) => ({
kind: 'event',
id: r.id,
slug: r.slug,
eventName: r.event_name,
eventDate: toIsoDateString(r.event_date),
eventTimeStart: r.event_time_start || null,
eventTimeEnd: r.event_time_end || null,
isFullDay: hasEventCalendarCols
? (r.is_full_day === true || r.is_full_day === 1 || r.is_full_day === '1')
: true,
customerName: r.customer_name || null,
}));
// -- 2. Hour entries ---------------------------------------------------
// LEFT JOIN invoices so isEntryLocked has the invoice context it needs.
// We use the SAME predicate shape the service uses internally
// (customerHoursService._internal.isEntryLocked at lines 84-91) so
// the calendar's lock badge matches what the UI shows on the customer
// detail page.
const hoursRows = await db('customer_hour_entries as h')
.leftJoin('invoices as i', 'i.id', 'h.invoice_id')
.leftJoin('customer_accounts as c', 'c.id', 'h.customer_account_id')
.whereBetween('h.entry_date', [from, to])
.orderBy('h.entry_date', 'asc')
.select(
'h.id', 'h.customer_account_id', 'h.entry_date',
'h.start_time', 'h.end_time', 'h.description',
'h.invoice_id', 'h.invoice_line_item_id', 'h.status',
'i.status as invoice_status',
'i.is_monthly_draft as invoice_is_monthly_draft',
'i.scheduled_send_at as invoice_scheduled_send_at',
'c.display_name as customer_display_name',
'c.first_name as customer_first_name',
'c.last_name as customer_last_name',
'c.company_name as customer_company_name',
'c.email as customer_email',
);
const isEntryLocked = customerHoursService._internal.isEntryLocked;
const hours = hoursRows.map((r) => {
// Reconstruct the minimal entry + invoice shapes the locked
// predicate expects.
const entry = {
id: r.id,
invoice_id: r.invoice_id,
status: r.status,
};
const invoice = r.invoice_id ? {
id: r.invoice_id,
status: r.invoice_status,
is_monthly_draft: r.invoice_is_monthly_draft,
scheduled_send_at: r.invoice_scheduled_send_at,
} : null;
const locked = isEntryLocked(entry, invoice);
const customerName = r.customer_company_name
|| [r.customer_first_name, r.customer_last_name].filter(Boolean).join(' ')
|| r.customer_display_name
|| r.customer_email
|| null;
return {
kind: 'hours',
id: r.id,
customerAccountId: r.customer_account_id,
entryDate: toIsoDateString(r.entry_date),
startTime: r.start_time,
endTime: r.end_time,
description: r.description || null,
status: r.status,
invoiceId: r.invoice_id || null,
invoiceStatus: r.invoice_status || null,
locked,
customerName,
};
});
// -- 3. Pending quotes ------------------------------------------------
// Only quotes with status IN ('sent','accepted') AND no converted
// event yet. The frontend renders these dashed amber.
const quotesRows = await db('quotes as q')
.leftJoin('customer_accounts as c', 'c.id', 'q.customer_account_id')
.whereIn('q.status', PENDING_QUOTE_STATUSES)
.whereNull('q.converted_event_id')
.whereNotNull('q.event_date')
.whereBetween('q.event_date', [from, to])
.orderBy('q.event_date', 'asc')
.select(
'q.id', 'q.quote_number', 'q.event_name', 'q.event_date',
'q.event_time_start', 'q.event_time_end', 'q.status',
'c.display_name as customer_display_name',
'c.first_name as customer_first_name',
'c.last_name as customer_last_name',
'c.company_name as customer_company_name',
'c.email as customer_email',
);
const quotes = quotesRows.map((r) => ({
kind: 'quote',
id: r.id,
quoteNumber: r.quote_number,
eventName: r.event_name || null,
eventDate: toIsoDateString(r.event_date),
eventTimeStart: r.event_time_start || null,
eventTimeEnd: r.event_time_end || null,
status: r.status,
customerName: r.customer_company_name
|| [r.customer_first_name, r.customer_last_name].filter(Boolean).join(' ')
|| r.customer_display_name
|| r.customer_email
|| null,
}));
// -- 4. Pending contracts --------------------------------------------
const contractsRows = await db('contracts as c')
.leftJoin('customer_accounts as ca', 'ca.id', 'c.customer_account_id')
.whereIn('c.status', PENDING_CONTRACT_STATUSES)
.whereNull('c.converted_event_id')
.whereNotNull('c.event_date')
.whereBetween('c.event_date', [from, to])
.orderBy('c.event_date', 'asc')
.select(
'c.id', 'c.contract_number', 'c.event_name', 'c.event_date',
'c.event_time_start', 'c.event_time_end', 'c.status',
'ca.display_name as customer_display_name',
'ca.first_name as customer_first_name',
'ca.last_name as customer_last_name',
'ca.company_name as customer_company_name',
'ca.email as customer_email',
);
const contracts = contractsRows.map((r) => ({
kind: 'contract',
id: r.id,
contractNumber: r.contract_number,
eventName: r.event_name || null,
eventDate: toIsoDateString(r.event_date),
eventTimeStart: r.event_time_start || null,
eventTimeEnd: r.event_time_end || null,
status: r.status,
customerName: r.customer_company_name
|| [r.customer_first_name, r.customer_last_name].filter(Boolean).join(' ')
|| r.customer_display_name
|| r.customer_email
|| null,
}));
const items = [...events, ...hours, ...quotes, ...contracts];
return successResponse(res, { items, range: { from, to } });
}),
);
module.exports = router;
+610
View File
@@ -0,0 +1,610 @@
/**
* Admin → Contracts Routes
*
* Endpoint mounted at /api/admin/contracts. Surface:
* GET / list (filter + sort + paginate)
* POST / create (status=draft, seeded with all active system blocks)
* GET /:id detail (contract + included blocks)
* PUT /:id update (block toggles + scalars; draft only)
* POST /:id/send render PDF + mint token + queue email
* POST /:id/cancel cancel (draft|sent)
* POST /:id/countersign admin in-browser counter-signature
* POST /:id/upload-signed-pdf attach wet-signed PDF (multer single)
* GET /:id/pdf download / preview the system PDF
* GET /:id/signed-pdf download the wet-signed PDF (when present)
* GET /:id/preview render fresh PDF for preview (no DB write)
* GET /blocks list block library
* POST /blocks create admin-authored block
* PUT /blocks/:id update a block (system blocks: body remains editable)
* DELETE /blocks/:id delete an admin-authored block (system blocks refuse)
*
* Permissions: `contracts.view` for reads, `contracts.manage` for writes.
* The global `contracts` feature flag is checked at the route layer.
*/
const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const { assertContractPdfPath } = require('../utils/safePath');
const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { validateFileType } = require('../utils/fileSecurityUtils');
const contractService = require('../services/contractService');
const contractBlocksService = require('../services/contractBlocksService');
const { db } = require('../database/db');
const router = express.Router();
// ----- feature flag gate (admin global) -------------------------------
async function requireContractsFlag(req, res, next) {
try {
const row = await db('feature_flags').where({ key: 'contracts' }).first();
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
if (!enabled) {
return res.status(403).json({ error: 'Contracts feature is disabled', code: 'CONTRACTS_DISABLED' });
}
next();
} catch (err) {
next(err);
}
}
router.use(adminAuth);
router.use(requireContractsFlag);
// ----- multer upload (wet-signed PDF) --------------------------------
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const signedPdfStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(getStoragePath(), 'uploads/contracts/signed');
fs.mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || '.pdf';
cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
},
});
const signedPdfUpload = multer({
storage: signedPdfStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (req, file, cb) => {
const allowed = ['application/pdf'];
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
return cb(new Error('Only PDF files are allowed'));
},
});
// ---------------------------------------------------------------------
// Transforms (snake_case DB → camelCase API)
// ---------------------------------------------------------------------
function transformContract(c, inclusions) {
if (!c) return null;
return {
id: c.id,
contractNumber: c.contract_number,
customerAccountId: c.customer_account_id,
customer: {
email: c.customer_email,
displayName: c.customer_display_name,
firstName: c.customer_first_name,
lastName: c.customer_last_name,
companyName: c.customer_company_name,
preferredLanguage: c.customer_preferred_language,
},
status: c.status,
// Migration 140 — cross-document lineage UUID. See adminQuotes
// transform for the same note; lets the lineage card fetch every
// related doc in one query.
dealUuid: c.deal_uuid || null,
language: c.language,
issueDate: c.issue_date,
validUntil: c.valid_until,
title: c.title,
// Event snapshot fields (migration 130 in-place edit). Null when
// the standalone contract didn't set them OR when the column
// hasn't migrated yet on this install — the API surface stays
// stable either way.
eventName: c.event_name || null,
eventDate: c.event_date || null,
eventTimeStart: c.event_time_start || null,
eventTimeEnd: c.event_time_end || null,
introText: c.intro_text,
outroText: c.outro_text,
pdfPath: c.pdf_path,
signedPdfPath: c.signed_pdf_path,
// Audit defence: SHA-256 hashes of the on-disk PDFs computed at
// each write. Either party can re-hash the PDF they hold and
// compare against these to prove the file hasn't been tampered
// with since we issued it.
pdfSha256: c.pdf_sha256 || null,
signedPdfSha256: c.signed_pdf_sha256 || null,
// Migration 136 — surface the post-sign re-stamp failure marker so
// the admin detail page can render a recovery banner. Null when
// the most recent stamp succeeded (or the migration hasn't run on
// this install — the front-end branches on truthiness).
signedPdfRenderFailedAt: c.signed_pdf_render_failed_at || null,
signedPdfRenderError: c.signed_pdf_render_error || null,
sentAt: c.sent_at,
signedByCustomerAt: c.signed_by_customer_at,
signedByAdminAt: c.signed_by_admin_at,
signedCustomerName: c.signed_customer_name,
signedCustomerIp: c.signed_customer_ip,
signedCustomerSignaturePath: c.signed_customer_signature_path,
signedAdminName: c.signed_admin_name,
signedAdminIp: c.signed_admin_ip,
signedAdminSignaturePath: c.signed_admin_signature_path,
createdByAdminId: c.created_by_admin_id,
// Lineage back-pointers (migration 130). Surfaced so the
// ContractDetailPage can render "Linked quote" + "Linked
// invoices" panels alongside the existing block list. The
// values are nullable when the back-pointers haven't been
// populated (e.g. dev DB without the column migration; the
// service writes them through hasColumn guards).
sourceQuoteId: c.source_quote_id || null,
convertedEventId: c.converted_event_id || null,
createdAt: c.created_at,
updatedAt: c.updated_at,
inclusions: Array.isArray(inclusions)
? inclusions.map((inc) => ({
id: inc.id,
blockId: inc.block_id,
section: inc.section,
position: inc.position,
included: inc.included === true || inc.included === 1 || inc.included === '1',
block: {
slug: inc.block_slug,
name: inc.block_name,
description: inc.block_description,
bodyText: inc.block_body_text,
bodyTextDe: inc.block_body_text_de,
isSystem: inc.block_is_system === true || inc.block_is_system === 1 || inc.block_is_system === '1',
},
bodyTextSnapshot: inc.body_text_snapshot,
bodyTextDeSnapshot: inc.body_text_de_snapshot,
}))
: undefined,
};
}
function transformBlock(b) {
if (!b) return null;
return {
id: b.id,
slug: b.slug,
section: b.section,
name: b.name,
description: b.description,
bodyText: b.body_text,
bodyTextDe: b.body_text_de,
// Migration 131 — additional language bodies. Fall back to null
// on schema-drift (column missing on a not-yet-migrated install)
// so the field always exists on the JSON shape.
bodyTextRu: b.body_text_ru ?? null,
bodyTextPt: b.body_text_pt ?? null,
bodyTextNl: b.body_text_nl ?? null,
bodyTextFr: b.body_text_fr ?? null,
isSystem: b.is_system === true || b.is_system === 1 || b.is_system === '1',
isActive: b.is_active === true || b.is_active === 1 || b.is_active === '1',
displayOrder: b.display_order,
createdAt: b.created_at,
updatedAt: b.updated_at,
};
}
// ---------------------------------------------------------------------
// Block library — placed BEFORE /:id routes so 'blocks' isn't captured
// as an id (express-validator wouldn't matter, but Express order would).
// ---------------------------------------------------------------------
router.get(
'/blocks',
requirePermission('contracts.view'),
[query('section').optional().isString(), query('includeInactive').optional().isBoolean()],
handleAsync(async (req, res) => {
validateRequest(req);
const blocks = await contractBlocksService.listBlocks({
section: req.query.section,
includeInactive: req.query.includeInactive === 'true' || req.query.includeInactive === true,
});
return successResponse(res, { blocks: blocks.map(transformBlock) });
}),
);
router.post(
'/blocks',
requirePermission('contracts.manage'),
[
body('section').isString().isIn(contractBlocksService.ALLOWED_SECTIONS),
body('name').isString().isLength({ min: 1, max: 128 }),
body('bodyText').isString().isLength({ min: 1 }),
body('bodyTextDe').optional({ nullable: true }).isString(),
body('bodyTextRu').optional({ nullable: true }).isString(),
body('bodyTextPt').optional({ nullable: true }).isString(),
body('bodyTextNl').optional({ nullable: true }).isString(),
body('bodyTextFr').optional({ nullable: true }).isString(),
body('description').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('displayOrder').optional({ nullable: true }).isInt({ min: 0 }),
body('isActive').optional().isBoolean(),
],
handleAsync(async (req, res) => {
validateRequest(req);
const block = await contractBlocksService.createBlock(req.body);
return successResponse(res, { block: transformBlock(block) }, 201);
}),
);
router.put(
'/blocks/:id',
requirePermission('contracts.manage'),
[
param('id').isInt({ min: 1 }),
body('section').optional().isString().isIn(contractBlocksService.ALLOWED_SECTIONS),
body('name').optional().isString().isLength({ min: 1, max: 128 }),
body('bodyText').optional().isString().isLength({ min: 1 }),
body('bodyTextDe').optional({ nullable: true }).isString(),
body('bodyTextRu').optional({ nullable: true }).isString(),
body('bodyTextPt').optional({ nullable: true }).isString(),
body('bodyTextNl').optional({ nullable: true }).isString(),
body('bodyTextFr').optional({ nullable: true }).isString(),
body('description').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('displayOrder').optional({ nullable: true }).isInt({ min: 0 }),
body('isActive').optional().isBoolean(),
],
handleAsync(async (req, res) => {
validateRequest(req);
const block = await contractBlocksService.updateBlock(parseInt(req.params.id, 10), req.body);
return successResponse(res, { block: transformBlock(block) });
}),
);
router.delete(
'/blocks/:id',
requirePermission('contracts.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
await contractBlocksService.deleteBlock(parseInt(req.params.id, 10));
return successResponse(res, { ok: true });
}),
);
// ---------------------------------------------------------------------
// Contracts
// ---------------------------------------------------------------------
router.get(
'/',
requirePermission('contracts.view'),
[
query('status').optional().isString(),
query('customerAccountId').optional().isInt({ min: 1 }),
query('q').optional().isString(),
query('sort').optional().isIn(['newest', 'oldest', 'customer_asc']),
query('page').optional().isInt({ min: 1 }),
query('pageSize').optional().isInt({ min: 1, max: 200 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const filters = {};
if (req.query.status) {
filters.status = String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean);
}
if (req.query.customerAccountId) filters.customerAccountId = parseInt(req.query.customerAccountId, 10);
if (req.query.q) filters.q = String(req.query.q);
const page = parseInt(req.query.page, 10) || 1;
const pageSize = parseInt(req.query.pageSize, 10) || 25;
const result = await contractService.listContracts({
filters,
sort: req.query.sort || 'newest',
page,
pageSize,
});
return successResponse(res, {
contracts: result.rows.map((row) => transformContract(row)),
total: result.total,
page: result.page,
pageSize: result.pageSize,
});
}),
);
router.post(
'/',
requirePermission('contracts.manage'),
[
body('customerAccountId').isInt({ min: 1 }),
body('language').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('title').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('eventName').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('eventDate').optional({ nullable: true }).isISO8601(),
body('eventTimeStart').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('eventTimeEnd').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('introText').optional({ nullable: true }).isString(),
body('outroText').optional({ nullable: true }).isString(),
body('issueDate').optional({ nullable: true }).isISO8601(),
body('validUntil').optional({ nullable: true }).isISO8601(),
],
handleAsync(async (req, res) => {
validateRequest(req);
const id = await contractService.createContract(req.body, req.admin?.id);
const data = await contractService.getContractById(id);
return successResponse(res, { contract: transformContract(data.contract, data.inclusions) }, 201);
}),
);
router.get(
'/:id',
requirePermission('contracts.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const data = await contractService.getContractById(parseInt(req.params.id, 10));
if (!data) return res.status(404).json({ error: 'Contract not found' });
return successResponse(res, { contract: transformContract(data.contract, data.inclusions) });
}),
);
router.put(
'/:id',
requirePermission('contracts.manage'),
[
param('id').isInt({ min: 1 }),
body('title').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('eventName').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('eventDate').optional({ nullable: true }).isISO8601(),
body('eventTimeStart').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('eventTimeEnd').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('introText').optional({ nullable: true }).isString(),
body('outroText').optional({ nullable: true }).isString(),
body('language').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('issueDate').optional({ nullable: true }).isISO8601(),
body('validUntil').optional({ nullable: true }).isISO8601(),
body('blocks').optional().isArray(),
body('blocks.*.blockId').optional().isInt({ min: 1 }),
body('blocks.*.included').optional().isBoolean(),
body('blocks.*.position').optional().isInt({ min: 0 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
await contractService.updateContract(parseInt(req.params.id, 10), req.body, req.admin?.id);
const data = await contractService.getContractById(parseInt(req.params.id, 10));
return successResponse(res, { contract: transformContract(data.contract, data.inclusions) });
}),
);
router.post(
'/:id/send',
requirePermission('contracts.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.sendContract(parseInt(req.params.id, 10), req.admin?.id);
return successResponse(res, result);
}),
);
router.post(
'/:id/cancel',
requirePermission('contracts.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.cancelContract(parseInt(req.params.id, 10), req.admin?.id);
return successResponse(res, result);
}),
);
// Convert a fully-signed contract into an event + scheduled invoices.
// Delegates to quoteService via the contract's source_quote_id; refuses
// when source_quote_id is null (standalone contracts have no line items
// to replay).
router.post(
'/:id/convert-to-event',
requirePermission('contracts.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.convertToEvent(parseInt(req.params.id, 10), req.admin?.id);
return successResponse(res, result, 200,
result.alreadyConverted ? 'Already converted to event' : 'Contract converted to event');
}),
);
// Convert a fully-signed contract into invoice(s) only — no event.
router.post(
'/:id/convert-to-invoice',
requirePermission('contracts.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.convertToInvoiceOnly(parseInt(req.params.id, 10), req.admin?.id);
return successResponse(res, result, 200, 'Invoices created from contract');
}),
);
// Re-render the signed PDF (when it's a system render, not a wet-
// signed upload) and resend the contract_fully_signed email to both
// parties. Recovery action for contracts where the initial dual-party
// send failed silently, or where the customer claims they didn't
// receive the email.
router.post(
'/:id/resend-signed',
requirePermission('contracts.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.rerenderAndResend(parseInt(req.params.id, 10), req.admin?.id);
return successResponse(res, result, 200, 'Signed contract re-sent to both parties');
}),
);
// Re-stamp one or both signature images on a contract whose original
// sign happened before the canvas worked correctly. The admin draws
// the missing signature(s) on the detail page; this endpoint persists
// the PNGs, updates signature_path columns, and re-renders the PDF.
// The customer's typed name + timestamp + IP stay untouched — only
// the image bound to those evidence fields gets refreshed.
router.post(
'/:id/restamp-signatures',
requirePermission('contracts.manage'),
[
param('id').isInt({ min: 1 }),
body('customerSignatureDataUrl').optional({ nullable: true }).isString(),
body('adminSignatureDataUrl').optional({ nullable: true }).isString(),
],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.restampSignatures(
parseInt(req.params.id, 10),
{
customerSignatureDataUrl: req.body.customerSignatureDataUrl || null,
adminSignatureDataUrl: req.body.adminSignatureDataUrl || null,
},
req.admin?.id,
);
return successResponse(res, result, 200, 'Signatures re-stamped and PDF re-rendered');
}),
);
router.post(
'/:id/countersign',
requirePermission('contracts.manage'),
[
param('id').isInt({ min: 1 }),
body('name').isString().isLength({ min: 1, max: 255 }),
body('signatureDataUrl').optional({ nullable: true }).isString(),
],
handleAsync(async (req, res) => {
validateRequest(req);
const ip = req.ip || req.headers['x-forwarded-for'] || null;
const result = await contractService.recordAdminCountersignature(
parseInt(req.params.id, 10),
{ name: req.body.name, ip, signatureDataUrl: req.body.signatureDataUrl },
req.admin?.id,
);
return successResponse(res, result);
}),
);
router.post(
'/:id/upload-signed-pdf',
requirePermission('contracts.manage'),
[param('id').isInt({ min: 1 })],
signedPdfUpload.single('file'),
handleAsync(async (req, res) => {
validateRequest(req);
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
}
const result = await contractService.attachSignedPdfUpload(
parseInt(req.params.id, 10),
req.file.path,
'admin',
);
return successResponse(res, result);
}),
);
router.get(
'/:id/pdf',
requirePermission('contracts.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const data = await contractService.getContractById(parseInt(req.params.id, 10));
if (!data) return res.status(404).json({ error: 'Contract not found' });
if (!data.contract.pdf_path) {
return res.status(404).json({ error: 'PDF not yet rendered', code: 'PDF_MISSING' });
}
if (!fs.existsSync(data.contract.pdf_path)) {
return res.status(404).json({ error: 'PDF file missing from disk', code: 'PDF_MISSING_ON_DISK' });
}
// Defence-in-depth: reject any path that resolves outside the
// contract storage roots before we open the stream. Today the DB
// paths are always written by the service layer, but a future
// migration bug or hand-edited row should not turn this endpoint
// into an arbitrary-file-read primitive.
const safePath = assertContractPdfPath(data.contract.pdf_path);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`inline; filename="${data.contract.contract_number}.pdf"`,
);
fs.createReadStream(safePath).pipe(res);
}),
);
router.get(
'/:id/signed-pdf',
requirePermission('contracts.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const data = await contractService.getContractById(parseInt(req.params.id, 10));
if (!data) return res.status(404).json({ error: 'Contract not found' });
if (!data.contract.signed_pdf_path) {
return res.status(404).json({ error: 'No signed PDF uploaded', code: 'SIGNED_PDF_MISSING' });
}
if (!fs.existsSync(data.contract.signed_pdf_path)) {
return res.status(404).json({ error: 'Signed PDF missing from disk', code: 'SIGNED_PDF_MISSING_ON_DISK' });
}
const safePath = assertContractPdfPath(data.contract.signed_pdf_path);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`inline; filename="${data.contract.contract_number}-signed.pdf"`,
);
fs.createReadStream(safePath).pipe(res);
}),
);
// Audit trail — chronological activity_logs entries for this contract.
// Used by the AuditTrailCard on the admin detail page; read-only.
router.get(
'/:id/audit-trail',
requirePermission('contracts.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const entries = await contractService.getAuditTrail(parseInt(req.params.id, 10));
return successResponse(res, { entries });
}),
);
// Integrity check — re-hashes pdf_path + signed_pdf_path on disk and
// compares to the stored pdf_sha256 / signed_pdf_sha256 (migration
// 131). Lets the admin confirm a contract PDF on disk still matches
// what was issued, catching backup-corruption / manual-edit cases
// without needing to drop to a shell.
router.get(
'/:id/verify-integrity',
requirePermission('contracts.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await contractService.verifyIntegrity(parseInt(req.params.id, 10));
return successResponse(res, result);
}),
);
router.get(
'/:id/preview',
requirePermission('contracts.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const buffer = await contractService.renderContractPdfBuffer(parseInt(req.params.id, 10));
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="contract-preview.pdf"');
return res.send(buffer);
}),
);
module.exports = router;
+335 -10
View File
@@ -12,6 +12,8 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const customerAccountsService = require('../services/customerAccountsService');
const customerHoursService = require('../services/customerHoursService');
const invoiceService = require('../services/invoiceService');
const router = express.Router();
@@ -38,14 +40,32 @@ function transformCustomer(c) {
city: c.city,
state: c.state,
countryCode: c.country_code,
countryName: c.country_name,
preferredLanguage: c.preferred_language,
// CRM billing cadence override (migration 102). Drives whether the
// invoice scheduler honours the quote's installment plan or snaps
// every bill to the customer's monthly/quarterly cycle day.
billingCadence: c.billing_cadence || 'per_event',
billingCycleDay: c.billing_cycle_day == null ? 1 : Number(c.billing_cycle_day),
notes: c.notes,
isActive: c.is_active,
// Passive customers (admin-only, no portal access) are identified
// by a null password_hash. We never expose the hash itself —
// this boolean is the only thing the frontend ever sees, and it
// drives the "Passive — admin only" badge + the "Send portal
// invitation" button on the detail page.
isPassive: c.password_hash == null,
// Per-customer feature flags (#354 follow-up). Coerce to bool so the
// frontend doesn't have to deal with SQLite's 0/1 values.
featureCalendar: c.feature_calendar === true || c.feature_calendar === 1,
featureQuotes: c.feature_quotes === true || c.feature_quotes === 1,
featureBills: c.feature_bills === true || c.feature_bills === 1,
// Hours logging (migration 129) — fourth per-customer flag.
// Default hourly rate (in minor units) is null when admin hasn't
// set one; the editor surfaces it as an empty input and forces a
// per-entry override on every logged block.
featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1,
hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null,
lastLogin: c.last_login,
createdAt: c.created_at,
updatedAt: c.updated_at,
@@ -141,6 +161,11 @@ router.post('/invite', [
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
// Per-customer preferred language. Drives portal UI + quote/invoice
// PDF locale. Defaults at insert time to the business profile's
// default_locale when the admin doesn't supply one (see
// customerAccountsService.acceptInvitation).
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const invitation = await customerAccountsService.createInvitation({
@@ -162,7 +187,15 @@ router.post('/invite', [
expiresAt: invitation.expiresAt,
},
};
if (process.env.NODE_ENV !== 'production') {
// C.7 — hardened token echo. The previous shape gated on
// `NODE_ENV !== 'production'`, which is true in dev AND when the
// variable is unset entirely (some hosting setups never set
// NODE_ENV in their entrypoint). That meant the raw invitation
// token could leak in production-shaped deployments where the env
// happened to be unset. Now requires an EXPLICIT opt-in
// (`PICPEAK_ECHO_INVITE_TOKEN=1`) so a misconfigured production
// host fails closed instead of open.
if (process.env.PICPEAK_ECHO_INVITE_TOKEN === '1') {
payload.invitation.token = invitation.token;
}
successResponse(res, payload, 201);
@@ -181,6 +214,114 @@ router.delete('/invitations/:id', [
successResponse(res, { message: 'Invitation cancelled' });
}));
// ---- create passive customer (no invitation, admin-only) ----------------
//
// Counterpart to POST /invite: instead of creating an invitation row +
// email, this endpoint inserts the customer directly with
// password_hash=null (passive). The admin uses this when they have all
// the customer's info on hand and just need an identity to attach a
// quote / invoice / gallery to — no portal access required.
//
// Same per-field validators as /invite's prefill block, plus `email`
// required at the top level. Permission: customers.create.
router.post('/', [
adminAuth,
requirePermission('customers.create'),
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('prefill').optional().isObject(),
body('prefill.salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
body('prefill.first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('prefill.last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('prefill.display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('prefill.company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('prefill.address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('prefill.address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const { id } = await customerAccountsService.createDirect({
email: req.body.email,
prefill: req.body.prefill,
createdByAdminId: req.admin.id,
});
const customer = await customerAccountsService.getCustomerById(id);
successResponse(res, { customer: transformCustomer(customer) }, 201);
}));
// ---- promote a passive customer to active (send portal invitation) ------
//
// Fires the standard customer-invitation email flow at a customer who
// currently has no password_hash. The customer clicks the link, lands
// on the accept page (pre-populated with their existing profile),
// chooses a password, and is now active. The customer's id stays the
// same — all their invoices/quotes/gallery assignments survive.
//
// 409 with code CUSTOMER_ALREADY_ACTIVE when the customer already has
// a password set, so the button on the detail page can render an
// appropriate error toast.
router.post('/:id/send-invite', [
adminAuth,
requirePermission('customers.create'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const customerId = parseInt(req.params.id, 10);
const customer = await customerAccountsService.getCustomerById(customerId);
if (customer.password_hash) {
return res.status(409).json({
error: 'Customer already has portal access — no invitation needed.',
code: 'CUSTOMER_ALREADY_ACTIVE',
});
}
// Derive the invitation prefill from the customer's existing
// profile so the accept page is pre-populated with what the admin
// already entered for them (saves the customer typing it again).
// Only the whitelisted fields go through.
const prefill = {
salutation: customer.salutation,
first_name: customer.first_name,
last_name: customer.last_name,
display_name: customer.display_name,
phone: customer.phone,
company_name: customer.company_name,
vat_id: customer.vat_id,
address_line1: customer.address_line1,
address_line2: customer.address_line2,
postal_code: customer.postal_code,
city: customer.city,
state: customer.state,
country_code: customer.country_code,
country_name: customer.country_name,
preferred_language: customer.preferred_language,
};
const invitation = await customerAccountsService.createInvitation({
email: customer.email,
invitedById: req.admin.id,
prefill,
});
const payload = {
invitation: {
id: invitation.id,
email: invitation.email,
expiresAt: invitation.expiresAt,
},
};
// C.7 — see the matching gate on POST /invite. Explicit opt-in
// (`PICPEAK_ECHO_INVITE_TOKEN=1`) fails closed when NODE_ENV is
// unset in a production-shaped deployment.
if (process.env.PICPEAK_ECHO_INVITE_TOKEN === '1') {
payload.invitation.token = invitation.token;
}
successResponse(res, payload, 201);
}));
// ---- customer record ----------------------------------------------------
router.get('/:id', [
@@ -197,15 +338,24 @@ router.get('/:id', [
router.put('/:id', [
adminAuth,
requirePermission('customers.create'),
// Migration 134 — record-edit scope split out of customers.create.
// Roles that previously held customers.create were granted
// customers.edit on upgrade so behavior is preserved.
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
body('email').optional().isEmail().normalizeEmail(),
body('salutation').optional().isString().isLength({ max: 32 }),
body('first_name').optional().isString().isLength({ max: 80 }),
body('last_name').optional().isString().isLength({ max: 80 }),
body('display_name').optional().isString().isLength({ max: 120 }),
body('phone').optional().isString().isLength({ max: 40 }),
body('company_name').optional().isString().isLength({ max: 120 }),
// `{ nullable: true }` so a passive customer who has no salutation /
// phone / company in their record can still save the page — the
// form sends `null` for those empty fields, and plain `.optional()`
// (which only skips `undefined`) would reject null at the
// subsequent `.isString()` step. Mirrors the existing pattern on
// billing_email / vat_id / address_* below.
body('salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
body('first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
body('display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('billing_email').optional({ nullable: true }).isString(),
body('vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
body('address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
@@ -214,12 +364,24 @@ router.put('/:id', [
body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('preferred_language').optional().isString().isLength({ max: 8 }),
body('country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('preferred_language').optional({ nullable: true }).isString().isLength({ max: 8 }),
body('notes').optional({ nullable: true }).isString(),
body('is_active').optional().isBoolean(),
body('feature_calendar').optional().isBoolean(),
body('feature_quotes').optional().isBoolean(),
body('feature_bills').optional().isBoolean(),
// Hours logging (migration 129).
body('feature_hours_logging').optional().isBoolean(),
body('hourly_rate_minor').optional({ nullable: true }).isInt({ min: 0 }),
// CRM billing cadence — see migration 102. `per_event` keeps the
// existing per-event payment plan; monthly/quarterly snap every
// generated invoice to billing_cycle_day of the next period.
// Cycle day spans -15..-1 (days before month end) and 1..28
// (day of month) per migration 128 + service-layer clamp.
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']),
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 })
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
], handleAsync(async (req, res) => {
validateRequest(req);
const customer = await customerAccountsService.updateCustomer(
@@ -327,7 +489,11 @@ router.post('/:id/password-reset', [
*/
router.put('/:id/events', [
adminAuth,
requirePermission('customers.create'),
// Migration 134 — event-assignment scope split out of customers.create.
// Lets an admin grant a coordinator the ability to re-target a customer
// between weddings without also unlocking VAT-ID / billing-address
// edits on every customer they can see.
requirePermission('customers.events'),
param('id').isInt({ min: 1 }),
body('event_ids').isArray(),
body('event_ids.*').isInt({ min: 1 }),
@@ -341,4 +507,163 @@ router.put('/:id/events', [
successResponse(res, result);
}));
// ---------------------------------------------------------------------
// Hour entries (migration 129).
//
// Five endpoints under /api/admin/customers/:id/hour-entries — list,
// create, update, delete, plus the per-event "Bill these hours"
// action. Mounted alongside the /events sub-resource above; permission
// tier is customers.create, same as the rest of the customer-write
// surface.
// ---------------------------------------------------------------------
router.get('/:id/hour-entries', [
adminAuth,
requirePermission('customers.view'),
param('id').isInt({ min: 1 }),
query('status').optional().isIn(['unbilled', 'billed', 'cancelled']),
], handleAsync(async (req, res) => {
validateRequest(req);
const rows = await customerHoursService.listEntries(
parseInt(req.params.id, 10),
{ status: req.query.status },
);
successResponse(res, { entries: rows.map(transformHourEntry) });
}));
router.post('/:id/hour-entries', [
adminAuth,
// Migration 134 — hour entries are customer-scoped writes; same scope
// as customer record edits, narrower than invite/create.
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
body('entryDate').isISO8601(),
body('startTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
body('endTime').matches(/^([01]\d|2[0-3]):[0-5]\d$/),
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerHoursService.createEntry(
parseInt(req.params.id, 10),
req.body,
req.admin.id,
);
successResponse(res, result, 201);
}));
router.put('/:id/hour-entries/:entryId', [
adminAuth,
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
param('entryId').isInt({ min: 1 }),
body('entryDate').optional().isISO8601(),
body('startTime').optional().matches(/^([01]\d|2[0-3]):[0-5]\d$/),
body('endTime').optional().matches(/^([01]\d|2[0-3]):[0-5]\d$/),
body('hourlyRateMinorOverride').optional({ nullable: true }).isInt({ min: 0 }),
body('description').optional({ nullable: true }).isString().isLength({ max: 1000 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerHoursService.updateEntry(
parseInt(req.params.entryId, 10),
req.body,
req.admin.id,
);
successResponse(res, result);
}));
router.delete('/:id/hour-entries/:entryId', [
adminAuth,
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
param('entryId').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerHoursService.deleteEntry(
parseInt(req.params.entryId, 10),
req.admin.id,
);
successResponse(res, result);
}));
router.post('/:id/hour-entries/bill', [
adminAuth,
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await customerHoursService.billUnbilledEntries(
parseInt(req.params.id, 10),
req.admin.id,
);
successResponse(res, result, 201);
}));
function transformHourEntry(h) {
return {
id: h.id,
customerAccountId: h.customer_account_id,
entryDate: typeof h.entry_date === 'string' ? h.entry_date.slice(0, 10) : h.entry_date,
startTime: h.start_time,
endTime: h.end_time,
durationMinutes: Number(h.duration_minutes),
hourlyRateMinorOverride: h.hourly_rate_minor_override != null ? Number(h.hourly_rate_minor_override) : null,
description: h.description,
status: h.status,
invoiceId: h.invoice_id,
invoiceLineItemId: h.invoice_line_item_id,
invoiceNumber: h.invoice_number || null,
invoiceStatus: h.invoice_status || null,
invoiceIsMonthlyDraft: h.invoice_is_monthly_draft === true || h.invoice_is_monthly_draft === 1,
invoiceScheduledSendAt: h.invoice_scheduled_send_at,
billedAt: h.billed_at,
recordedByAdminId: h.recorded_by_admin_id,
createdAt: h.created_at,
updatedAt: h.updated_at,
};
}
// ---------------------------------------------------------------------
// Monthly billing — manual trigger (migration 128 admin override).
//
// Issues the customer's running monthly draft NOW, bypassing the
// scheduler's cadence-day wait. Used when admin wants to bill out-of-
// cycle (e.g. customer requested an early invoice, project completed
// before cadence day). Permission tier is customers.create — same as
// the rest of the customer-write surface and matches the rest of the
// monthly-billing controls.
// ---------------------------------------------------------------------
router.post('/:id/trigger-monthly-bill', [
adminAuth,
// Migration 134 — admin-override fire is a customer-scoped write,
// not a create. Roles holding customers.create were granted
// customers.edit on upgrade so this still works for existing admins.
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await invoiceService.triggerMonthlyBillNow(
parseInt(req.params.id, 10),
req.admin.id,
);
successResponse(res, result, 201);
}));
// Preview the customer's open monthly draft (line items + totals) so
// the customer-detail page can show "what will ship on the next cycle
// day". Returns null draft when nothing has been queued yet. Same
// permission scope as the trigger endpoint — both read/operate on
// the same row.
router.get('/:id/monthly-draft', [
adminAuth,
// Migration 134 — kept aligned with /trigger-monthly-bill above;
// the same role that can fire the draft should be able to preview it.
requirePermission('customers.edit'),
param('id').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const draft = await invoiceService.getMonthlyDraft(parseInt(req.params.id, 10));
successResponse(res, { draft });
}));
module.exports = router;
+172
View File
@@ -353,4 +353,176 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
}
});
/**
* CRM overview stats — quote / invoice counts by status, rolling
* revenue windows, outstanding payments. Used by the CRM Overview
* tab at /admin/clients/overview.
*
* Permission gate: `bills.view` OR `quotes.view` — either CRM
* sub-feature unlocks the headline numbers.
*
* Currency handling: aggregates sum naively across currencies.
* Multi-currency installs get a `currency` field set to the
* business profile's default; admins running mixed-currency books
* should treat the headline figure as approximate. A multi-currency
* breakdown can be added later.
*/
router.get('/crm-stats', adminAuth, async (req, res) => {
try {
// Either CRM permission is enough — both sub-features stand
// alone (one studio may bill manually but quote via picpeak,
// another might invoice but not quote). Permissions aren't
// attached to req.admin synchronously; we have to ask the DB
// via userHasAnyPermission so our inline check matches the
// behaviour of the requirePermission middleware used elsewhere.
const { userHasAnyPermission } = require('../middleware/permissions');
const canSeeBills = await userHasAnyPermission(req.admin.id, ['bills.view']);
const canSeeQuotes = await userHasAnyPermission(req.admin.id, ['quotes.view']);
if (!canSeeBills && !canSeeQuotes) {
return res.status(403).json({ error: 'Forbidden' });
}
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const monthCutoff = new Date(now - 30 * DAY);
const quarterCutoff = new Date(now - 90 * DAY);
const yearCutoff = new Date(now - 365 * DAY);
// ---- quotes: counts by status ---------------------------------
let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 };
if (canSeeQuotes) {
try {
const rows = await db('quotes').select('status').count('id as count').groupBy('status');
for (const r of rows) {
if (r.status in quoteCounts) quoteCounts[r.status] = Number(r.count) || 0;
}
} catch (e) {
// Table may not exist on installs without CRM migrations.
// Treat as all-zero so the page still renders.
}
}
// ---- invoices: counts by status -------------------------------
let invoiceCounts = { scheduled: 0, sent: 0, paid: 0, overdue: 0, cancelled: 0 };
let revenueMonthMinor = 0;
let revenueQuarterMinor = 0;
let revenueYearMinor = 0;
let outstandingTotalMinor = 0;
let outstandingCount = 0;
if (canSeeBills) {
try {
// Same exclusions as the outstanding calc — Stornorechnungen
// and monthly drafts skew the per-status counts (Sent
// includes both real outstanding invoices and credit-note
// rows; Scheduled includes mid-period accumulators that the
// admin doesn't think of as "queued invoices" yet).
const rows = await db('invoices')
.andWhere(function() {
this.whereNot('kind', 'storno').orWhereNull('kind');
})
.andWhere(function() {
this.where('is_monthly_draft', false).orWhereNull('is_monthly_draft');
})
.select('status').count('id as count').groupBy('status');
for (const r of rows) {
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
}
// Revenue windows: sum of `paid_amount_minor` for invoices
// marked PAID where paid_at falls inside the window. Using
// paid_amount (not total) so partial payments are tracked
// accurately. Stornos excluded — they're never status='paid'
// in normal flow but the guard is defensive.
const winSum = async (cutoff) => {
const row = await db('invoices')
.where('status', 'paid')
.where('paid_at', '>=', cutoff)
.andWhere(function() {
this.whereNot('kind', 'storno').orWhereNull('kind');
})
.sum('paid_amount_minor as total')
.first();
return Number(row?.total || 0);
};
revenueMonthMinor = await winSum(monthCutoff);
revenueQuarterMinor = await winSum(quarterCutoff);
revenueYearMinor = await winSum(yearCutoff);
// Outstanding: every invoice that's been sent but not fully
// paid (sent + overdue). Outstanding = total - paid. We sum
// the gap per row rather than `total - sum(paid)` so partial
// payments contribute correctly.
//
// Exclusions:
// - kind='storno' rows. Stornorechnungen carry status='sent'
// and total_amount_minor < 0; without the filter they slip
// through the status check and inflate `invoiceCount` by 1
// per Storno (the per-row gap math correctly returns 0 for
// the amount, but the row still counts). They're
// accounting-side credit notes, not money the customer
// owes.
// - is_monthly_draft=true rows. Drafts ship via the monthly
// cycle and aren't owed money until they leave draft state.
const openRows = await db('invoices')
.whereIn('status', ['sent', 'overdue'])
.andWhere(function() {
this.whereNot('kind', 'storno').orWhereNull('kind');
})
.andWhere(function() {
// Belt-and-braces: a Storno is uniquely identified by
// having `cancels_invoice_id` set (migration 114). Even
// if `kind` is somehow NULL on a Storno row, this catches
// it. NULL on regular invoices passes through unchanged.
this.whereNull('cancels_invoice_id');
})
.andWhere(function() {
this.where('is_monthly_draft', false).orWhereNull('is_monthly_draft');
})
.andWhere('total_amount_minor', '>=', 0)
.select('total_amount_minor', 'paid_amount_minor', 'late_fee_amount_minor');
for (const r of openRows) {
const total = Number(r.total_amount_minor || 0) + Number(r.late_fee_amount_minor || 0);
const paid = Number(r.paid_amount_minor || 0);
const gap = Math.max(0, total - paid);
if (gap > 0) {
outstandingTotalMinor += gap;
outstandingCount += 1;
}
}
} catch (e) {
// Table missing — treat as empty (same as quotes above).
}
}
// Default currency for the headline figure. Pull from
// business_profile.default_currency when present; the renderer
// accepts the same fallback chain we use elsewhere.
let currency = 'CHF';
try {
const profile = await db('business_profile').where({ id: 1 }).first();
if (profile?.default_currency) currency = String(profile.default_currency).toUpperCase();
} catch (_) { /* leave default */ }
res.json({
currency,
quotes: quoteCounts,
invoices: invoiceCounts,
revenue: {
monthMinor: revenueMonthMinor,
quarterMinor: revenueQuarterMinor,
yearMinor: revenueYearMinor,
},
outstanding: {
totalMinor: outstandingTotalMinor,
invoiceCount: outstandingCount,
},
generatedAt: new Date().toISOString(),
});
} catch (error) {
require('../utils/logger').error('CRM stats error:', error);
res.status(500).json({ error: 'Failed to load CRM stats' });
}
});
module.exports = router;
+83
View File
@@ -0,0 +1,83 @@
/**
* Admin → deals lineage endpoint.
*
* One UUID per customer engagement spans every quote, contract, and
* invoice (migration 140). This route exposes the union: given a
* deal_uuid, return every related document so the frontend's
* DocumentLineageCard can render the full chain with a single query
* instead of walking the legacy point-to-point FKs in JS.
*
* Read-only. The same `customers.view` permission used elsewhere for
* lineage display is the gate here — anyone who can read a quote or
* invoice detail page can read its deal lineage.
*
* Sibling routes (`/api/admin/quotes/:id/lineage`,
* `/api/admin/contracts/:id/lineage`, `/api/admin/invoices/:id/lineage`)
* also exist as conveniences so the frontend doesn't have to fetch
* the deal_uuid first; they resolve and delegate to the same service.
*/
const express = require('express');
const { param, body } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const dealsService = require('../services/dealsService');
const invoiceService = require('../services/invoiceService');
const { db } = require('../database/db');
const router = express.Router();
router.use(adminAuth);
router.get(
'/:uuid/documents',
requirePermission('customers.view'),
// UUID v4 format check — adminCalendar uses a similar pattern.
// Length window 3236 covers both hyphenated and non-hyphenated
// forms; the service does the actual lookup.
[param('uuid').isString().isLength({ min: 32, max: 36 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await dealsService.getDealDocuments(req.params.uuid);
return successResponse(res, result);
}),
);
/**
* Atomically reshape an installment plan after siblings have spawned.
* Delegates to invoiceService.updateInstallmentPlan inside a transaction.
* See that service function for the guard/reuse/grow/trim semantics.
*
* 400 — invalid input (validator or service-side percent sum / unknown
* trigger / single-invoice deal).
* 404 — deal_uuid owns no invoices.
* 409 — at least one sibling is past `scheduled`/`pending_delivery`, or
* the deal contains a Storno.
*/
router.put(
'/:uuid/installment-plan',
requirePermission('bills.manage'),
[
param('uuid').isString().isLength({ min: 32, max: 36 }),
body('installments').isArray({ min: 1 }),
body('installments.*.percent').isFloat({ min: 0, max: 100 }),
body('installments.*.trigger').isIn([
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
]),
body('installments.*.offset_days').isInt(),
body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 200 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const adminId = req.admin?.id;
const result = await db.transaction((trx) => invoiceService.updateInstallmentPlan({
trx,
dealUuid: req.params.uuid,
installments: req.body.installments,
adminId,
}));
return successResponse(res, result);
}),
);
module.exports = router;
+436
View File
@@ -0,0 +1,436 @@
/**
* Admin → Dev tools
*
* Internal-use endpoints surfaced via the "Development" sub-tab
* under Clients. Strictly gated behind THREE layers:
* - admin auth + `settings.edit` permission
* - the `crmDevelopment` feature flag (defense-in-depth — the
* frontend hides the tab when off, this check stops API
* callers from poking endpoints that aren't supposed to fire)
* - the `PICPEAK_ENABLE_DEV_TOOLS=1` environment variable
* (production-safety hard gate — a stray feature flag flip in
* a real install can't enable these endpoints)
*
* Currently exposes:
* POST /send-test-email queue any CRM email template to the
* currently-logged-in admin's mailbox,
* with SYNTHETIC data only (PDFs are
* rendered from hard-coded sample data,
* never from real customer records)
*
* Security history: a prior version of this route queried real
* customer records (`SELECT … FROM quotes ORDER BY id DESC LIMIT 1`)
* to source the sample PDFs, which leaked one customer's invoice to
* a different admin's inbox in multi-admin installs. The synthetic-
* only data path closes that leak; the env gate prevents accidental
* production exposure if the feature flag is ever flipped on by
* mistake.
*/
const express = require('express');
const { body } = require('express-validator');
const path = require('path');
const fs = require('fs');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { db } = require('../database/db');
const emailProcessor = require('../services/emailProcessor');
const pdfService = require('../services/pdfService');
const { AppError } = require('../utils/errors');
const logger = require('../utils/logger');
const router = express.Router();
router.use(adminAuth);
/**
* Production-safety gate. Even if the crmDevelopment feature flag is
* accidentally enabled in a production install (one wrong DB toggle),
* this env check prevents the endpoints from doing anything. Operators
* who genuinely want dev tools in a non-production environment set
* PICPEAK_ENABLE_DEV_TOOLS=1 in the env file.
*/
router.use(handleAsync(async (req, res, next) => {
if (process.env.PICPEAK_ENABLE_DEV_TOOLS !== '1') {
return res.status(403).json({
error: 'CRM development tools are disabled (PICPEAK_ENABLE_DEV_TOOLS env var not set)',
code: 'CRM_DEV_ENV_DISABLED',
});
}
next();
}));
/**
* Gate every endpoint below the crmDevelopment feature flag.
* Mirrors the parent /admin/clients/development route guard.
*/
router.use(handleAsync(async (req, res, next) => {
const row = await db('feature_flags').where({ key: 'crmDevelopment' }).first();
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
if (!enabled) {
return res.status(403).json({
error: 'CRM development tools are disabled',
code: 'CRM_DEV_DISABLED',
});
}
next();
}));
const TEMPLATES_KEYS = [
'quote_sent',
'quote_accepted_customer',
'quote_accepted_admin',
'quote_declined_admin',
'invoice_sent',
'invoice_reminder_first',
'invoice_reminder_second',
'invoice_payment_check_admin',
// Contracts (migration 130). All three flows are exercised:
// - contract_sent: admin → customer, with a sample contract PDF
// - contract_signed_admin_notification: customer-signed ping back
// to the admin (no attachment in the real flow either)
// - contract_fully_signed: dual-party send when both signatures
// are in. Real flow attaches the stamped contract + audit cert;
// the dev tester attaches the stamped contract only (the audit
// cert is reproducible from contract data so its absence here
// doesn't change what's being tested — the template body).
'contract_sent',
'contract_signed_admin_notification',
'contract_fully_signed',
];
router.get(
'/email-templates',
requirePermission('settings.edit'),
handleAsync(async (_req, res) => {
// Return the keys + whether each template exists in the DB so
// the UI can grey out missing ones (e.g. on an install that
// hasn't run migration 116 yet).
const rows = await db('email_templates')
.whereIn('template_key', TEMPLATES_KEYS)
.select('template_key');
const present = new Set(rows.map((r) => r.template_key));
return successResponse(res, {
templates: TEMPLATES_KEYS.map((k) => ({ key: k, present: present.has(k) })),
});
})
);
const FRONTEND_URL_FALLBACK = 'https://app.example.com';
const DEV_TEST_DIR = () => path.join(process.cwd(), 'storage', 'business-docs', 'dev-test');
function fakeMoney(major, currency, locale = 'de') {
return new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', {
style: 'currency', currency: (currency || 'CHF').toUpperCase(),
}).format(major);
}
function fakeShortDate(d) {
const date = d instanceof Date ? d : new Date(d);
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
}
/**
* Keep the dev-test PDF directory bounded: retain only the 7 newest
* files per cleanup pass. Each test-email render writes a fresh file;
* without cleanup the directory grows unbounded.
*
* Best-effort: failures are logged and swallowed (cleanup never blocks
* the test-email flow).
*/
function pruneDevTestDir() {
try {
const dir = DEV_TEST_DIR();
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isFile() && e.name.endsWith('.pdf'))
.map((e) => {
const full = path.join(dir, e.name);
return { full, mtime: fs.statSync(full).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime); // newest first
for (const old of entries.slice(7)) {
try { fs.unlinkSync(old.full); } catch (_) { /* best-effort */ }
}
} catch (err) {
logger.warn('dev send-test-email: cleanup of dev-test dir failed', { err: err.message });
}
}
/**
* Shared synthetic issuer + recipient blocks used by all three
* sample-PDF builders. The issuer pulls from `business_profile` so
* the admin sees their own brand on the test PDF (logo, address,
* fonts) — that's the operator's own data, safe to render. The
* recipient block is fully synthetic so no customer PII is ever
* embedded.
*
* Returning `null` for issuer is acceptable; pdfService's
* normaliseContext defaults each missing field. We still fetch the
* profile when available to make the test render look realistic.
*/
async function buildSyntheticParties() {
let profile = {};
try {
const businessProfileService = require('../services/businessProfileService');
profile = (await businessProfileService.getProfile()).profile || {};
} catch (_) {
// Fresh install with no business_profile row: render with
// generic defaults below.
}
const issuer = {
companyName: profile.company_name || 'Sample Studio',
addressLine1: profile.address_line1 || 'Beispielstrasse 1',
addressLine2: profile.address_line2,
postalCode: profile.postal_code || '8000',
city: profile.city || 'Zürich',
state: profile.state,
countryCode: profile.country_code || 'CH',
phone: profile.phone,
mobile: profile.mobile,
email: profile.email || '[email protected]',
website: profile.website,
footerLine: profile.footer_line,
vatId: profile.vat_id,
logoPath: null, // skip logo file lookup for the synthetic render
pdfFontTtfPath: profile.pdf_font_ttf_path,
pdfFontFamily: profile.pdf_font_family || null,
countryName: profile.country_name || null,
showLogo: false,
showCompanyName: true,
logoHeight: 56,
companyNameInline: false,
foldingMarks: 'none',
quoteShowNetDays: false,
quoteShowSkonto: false,
};
const recipient = {
issuerLine: profile.company_name
? `${profile.company_name} * ${profile.address_line1 || ''} * ${profile.postal_code || ''} ${profile.city || ''}`
: '',
companyName: 'Sample Customer GmbH',
hasCompany: true,
attentionLine: 'z. Hd. Maria Sample',
salutation: 'Frau',
lastName: 'Sample',
addressLine1: 'Musterstrasse 1',
addressLine2: null,
postalCode: '8000',
city: 'Zürich',
country: null,
countryCodeIso: 'CH',
};
return { issuer, recipient };
}
const SYNTHETIC_LINE_ITEMS = [
{ quantity: 1, description: 'Photo session (sample)', unitPriceMinor: 80000, discountPercent: 0, lineTotalMinor: 80000, parentLineItemId: null, parentPosition: null, detailsText: null },
{ quantity: 2, description: 'Photo prints A4 (sample)', unitPriceMinor: 1500, discountPercent: 0, lineTotalMinor: 3000, parentLineItemId: null, parentPosition: null, detailsText: null },
];
const SYNTHETIC_TOTALS = { netAmountMinor: 83000, vatRate: 7.7, vatAmountMinor: 6391, shippingAmountMinor: 0, totalAmountMinor: 89391 };
/**
* Render a sample QUOTE PDF from synthetic data — no DB read of real
* quotes. Uses pdfService.renderQuoteToBuffer directly with a render
* context matching the shape produced by quoteService.buildRenderContext.
*/
async function renderSyntheticQuotePdf(adminId) {
try {
const { issuer, recipient } = await buildSyntheticParties();
const today = new Date();
const ctx = {
locale: 'de',
currency: 'CHF',
qrFormat: 'none',
issuer,
recipient,
lineItems: SYNTHETIC_LINE_ITEMS,
totals: SYNTHETIC_TOTALS,
doc: {
quoteNumber: 'Q-DEV-0001',
issueDate: today,
validUntil: new Date(today.getTime() + 14 * 86400000),
introText: 'Sample quote — synthetic data only. Not a real customer record.',
outroText: null,
totalAmountMinor: SYNTHETIC_TOTALS.totalAmountMinor,
},
bank: null,
paymentTerm: null,
};
const buffer = await pdfService.renderQuoteToBuffer(ctx);
return writeSyntheticPdf(buffer, `quote-sample-${adminId}-${Date.now()}.pdf`, 'Q-DEV-0001-sample.pdf');
} catch (err) {
logger.warn('dev send-test-email: synthetic quote PDF render failed', { err: err.message });
return null;
}
}
async function renderSyntheticInvoicePdf(adminId) {
try {
const { issuer, recipient } = await buildSyntheticParties();
const today = new Date();
const ctx = {
locale: 'de',
currency: 'CHF',
qrFormat: 'none',
issuer,
recipient,
lineItems: SYNTHETIC_LINE_ITEMS,
totals: SYNTHETIC_TOTALS,
doc: {
invoiceNumber: 'R-DEV-0001',
issueDate: today,
dueDate: new Date(today.getTime() + 30 * 86400000),
introText: 'Sample invoice — synthetic data only. Not a real customer record.',
outroText: null,
kind: 'invoice',
lateFeeMinor: 0,
},
bank: null,
paymentTerm: null,
};
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
return writeSyntheticPdf(buffer, `invoice-sample-${adminId}-${Date.now()}.pdf`, 'R-DEV-0001-sample.pdf');
} catch (err) {
logger.warn('dev send-test-email: synthetic invoice PDF render failed', { err: err.message });
return null;
}
}
async function renderSyntheticContractPdf(adminId) {
try {
const { issuer, recipient } = await buildSyntheticParties();
const today = new Date();
const ctx = {
locale: 'de',
dateFormat: null,
issuer,
recipient,
today,
doc: {
contractNumber: 'C-DEV-0001',
title: 'Sample contract — synthetic data only',
issueDate: today,
validUntil: new Date(today.getTime() + 30 * 86400000),
introText: null,
outroText: null,
},
sections: [{
section: 'basics',
blocks: [{
slug: 'basics_service',
name: 'Subject of contract (sample)',
section: 'basics',
body: 'This is a synthetic dev-test contract. Not a real customer agreement.',
}],
}],
signatures: { customer: null, admin: null },
};
const buffer = await pdfService.renderContractToBuffer(ctx);
return writeSyntheticPdf(buffer, `contract-sample-${adminId}-${Date.now()}.pdf`, 'C-DEV-0001-sample.pdf');
} catch (err) {
logger.warn('dev send-test-email: synthetic contract PDF render failed', { err: err.message });
return null;
}
}
function writeSyntheticPdf(buffer, onDiskName, attachmentName) {
const dir = DEV_TEST_DIR();
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, onDiskName);
fs.writeFileSync(filePath, buffer);
pruneDevTestDir();
return { path: filePath, filename: attachmentName };
}
/**
* Build a payload tailored to each template. All variables map back
* to the `{{tokens}}` the seeded templates reference, so the email
* the admin sees is identical to what the real flow would send.
*/
async function buildPayloadFor(key, adminId, frontendUrl) {
const dummyToken = 'dev-test-token-' + Math.random().toString(16).slice(2, 12).padEnd(64, '0').slice(0, 64);
const total = 1234.56;
const lateFee = 25.00;
const today = new Date();
const dueDate = new Date(today.getTime() - 5 * 86400000);
const validUntil = new Date(today.getTime() + 14 * 86400000);
const common = {
customer_name: 'Sample Customer',
customer_email: '[email protected]',
event_name: 'Sample Event',
invoice_number: 'R-DEV-0001',
quote_number: 'Q-DEV-0001',
total_amount: fakeMoney(total, 'CHF'),
new_total_amount: fakeMoney(total + lateFee, 'CHF'),
late_fee_amount: fakeMoney(lateFee, 'CHF'),
late_fee_due: true,
due_date: fakeShortDate(dueDate),
valid_until: fakeShortDate(validUntil),
days_overdue: 5,
installment_label: 'Anzahlung',
installment_index: 1,
installment_total: 2,
admin_dashboard_url: `${frontendUrl}/admin/clients/bills`,
response_url: `${frontendUrl}/quote/${dummyToken}`,
accept_url: `${frontendUrl}/quote/${dummyToken}?action=accept`,
decline_url: `${frontendUrl}/quote/${dummyToken}?action=decline`,
paid_url: `${frontendUrl}/payment-check/${dummyToken}?action=paid_full`,
partial_url: `${frontendUrl}/payment-check/${dummyToken}?action=partial`,
unpaid_url: `${frontendUrl}/payment-check/${dummyToken}?action=unpaid`,
accepted_on_behalf: true,
// Contract-specific variables. Title + contract_number stand in
// for the matching {{tokens}} in the seeded contract templates.
contract_number: 'C-DEV-0001',
title: 'Sample contract — synthetic data only',
signed_customer_name: 'Sample Customer',
};
// Templates with PDF attachments get a SYNTHETIC sample PDF. Never
// pulls from real records on disk — every render builds from
// hardcoded sample data via the renderSynthetic*Pdf helpers above.
let attachments;
if (key === 'quote_sent' || key === 'quote_accepted_customer') {
const pdf = await renderSyntheticQuotePdf(adminId);
if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }];
} else if (key === 'invoice_sent' || key === 'invoice_reminder_first' || key === 'invoice_reminder_second') {
const pdf = await renderSyntheticInvoicePdf(adminId);
if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }];
} else if (key === 'contract_sent' || key === 'contract_fully_signed') {
const pdf = await renderSyntheticContractPdf(adminId);
if (pdf) attachments = [{ filename: pdf.filename, contentPath: pdf.path, contentType: 'application/pdf' }];
}
return attachments ? { ...common, attachments } : common;
}
router.post(
'/send-test-email',
requirePermission('settings.edit'),
[body('templateKey').isString().isIn(TEMPLATES_KEYS)],
handleAsync(async (req, res) => {
validateRequest(req);
const admin = await db('admin_users').where({ id: req.admin.id }).first();
if (!admin?.email) throw new AppError('Logged-in admin has no email on file', 400);
const template = await db('email_templates')
.where({ template_key: req.body.templateKey }).first();
if (!template) {
throw new AppError(`Template "${req.body.templateKey}" not seeded yet — run migrations`, 409, 'TEMPLATE_MISSING');
}
const frontendUrl = (process.env.FRONTEND_URL || FRONTEND_URL_FALLBACK).replace(/\/$/, '');
const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl);
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload);
return successResponse(res, {
sent: true,
to: admin.email,
template: req.body.templateKey,
}, 200, 'Test email queued');
})
);
module.exports = router;
+98
View File
@@ -303,6 +303,16 @@ async function getTemplateTranslations(templateId, template) {
// Get email templates
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
// Self-heal: ensure the seeded event-reminder templates exist + are
// backfilled with example content on already-migrated installs. The
// function is idempotent and short-circuits via a module-level cache
// after one successful pass, so this is free on subsequent calls.
try {
const { ensureEventReminderTemplatesSeeded } = require('../services/eventReminderTemplates');
const log = require('../utils/logger');
await ensureEventReminderTemplatesSeeded(db, log);
} catch (_e) { /* non-fatal */ }
const templates = await db('email_templates')
.select('*')
.orderBy('template_key');
@@ -452,6 +462,94 @@ router.put('/templates/:key', [
}
});
// Create a new email template. Used by the ReminderTemplatesPage to
// mint a per-event-type reminder (template_key like
// `event_reminder_<slug_prefix>`). Idempotent at the API level — if
// the key already exists we return 409 so the caller knows to PUT
// instead.
router.post('/templates', [
adminAuth,
requirePermission('email.edit'),
], async (req, res) => {
try {
const {
template_key: templateKey,
translations,
category,
subcategory,
feature_flag: featureFlag,
variables,
} = req.body;
if (!templateKey || typeof templateKey !== 'string' || !/^[a-z0-9_]+$/.test(templateKey)) {
return res.status(400).json({ error: 'template_key must be a snake_case identifier' });
}
if (!translations || typeof translations !== 'object') {
return res.status(400).json({ error: 'translations object is required' });
}
const existing = await db('email_templates').where({ template_key: templateKey }).first();
if (existing) {
return res.status(409).json({
error: 'Template already exists. Use PUT /templates/:key to update.',
code: 'TEMPLATE_EXISTS',
});
}
const cols = await db('email_templates').columnInfo();
const enContent = translations.en || {};
// Build the master row. The legacy single-row columns are populated
// from EN so older readers that don't consult the translations
// table still see something sensible.
const masterRow = { template_key: templateKey };
if (variables && 'variables' in cols) masterRow.variables = JSON.stringify(variables);
if (category && 'category' in cols) masterRow.category = category;
if (subcategory && 'subcategory' in cols) masterRow.subcategory = subcategory;
if (featureFlag && 'feature_flag' in cols) masterRow.feature_flag = featureFlag;
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
for (const colName of Object.keys(cols)) {
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.subject || '';
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_html || '';
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_text || '';
}
}
const inserted = await db('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Per-language rows in email_template_translations.
const hasTranslations = await db.schema.hasTable('email_template_translations');
if (hasTranslations && templateId) {
for (const [language, content] of Object.entries(translations)) {
if (!content || typeof content !== 'object') continue;
await db('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject || '',
body_html: content.body_html || '',
body_text: content.body_text || '',
created_at: new Date(),
updated_at: new Date(),
});
}
}
await logActivity('email_template_created',
{ template_key: templateKey, languages: Object.keys(translations) },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username });
return res.status(201).json({ template_key: templateKey, id: templateId });
} catch (error) {
console.error('Email template create error:', error);
return res.status(500).json({ error: 'Failed to create email template' });
}
});
// Preview email template
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
+69
View File
@@ -20,6 +20,8 @@ const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { normaliseEventTimeTriple } = require('../services/eventService');
const { hasColumnCached } = require('../utils/schemaCache');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
@@ -334,6 +336,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}),
body('event_name').notEmpty().trim(),
body('event_date').optional({ values: 'falsy' }).isDate(),
// Migration 137 — calendar time fields.
body('event_time_start').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_start must be HH:MM 24h'),
body('event_time_end').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_end must be HH:MM 24h'),
body('is_full_day').optional().isBoolean().toBoolean(),
body('customer_name').optional().trim(),
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
@@ -425,6 +433,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_type,
event_name,
event_date,
// Migration 137 — calendar time fields. is_full_day defaults to
// true at the service layer when undefined (legacy form payloads).
event_time_start,
event_time_end,
is_full_day,
admin_email,
password,
welcome_message = '',
@@ -634,12 +647,24 @@ router.post('/', adminAuth, requirePermission('events.create'), [
? protectionDefaults.enable_devtools_protection
: true;
// Migration 137 — normalise calendar time triple. Throws AppError
// 400 when is_full_day=false but times are malformed/inverted.
const calendarTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date: event_date || null,
...(calendarColumnsExist ? {
event_time_start: calendarTriple.event_time_start,
event_time_end: calendarTriple.event_time_end,
is_full_day: formatBoolean(calendarTriple.is_full_day),
} : {}),
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null,
@@ -1102,12 +1127,30 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
body('event_date').optional({ values: 'falsy' }).isDate(),
// Migration 137 — calendar time fields. Same regex/range rule as POST.
body('event_time_start').optional({ values: 'falsy', nullable: true })
.matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_start must be HH:MM 24h'),
body('event_time_end').optional({ values: 'falsy', nullable: true })
.matches(/^([01]\d|2[0-3]):[0-5]\d$/)
.withMessage('event_time_end must be HH:MM 24h'),
body('is_full_day').optional().isBoolean().toBoolean(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
// Migration 143 — per-event reminder overrides. All three are
// optional; nullable values are accepted so admins can clear an
// override (e.g. drop a custom offset back to the global default).
body('event_reminder_disabled').optional().isBoolean(),
body('event_reminder_offset_days').optional({ nullable: true })
.custom((v) => v === null || (Number.isInteger(Number(v)) && Number(v) >= 0))
.withMessage('event_reminder_offset_days must be a non-negative integer or null'),
body('event_reminder_body_override').optional({ nullable: true, checkFalsy: true })
.isString().isLength({ max: 10_000 }),
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
@@ -1291,6 +1334,32 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
// the entire edit with 500 Failed to update event.
delete updates.customer_account_ids;
// Migration 137 — calendar time triple. Renormalise only when at
// least one of the three fields was supplied; otherwise leave the
// row's current values alone. is_full_day=true forces both times
// to null. Drop the fields silently on un-migrated installs.
const timeFieldsTouched = (
Object.prototype.hasOwnProperty.call(updates, 'event_time_start')
|| Object.prototype.hasOwnProperty.call(updates, 'event_time_end')
|| Object.prototype.hasOwnProperty.call(updates, 'is_full_day')
);
if (timeFieldsTouched) {
if (await hasColumnCached('events', 'is_full_day')) {
const triple = normaliseEventTimeTriple({
event_time_start: updates.event_time_start,
event_time_end: updates.event_time_end,
is_full_day: updates.is_full_day,
});
updates.event_time_start = triple.event_time_start;
updates.event_time_end = triple.event_time_end;
updates.is_full_day = formatBoolean(triple.is_full_day);
} else {
delete updates.event_time_start;
delete updates.event_time_end;
delete updates.is_full_day;
}
}
// Log the update request for debugging
logger.debug('Update event request', {
id,
+44 -2
View File
@@ -41,13 +41,38 @@ const KNOWN_FLAGS = [
// Customer-side portal surface (#354). Gates /customer/* routes
// and the Accounts sub-page under Clients. See migration 095.
'customerPortal',
// CRM developer tools sub-tab — internal helpers (test the
// payment-check email flow without waiting 30 days, etc.).
// Strictly opt-in.
'crmDevelopment',
// Tax / Steuer report sub-tab under Clients. Independent toggle so
// admins who use Bills but don't need the tax export (or aren't
// ready to enable it yet) can leave it off. Forced off when `bills`
// is off (no invoices → nothing to report).
'taxReport',
// Hours logging (migration 129). Master switch for the per-customer
// Hours card + the auto-append into monthly draft / "Bill these
// hours" flow. Independent of `bills` because hours are an INPUT to
// bills — admin who's still in the dogfood phase may want to log
// hours without enabling the full billing surface yet.
'hoursLogging',
// Contracts (migration 130). Independent of quotes/bills — contracts
// are a standalone legal document type with their own composition
// (blocks) and signing flow (in-browser canvas + wet-signed PDF
// upload). Seeded block bodies are EXAMPLES ONLY; admins must have a
// lawyer review before sending. See docs/crm-disclaimers.md.
'contracts',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
// new release that hasn't run its migration yet on this instance).
const DEFAULT_FLAGS = {
galleries: true,
reminderEmails: true,
// F.3 — reminderEmails is a placeholder card in the Features tab
// (lockedReason: NOT_YET_AVAILABLE). Default FALSE so it matches
// the locked-but-off visual state of messaging / calendarBooking
// instead of being a confusing "on but locked".
reminderEmails: false,
calendar: false,
calendarBooking: false,
quotes: false,
@@ -56,6 +81,9 @@ const DEFAULT_FLAGS = {
analytics: true,
userManagement: true,
clients: false,
taxReport: false,
hoursLogging: false,
contracts: false,
};
async function readAllFlags() {
@@ -76,6 +104,10 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = false;
if (out.calendar === false) out.calendarBooking = false;
// Tax report only makes sense when bills are on — turning bills off
// implicitly turns the tax report off too. Admins enabling tax
// report must first enable bills.
if (out.bills === false) out.taxReport = false;
// Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly in the Features tab — they enable a specific
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
@@ -85,7 +117,17 @@ function applyDependencyRules(flags) {
// ever drifts (e.g. partial migration run).
out.clients = Boolean(
out.customerPortal
// future siblings (out.calendar || out.quotes || out.bills || out.messaging) go here
|| out.crmDevelopment
|| out.quotes
|| out.bills
|| out.taxReport
|| out.hoursLogging
|| out.contracts
// Migration 137 — admin calendar lights up the Clients section.
// (calendarBooking is gated behind `calendar` so adding the parent
// is sufficient.)
|| out.calendar
// future siblings (out.messaging) go here
);
return out;
}
+882
View File
@@ -0,0 +1,882 @@
/**
* Admin → Invoices Routes
*
* Endpoint mounted at /api/admin/invoices. Surface:
* GET / list (filter + sort + paginate)
* POST / create (status=scheduled or sent)
* GET /:id detail incl. line items + payments
* PUT /:id update (only when not paid/cancelled)
* POST /:id/send render PDF + queue email now
* POST /:id/mark-paid record a payment
* POST /:id/send-reminder manually trigger reminder ladder
* POST /:id/cancel cancel a non-paid invoice
* GET /:id/pdf preview / download PDF
* GET /:id/payment-log list payment log entries
* POST /preview render PDF from unsaved payload
*
* Permissions: `bills.view` for reads, `bills.manage` for writes.
* Global `bills` feature flag enforced at the route layer.
*/
const express = require('express');
const { body, param, query } = require('express-validator');
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { getStoragePath } = require('../config/storage');
const invoiceService = require('../services/invoiceService');
const { db } = require('../database/db');
const router = express.Router();
// Multer config for "import historical invoice" PDF uploads. Stored
// under storage/business-docs/invoice-imports/<year>/<filename> so
// imported files don't collide with the renderer's own output under
// storage/business-docs/invoice/<year>/. PDF-only, 10MB cap.
const importedInvoiceStorage = multer.diskStorage({
destination: async (_req, _file, cb) => {
const year = new Date().getFullYear();
const dir = path.join(getStoragePath(), 'business-docs', 'invoice-imports', String(year));
await fs.mkdir(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.pdf';
cb(null, `imported-${Date.now()}${ext}`);
},
});
const importedInvoiceUpload = multer({
storage: importedInvoiceStorage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Only PDF files are allowed for imported invoices'));
},
});
async function requireBillsFlag(req, res, next) {
try {
const row = await db('feature_flags').where({ key: 'bills' }).first();
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
if (!enabled) return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' });
next();
} catch (err) { next(err); }
}
router.use(adminAuth);
router.use(requireBillsFlag);
function transformInvoice(i) {
if (!i) return null;
return {
id: i.id,
invoiceNumber: i.invoice_number,
customerAccountId: i.customer_account_id,
customer: {
email: i.customer_email,
displayName: i.customer_display_name,
firstName: i.customer_first_name,
lastName: i.customer_last_name,
companyName: i.customer_company_name,
// Passive customers (admin-only, no portal access) are
// identified by a null password_hash. We expose just the
// boolean — the hash itself is dropped here.
isPassive: i.customer_password_hash == null,
},
// Migration 140 — cross-document lineage UUID. See adminQuotes
// transform for the rationale; lets the lineage card pull the
// whole deal in one query.
dealUuid: i.deal_uuid || null,
sourceQuoteId: i.source_quote_id,
sourceQuoteNumber: i.source_quote_number || null,
// Migration 130 lineage: set by contractService.convertToInvoiceOnly
// so BillDetailPage can render a "From contract" badge. The number
// (e.g. LBM-C-2026-0010) comes from the src_contract JOIN; the id
// is kept as a fallback for invoices generated before the JOIN
// was wired in.
sourceContractId: i.source_contract_id || null,
sourceContractNumber: i.source_contract_number || null,
eventId: i.event_id,
language: i.language,
currency: i.currency,
issueDate: i.issue_date,
dueDate: i.due_date,
installmentIndex: i.installment_index,
installmentTotal: i.installment_total,
installmentLabel: i.installment_label,
installmentTrigger: i.installment_trigger,
status: i.status,
scheduledSendAt: i.scheduled_send_at,
sentAt: i.sent_at,
netAmountMinor: i.net_amount_minor,
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
vatAmountMinor: i.vat_amount_minor,
shippingAmountMinor: i.shipping_amount_minor,
totalAmountMinor: i.total_amount_minor,
paidAmountMinor: i.paid_amount_minor,
paidAt: i.paid_at,
paymentMethod: i.payment_method,
paymentReference: i.payment_reference,
reminderLevel: i.reminder_level,
lastReminderSentAt: i.last_reminder_sent_at,
lateFeeAmountMinor: i.late_fee_amount_minor,
ccPdfEmail: i.cc_pdf_email,
qrFormat: i.qr_format,
pdfPath: i.pdf_path,
businessBankAccountId: i.business_bank_account_id,
paymentTermTemplateId: i.payment_term_template_id || null,
// Split payment-term picker (migration 124). Two new FKs; the
// editor prefers these. Both must be present for the new path to
// engage server-side.
paymentNetDaysTemplateId: i.payment_net_days_template_id || null,
paymentTimingTemplateId: i.payment_timing_template_id || null,
// Migration 126 — per-invoice Skonto opt-out. Editor surfaces
// this as a checkbox so admin can suppress the discount for one
// invoice without touching the template or global default.
skontoDisabled: i.skonto_disabled === true || i.skonto_disabled === 1,
// Monthly billing (migration 128). isMonthlyDraft=true marks the
// accumulator the editor's banner + save-button-label react to.
// monthlyPeriodStart/End drive the period banner on the customer
// detail page and (later) the PDF header.
isMonthlyDraft: i.is_monthly_draft === true || i.is_monthly_draft === 1,
monthlyPeriodStart: i.monthly_period_start || null,
monthlyPeriodEnd: i.monthly_period_end || null,
// Storno wiring (migration 114). The four FK columns drive the
// admin UI's banners + action gating:
// - kind: 'invoice' | 'storno' — defaults to 'invoice' for rows
// seeded before the column existed (legacy installs).
// - replacesInvoiceId: on a reissued invoice → original cancelled id.
// - cancelsInvoiceId: on a Storno row → invoice it reverses.
// - cancellationStornoId: on a cancelled original → Storno that
// cancelled it (so the detail view can link forward).
kind: i.kind || 'invoice',
replacesInvoiceId: i.replaces_invoice_id || null,
cancelsInvoiceId: i.cancels_invoice_id || null,
cancelsInvoiceNumber: i.cancels_invoice_number || null,
cancellationStornoId: i.cancellation_storno_id || null,
cancellationStornoNumber: i.cancellation_storno_number || null,
// Inline event snapshot (migration 123). The editor binds to
// these, the list page shows event_name as a column, and email
// / tax-report rendering reads them in preference to the FK.
eventName: i.event_name || null,
eventDate: i.event_date || null,
eventTimeStart: i.event_time_start || null,
eventTimeEnd: i.event_time_end || null,
// `isImported` surfaces the historical-PDF flag to the admin UI
// so the list / detail page can hide line-item editing on rows
// that originated from a different billing system (migration 111).
isImported: !!i.imported_pdf_path,
createdAt: i.created_at,
updatedAt: i.updated_at,
};
}
function transformLineItem(li) {
return {
id: li.id,
position: li.position,
quantity: Number(li.quantity),
description: li.description,
unitPriceMinor: li.unit_price_minor,
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
lineTotalMinor: li.line_total_minor,
// Hierarchy (migration 119). parentPosition comes from the
// self-join in getInvoiceById; parentLineItemId is the raw FK.
// detailsText is the optional free-form notes block rendered
// below the description on the PDF and customer view.
parentLineItemId: li.parent_line_item_id || null,
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
detailsText: li.details_text || null,
};
}
function transformPaymentLog(p) {
return {
id: p.id,
amountMinor: p.amount_minor,
paidAt: p.paid_at,
paymentMethod: p.payment_method,
reference: p.reference,
notes: p.notes,
recordedByAdminId: p.recorded_by_admin_id,
createdAt: p.created_at,
};
}
const INVOICE_BODY_VALIDATORS = [
body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('issueDate').optional({ values: 'falsy' }).isISO8601(),
body('dueDate').optional({ values: 'falsy' }).isISO8601(),
body('scheduledSendAt').optional({ values: 'falsy' }).isISO8601(),
body('installmentIndex').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('installmentTotal').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('installmentLabel').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('installmentTrigger').optional({ values: 'falsy' }).isString().isLength({ max: 32 }),
body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('qrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
// Split payment-term picker (migration 124). Both optional at the
// validator level so legacy clients still work; the editor will
// require them once it's updated.
body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
// Ad-hoc installments override (commit #6 of the deal_uuid PR).
// When the array has ≥2 rows with percent>0, createInvoice routes
// through spawnInstallmentInvoices (commit #4) and returns
// invoiceIds[].
body('installments').optional().isArray(),
body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('installments.*.percent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('installments.*.trigger').optional({ values: 'falsy' }).isIn(['quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date']),
body('installments.*.offset_days').optional({ values: 'falsy' }).isInt(),
body('skontoDisabled').optional().isBoolean(),
// Inline event snapshot (migration 123). Mirrors quotes — kept
// optional because standalone invoices may not have an event yet.
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('lineItems').optional({ values: 'falsy' }).isArray(),
body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }),
body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }),
body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
// Migration 119: sub-item + details support. Cross-row constraints
// (parent must exist, max 1 level deep) are enforced by the service
// (validateLineItemHierarchy).
body('lineItems.*.parentPosition').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('lineItems.*.detailsText').optional({ values: 'falsy' }).isString().isLength({ max: 2000 }),
];
function mapPayloadToService(body) {
const out = {};
const map = {
customerAccountId: 'customerAccountId',
sourceQuoteId: 'sourceQuoteId',
eventId: 'eventId',
language: 'language', currency: 'currency',
issueDate: 'issueDate', dueDate: 'dueDate',
scheduledSendAt: 'scheduledSendAt',
installmentIndex: 'installmentIndex',
installmentTotal: 'installmentTotal',
installmentLabel: 'installmentLabel',
installmentTrigger: 'installmentTrigger',
vatRate: 'vatRate', shippingAmountMinor: 'shippingAmountMinor',
ccPdfEmail: 'ccPdfEmail', businessBankAccountId: 'businessBankAccountId',
qrFormat: 'qrFormat',
eventName: 'eventName',
eventDate: 'eventDate',
eventTimeStart: 'eventTimeStart',
eventTimeEnd: 'eventTimeEnd',
paymentTermTemplateId: 'paymentTermTemplateId',
paymentNetDaysTemplateId: 'paymentNetDaysTemplateId',
paymentTimingTemplateId: 'paymentTimingTemplateId',
skontoDisabled: 'skontoDisabled',
// Ad-hoc installment plan from the InstallmentsPanel. When the
// array has ≥2 entries with percent > 0, createInvoice routes
// through spawnInstallmentInvoices (commit #4).
installments: 'installments',
};
for (const [api, svc] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api];
}
if (Array.isArray(body.lineItems)) {
out.lineItems = body.lineItems.map((li, idx) => ({
position: li.position == null ? idx + 1 : li.position,
quantity: li.quantity,
description: li.description,
unit_price_minor: li.unitPriceMinor,
discount_percent: li.discountPercent,
// Migration 119 sub-item + details support — same mapping as
// quotes so the editor's payload shape is identical for both.
parent_position: li.parentPosition == null || li.parentPosition === '' ? null : Number(li.parentPosition),
details_text: li.detailsText == null ? null : String(li.detailsText),
}));
}
return out;
}
// ---- list + read -----------------------------------------------------
router.get(
'/',
requirePermission('bills.view'),
[
query('status').optional({ values: 'falsy' }).isString(),
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc']),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const statusFilter = req.query.status
? String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean)
: [];
const { rows, total, page, pageSize } = await invoiceService.listInvoices({
filters: {
status: statusFilter,
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null,
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
q: req.query.q,
},
sort: req.query.sort || 'newest',
page: req.query.page ? parseInt(req.query.page, 10) : 1,
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
});
return successResponse(res, {
invoices: rows.map(transformInvoice),
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
});
})
);
router.get(
'/:id',
requirePermission('bills.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const data = await invoiceService.getInvoiceById(id);
if (!data) return res.status(404).json({ error: 'Invoice not found' });
// Resolve the effective Skonto percentage so the BillDetail
// "Record payment" dialog can render the "Paid with Skonto"
// checkbox + auto-fill the discounted amount (migration 126).
// Reuses the same resolver the payment-check email path uses so
// the two surfaces agree on whether the invoice qualifies.
const skontoPercent = await invoiceService.resolveSkontoPercentForInvoice(data.invoice);
const invoiceOut = transformInvoice(data.invoice);
invoiceOut.skontoPercent = skontoPercent || null;
return successResponse(res, {
invoice: invoiceOut,
lineItems: data.lineItems.map(transformLineItem),
payments: data.payments.map(transformPaymentLog),
});
})
);
// ---- create + update -------------------------------------------------
router.post(
'/',
requirePermission('bills.manage'),
[body('customerAccountId').isInt({ min: 1 }), ...INVOICE_BODY_VALIDATORS],
handleAsync(async (req, res) => {
validateRequest(req);
// createInvoice always returns `{ invoiceIds: number[] }` —
// single-installment / standalone case is a one-element array,
// multi-installment is N (auto-routed through
// spawnInstallmentInvoices). The response surfaces the first
// invoice's payload (the one the editor redirects to) plus the
// full id list so the editor can show "N invoices created".
const { invoiceIds } = await invoiceService.createInvoice(mapPayloadToService(req.body), req.admin.id);
const firstId = invoiceIds[0];
const data = await invoiceService.getInvoiceById(firstId);
return successResponse(res, {
invoice: transformInvoice(data.invoice),
lineItems: data.lineItems.map(transformLineItem),
invoiceIds,
}, 201, 'Invoice created');
})
);
// POST /import — attach a historical invoice PDF to a customer's
// account. Inserts a minimal invoice row whose `imported_pdf_path`
// points at the uploaded file. Every PDF endpoint (admin + customer)
// short-circuits the renderer when this column is populated, so the
// customer downloads the original document untouched.
//
// Use case: migrating from QuickBooks / Bexio / Xero — the admin
// keeps the legal records intact but the customer still sees a
// consolidated history in their portal.
//
// Form fields (multipart/form-data):
// pdf file (required, application/pdf, max 10MB)
// customerAccountId int (required)
// invoiceNumber string (required — admin types the original)
// issueDate ISO date (required)
// dueDate ISO date (optional, defaults to issueDate)
// totalAmountMinor int minor units (required)
// currency 3-letter ISO (optional, default profile/CHF)
// status 'sent' | 'paid' | 'overdue' (default 'sent')
// paidAmountMinor int (optional, for status='paid')
// language string (optional, default 'de')
router.post(
'/import',
requirePermission('bills.manage'),
importedInvoiceUpload.single('pdf'),
[
body('customerAccountId').isInt({ min: 1 }),
body('invoiceNumber').isString().isLength({ min: 1, max: 64 }),
body('issueDate').isISO8601(),
body('dueDate').optional({ values: 'falsy' }).isISO8601(),
body('totalAmountMinor').isInt({ min: 0 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']),
body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
if (!req.file) return res.status(400).json({ error: 'PDF file is required' });
// Confirm the customer exists + has bills enabled (same gate as
// the regular createInvoice).
const customer = await db('customer_accounts').where({ id: req.body.customerAccountId }).first();
if (!customer) {
// Clean up the uploaded file so failed imports don't leave
// orphans on disk.
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
return res.status(404).json({ error: 'Customer not found' });
}
if (customer.feature_bills === false || customer.feature_bills === 0 || customer.feature_bills === '0') {
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
return res.status(409).json({
error: 'This customer has bills disabled',
code: 'CUSTOMER_FEATURE_DISABLED',
});
}
// Refuse duplicate invoice numbers — tax compliance requires
// uniqueness within the issuer's books.
const conflict = await db('invoices').where({ invoice_number: req.body.invoiceNumber }).first();
if (conflict) {
try { await fs.unlink(req.file.path); } catch (_) { /* ignore */ }
return res.status(409).json({
error: `Invoice number "${req.body.invoiceNumber}" already exists`,
code: 'INVOICE_NUMBER_TAKEN',
});
}
const totalMinor = parseInt(req.body.totalAmountMinor, 10);
const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0;
const status = req.body.status || 'sent';
const issueDate = req.body.issueDate;
const dueDate = req.body.dueDate || issueDate;
const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase();
const language = req.body.language || customer.preferred_language || 'de';
const row = {
invoice_number: req.body.invoiceNumber,
customer_account_id: customer.id,
source_quote_id: null,
event_id: null,
language,
currency,
issue_date: issueDate,
due_date: dueDate,
installment_index: 0,
installment_total: 1,
installment_label: null,
installment_trigger: null,
status,
scheduled_send_at: null,
sent_at: status !== 'scheduled' ? new Date() : null,
net_amount_minor: totalMinor, // imported docs lack a breakdown
vat_rate: 0, // VAT info lives in the imported PDF
vat_amount_minor: 0,
shipping_amount_minor: 0,
total_amount_minor: totalMinor,
paid_amount_minor: paidMinor,
paid_at: status === 'paid' ? new Date() : null,
// Store the path RELATIVE to STORAGE_PATH so the value survives
// a host migration (Docker volume remount on a new host with a
// different absolute path).
imported_pdf_path: path.relative(getStoragePath(), req.file.path),
created_by_admin_id: req.admin.id,
created_at: new Date(),
updated_at: new Date(),
};
const inserted = await db('invoices').insert(row).returning('id');
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
return successResponse(res, {
invoice: transformInvoice(await db('invoices').where({ id: invoiceId }).first()),
}, 201, 'Invoice imported');
})
);
// PUT — full re-save delegated through createInvoice's helper isn't
// straightforward (we keep the existing row). Implementing as a small
// inline shim that overrides scalars + replaces line items.
router.put(
'/:id',
requirePermission('bills.manage'),
[param('id').isInt({ min: 1 }), ...INVOICE_BODY_VALIDATORS],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const existing = await db('invoices').where({ id }).first();
if (!existing) return res.status(404).json({ error: 'Invoice not found' });
// Once an invoice has been sent to the customer it becomes a
// legal record under CH/LI/DE/AT tax rules ("Rechnung ist
// ausgestellt"). Modifying it in place would break the audit
// trail — the correct workflow is to cancel the original +
// issue a new one. Only `scheduled` (not yet sent) invoices
// remain editable.
if (existing.status !== 'scheduled') {
return res.status(409).json({
error: `Cannot edit invoice with status '${existing.status}'. Sent invoices are locked — cancel and reissue if changes are needed.`,
code: 'INVOICE_LOCKED',
});
}
const payload = mapPayloadToService(req.body);
// Recompute totals if line items are present.
let updates = { updated_at: new Date() };
const map = {
language: 'language', currency: 'currency',
issueDate: 'issue_date', dueDate: 'due_date',
scheduledSendAt: 'scheduled_send_at',
installmentIndex: 'installment_index',
installmentTotal: 'installment_total',
installmentLabel: 'installment_label',
installmentTrigger: 'installment_trigger',
vatRate: 'vat_rate', shippingAmountMinor: 'shipping_amount_minor',
ccPdfEmail: 'cc_pdf_email', businessBankAccountId: 'business_bank_account_id',
qrFormat: 'qr_format',
// Per-invoice Skonto opt-out (migration 126).
skontoDisabled: 'skonto_disabled',
// Inline event snapshot (migration 123) — editable as long as
// the invoice is still in 'scheduled' status (this route already
// gates on that above).
eventName: 'event_name',
eventDate: 'event_date',
eventTimeStart: 'event_time_start',
eventTimeEnd: 'event_time_end',
};
for (const [api, col] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(payload, api)) updates[col] = payload[api];
}
// Payment-term selection: re-snapshot the template when the admin
// changes it. Mirrors createInvoice — once the column is set the
// PDF renderer prefers it over the source-quote fallback.
if (Object.prototype.hasOwnProperty.call(payload, 'paymentTermTemplateId')) {
const id = parseInt(payload.paymentTermTemplateId, 10);
if (id) {
const tpl = await db('payment_term_templates').where({ id }).first();
if (tpl) {
updates.payment_term_template_id = tpl.id;
updates.payment_term_snapshot = 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,
});
}
} else {
// Explicit clear — admin picked "no template".
updates.payment_term_template_id = null;
updates.payment_term_snapshot = null;
}
}
// Migration 124 — split payment-term picker. When both new FKs are
// present, prefer them and re-compose the snapshot from the pair.
// The editor sends both together so we don't have to handle the
// half-set case; it stays a noop here when only one is supplied.
if (
Object.prototype.hasOwnProperty.call(payload, 'paymentNetDaysTemplateId')
&& Object.prototype.hasOwnProperty.call(payload, 'paymentTimingTemplateId')
) {
const netDaysId = parseInt(payload.paymentNetDaysTemplateId, 10);
const timingId = parseInt(payload.paymentTimingTemplateId, 10);
if (netDaysId && timingId) {
const [netDays, timing] = await Promise.all([
db('payment_net_days_templates').where({ id: netDaysId }).first(),
db('payment_timing_templates').where({ id: timingId }).first(),
]);
if (netDays && timing) {
updates.payment_net_days_template_id = netDays.id;
updates.payment_timing_template_id = timing.id;
// Clear the legacy FK — the editor is moving off it.
updates.payment_term_template_id = null;
updates.payment_term_snapshot = 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 {
// Explicit clear — admin emptied both.
updates.payment_net_days_template_id = null;
updates.payment_timing_template_id = null;
updates.payment_term_snapshot = null;
}
}
if (Array.isArray(payload.lineItems)) {
// Recompute everything authoritatively. Migration 119 — sub-
// items don't roll into net directly; parent totals auto-
// resolve from priced sub-items via resolveParentTotalsFromSubItems
// (shared helper in quoteService._internal).
const items = payload.lineItems.map((li, idx) => {
const qty = Number(li.quantity || 1);
const unit = parseInt(li.unit_price_minor, 10) || 0;
const disc = Number(li.discount_percent || 0);
const lineTotal = Math.round(Math.round(qty * unit) * (1 - disc / 100));
const isSubItem = li.parent_position != null && li.parent_position !== '';
return {
position: parseInt(li.position, 10) || (idx + 1),
quantity: qty,
description: String(li.description || ''),
unit_price_minor: unit,
discount_percent: disc,
line_total_minor: lineTotal,
parent_position: isSubItem ? parseInt(li.parent_position, 10) : null,
details_text: li.details_text || null,
};
});
const { resolveParentTotalsFromSubItems } = require('../services/quoteService')._internal;
resolveParentTotalsFromSubItems(items);
let net = 0;
for (const it of items) {
if (it.parent_position == null) net += parseInt(it.line_total_minor, 10) || 0;
}
const vatRate = Number(payload.vatRate ?? existing.vat_rate ?? 0);
const vatAmount = Math.round(net * vatRate / 100);
const shipping = parseInt(payload.shippingAmountMinor ?? existing.shipping_amount_minor ?? 0, 10);
updates.net_amount_minor = net;
updates.vat_amount_minor = vatAmount;
updates.vat_rate = vatRate;
updates.shipping_amount_minor = shipping;
updates.total_amount_minor = net + vatAmount + shipping;
const quoteService = require('../services/quoteService');
const { validateLineItemHierarchy, insertLineItemsHierarchical } = quoteService._internal;
await db.transaction(async (trx) => {
await trx('invoice_line_items').where({ invoice_id: id }).del();
if (items.length > 0) {
validateLineItemHierarchy(items);
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', id, items);
}
await trx('invoices').where({ id }).update(updates);
});
} else {
await db('invoices').where({ id }).update(updates);
}
const data = await invoiceService.getInvoiceById(id);
return successResponse(res, {
invoice: transformInvoice(data.invoice),
lineItems: data.lineItems.map(transformLineItem),
}, 200, 'Invoice updated');
})
);
// ---- send / pay / remind / cancel ------------------------------------
router.post(
'/:id/send',
requirePermission('bills.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
await invoiceService.sendInvoice(parseInt(req.params.id, 10), req.admin.id);
return successResponse(res, { sent: true });
})
);
router.post(
'/:id/mark-paid',
requirePermission('bills.manage'),
[
param('id').isInt({ min: 1 }),
body('amountMinor').isInt({ min: 1 }),
body('paidAt').optional({ values: 'falsy' }).isISO8601(),
body('paymentMethod').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('reference').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('notes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('skontoApplied').optional().isBoolean(),
],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await invoiceService.markPaid(parseInt(req.params.id, 10), {
amountMinor: req.body.amountMinor,
paidAt: req.body.paidAt,
paymentMethod: req.body.paymentMethod,
reference: req.body.reference,
notes: req.body.notes,
skontoApplied: req.body.skontoApplied,
}, req.admin.id);
return successResponse(res, result);
})
);
router.post(
'/:id/send-reminder',
requirePermission('bills.manage'),
[
param('id').isInt({ min: 1 }),
body('level').optional({ values: 'falsy' }).isInt({ min: 1, max: 2 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await invoiceService.sendReminder(
parseInt(req.params.id, 10),
req.body.level || null,
req.admin.id
);
return successResponse(res, result, 200, 'Reminder sent');
})
);
// Test the admin payment-check email manually — bypasses the 24h
// throttle so the admin can verify the full flow (email → token
// page → action recorded) without waiting for the invoice to age
// past its reminder threshold. Only operates on sent/overdue
// invoices (same gate as the scheduled path).
router.post(
'/:id/test-payment-check',
requirePermission('bills.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await invoiceService.queuePaymentCheckEmail(
parseInt(req.params.id, 10),
{ skipThrottle: true }
);
if (!result.sent) {
return res.status(409).json({
error: `Payment-check email not sent: ${result.reason}`,
code: 'PAYMENT_CHECK_NOT_SENT',
reason: result.reason,
});
}
return successResponse(res, result, 200, 'Test payment-check email queued');
})
);
// Cancel + reissue — atomically cancels the existing invoice and
// creates a fresh scheduled duplicate with a new sequential number,
// linked via replaces_invoice_id (migration 114). The PDF renderer
// stamps "Bezug: Ersetzt Rechnung R-XXXX vom DATE" on the new
// invoice so the customer + auditors can trace the chain.
router.post(
'/:id/reissue',
requirePermission('bills.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await invoiceService.reissueInvoice(parseInt(req.params.id, 10), req.admin.id);
return successResponse(res, result, 201, 'Invoice reissued');
})
);
// Release a pending_delivery invoice — photographer has confirmed
// delivery and wants the final installment to fire now.
router.post(
'/:id/release-for-delivery',
requirePermission('bills.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await invoiceService.releaseForDelivery(parseInt(req.params.id, 10), req.admin.id);
return successResponse(res, result, 200, 'Delivery invoice released');
})
);
router.post(
'/:id/cancel',
requirePermission('bills.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
// Service returns { cancelled, stornoId } — pass through so the
// frontend can show "Storno S-XXXX wurde erzeugt" feedback
// when the invoice was already issued (vs. silent soft-cancel
// on drafts).
const result = await invoiceService.cancelInvoice(parseInt(req.params.id, 10), req.admin.id);
return successResponse(res, result);
})
);
// ---- PDF -------------------------------------------------------------
router.get(
'/:id/pdf',
requirePermission('bills.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const buf = await invoiceService.renderInvoicePdfBuffer(id);
// Build a useful filename: `<invoiceNumber>_<customerName>.pdf`.
// The number + customer come from a small joined fetch; we
// already loaded everything inside renderInvoicePdfBuffer, but
// re-fetching here keeps the route a thin shim over the
// service rather than reaching inside its internals.
const { buildPdfFilename } = require('../utils/pdfFilename');
const inv = await db('invoices').where({ id }).first();
const customer = inv ? await db('customer_accounts').where({ id: inv.customer_account_id }).first() : null;
const filename = buildPdfFilename({
docNumber: inv?.invoice_number,
customer,
fallback: `invoice-${id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
})
);
router.post(
'/preview',
requirePermission('bills.manage'),
INVOICE_BODY_VALIDATORS,
handleAsync(async (req, res) => {
validateRequest(req);
const payload = mapPayloadToService(req.body);
const buf = await invoiceService.renderInvoicePdfFromPayload(payload);
// Preview is unsaved — there's no invoice_number yet. Look up
// the customer so the filename still reflects who the invoice
// is for; the number segment falls back to "invoice-preview".
const { buildPdfFilename } = require('../utils/pdfFilename');
const customer = payload.customerAccountId
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
: null;
const filename = buildPdfFilename({
docNumber: null,
customer,
fallback: 'invoice-preview',
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
})
);
router.get(
'/:id/payment-log',
requirePermission('bills.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const data = await invoiceService.getInvoiceById(parseInt(req.params.id, 10));
if (!data) return res.status(404).json({ error: 'Invoice not found' });
return successResponse(res, { payments: data.payments.map(transformPaymentLog) });
})
);
module.exports = router;
+820
View File
@@ -0,0 +1,820 @@
/**
* Admin → Quotes Routes
*
* Endpoint mounted at /api/admin/quotes. Surface:
* GET / list (filter + sort + paginate)
* POST / create (status=draft)
* GET /:id detail
* PUT /:id update (line items + scalars)
* POST /:id/send render PDF + queue email
* POST /:id/duplicate clone as new draft
* POST /:id/convert convert accepted quote → event
* GET /:id/pdf preview / download persisted PDF
* POST /preview render PDF from unsaved payload
* GET /presets/line-items
* POST /presets/line-items
* PUT /presets/line-items/:id
* DELETE /presets/line-items/:id
* GET /presets/payment-terms
* POST /presets/payment-terms
* PUT /presets/payment-terms/:id
* DELETE /presets/payment-terms/:id
*
* Permissions: `quotes.view` for reads, `quotes.manage` for writes.
* The global `quotes` feature flag is checked at the route layer so a
* disabled installation returns 403 cleanly without the route bodies
* ever running.
*/
const express = require('express');
const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const quoteService = require('../services/quoteService');
const { db } = require('../database/db');
const router = express.Router();
// ----- feature flag gate (admin global) -------------------------------
async function requireQuotesFlag(req, res, next) {
try {
const row = await db('feature_flags').where({ key: 'quotes' }).first();
const enabled = row && (row.value === true || row.value === 1 || row.value === '1');
if (!enabled) {
return res.status(403).json({ error: 'Quotes feature is disabled', code: 'QUOTES_DISABLED' });
}
next();
} catch (err) {
next(err);
}
}
router.use(adminAuth);
router.use(requireQuotesFlag);
// ---------------------------------------------------------------------
// Transforms (snake_case DB → camelCase API)
// ---------------------------------------------------------------------
function transformQuote(q) {
if (!q) return null;
return {
id: q.id,
quoteNumber: q.quote_number,
customerAccountId: q.customer_account_id,
customer: {
email: q.customer_email,
displayName: q.customer_display_name,
firstName: q.customer_first_name,
lastName: q.customer_last_name,
companyName: q.customer_company_name,
// Passive customers (admin-only, no portal access) flagged
// by null password_hash. Hash itself is dropped here.
isPassive: q.customer_password_hash == null,
},
status: q.status,
// Migration 140 — cross-document lineage UUID. Lets the frontend
// call /api/admin/deals/:uuid/documents in one shot to render the
// full lineage (quote + contract + N invoices + Storni).
dealUuid: q.deal_uuid || null,
language: q.language,
currency: q.currency,
issueDate: q.issue_date,
validUntil: q.valid_until,
eventName: q.event_name,
eventDate: q.event_date,
eventTimeStart: q.event_time_start,
eventTimeEnd: q.event_time_end,
expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours),
paymentTermTemplateId: q.payment_term_template_id,
// Split payment-term picker (migration 124).
paymentNetDaysTemplateId: q.payment_net_days_template_id || null,
paymentTimingTemplateId: q.payment_timing_template_id || null,
netAmountMinor: q.net_amount_minor,
vatRate: q.vat_rate == null ? null : Number(q.vat_rate),
vatAmountMinor: q.vat_amount_minor,
shippingAmountMinor: q.shipping_amount_minor,
totalAmountMinor: q.total_amount_minor,
introText: q.intro_text,
outroText: q.outro_text,
internalNotes: q.internal_notes,
ccPdfEmail: q.cc_pdf_email,
sentAt: q.sent_at,
respondedAt: q.responded_at,
responseLockedAt: q.response_locked_at,
acceptedAt: q.accepted_at,
declinedAt: q.declined_at,
convertedEventId: q.converted_event_id,
// Migration 130 lineage. Null until quoteService.createFromQuote
// sets it. Surfaced so QuoteDetailPage can render a "Linked
// contract" badge alongside the existing resulting-invoices list.
// contract_number comes from the conv_contract JOIN — falls back
// to null when the converted contract has been deleted (FK is
// ON DELETE SET NULL).
convertedContractId: q.converted_contract_id || null,
convertedContractNumber: q.converted_contract_number || null,
pdfPath: q.pdf_path,
businessBankAccountId: q.business_bank_account_id,
createdAt: q.created_at,
updatedAt: q.updated_at,
};
}
function transformLineItem(li) {
return {
id: li.id,
position: li.position,
quantity: Number(li.quantity),
description: li.description,
unitPriceMinor: li.unit_price_minor,
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
lineTotalMinor: li.line_total_minor,
// Hierarchy (migration 119). `parentPosition` is what the editor
// uses to thread sub-items in unsaved drafts; on existing rows
// we hydrate it from the actual parent's position via a join in
// getQuoteById (see service). NULL = top-level item.
parentLineItemId: li.parent_line_item_id || null,
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
detailsText: li.details_text || null,
};
}
function transformPaymentTermTemplate(t) {
if (!t) return null;
return {
id: t.id,
name: t.name,
description: t.description,
netDays: t.net_days,
skontoPercent: t.skonto_percent == null ? null : Number(t.skonto_percent),
skontoWithinDays: t.skonto_within_days,
installments: typeof t.installments === 'string' ? JSON.parse(t.installments) : t.installments,
isSystem: t.is_system === 1 || t.is_system === true,
isActive: t.is_active === 1 || t.is_active === true,
displayOrder: t.display_order,
};
}
// Split payment-term templates (migration 124). Two transforms because
// the rows have different shapes — net-days carries Skonto, timing
// carries the installments array.
function transformPaymentNetDaysTemplate(t) {
if (!t) return null;
return {
id: t.id,
name: t.name,
description: t.description,
netDays: t.net_days,
skontoPercent: t.skonto_percent == null ? null : Number(t.skonto_percent),
skontoWithinDays: t.skonto_within_days,
isSystem: t.is_system === 1 || t.is_system === true,
isActive: t.is_active === 1 || t.is_active === true,
displayOrder: t.display_order,
};
}
function transformPaymentTimingTemplate(t) {
if (!t) return null;
return {
id: t.id,
name: t.name,
description: t.description,
installments: typeof t.installments === 'string' ? JSON.parse(t.installments) : t.installments,
isSystem: t.is_system === 1 || t.is_system === true,
isActive: t.is_active === 1 || t.is_active === true,
displayOrder: t.display_order,
};
}
function transformLineItemPreset(p) {
if (!p) return null;
return {
id: p.id,
name: p.name,
description: p.description,
unitPriceMinor: p.unit_price_minor,
currency: p.currency,
quantityDefault: Number(p.quantity_default),
displayOrder: p.display_order,
isActive: p.is_active === 1 || p.is_active === true,
};
}
// ----- payload conversion helpers ------------------------------------
function mapPayloadToService(body) {
const out = {};
const map = {
customerAccountId: 'customerAccountId',
language: 'language', currency: 'currency',
issueDate: 'issueDate', validUntil: 'validUntil',
eventName: 'eventName', eventDate: 'eventDate',
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
expectedDurationHours: 'expectedDurationHours',
paymentTermTemplateId: 'paymentTermTemplateId',
paymentNetDaysTemplateId: 'paymentNetDaysTemplateId',
paymentTimingTemplateId: 'paymentTimingTemplateId',
// Ad-hoc installments override (commit #6). Stored on quotes
// as payment_term_installments_override via migration 142.
installments: 'installments',
vatRate: 'vatRate', shippingAmountMinor: 'shippingAmountMinor',
introText: 'introText', outroText: 'outroText',
internalNotes: 'internalNotes', ccPdfEmail: 'ccPdfEmail',
businessBankAccountId: 'businessBankAccountId',
};
for (const [api, svc] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(body, api)) out[svc] = body[api];
}
if (Array.isArray(body.lineItems)) {
out.lineItems = body.lineItems.map((li, idx) => ({
position: li.position == null ? idx + 1 : li.position,
quantity: li.quantity,
description: li.description,
unit_price_minor: li.unitPriceMinor,
discount_percent: li.discountPercent,
// Migration 119 — sub-item + details support. parentPosition
// refers to another item's position in the same payload; the
// service resolves it to parent_line_item_id after inserting
// the parents.
parent_position: li.parentPosition == null || li.parentPosition === '' ? null : Number(li.parentPosition),
details_text: li.detailsText == null ? null : String(li.detailsText),
}));
}
return out;
}
// ---------------------------------------------------------------------
// List + read
// ---------------------------------------------------------------------
router.get(
'/',
requirePermission('quotes.view'),
[
query('status').optional({ values: 'falsy' }).isString(),
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('from').optional({ values: 'falsy' }).isISO8601(),
query('to').optional({ values: 'falsy' }).isISO8601(),
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'customer_asc', 'value_asc', 'value_desc']),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const statusFilter = req.query.status
? String(req.query.status).split(',').map((s) => s.trim()).filter(Boolean)
: [];
const { rows, total, page, pageSize } = await quoteService.listQuotes({
filters: {
status: statusFilter,
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
from: req.query.from, to: req.query.to, q: req.query.q,
},
sort: req.query.sort || 'newest',
page: req.query.page ? parseInt(req.query.page, 10) : 1,
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
});
return successResponse(res, {
quotes: rows.map(transformQuote),
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
});
})
);
router.get(
'/:id',
requirePermission('quotes.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const data = await quoteService.getQuoteById(id);
if (!data) return res.status(404).json({ error: 'Quote not found' });
return successResponse(res, {
quote: transformQuote(data.quote),
lineItems: data.lineItems.map(transformLineItem),
});
})
);
// ---------------------------------------------------------------------
// Create + update
// ---------------------------------------------------------------------
const QUOTE_BODY_VALIDATORS = [
body('customerAccountId').isInt({ min: 1 }).withMessage('Customer is required'),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('issueDate').optional({ values: 'falsy' }).isISO8601(),
body('validUntil').optional({ values: 'falsy' }).isISO8601(),
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('expectedDurationHours').optional({ values: 'falsy' }).isFloat({ min: 0, max: 99.99 }),
body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
// Ad-hoc installments override (commit #6 of the deal_uuid PR).
// Each row carries { label, percent, trigger, offset_days }; the
// service validates internal consistency (percents sum to 100).
body('installments').optional().isArray(),
body('installments.*.label').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('installments.*.percent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('installments.*.trigger').optional({ values: 'falsy' }).isIn(['quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date']),
body('installments.*.offset_days').optional({ values: 'falsy' }).isInt(),
body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('introText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('outroText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('internalNotes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('lineItems').optional({ values: 'falsy' }).isArray(),
body('lineItems.*.description').optional({ values: 'falsy' }).isString().isLength({ min: 1, max: 1000 }),
body('lineItems.*.quantity').optional({ values: 'falsy' }).isFloat({ min: 0 }),
body('lineItems.*.unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('lineItems.*.discountPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
// Migration 119: sub-item + details support. Cross-row constraints
// (parent must exist, max 1 level deep) are enforced by the service
// (validateLineItemHierarchy); these per-field validators just keep
// bad data from reaching it.
body('lineItems.*.parentPosition').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('lineItems.*.detailsText').optional({ values: 'falsy' }).isString().isLength({ max: 2000 }),
];
router.post(
'/',
requirePermission('quotes.manage'),
QUOTE_BODY_VALIDATORS,
handleAsync(async (req, res) => {
validateRequest(req);
const id = await quoteService.createQuote(mapPayloadToService(req.body), req.admin.id);
const data = await quoteService.getQuoteById(id);
return successResponse(res, {
quote: transformQuote(data.quote),
lineItems: data.lineItems.map(transformLineItem),
}, 201, 'Quote created');
})
);
router.put(
'/:id',
requirePermission('quotes.manage'),
// PUT accepts partial updates. We declare the same fields as POST but
// every chain begins with `.optional({ values: 'falsy' })` so missing fields don't fail
// validation. Doing `.map(v => v.optional)` (without invoking it) was
// a bug that registered method references as middleware.
[
param('id').isInt({ min: 1 }),
body('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('issueDate').optional({ values: 'falsy' }).isISO8601(),
body('validUntil').optional({ values: 'falsy' }).isISO8601(),
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('eventTimeEnd').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('expectedDurationHours').optional({ values: 'falsy' }).isFloat({ min: 0, max: 99.99 }),
body('paymentTermTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('paymentNetDaysTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('paymentTimingTemplateId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('vatRate').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('shippingAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('introText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('outroText').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('internalNotes').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('ccPdfEmail').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('businessBankAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('lineItems').optional({ values: 'falsy' }).isArray(),
],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
await quoteService.updateQuote(id, mapPayloadToService(req.body), req.admin.id);
const data = await quoteService.getQuoteById(id);
return successResponse(res, {
quote: transformQuote(data.quote),
lineItems: data.lineItems.map(transformLineItem),
}, 200, 'Quote updated');
})
);
// ---------------------------------------------------------------------
// Send / duplicate / convert
// ---------------------------------------------------------------------
router.post(
'/:id/send',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.sendQuote(id, req.admin.id);
return successResponse(res, { sent: true, token: result.token }, 200, 'Quote sent');
})
);
router.post(
'/:id/duplicate',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const newId = await quoteService.duplicateQuote(parseInt(req.params.id, 10), req.admin.id);
return successResponse(res, { id: newId }, 201, 'Quote duplicated');
})
);
// Admin "accept on behalf of customer" — flips the quote straight
// to `accepted` without going through the public token + response
// window. For phone-call workflows where the customer verbally
// agrees and the admin wants to immediately convert.
router.post(
'/:id/accept',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.adminAcceptQuote(id, req.admin.id);
return successResponse(res, result, 200, 'Quote accepted');
})
);
router.post(
'/:id/convert',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.convertToEvent(id, req.admin.id);
return successResponse(res, result, 200, result.alreadyConverted ? 'Already converted' : 'Quote converted');
})
);
// Convert directly to invoice(s) — no event, no gallery. Used for
// engagements like consulting / equipment hire where there's no photo
// deliverable to ship.
router.post(
'/:id/convert-to-invoice',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.convertToInvoiceOnly(id, req.admin.id);
return successResponse(res, result, 200, 'Invoices created from quote');
})
);
// Convert to a draft contract — the new middle step between accepted
// quote and event/invoice generation. The contracts feature flag is
// checked in contractService (it pulls the same db('feature_flags')
// row that the adminContracts router gates on); declining at the route
// layer here would force admins to flip TWO flags to use the workflow.
router.post(
'/:id/convert-to-contract',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
// Lazy require to keep the route file dep-light + avoid the
// quoteService ↔ contractService cycle bleeding through.
const contractService = require('../services/contractService');
const id = parseInt(req.params.id, 10);
const result = await contractService.createFromQuote(id, req.admin.id);
return successResponse(res, result, 200,
result.alreadyConverted ? 'Already linked to a contract' : 'Contract drafted from quote');
})
);
// ---------------------------------------------------------------------
// PDF — preview (unsaved payload) + download (persisted)
// ---------------------------------------------------------------------
router.get(
'/:id/pdf',
requirePermission('quotes.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const buf = await quoteService.renderQuotePdfBuffer(id);
const { buildPdfFilename } = require('../utils/pdfFilename');
const quote = await db('quotes').where({ id }).first();
const customer = quote ? await db('customer_accounts').where({ id: quote.customer_account_id }).first() : null;
const filename = buildPdfFilename({
docNumber: quote?.quote_number,
customer,
fallback: `quote-${id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
})
);
router.post(
'/preview',
requirePermission('quotes.manage'),
QUOTE_BODY_VALIDATORS,
handleAsync(async (req, res) => {
validateRequest(req);
const payload = mapPayloadToService(req.body);
const buf = await quoteService.renderQuotePdfFromPayload(payload);
const { buildPdfFilename } = require('../utils/pdfFilename');
const customer = payload.customerAccountId
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
: null;
const filename = buildPdfFilename({
docNumber: null,
customer,
fallback: 'quote-preview',
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
})
);
// ---------------------------------------------------------------------
// Presets — line items
// ---------------------------------------------------------------------
router.get(
'/presets/line-items',
requirePermission('quotes.view'),
handleAsync(async (req, res) => {
const rows = await quoteService.listLineItemPresets();
return successResponse(res, { presets: rows.map(transformLineItemPreset) });
})
);
router.post(
'/presets/line-items',
requirePermission('quotes.manage'),
[
body('name').isString().isLength({ min: 1, max: 128 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('unitPriceMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('quantityDefault').optional({ values: 'falsy' }).isFloat({ min: 0 }),
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const row = await quoteService.createLineItemPreset({
name: req.body.name,
description: req.body.description,
unit_price_minor: req.body.unitPriceMinor,
currency: req.body.currency,
quantity_default: req.body.quantityDefault,
display_order: req.body.displayOrder,
});
return successResponse(res, { preset: transformLineItemPreset(row) }, 201);
})
);
router.put(
'/presets/line-items/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const row = await quoteService.updateLineItemPreset(id, {
name: req.body.name,
description: req.body.description,
unit_price_minor: req.body.unitPriceMinor,
currency: req.body.currency,
quantity_default: req.body.quantityDefault,
display_order: req.body.displayOrder,
is_active: req.body.isActive,
});
return successResponse(res, { preset: transformLineItemPreset(row) });
})
);
router.delete(
'/presets/line-items/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
await quoteService.deleteLineItemPreset(parseInt(req.params.id, 10));
return successResponse(res, { deleted: true });
})
);
// ---------------------------------------------------------------------
// Presets — payment terms
// ---------------------------------------------------------------------
router.get(
'/presets/payment-terms',
requirePermission('quotes.view'),
handleAsync(async (req, res) => {
const rows = await quoteService.listPaymentTermTemplates();
return successResponse(res, { templates: rows.map(transformPaymentTermTemplate) });
})
);
router.post(
'/presets/payment-terms',
requirePermission('quotes.manage'),
[
body('name').isString().isLength({ min: 1, max: 128 }),
body('installments').isArray({ min: 1 }),
body('netDays').optional({ values: 'falsy' }).isInt({ min: 1, max: 365 }),
body('skontoPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('skontoWithinDays').optional({ values: 'falsy' }).isInt({ min: 0, max: 365 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const row = await quoteService.createPaymentTermTemplate({
name: req.body.name,
description: req.body.description,
net_days: req.body.netDays,
skonto_percent: req.body.skontoPercent,
skonto_within_days: req.body.skontoWithinDays,
installments: req.body.installments,
display_order: req.body.displayOrder,
});
return successResponse(res, { template: transformPaymentTermTemplate(row) }, 201);
})
);
router.put(
'/presets/payment-terms/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const row = await quoteService.updatePaymentTermTemplate(id, {
name: req.body.name,
description: req.body.description,
net_days: req.body.netDays,
skonto_percent: req.body.skontoPercent,
skonto_within_days: req.body.skontoWithinDays,
installments: req.body.installments,
display_order: req.body.displayOrder,
is_active: req.body.isActive,
});
return successResponse(res, { template: transformPaymentTermTemplate(row) });
})
);
router.delete(
'/presets/payment-terms/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
await quoteService.deletePaymentTermTemplate(parseInt(req.params.id, 10));
return successResponse(res, { deleted: true });
})
);
// ---------------------------------------------------------------------
// Presets — payment net-days (migration 124, half of the split)
// ---------------------------------------------------------------------
router.get(
'/presets/payment-net-days',
requirePermission('quotes.view'),
handleAsync(async (req, res) => {
const rows = await quoteService.listPaymentNetDaysTemplates();
return successResponse(res, { templates: rows.map(transformPaymentNetDaysTemplate) });
})
);
router.post(
'/presets/payment-net-days',
requirePermission('quotes.manage'),
[
body('name').isString().isLength({ min: 1, max: 128 }),
// net_days = 0 is "Sofort fällig" — valid.
body('netDays').isInt({ min: 0, max: 365 }),
body('skontoPercent').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
body('skontoWithinDays').optional({ values: 'falsy' }).isInt({ min: 0, max: 365 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const row = await quoteService.createPaymentNetDaysTemplate({
name: req.body.name,
description: req.body.description,
net_days: req.body.netDays,
skonto_percent: req.body.skontoPercent,
skonto_within_days: req.body.skontoWithinDays,
display_order: req.body.displayOrder,
});
return successResponse(res, { template: transformPaymentNetDaysTemplate(row) }, 201);
})
);
router.put(
'/presets/payment-net-days/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const row = await quoteService.updatePaymentNetDaysTemplate(id, {
name: req.body.name,
description: req.body.description,
net_days: req.body.netDays,
skonto_percent: req.body.skontoPercent,
skonto_within_days: req.body.skontoWithinDays,
display_order: req.body.displayOrder,
is_active: req.body.isActive,
});
return successResponse(res, { template: transformPaymentNetDaysTemplate(row) });
})
);
router.delete(
'/presets/payment-net-days/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
await quoteService.deletePaymentNetDaysTemplate(parseInt(req.params.id, 10));
return successResponse(res, { deleted: true });
})
);
// ---------------------------------------------------------------------
// Presets — payment timing (migration 124, other half of the split)
// ---------------------------------------------------------------------
router.get(
'/presets/payment-timing',
requirePermission('quotes.view'),
handleAsync(async (req, res) => {
const rows = await quoteService.listPaymentTimingTemplates();
return successResponse(res, { templates: rows.map(transformPaymentTimingTemplate) });
})
);
router.post(
'/presets/payment-timing',
requirePermission('quotes.manage'),
[
body('name').isString().isLength({ min: 1, max: 128 }),
body('installments').isArray({ min: 1 }),
body('description').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('displayOrder').optional({ values: 'falsy' }).isInt({ min: 0, max: 9999 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const row = await quoteService.createPaymentTimingTemplate({
name: req.body.name,
description: req.body.description,
installments: req.body.installments,
display_order: req.body.displayOrder,
});
return successResponse(res, { template: transformPaymentTimingTemplate(row) }, 201);
})
);
router.put(
'/presets/payment-timing/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const row = await quoteService.updatePaymentTimingTemplate(id, {
name: req.body.name,
description: req.body.description,
installments: req.body.installments,
display_order: req.body.displayOrder,
is_active: req.body.isActive,
});
return successResponse(res, { template: transformPaymentTimingTemplate(row) });
})
);
router.delete(
'/presets/payment-timing/:id',
requirePermission('quotes.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
await quoteService.deletePaymentTimingTemplate(parseInt(req.params.id, 10));
return successResponse(res, { deleted: true });
})
);
module.exports = router;
+16 -3
View File
@@ -94,11 +94,24 @@ const faviconUpload = multer({
}
});
// Get all settings
// Get all settings, or a subset when ?keys=k1,k2,… is supplied.
// Many caller pages only need a handful of keys (e.g. ReminderTemplates
// reads 2 of the ~100 rows). The keys filter is allowlist-bounded by
// what's stored, so passing unknown keys just returns them as `null`
// — no enumeration risk beyond what GET / returned already.
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await db('app_settings').select('*');
const keysParam = typeof req.query.keys === 'string' ? req.query.keys : null;
const keysFilter = keysParam
? keysParam.split(',').map((k) => k.trim()).filter(Boolean).slice(0, 100)
: null;
const query = db('app_settings').select('*');
if (keysFilter && keysFilter.length > 0) {
query.whereIn('setting_key', keysFilter);
}
const settings = await query;
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
+123
View File
@@ -0,0 +1,123 @@
/**
* Admin → Tax Report Routes
*
* Mounted at /api/admin/tax-report. Three endpoints with the same
* query-string contract (from / to / currency / locale):
*
* GET / → JSON: { rows, totalsByVatRate, grandTotal*, ... }
* GET /pdf → landscape A4 PDF, Content-Disposition: attachment
* GET /csv → RFC-4180 CSV, Content-Disposition: attachment
*
* Reuses the existing `bills` feature flag + `bills.view` permission.
* Tax data is just a different lens on invoice data — admins who can
* read invoices can read the tax report; no new RBAC surface needed.
*/
const express = require('express');
const { query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const taxReportService = require('../services/taxReportService');
const { db } = require('../database/db');
const router = express.Router();
// The tax report has its own dedicated flag (taxReport) — independent
// from `bills` so admins can leave it off until they actually need to
// run the export. The frontend mirrors the dependency rule (bills off
// → taxReport off) but we re-check both server-side for defence in
// depth.
async function requireTaxReportFlag(req, res, next) {
try {
const rows = await db('feature_flags').whereIn('key', ['bills', 'taxReport']).select('key', 'value');
const isOn = (row) => row && (row.value === true || row.value === 1 || row.value === '1');
const bills = isOn(rows.find((r) => r.key === 'bills'));
const taxReport = isOn(rows.find((r) => r.key === 'taxReport'));
if (!bills) {
return res.status(403).json({ error: 'Bills feature is disabled', code: 'BILLS_DISABLED' });
}
if (!taxReport) {
return res.status(403).json({ error: 'Tax report feature is disabled', code: 'TAX_REPORT_DISABLED' });
}
next();
} catch (err) { next(err); }
}
router.use(adminAuth);
router.use(requireTaxReportFlag);
// Shared validators for from/to/currency. ISO date (YYYY-MM-DD) and
// ISO 4217 alpha-3 currency are enforced — anything else is rejected
// before the service layer to keep error messages crisp.
const QUERY_VALIDATORS = [
query('from').exists().withMessage('from is required')
.matches(/^\d{4}-\d{2}-\d{2}$/).withMessage('from must be YYYY-MM-DD'),
query('to').exists().withMessage('to is required')
.matches(/^\d{4}-\d{2}-\d{2}$/).withMessage('to must be YYYY-MM-DD'),
query('currency').exists().withMessage('currency is required')
.matches(/^[A-Za-z]{3}$/).withMessage('currency must be an ISO 4217 alpha-3 code'),
query('locale').optional({ values: 'falsy' })
.isIn(['en', 'de', 'fr', 'nl', 'pt', 'ru'])
.withMessage('locale must be one of en/de/fr/nl/pt/ru'),
];
function parseParams(req) {
return {
from: req.query.from,
to: req.query.to,
currency: String(req.query.currency || '').toUpperCase(),
locale: req.query.locale || undefined,
};
}
// ---- JSON ------------------------------------------------------------
router.get(
'/',
requirePermission('bills.view'),
QUERY_VALIDATORS,
handleAsync(async (req, res) => {
validateRequest(req);
const report = await taxReportService.getTaxReport(parseParams(req));
return successResponse(res, { report });
})
);
// ---- PDF -------------------------------------------------------------
router.get(
'/pdf',
requirePermission('bills.view'),
QUERY_VALIDATORS,
handleAsync(async (req, res) => {
validateRequest(req);
const params = parseParams(req);
const buffer = await taxReportService.renderTaxReportPdf(params);
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.pdf`;
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `attachment; filename="${filename}"`);
res.set('Content-Length', String(buffer.length));
return res.end(buffer);
})
);
// ---- CSV -------------------------------------------------------------
router.get(
'/csv',
requirePermission('bills.view'),
QUERY_VALIDATORS,
handleAsync(async (req, res) => {
validateRequest(req);
const params = parseParams(req);
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv(params);
res.set('Content-Type', contentType);
res.set('Content-Disposition', `attachment; filename="${filename}"`);
// BOM for Excel UTF-8 detection — without it Excel on Windows
// mis-decodes Umlauts/special chars. Three-byte EF BB BF prefix.
const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
const body = Buffer.concat([bom, Buffer.from(content, 'utf8')]);
res.set('Content-Length', String(body.length));
return res.end(body);
})
);
module.exports = router;
+342
View File
@@ -368,4 +368,346 @@ router.post('/profile/password', [
}
});
// ---- quotes (customer-facing read-only) ------------------------------
// Lists quotes belonging to the logged-in customer. Scoped strictly to
// the customer's own customer_account_id so a stale or stolen token can
// never see another customer's quotes. Returns the same shape the admin
// list does, minus fields that are admin-only (internal_notes, pdf_path,
// created_by_admin_id). Disabled when the customer has `feature_quotes`
// off OR the global `quotes` flag is off — the frontend's RequireFeature
// already hides the sidebar entry, but we belt-and-braces it here so a
// direct API hit gets a 403 instead of leaking rows.
router.get('/quotes', customerAuth, async (req, res) => {
try {
const { db: dbi } = require('../database/db');
// Customer-feature gate. is_active is enforced by customerAuth.
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
if (!customer || customer.feature_quotes === false || customer.feature_quotes === 0) {
return res.status(403).json({ error: 'Quotes are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' });
}
const rows = await dbi('quotes')
.where({ customer_account_id: req.customer.id })
// Hide drafts — they're admin scratch work; nothing has been
// sent to the customer yet. Mirrors the invoice list above
// which suppresses 'scheduled' + 'cancelled' for the same
// reason. Customers should only see quotes the admin has
// actually issued (sent / accepted / declined / expired /
// converted).
.whereNotIn('status', ['draft'])
.orderBy('issue_date', 'desc')
.orderBy('id', 'desc')
.select(
'id', 'quote_number', 'status', 'currency',
'issue_date', 'valid_until', 'event_name', 'event_date',
'net_amount_minor', 'vat_rate', 'vat_amount_minor',
'shipping_amount_minor', 'total_amount_minor',
'intro_text', 'outro_text',
'sent_at', 'responded_at', 'response_locked_at',
'accepted_at', 'declined_at',
);
// Look up the active accept/decline token for each non-locked
// quote so the customer dashboard can deep-link back into the
// public response page when the admin already sent it. We avoid
// re-issuing tokens here — the dashboard is for review, not
// re-sending.
const tokensByQuote = new Map();
if (rows.length > 0) {
const tokens = await dbi('quote_action_tokens')
.whereIn('quote_id', rows.map((r) => r.id))
.whereNull('used_at')
.where('expires_at', '>', new Date())
.select('quote_id', 'token');
for (const t of tokens) tokensByQuote.set(t.quote_id, t.token);
}
res.json({
quotes: rows.map((q) => ({
id: q.id,
quoteNumber: q.quote_number,
status: q.status,
currency: q.currency,
issueDate: q.issue_date,
validUntil: q.valid_until,
eventName: q.event_name,
eventDate: q.event_date,
netAmountMinor: q.net_amount_minor,
vatRate: q.vat_rate == null ? null : Number(q.vat_rate),
vatAmountMinor: q.vat_amount_minor,
shippingAmountMinor: q.shipping_amount_minor,
totalAmountMinor: q.total_amount_minor,
introText: q.intro_text,
outroText: q.outro_text,
sentAt: q.sent_at,
respondedAt: q.responded_at,
responseLockedAt: q.response_locked_at,
acceptedAt: q.accepted_at,
declinedAt: q.declined_at,
responseToken: tokensByQuote.get(q.id) || null,
})),
});
} catch (error) {
logger.error('Customer quotes list error:', error);
res.status(500).json({ error: 'Failed to load quotes' });
}
});
// ---- invoices (customer-facing read-only + PDF) ----------------------
// Mirrors /quotes — list owned by the customer with the same feature
// gate. Adds a PDF download endpoint so customers can grab the rendered
// invoice from their dashboard.
router.get('/invoices', customerAuth, async (req, res) => {
try {
const { db: dbi } = require('../database/db');
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
if (!customer || customer.feature_bills === false || customer.feature_bills === 0) {
return res.status(403).json({ error: 'Invoices are disabled for this account', code: 'CUSTOMER_FEATURE_DISABLED' });
}
// Visibility rules for the customer-facing list:
// - Hide `scheduled` always (drafts the admin is still tweaking).
// - Show `sent`, `overdue`, `paid` always (the customer's
// outstanding + paid history).
// - Show `cancelled` ONLY when `cancellation_storno_id IS NOT NULL`,
// i.e. the cancellation was made customer-visible via a
// Stornorechnung (migration 114). Soft-cancelled drafts stay
// hidden — the customer never saw the draft, so a "cancelled"
// phantom in their list would just be confusing.
// - Show `kind='storno'` rows (status='sent' after sendStorno)
// unconditionally — they're the customer's legal proof of
// cancellation and the only document with the §14c reversal.
const rows = await dbi('invoices')
.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.customer_account_id': req.customer.id })
.whereNot('invoices.status', 'scheduled')
.whereNot('invoices.status', 'skipped')
.andWhere(function () {
this.whereNot('invoices.status', 'cancelled').orWhereNotNull('invoices.cancellation_storno_id');
})
.orderBy('invoices.issue_date', 'desc')
.orderBy('invoices.id', 'desc')
.select(
'invoices.id', 'invoices.kind', 'invoices.invoice_number', 'invoices.status', 'invoices.currency',
'invoices.issue_date', 'invoices.due_date',
// Inline event snapshot (migration 123) — the customer portal
// shows event_name next to the invoice number, mirroring the
// quotes list.
'invoices.event_name', 'invoices.event_date',
'invoices.installment_index', 'invoices.installment_total', 'invoices.installment_label',
'invoices.net_amount_minor', 'invoices.vat_rate', 'invoices.vat_amount_minor',
'invoices.shipping_amount_minor', 'invoices.total_amount_minor',
'invoices.paid_amount_minor', 'invoices.paid_at',
'invoices.late_fee_amount_minor', 'invoices.reminder_level', 'invoices.sent_at',
// Lineage — drives the Storno banner / cancelled-by-Storno
// indicator on the customer's bills page. Self-join the
// linked rows so we can surface the human invoice_number,
// not just the bare DB row id.
'invoices.cancels_invoice_id', 'invoices.cancellation_storno_id',
'cancels_inv.invoice_number as cancels_invoice_number',
'cancellation_storno.invoice_number as cancellation_storno_number',
);
res.json({
invoices: rows.map((i) => ({
id: i.id,
kind: i.kind || 'invoice',
invoiceNumber: i.invoice_number,
status: i.status,
currency: i.currency,
issueDate: i.issue_date,
dueDate: i.due_date,
installmentIndex: i.installment_index,
installmentTotal: i.installment_total,
installmentLabel: i.installment_label,
netAmountMinor: i.net_amount_minor,
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
vatAmountMinor: i.vat_amount_minor,
shippingAmountMinor: i.shipping_amount_minor,
totalAmountMinor: i.total_amount_minor,
paidAmountMinor: i.paid_amount_minor,
paidAt: i.paid_at,
lateFeeAmountMinor: i.late_fee_amount_minor,
reminderLevel: i.reminder_level,
sentAt: i.sent_at,
cancelsInvoiceId: i.cancels_invoice_id || null,
cancelsInvoiceNumber: i.cancels_invoice_number || null,
cancellationStornoId: i.cancellation_storno_id || null,
cancellationStornoNumber: i.cancellation_storno_number || null,
eventName: i.event_name || null,
eventDate: i.event_date || null,
})),
});
} catch (error) {
logger.error('Customer invoice list error:', error);
res.status(500).json({ error: 'Failed to load invoices' });
}
});
/**
* Customer-side quote PDF — mirrors the invoice PDF endpoint above.
* The customer can re-download any quote that's been sent to them
* (the public response page also uses this view). Draft quotes are
* hidden — they're not yet meant for the customer.
*/
router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
try {
// Feature-gate identically to /quotes (list endpoint).
if (req.customer.feature_quotes === false || req.customer.feature_quotes === 0 || req.customer.feature_quotes === '0') {
return res.status(403).json({ error: 'Quotes are disabled for this account' });
}
const { db: dbi } = require('../database/db');
const quote = await dbi('quotes')
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
.first();
if (!quote) return res.status(404).json({ error: 'Quote not found' });
if (quote.status === 'draft') {
// Drafts aren't visible to the customer.
return res.status(404).json({ error: 'Quote not found' });
}
const quoteService = require('../services/quoteService');
const buf = await quoteService.renderQuotePdfBuffer(quote.id);
const { buildPdfFilename } = require('../utils/pdfFilename');
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
const filename = buildPdfFilename({
docNumber: quote.quote_number,
customer,
fallback: `quote-${quote.id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
} catch (error) {
logger.error('Customer quote PDF error:', error);
res.status(500).json({ error: 'Failed to render quote PDF' });
}
});
router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
try {
const { db: dbi } = require('../database/db');
const invoice = await dbi('invoices')
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
.first();
if (!invoice) return res.status(404).json({ error: 'Invoice not found' });
if (['scheduled', 'cancelled', 'skipped'].includes(invoice.status)) {
// Don't expose scheduled drafts, cancelled docs, or
// skipped empty-monthly placeholders.
return res.status(404).json({ error: 'Invoice not found' });
}
const invoiceService = require('../services/invoiceService');
const buf = await invoiceService.renderInvoicePdfBuffer(invoice.id);
const { buildPdfFilename } = require('../utils/pdfFilename');
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
const filename = buildPdfFilename({
docNumber: invoice.invoice_number,
customer,
fallback: `invoice-${invoice.id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
} catch (error) {
logger.error('Customer invoice PDF error:', error);
res.status(500).json({ error: 'Failed to render invoice PDF' });
}
});
// ---- contracts (customer-facing read-only + PDF + signed-PDF) -------
// Same shape as /quotes and /invoices. Drafts are hidden; everything
// from `sent` onwards is visible. Two PDF download endpoints because
// the signed PDF (stamped with signatures OR a wet-signed upload) is
// the authoritative copy customers want after both parties sign.
router.get('/contracts', customerAuth, async (req, res) => {
try {
const { db: dbi } = require('../database/db');
if (!(await dbi.schema.hasTable('contracts'))) {
// Feature not migrated on this install yet.
return res.json({ contracts: [] });
}
const rows = await dbi('contracts')
.where({ customer_account_id: req.customer.id })
.whereNotIn('status', ['draft'])
.orderBy('issue_date', 'desc')
.orderBy('id', 'desc')
.select(
'id', 'contract_number', 'status', 'language',
'issue_date', 'valid_until', 'title',
'sent_at', 'signed_by_customer_at', 'signed_by_admin_at',
'signed_customer_name', 'signed_admin_name',
'pdf_path', 'signed_pdf_path',
);
// Live tokens for the public sign page so customer dashboard can
// deep-link the "Sign now" button on `sent` contracts.
const tokensByContract = new Map();
if (rows.length > 0 && await dbi.schema.hasTable('contract_action_tokens')) {
const tokens = await dbi('contract_action_tokens')
.whereIn('contract_id', rows.map((r) => r.id))
.whereNull('used_at')
.where('expires_at', '>', new Date())
.select('contract_id', 'token');
for (const tk of tokens) tokensByContract.set(tk.contract_id, tk.token);
}
res.json({
contracts: rows.map((c) => ({
id: c.id,
contractNumber: c.contract_number,
status: c.status,
language: c.language,
issueDate: c.issue_date,
validUntil: c.valid_until,
title: c.title,
sentAt: c.sent_at,
signedByCustomerAt: c.signed_by_customer_at,
signedByAdminAt: c.signed_by_admin_at,
signedCustomerName: c.signed_customer_name,
signedAdminName: c.signed_admin_name,
// Surface flags only — no paths leaked to the customer.
hasPdf: !!c.pdf_path,
hasSignedPdf: !!c.signed_pdf_path,
responseToken: tokensByContract.get(c.id) || null,
})),
});
} catch (error) {
logger.error('Customer contracts list error:', error);
res.status(500).json({ error: 'Failed to load contracts' });
}
});
router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
try {
const { db: dbi } = require('../database/db');
if (!(await dbi.schema.hasTable('contracts'))) {
return res.status(404).json({ error: 'Contract not found' });
}
const contract = await dbi('contracts')
.where({ id: parseInt(req.params.id, 10), customer_account_id: req.customer.id })
.first();
if (!contract) return res.status(404).json({ error: 'Contract not found' });
if (contract.status === 'draft') {
return res.status(404).json({ error: 'Contract not found' });
}
// Prefer the wet-signed PDF when present, otherwise the system-
// generated PDF (signed in-browser, stamped, or unsigned).
const path = require('path');
const fs = require('fs');
const filePath = contract.signed_pdf_path || contract.pdf_path;
if (!filePath || !fs.existsSync(filePath)) {
// Render on-demand so customers who hit the link before the
// first send still get something usable.
const contractService = require('../services/contractService');
const buf = await contractService.renderContractPdfBuffer(contract.id);
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${contract.contract_number}.pdf"`);
return res.send(buf);
}
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
fs.createReadStream(filePath).pipe(res);
} catch (error) {
logger.error('Customer contract PDF error:', error);
res.status(500).json({ error: 'Failed to render contract PDF' });
}
});
module.exports = router;
+342
View File
@@ -0,0 +1,342 @@
/**
* Public → Contracts Routes
*
* Mounted at /api/public/contracts. NO authentication — the link in
* the customer's signing email is the only secret.
*
* Surface:
* GET /:token read-only contract view + included blocks
* POST /:token/sign body: { name, signatureDataUrl?, accepted: true }
* POST /:token/upload-signed-pdf multer single — customer uploads their wet-signed PDF
*
* No state mutation flows from /:token (GET) — only the two POST routes
* affect the contract. IP is captured for the signature evidence /
* upload audit row.
*/
const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const rateLimit = require('express-rate-limit');
const { body, param } = require('express-validator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { validateFileType } = require('../utils/fileSecurityUtils');
const contractService = require('../services/contractService');
const { getAppSetting } = require('../utils/appSettings');
const { clientIpForAudit } = require('../utils/clientIp');
const { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards');
const { db } = require('../database/db');
const router = express.Router();
const previewLimiter = rateLimit({
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
});
const respondLimiter = rateLimit({
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
});
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const signedPdfStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(getStoragePath(), 'uploads/contracts/signed');
fs.mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || '.pdf';
cb(null, `contract-token-${req.params.token.slice(0, 12)}-${Date.now()}${ext}`);
},
});
const signedPdfUpload = multer({
storage: signedPdfStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, ['application/pdf'])) return cb(null, true);
return cb(new Error('Only PDF files are allowed'));
},
});
/**
* Public-safe projection of the contract. We deliberately omit:
* - intro/outro text remain visible (customer-facing by design)
* - admin notes (none on contracts today)
* - admin IP + signature paths (signed_*_path is admin-only)
*
* The IP / signature image paths are NEVER exposed publicly even after
* signing — they're audit evidence.
*/
function publicContractView(contract, inclusions, customer, profile, locale) {
const orderedSections = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
const blocksBySection = {};
for (const s of orderedSections) blocksBySection[s] = [];
for (const inc of inclusions) {
if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue;
const bodyEn = inc.body_text_snapshot || inc.block_body_text || '';
const bodyDe = inc.body_text_de_snapshot || inc.block_body_text_de || '';
// 1) Strip the leading `**Title**\n` line — the block.name is
// already rendered above as a bold sub-heading, so a bold
// first line in the body would duplicate it.
// 2) Strip remaining `**bold**` inline markers — the React sign
// page renders body as plain `whitespace-pre-line` text and
// has no inline-bold UI. The PDF path keeps them as bold
// runs via pdfService.renderBodyMarkdown.
const body = (locale === 'de' ? (bodyDe || bodyEn) : (bodyEn || bodyDe))
.replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, '')
.replace(/\*\*([^*]+)\*\*/g, '$1');
if (!blocksBySection[inc.section]) continue;
blocksBySection[inc.section].push({
blockId: inc.block_id,
section: inc.section,
position: inc.position,
name: inc.block_name,
body,
});
}
const sections = orderedSections
.map((s) => ({ section: s, blocks: blocksBySection[s] }))
.filter((s) => s.blocks.length > 0);
return {
contractNumber: contract.contract_number,
status: contract.status,
language: contract.language,
issueDate: contract.issue_date,
validUntil: contract.valid_until,
title: contract.title,
introText: contract.intro_text,
outroText: contract.outro_text,
sentAt: contract.sent_at,
signedByCustomerAt: contract.signed_by_customer_at,
signedByAdminAt: contract.signed_by_admin_at,
signedCustomerName: contract.signed_customer_name,
signedAdminName: contract.signed_admin_name,
// The customer's own IP is fine to surface back — it's THEIR
// identifier on the audit trail. The admin's IP is NOT exposed
// publicly: it's a counter-party's identifier (operator's office /
// home network) and shouldn't reach the customer's browser via
// a token-only-secret endpoint. Admin sees their own IP on the
// admin detail page; customer doesn't need it.
signedCustomerIp: contract.signed_customer_ip || null,
// signed_pdf_path itself is admin-only; we just flag presence so
// the public page can show a "wet-signed copy attached" hint.
hasSignedPdf: !!contract.signed_pdf_path,
// SHA-256 of the on-disk PDFs — surfaced so the customer can
// re-hash their downloaded copy and confirm it matches what
// we issued. Audit-trail evidence #1 from the maintainer plan.
pdfSha256: contract.pdf_sha256 || null,
signedPdfSha256: contract.signed_pdf_sha256 || null,
canSign: contract.status === 'sent',
sections,
recipient: customer ? {
displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '),
companyName: customer.company_name,
email: customer.email,
} : null,
issuer: profile ? {
companyName: profile.company_name,
addressLine1: profile.address_line1,
postalCode: profile.postal_code,
city: profile.city,
email: profile.email,
website: profile.website,
} : null,
};
}
router.get(
'/:token',
previewLimiter,
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
handleAsync(async (req, res) => {
validateRequest(req);
const tokenRow = await loadActionToken(req, res, {
tableName: 'contract_action_tokens',
token: req.params.token,
});
if (!tokenRow) return;
const data = await contractService.getContractById(tokenRow.contract_id);
if (!data) return res.status(404).json({ error: 'Contract not found' });
const customer = await db('customer_accounts').where({ id: data.contract.customer_account_id }).first();
const profile = await db('business_profile').where({ id: 1 }).first();
// Surface the admin-tunable behaviour toggles on the view so the
// React page can hide the upload-PDF section when disabled and
// enforce the drawn-signature requirement client-side. The server
// re-enforces both, so client tampering only changes the UX.
const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false;
const requireDrawnSignature = (await getAppSetting('crm_contracts_require_drawn_signature')) === true;
const view = publicContractView(
data.contract,
data.inclusions,
customer,
profile,
data.contract.language || 'de',
);
view.allowPdfUpload = allowPdfUpload;
view.requireDrawnSignature = requireDrawnSignature;
return successResponse(res, { contract: view });
}),
);
router.post(
'/:token/sign',
respondLimiter,
[
param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
body('name').isString().isLength({ min: 1, max: 255 }),
body('accepted').isBoolean(),
body('signatureDataUrl').optional({ nullable: true }).isString(),
],
handleAsync(async (req, res) => {
validateRequest(req);
// Audit IP source: req.ip ONLY. See utils/clientIp.js for the
// full rationale — reading X-Forwarded-For directly bypassed
// Express's trust-proxy safety net and let direct (non-proxied)
// POSTs spoof the audit IP, defeating the legal-evidence promise
// of the contract signing flow. Operators whose nginx topology
// needs different trust rules adjust `TRUST_PROXY` in server.js.
const ip = clientIpForAudit(req);
try {
const result = await contractService.recordCustomerSignature({
token: req.params.token,
name: req.body.name,
signatureDataUrl: req.body.signatureDataUrl,
accepted: req.body.accepted === true,
ip,
});
return successResponse(res, result);
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message, code: err.code });
}
throw err;
}
}),
);
// Server-side guard for the "allow PDF upload" toggle. When the admin
// turns it off in Settings → CRM behaviour → Contracts the public sign
// page hides the upload section, but a hand-crafted POST would still
// hit this route — refuse here too BEFORE multer reads the body so a
// disabled-toggle install never writes attacker bytes to disk.
async function uploadSignedPdfSettingGuard(req, res, next) {
const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false;
if (!allowPdfUpload) {
return res.status(403).json({
error: 'Uploading a wet-signed PDF is disabled for this installation. Please sign in your browser instead.',
code: 'UPLOAD_DISABLED',
});
}
next();
}
router.post(
'/:token/upload-signed-pdf',
respondLimiter,
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
// CRITICAL ORDERING: setting guard + token guard run BEFORE multer.
// Previously these checks lived after multer.single, which meant a
// disabled-toggle install OR an expired/invalid token still cost a
// disk write — captured tokens could be replayed to spam the disk
// up to multer's 10 MB cap per request. Pre-multer rejection costs
// a DB lookup and nothing more.
uploadSignedPdfSettingGuard,
preMulterTokenGuard('contract_action_tokens'),
signedPdfUpload.single('file'),
handleAsync(async (req, res) => {
validateRequest(req);
const tokenRow = req.publicTokenRow; // attached by preMulterTokenGuard
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded', code: 'NO_FILE' });
}
const result = await contractService.attachSignedPdfUpload(
tokenRow.contract_id,
req.file.path,
'customer',
);
// Mark the token as used so the link can't be re-played.
// IP storage is gated by the crm_contracts_store_ip setting so
// privacy-strict operators can opt out — same toggle that gates
// the in-browser-sign IP captures. See utils/clientIp.js for
// why we trust req.ip only.
const rawIp = clientIpForAudit(req);
const storeIpEnabled = (await getAppSetting('crm_contracts_store_ip')) !== false;
await db('contract_action_tokens').where({ id: tokenRow.id }).update({
used_at: new Date(),
used_action: 'uploaded_signed_pdf',
used_ip: storeIpEnabled ? rawIp : null,
});
return successResponse(res, result);
}),
);
/**
* Public PDF download — token-scoped. Once the customer has signed,
* they can re-fetch the signed copy from the same link rather than
* waiting for the contract_fully_signed email (which only arrives
* after admin counter-sign). Streams signed_pdf_path when present,
* falls back to pdf_path. Returns 410 once the link has expired.
*
* Security note: this route deliberately honours `expires_at` now —
* previous behaviour was "expired tokens still allow downloads, the
* customer may need their signed copy after the window closes" but
* that turned the token into a permanent unauthenticated download
* URL once leaked (referer headers, browser history, email forward).
* Customers needing a post-expiry copy receive the signed PDF in the
* `contract_fully_signed` email, OR the admin can issue a fresh
* download link via the admin detail page.
*
* Future enhancement (audit: "public token model rework"): swap the
* long-lived contract token for a short-lived download sub-token
* (~5 min) generated after sign, so the download URL itself never
* embeds the long-lived secret. Tracked in the CRM backlog.
*/
router.get(
'/:token/pdf',
previewLimiter,
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
handleAsync(async (req, res) => {
validateRequest(req);
const tokenRow = await loadActionToken(req, res, {
tableName: 'contract_action_tokens',
token: req.params.token,
});
if (!tokenRow) return;
const contract = await db('contracts').where({ id: tokenRow.contract_id }).first();
if (!contract) return res.status(404).json({ error: 'Contract not found' });
const fs = require('fs');
const path = require('path');
const { assertContractPdfPath } = require('../utils/safePath');
const filePath = contract.signed_pdf_path || contract.pdf_path;
// Content-Disposition: attachment + Referrer-Policy: no-referrer
// so the long-lived contract token doesn't leak via referer
// headers if the customer opens the PDF in an external viewer
// that loads remote resources.
res.set('Referrer-Policy', 'no-referrer');
if (!filePath || !fs.existsSync(filePath)) {
// Render on-demand so the link works even if the on-disk
// file was wiped (cleanup, S3 sync, etc.).
const contractService = require('../services/contractService');
const buf = await contractService.renderContractPdfBuffer(contract.id);
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `attachment; filename="${contract.contract_number}.pdf"`);
return res.send(buf);
}
// C.7 — defence-in-depth: reject if filePath resolves outside the
// contract storage roots. The customer signing token is far less
// privileged than an admin, so getting this wrong has higher blast
// radius (a forged token could otherwise read any file the node
// process has access to). assertContractPdfPath throws AppError
// which the error middleware converts to a clean 403/404.
const safePath = assertContractPdfPath(filePath);
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `attachment; filename="${path.basename(safePath)}"`);
fs.createReadStream(safePath).pipe(res);
}),
);
module.exports = router;
+101
View File
@@ -0,0 +1,101 @@
/**
* Public → Invoice payment-check Routes
*
* Mounted at /api/public/payment-check. NO authentication — the
* admin's email link carries a 64-char hex token; that's the only
* gate. The page at /payment-check/:token uses these endpoints to:
*
* GET /:token read invoice summary for the page
* POST /:token record the admin's selection:
* action: 'paid_full' | 'partial' | 'unpaid'
* amountMinor: optional, for 'partial'
*
* Mirrors the publicQuotes.js shape (rate limits, token format
* validation, error code surface) so the same defensive patterns
* apply.
*/
const express = require('express');
const { body, param } = require('express-validator');
const rateLimit = require('express-rate-limit');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const invoiceService = require('../services/invoiceService');
const router = express.Router();
// 30 reads / minute / IP; 10 records / minute / IP.
const previewLimiter = rateLimit({
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
});
const recordLimiter = rateLimit({
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
});
router.get(
'/:token',
previewLimiter,
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
handleAsync(async (req, res) => {
validateRequest(req);
try {
const view = await invoiceService.getPaymentCheckByToken(req.params.token);
// Branding block — same shape `publicQuotes.js` returns so the
// frontend can render a consistent header (logo + company
// name) and respect the admin's branding colour palette. Web
// pages use the global Settings → Branding logo, NOT the
// dedicated PDF logo (business_profile.logo_path is print-
// only).
const { db } = require('../database/db');
const { getAppSetting } = require('../utils/appSettings');
const profile = await db('business_profile').where({ id: 1 }).first();
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const issuer = profile ? {
companyName: profile.company_name || '',
email: profile.email || '',
website: profile.website || '',
logoUrl: (() => {
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
if (!raw) return null;
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
return `/uploads/${raw.replace(/^uploads\//, '')}`;
})(),
} : null;
return successResponse(res, { invoice: view, issuer });
} catch (err) {
if (err.code === 'TOKEN_ALREADY_USED') {
return res.status(410).json({
error: err.message,
code: err.code,
usedAt: err.usedAt,
usedAction: err.usedAction,
});
}
throw err;
}
})
);
router.post(
'/:token',
recordLimiter,
[
param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
body('action').isIn(['paid_full', 'paid_with_skonto', 'partial', 'unpaid']),
body('amountMinor').optional({ values: 'falsy' }).isInt({ min: 1 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await invoiceService.recordPaymentCheckAction({
token: req.params.token,
action: req.body.action,
amountMinor: req.body.amountMinor,
ip: req.ip,
adminId: null,
});
return successResponse(res, result);
})
);
module.exports = router;
+184
View File
@@ -0,0 +1,184 @@
/**
* Public → Quotes Routes
*
* Mounted at /api/public/quotes. NO authentication — the link in the
* customer email is the only secret. The route layer must:
* - never leak admin-only fields (internal_notes, etc.)
* - rate-limit by IP/token to soften brute-force token guessing
* - honour the 15-min re-toggle window enforced at the service layer
*
* Surface:
* GET /:token read-only quote view for the customer
* POST /:token/respond body: { action: 'accept' | 'decline' }
*/
const express = require('express');
const { body, param } = require('express-validator');
const rateLimit = require('express-rate-limit');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const quoteService = require('../services/quoteService');
const { db } = require('../database/db');
const { clientIpForAudit } = require('../utils/clientIp');
const { loadActionToken } = require('../utils/publicTokenGuards');
const router = express.Router();
// Rate-limit: 30 token previews per IP per minute, 10 responses.
const previewLimiter = rateLimit({
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
});
const respondLimiter = rateLimit({
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
});
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl) {
return {
quoteNumber: quote.quote_number,
status: quote.status,
language: quote.language,
currency: quote.currency,
issueDate: quote.issue_date,
validUntil: quote.valid_until,
eventName: quote.event_name,
eventDate: quote.event_date,
eventTimeStart: quote.event_time_start,
eventTimeEnd: quote.event_time_end,
introText: quote.intro_text,
outroText: quote.outro_text,
// Money — public surface.
netAmountMinor: quote.net_amount_minor,
vatRate: quote.vat_rate == null ? null : Number(quote.vat_rate),
vatAmountMinor: quote.vat_amount_minor,
shippingAmountMinor: quote.shipping_amount_minor,
totalAmountMinor: quote.total_amount_minor,
// Response state — drives the page UI.
respondedAt: quote.responded_at,
responseLockedAt: quote.response_locked_at,
canRespond: !!(quote.status === 'sent' || (
quote.responded_at && quote.response_locked_at &&
new Date(quote.response_locked_at).getTime() > Date.now()
)),
lineItems: lineItems.map((li) => ({
position: li.position,
quantity: Number(li.quantity),
description: li.description,
unitPriceMinor: li.unit_price_minor,
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
lineTotalMinor: li.line_total_minor,
})),
recipient: customer ? {
displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '),
email: customer.email,
companyName: customer.company_name,
} : null,
// Terms of Service surfaced to the customer when the global
// `crm_quotes_tos_required` flag is on. The text + URL are
// included unconditionally so admins can opt to display them
// without blocking acceptance; the frontend gates the checkbox.
// Snapshot is rendered when the quote has already been accepted
// so the customer sees exactly what they agreed to, not the
// current ToS text (which may have changed).
tos: {
required: tosRequired === true,
text: quote.tos_text_snapshot || tosText || '',
url: tosUrl || '',
acceptedAt: quote.tos_accepted_at || null,
},
issuer: profile ? {
companyName: profile.company_name,
email: profile.email,
website: profile.website,
footerLine: profile.footer_line,
// Logo source for the web quote page is ONLY the global
// Settings → Branding logo (`app_settings.branding_logo_url`).
//
// `business_profile.logo_path` is intentionally NOT consulted
// here — it's a dedicated PDF lightmode logo (PDFs always
// print on white paper, so admins upload a dark variant
// there). On the web page the existing site branding already
// serves both light + dark modes correctly, so falling back
// to a PDF-only image would override that with a light
// version that doesn't read in dark mode.
logoUrl: (() => {
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
if (!raw) return null;
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
return `/uploads/${raw.replace(/^uploads\//, '')}`;
})(),
} : null,
};
}
router.get(
'/:token',
previewLimiter,
[param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)],
handleAsync(async (req, res) => {
validateRequest(req);
const tokenRow = await loadActionToken(req, res, {
tableName: 'quote_action_tokens',
token: req.params.token,
});
if (!tokenRow) return;
const data = await quoteService.getQuoteById(tokenRow.quote_id);
if (!data) return res.status(404).json({ error: 'Quote not found' });
const customer = await db('customer_accounts').where({ id: data.quote.customer_account_id }).first();
const businessProfileService = require('../services/businessProfileService');
const { profile } = await businessProfileService.getProfile();
// Pull the three ToS keys via the shared helper so it works
// regardless of how setting_value is encoded (JSON-stringified vs
// raw). All three are optional.
const { getAppSetting } = require('../utils/appSettings');
const tosRequired = await getAppSetting('crm_quotes_tos_required', false);
const tosText = await getAppSetting('crm_quotes_tos_text', '');
const tosUrl = await getAppSetting('crm_quotes_tos_url', '');
// Fallback logo when business_profile has no dedicated CRM logo
// — admins typically upload one logo via Settings → Branding and
// expect it to flow through the customer-facing pages too.
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
return successResponse(res, {
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl),
});
})
);
router.post(
'/:token/respond',
respondLimiter,
[
param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
body('action').isIn(['accept', 'decline']),
// ToS box: optional flag, only meaningful when the global
// `crm_quotes_tos_required` setting is on. Service enforces.
body('tosAccepted').optional().isBoolean(),
],
handleAsync(async (req, res) => {
validateRequest(req);
try {
// See utils/clientIp.js — trust req.ip (configured via Express
// trust-proxy), never read X-Forwarded-For directly.
const ip = clientIpForAudit(req);
const result = await quoteService.recordResponse({
token: req.params.token,
action: req.body.action,
ip,
tosAccepted: req.body.tosAccepted === true,
});
return successResponse(res, { status: result.status, lockedAt: result.lockedAt });
} catch (err) {
if (err.code === 'RESPONSE_LOCKED') {
return res.status(423).json({
error: err.message,
code: 'RESPONSE_LOCKED',
currentStatus: err.currentStatus,
lockedAt: err.lockedAt,
});
}
throw err;
}
})
);
module.exports = router;
+13
View File
@@ -105,6 +105,19 @@ router.get('/', async (req, res) => {
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
general_date_format: settingsObject.general_date_format || 'PPP',
// '12h' / '24h' — controls how times are rendered in admin +
// customer views via the useLocalizedDate hook. The underlying
// storage is always HH:mm (24h); only the displayed form toggles.
// Default '24h' to match the operator's CH/DE locale.
general_time_format: settingsObject.general_time_format === '12h' ? '12h' : '24h',
// CRM overview tile visibility (admin-only — these are surfaced
// via the public-settings endpoint because the dashboard reads
// them on mount and the value never depends on auth state. All
// four default ON; only explicit false hides the tile.
crm_overview_show_revenue: settingsObject.crm_overview_show_revenue !== false,
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== false,
crm_overview_show_invoices: settingsObject.crm_overview_show_invoices !== false,
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
+172
View File
@@ -0,0 +1,172 @@
/**
* Shared render-context helpers for the three document services
* (quoteService, invoiceService, contractService).
*
* **Why this file exists**
*
* The audit flagged that the issuer + recipient blocks of
* `buildRenderContext` were copy-pasted across all three services and
* had already drifted (contractService's recipient gated `attentionLine`
* on `trimmedCompany` while quote/invoice fire it whenever a person
* name is present). The PDF renderer happens to gate on `hasCompany`
* downstream, so neither variant produced wrong output — but the drift
* is a maintenance trap and any future renderer change relying on the
* raw string would fail surprisingly on one document type.
*
* Two helpers live here:
*
* - `buildIssuerBlock(profile, resolvedLogoPath, options?)` — the
* full issuer shape consumed by pdfService.drawIssuerBlock. Honors
* the existing pdf_show_logo / pdf_show_company_name visibility
* toggles, logo-height, folding-marks, etc. `options.quoteToggles`
* adds the two quote-only fields (`quoteShowNetDays`,
* `quoteShowSkonto`) — they are silently dropped for invoice and
* contract callers so the same helper serves all three doc types.
*
* - `buildRecipientBlock(profile, customer)` — the recipient address
* block. Honors the maintainer spec: companies bold the company
* name on line 1 + "z. Hd. <person>" on line 2; private customers
* bold the person name on line 1 with no z.Hd. line at all. The
* attentionLine string is always populated when a person+salutation
* exists (the downstream renderer gates emission on hasCompany);
* keeping the string non-empty preserves the back-pointer for
* audit/debug surfaces that read the context directly.
*
* **What stayed in each service**
*
* Doc-type-specific fields (line items, totals, payment-term resolution,
* Skonto fallback chain, doc/title block, contract signatures + audit
* trail, source-quote line-items table) all stay where they are. Only
* the issuer + recipient blocks are extracted, since those are
* verbatim duplicates across all three services.
*/
/**
* Build the `issuer` field for a render context. The shape mirrors the
* legacy inline construction exactly so existing callers + the
* pdfService.drawIssuerBlock consumer don't need to change.
*
* @param {object} profile business_profile row (may be empty)
* @param {string|null} logoPath pre-resolved absolute logo path (see resolveLogoFile)
* @param {object} [options]
* @param {boolean} [options.quoteToggles] include pdf_quote_show_net_days
* + pdf_quote_show_skonto fields
* @returns {object}
*/
function buildIssuerBlock(profile, logoPath, options = {}) {
if (!profile) return {};
const base = {
companyName: profile.company_name,
addressLine1: profile.address_line1,
addressLine2: profile.address_line2,
postalCode: profile.postal_code,
city: profile.city,
state: profile.state,
countryCode: profile.country_code,
phone: profile.phone,
mobile: profile.mobile,
email: profile.email,
website: profile.website,
footerLine: profile.footer_line,
vatId: profile.vat_id,
// Steuernummer (migration 139). Rendered alongside VAT-ID on the
// PDF issuer block — §14 UStG requires one or both on every
// invoice. Kleinunternehmer without a USt-IdNr. carry only this.
taxId: profile.tax_id || null,
// pre-resolved absolute path; renderer never re-resolves.
logoPath,
pdfFontTtfPath: profile.pdf_font_ttf_path,
// Bundled fonts dropdown (migration 121). When set, pdfService loads
// <family>/400.ttf + <family>/700.ttf from backend/assets/fonts/.
// Priority: pdfFontTtfPath wins if both are present.
pdfFontFamily: profile.pdf_font_family || null,
// Free-text country name override (migration 107).
countryName: profile.country_name || null,
// Visibility toggles (migration 106). Default true when the column
// is missing on older installs that haven't migrated yet — keeps
// the previously implicit "always show" behavior pinned.
showLogo: profile.pdf_show_logo == null ? true
: (profile.pdf_show_logo === true || profile.pdf_show_logo === 1 || profile.pdf_show_logo === '1'),
showCompanyName: profile.pdf_show_company_name == null ? true
: (profile.pdf_show_company_name === true || profile.pdf_show_company_name === 1 || profile.pdf_show_company_name === '1'),
// Layout customisation (migration 108).
logoHeight: profile.pdf_logo_height == null ? 56 : Number(profile.pdf_logo_height),
companyNameInline: profile.pdf_company_name_inline === true || profile.pdf_company_name_inline === 1 || profile.pdf_company_name_inline === '1',
foldingMarks: profile.pdf_folding_marks || 'none',
};
if (options.quoteToggles) {
// Quote payment-block toggles (migration 110). Quote-only — invoices
// ignore these and always show the payment block. Default FALSE
// when the column is missing (a quote is an offer, not a demand
// for payment; admins opt IN via the Business profile UI).
base.quoteShowNetDays = profile.pdf_quote_show_net_days === true
|| profile.pdf_quote_show_net_days === 1 || profile.pdf_quote_show_net_days === '1';
base.quoteShowSkonto = profile.pdf_quote_show_skonto === true
|| profile.pdf_quote_show_skonto === 1 || profile.pdf_quote_show_skonto === '1';
}
return base;
}
/**
* Build the `recipient` field for a render context. Maintainer spec:
*
* - customer.company_name set → bold company on line 1, then
* "z. Hd. <person>" on line 2 (rendered by pdfService when
* hasCompany is true).
* - else → bold person/display_name/email on line 1, NO z.Hd. line
* (avoids "Luca Bresch / z. Hd. Luca Bresch" duplication).
*
* Empty-string trim guard: customer rows saved with company_name = ""
* (not NULL) used to engage the company-header path with a blank line
* before the trim was added.
*
* @param {object} profile business_profile row (may be empty)
* @param {object} customer customer_accounts row (may be null)
* @returns {object}
*/
function buildRecipientBlock(profile, customer) {
const trimmedCompany = (customer?.company_name || '').trim();
const personFull = [customer?.first_name, customer?.last_name]
.map((s) => (s || '').trim()).filter(Boolean).join(' ');
const headerWithCompany = !!trimmedCompany;
const header = trimmedCompany
|| personFull
|| (customer?.display_name || '').trim()
|| customer?.email
|| '';
// Always populate the attention string when we have a person —
// pdfService.drawRecipientBlock gates emission on hasCompany so the
// dead-data case (no company, has person) doesn't end up on the
// PDF, but having the string available means audit views can show
// it. This unifies the previously-drifted contractService and
// quote/invoice behavior under the renderer-aware contract.
const attentionParts = [customer?.salutation, personFull].filter(Boolean);
const attentionLine = attentionParts.length > 0
? `z. Hd. ${attentionParts.join(' ')}`
: '';
return {
issuerLine: profile?.company_name
? `${profile.company_name} * ${profile.address_line1 || ''} * ${profile.postal_code || ''} ${profile.city || ''}`
: '',
companyName: header,
hasCompany: headerWithCompany,
attentionLine,
// Honorific + last name for personalised salutation
// ("Sehr geehrter Herr Bresch,"). Renderer requires BOTH.
salutation: customer?.salutation || null,
lastName: (customer?.last_name || '').trim() || null,
addressLine1: customer?.address_line1,
addressLine2: customer?.address_line2,
postalCode: customer?.postal_code,
city: customer?.city,
// Country name override (migration 107); falls back to the
// locale-aware COUNTRY_NAMES lookup on countryCodeIso in pdfService.
country: customer?.country_name || null,
countryCodeIso: customer?.country_code,
};
}
module.exports = {
buildIssuerBlock,
buildRecipientBlock,
};
@@ -0,0 +1,340 @@
/**
* businessProfileService — single source of truth for the issuer block
* printed at the top of every quote/invoice PDF.
*
* Two tables back this:
* - business_profile singleton row (id=1) seeded by migration 102
* - business_bank_accounts 1:N from business_profile
*
* Bank accounts are partitioned by currency: at most one default per
* currency. The Quote/Invoice editors auto-pick the matching default when
* the user changes the doc currency. The defaulting rule is enforced at
* the service layer (inside a transaction) — the DB doesn't have a
* partial unique index so we can't rely on it cross-dialect.
*/
const { db, withRetry } = require('../database/db');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const ALLOWED_PROFILE_FIELDS = [
'company_name',
'address_line1',
'address_line2',
'postal_code',
'city',
'state',
'country_code',
// Free-text country name (migration 107). Overrides the lookup
// when set; falls back to COUNTRY_NAMES[locale][country_code] in
// the PDF renderer when blank.
'country_name',
'phone',
'mobile',
'email',
'website',
'vat_id',
// Steuernummer (migration 139). DE/AT §14 UStG accepts either
// USt-IdNr. (vat_id) or local tax number (tax_id) on invoices; many
// Kleinunternehmer only have the latter.
'tax_id',
'vat_label',
'vat_rate_default',
'default_currency',
'default_locale',
'default_qr_format',
'footer_line',
'logo_path',
// Bundled-fonts dropdown (migration 121). Stores the on-disk
// directory name under backend/assets/fonts/ (e.g. "Inter",
// "Playfair-Display"). pdfService loads <family>/400.ttf as body
// and <family>/700.ttf as bold at render time.
//
// Note: the legacy `pdf_font_ttf_path` column (migration 103) is
// intentionally NOT in this whitelist anymore — the UI for setting
// it was retired in favour of the dropdown. Existing values keep
// working at render time (pdfService still reads the column with
// priority), but new writes go exclusively through pdf_font_family.
'pdf_font_family',
// PDF letterhead visibility toggles (migration 106). Defaults true
// to keep existing PDFs visually identical after the migration runs.
'pdf_show_logo',
'pdf_show_company_name',
// PDF layout customisation (migration 108): folding marks at the
// page edge, logo banner height in pt, and a toggle to render the
// company name as inline plain text rather than as a bold title.
'pdf_folding_marks',
'pdf_logo_height',
'pdf_company_name_inline',
// Quote payment-block toggles (migration 110). Invoices always
// show the full payment block; these only affect quote PDFs.
'pdf_quote_show_net_days',
'pdf_quote_show_skonto',
// IANA timezone string for the admin calendar (migration 137). Used
// by the calendar UI to render timed blocks in the operator's
// working tz. Admin-only; never exposed via publicSettings.
'timezone',
];
const ALLOWED_BANK_FIELDS = [
'label',
'account_holder',
'iban',
'bic',
'currency',
'is_default',
'display_order',
];
const VALID_QR_FORMATS = new Set(['swiss', 'epc', 'none']);
function pickFields(payload, allowed) {
if (!payload || typeof payload !== 'object') return {};
const out = {};
for (const key of allowed) {
if (Object.prototype.hasOwnProperty.call(payload, key)) {
out[key] = payload[key];
}
}
return out;
}
function normaliseIban(iban) {
if (!iban) return iban;
return String(iban).replace(/\s+/g, '').toUpperCase();
}
function normaliseCurrency(currency) {
if (!currency) return currency;
return String(currency).trim().toUpperCase();
}
function normaliseCountryCode(cc) {
if (!cc) return cc;
return String(cc).trim().toUpperCase().slice(0, 2);
}
function sanitiseProfilePayload(payload) {
const updates = pickFields(payload, ALLOWED_PROFILE_FIELDS);
if (updates.country_code !== undefined) {
updates.country_code = normaliseCountryCode(updates.country_code);
}
if (updates.default_currency !== undefined) {
updates.default_currency = normaliseCurrency(updates.default_currency);
}
if (updates.default_qr_format !== undefined) {
const v = String(updates.default_qr_format || '').trim().toLowerCase();
updates.default_qr_format = VALID_QR_FORMATS.has(v) ? v : 'none';
}
// Trim free-text fields to avoid silent leading/trailing whitespace
// when the admin pastes from a printed letterhead.
for (const field of ['company_name', 'address_line1', 'address_line2',
'city', 'state', 'country_name', 'phone', 'mobile', 'email', 'website',
'vat_id', 'tax_id', 'vat_label', 'footer_line', 'logo_path']) {
if (typeof updates[field] === 'string') {
updates[field] = updates[field].trim();
}
}
// Normalise the boolean PDF visibility toggles. Empty / undefined
// stays untouched (so partial updates don't reset existing values).
for (const field of [
'pdf_show_logo', 'pdf_show_company_name', 'pdf_company_name_inline',
'pdf_quote_show_net_days', 'pdf_quote_show_skonto',
]) {
if (updates[field] !== undefined) {
updates[field] = formatBoolean(Boolean(updates[field]));
}
}
// Folding-mark enum — whitelisted set. Garbage values fall back to
// 'none' so a typo can't shoot itself in the foot.
if (updates.pdf_folding_marks !== undefined) {
const v = String(updates.pdf_folding_marks || '').toLowerCase();
updates.pdf_folding_marks = ['none', 'half', 'third', 'both'].includes(v) ? v : 'none';
}
// Logo height — clamp to a sensible range (24-200pt). Out-of-range
// values get snapped instead of rejected so the form can be lax.
if (updates.pdf_logo_height !== undefined) {
const n = parseInt(updates.pdf_logo_height, 10);
updates.pdf_logo_height = Number.isFinite(n)
? Math.max(24, Math.min(200, n))
: 56;
}
return updates;
}
function sanitiseBankPayload(payload) {
const updates = pickFields(payload, ALLOWED_BANK_FIELDS);
if (updates.iban !== undefined) {
updates.iban = normaliseIban(updates.iban);
}
if (updates.bic !== undefined && typeof updates.bic === 'string') {
updates.bic = updates.bic.replace(/\s+/g, '').toUpperCase();
}
if (updates.currency !== undefined) {
updates.currency = normaliseCurrency(updates.currency);
}
if (updates.is_default !== undefined) {
updates.is_default = formatBoolean(Boolean(updates.is_default));
}
for (const field of ['label', 'account_holder']) {
if (typeof updates[field] === 'string') {
updates[field] = updates[field].trim();
}
}
return updates;
}
/**
* Fetch the singleton business_profile row + its bank accounts.
* Always returns a profile object even if the row is empty — the
* Settings UI binds straight to this shape.
*/
async function getProfile() {
return await withRetry(async () => {
let profile = await db('business_profile').where({ id: 1 }).first();
if (!profile) {
// Belt-and-braces: migration 102 seeds id=1, but if a fresh install
// ran an earlier rollback that wiped the row, re-create it so the
// service never throws.
await db('business_profile').insert({ id: 1 });
profile = await db('business_profile').where({ id: 1 }).first();
}
const accounts = await db('business_bank_accounts')
.where({ business_profile_id: 1 })
.orderBy('display_order', 'asc')
.orderBy('id', 'asc');
return { profile, bankAccounts: accounts };
});
}
async function updateProfile(payload, adminId) {
const updates = sanitiseProfilePayload(payload);
if (Object.keys(updates).length === 0) {
return await getProfile();
}
updates.updated_at = new Date();
await withRetry(async () => {
await db('business_profile').where({ id: 1 }).update(updates);
});
logger.info('Business profile updated', {
adminId,
fields: Object.keys(updates).filter((k) => k !== 'updated_at'),
});
return await getProfile();
}
/**
* Insert a new bank account. If `is_default = true`, atomically clear
* the default flag on every other account in the same currency.
*/
async function createBankAccount(payload, adminId) {
const data = sanitiseBankPayload(payload);
if (!data.iban) {
throw new AppError('iban is required', 400);
}
data.business_profile_id = 1;
data.created_at = new Date();
data.updated_at = new Date();
// Default off when not specified — we don't want the first account
// accidentally becoming default just because the form omitted the field.
if (data.is_default === undefined) data.is_default = formatBoolean(false);
return await db.transaction(async (trx) => {
if (data.is_default && (data.is_default === true || data.is_default === 1)) {
await trx('business_bank_accounts')
.where({ business_profile_id: 1, currency: data.currency })
.update({ is_default: formatBoolean(false), updated_at: new Date() });
}
const inserted = await trx('business_bank_accounts').insert(data).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
logger.info('Business bank account created', {
adminId, id, iban: data.iban?.slice(-4), currency: data.currency,
});
return await trx('business_bank_accounts').where({ id }).first();
});
}
async function updateBankAccount(id, payload, adminId) {
const data = sanitiseBankPayload(payload);
data.updated_at = new Date();
return await db.transaction(async (trx) => {
const existing = await trx('business_bank_accounts').where({ id }).first();
if (!existing) {
throw new AppError('Bank account not found', 404);
}
// Honour the per-currency single-default rule.
if (data.is_default === true || data.is_default === 1 || data.is_default === formatBoolean(true)) {
const targetCurrency = data.currency || existing.currency;
await trx('business_bank_accounts')
.where({ business_profile_id: 1, currency: targetCurrency })
.andWhereNot({ id })
.update({ is_default: formatBoolean(false), updated_at: new Date() });
}
await trx('business_bank_accounts').where({ id }).update(data);
logger.info('Business bank account updated', { adminId, id });
return await trx('business_bank_accounts').where({ id }).first();
});
}
async function deleteBankAccount(id, adminId) {
return await withRetry(async () => {
const existing = await db('business_bank_accounts').where({ id }).first();
if (!existing) {
throw new AppError('Bank account not found', 404);
}
await db('business_bank_accounts').where({ id }).del();
logger.info('Business bank account deleted', { adminId, id });
return { deleted: true };
});
}
/**
* Resolve the bank account that should print on a quote/invoice for a
* given currency: explicit override → default for that currency →
* default for the profile's default_currency → first by display_order.
*/
async function resolveBankAccountForCurrency(currency, overrideId = null) {
return await withRetry(async () => {
if (overrideId) {
const explicit = await db('business_bank_accounts').where({ id: overrideId }).first();
if (explicit) return explicit;
}
if (currency) {
const match = await db('business_bank_accounts')
.where({ business_profile_id: 1, currency, is_default: formatBoolean(true) })
.first();
if (match) return match;
}
const anyDefault = await db('business_bank_accounts')
.where({ business_profile_id: 1, is_default: formatBoolean(true) })
.first();
if (anyDefault) return anyDefault;
return await db('business_bank_accounts')
.where({ business_profile_id: 1 })
.orderBy('display_order', 'asc').orderBy('id', 'asc').first();
});
}
module.exports = {
getProfile,
updateProfile,
createBankAccount,
updateBankAccount,
deleteBankAccount,
resolveBankAccountForCurrency,
};
@@ -0,0 +1,288 @@
/**
* contractBlocksService — CRUD for the contract block library.
*
* The library is shared across all contracts. System blocks (12 seeded
* by migration 130) cannot be deleted but their body text remains
* editable so the admin's lawyer can rewrite them. Admin-authored
* (non-system) blocks can be freely created, edited, and removed.
*
* Sections are validated against a fixed enum mirroring
* contractService.SECTIONS_ORDER — keeping these in sync is the
* "data-driven all the way down" guarantee (no orphan sections in
* the DB that the renderer can't display).
*/
const { db, withRetry } = require('../database/db');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
const ALLOWED_SECTIONS = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
/**
* System blocks added to the seed AFTER migration 131 was already
* deployed to beta. knex won't re-run an already-applied migration,
* so the new system blocks listed here need a runtime self-heal —
* same pattern as `ensureContractEmailTemplatesSeeded` for templates.
*
* Each entry must carry every column the row needs. `slug` is the
* uniqueness key; the seeder is no-op when the row already exists.
* EN/DE bodies only — non-EN/DE locales stay null until the admin
* fills them in via the block library UI.
*/
const RUNTIME_SEEDED_BLOCKS = [
{
slug: 'quote_line_items_table',
section: 'scope',
name: 'Quote line items',
description: 'Auto-inserts the source quote\'s line items as a table. Body text appears above the table.',
body_text: 'Service items per quote {{source_quote_number}}:',
body_text_de: 'Leistungspositionen gemäß Angebot {{source_quote_number}}:',
is_system: true,
is_active: true,
},
];
// Module-scope flag so the seed check runs once per process. The
// underlying queries are still idempotent — this just saves the round
// trip on every listBlocks / createContract call.
let _systemBlocksSeeded = false;
/**
* Self-heal: insert any RUNTIME_SEEDED_BLOCKS entries that don't yet
* exist in contract_blocks. Called from listBlocks + createContract
* paths so new system blocks appear automatically on installs that
* applied an earlier version of migration 131. Idempotent.
*/
async function ensureSystemBlocksSeeded() {
if (_systemBlocksSeeded) return [];
if (!(await db.schema.hasTable('contract_blocks'))) return [];
const newlyInserted = [];
for (const def of RUNTIME_SEEDED_BLOCKS) {
try {
const existing = await db('contract_blocks').where({ slug: def.slug }).first();
if (existing) continue;
// display_order = current MAX in the target section + 1 so the
// new block sorts to the end. Matches the migration's behaviour.
const maxOrderRow = await db('contract_blocks')
.where({ section: def.section })
.max('display_order as max').first();
const nextOrder = (maxOrderRow?.max || 0) + 1;
await db('contract_blocks').insert({
...def,
display_order: nextOrder,
created_at: new Date(),
updated_at: new Date(),
});
newlyInserted.push(def.slug);
logger.info(`Self-healed missing system contract block at runtime: ${def.slug}`);
} catch (err) {
logger.error(`Failed to seed system contract block ${def.slug}`, { message: err.message });
// Keep _systemBlocksSeeded=false so the next call retries.
return newlyInserted;
}
}
_systemBlocksSeeded = true;
return newlyInserted;
}
function ensureSection(section) {
if (!ALLOWED_SECTIONS.includes(section)) {
throw new AppError(
`Invalid section '${section}'. Must be one of: ${ALLOWED_SECTIONS.join(', ')}`,
400,
'INVALID_SECTION',
);
}
}
function slugify(name) {
const base = String(name || 'block')
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48);
// Append a 6-hex suffix so admin-authored blocks don't collide with
// each other or with seeded slugs.
const suffix = require('crypto').randomBytes(3).toString('hex');
return `${base || 'block'}_${suffix}`;
}
async function listBlocks({ section, includeInactive = false } = {}) {
// Self-heal before reading so the new system block (added to the
// already-deployed migration 131 in feat/crm) appears in the
// library UI immediately on first GET, without needing a fresh
// install. Safe to call repeatedly — guarded by _systemBlocksSeeded.
await ensureSystemBlocksSeeded();
return await withRetry(async () => {
let q = db('contract_blocks').select('*');
if (section) q = q.where({ section });
if (!includeInactive) q = q.where({ is_active: true });
q = q.orderBy('section', 'asc').orderBy('display_order', 'asc').orderBy('id', 'asc');
return await q;
});
}
async function getBlockById(id) {
return await db('contract_blocks').where({ id }).first();
}
async function createBlock(payload) {
if (!payload.name || !String(payload.name).trim()) {
throw new AppError('Block name is required', 400);
}
ensureSection(payload.section);
if (!payload.bodyText || !String(payload.bodyText).trim()) {
throw new AppError('Block body (EN) is required', 400);
}
const slug = payload.slug && /^[a-z0-9_]+$/.test(payload.slug)
? payload.slug
: slugify(payload.name);
// Ensure slug uniqueness (regenerate on the rare collision).
let finalSlug = slug;
let attempt = 0;
while (await db('contract_blocks').where({ slug: finalSlug }).first()) {
attempt += 1;
finalSlug = slugify(payload.name);
if (attempt > 5) {
throw new AppError('Could not generate a unique block slug', 500);
}
}
const row = {
slug: finalSlug,
section: payload.section,
name: String(payload.name).trim().slice(0, 128),
description: payload.description ? String(payload.description).slice(0, 255) : null,
body_text: String(payload.bodyText),
body_text_de: payload.bodyTextDe ? String(payload.bodyTextDe) : null,
is_system: false,
is_active: payload.isActive !== false,
display_order: Number.isFinite(payload.displayOrder) ? Number(payload.displayOrder) : 100,
created_at: new Date(),
updated_at: new Date(),
};
// Schema-drift guard — migration 131 adds these columns. On installs
// that haven't migrated yet, only EN+DE bodies persist; the other
// four fields are accepted from the payload but silently dropped.
for (const [field, payloadKey] of [
['body_text_ru', 'bodyTextRu'],
['body_text_pt', 'bodyTextPt'],
['body_text_nl', 'bodyTextNl'],
['body_text_fr', 'bodyTextFr'],
]) {
if (payload[payloadKey] != null
&& await hasColumnCached('contract_blocks', field)) {
row[field] = payload[payloadKey] ? String(payload[payloadKey]) : null;
}
}
const inserted = await db('contract_blocks').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
return await getBlockById(id);
}
/**
* Update a block. System blocks: every field is editable so the
* admin's lawyer can rewrite the body text in place. The only
* protection on system blocks is that they can't be hard-deleted —
* an admin who wants to retire one toggles `is_active=false`.
*/
async function updateBlock(id, payload) {
const existing = await getBlockById(id);
if (!existing) throw new AppError('Block not found', 404);
const updates = { updated_at: new Date() };
if ('section' in payload) {
ensureSection(payload.section);
updates.section = payload.section;
}
if ('name' in payload) {
if (!payload.name || !String(payload.name).trim()) {
throw new AppError('Block name is required', 400);
}
updates.name = String(payload.name).trim().slice(0, 128);
}
if ('description' in payload) {
updates.description = payload.description ? String(payload.description).slice(0, 255) : null;
}
if ('bodyText' in payload) {
if (!payload.bodyText || !String(payload.bodyText).trim()) {
throw new AppError('Block body (EN) is required', 400);
}
updates.body_text = String(payload.bodyText);
}
if ('bodyTextDe' in payload) {
updates.body_text_de = payload.bodyTextDe ? String(payload.bodyTextDe) : null;
}
// Same schema-drift guard as createBlock — accept ru/pt/nl/fr only
// when the column actually exists, so beta installs running this
// service against a not-yet-migrated DB don't throw.
for (const [field, payloadKey] of [
['body_text_ru', 'bodyTextRu'],
['body_text_pt', 'bodyTextPt'],
['body_text_nl', 'bodyTextNl'],
['body_text_fr', 'bodyTextFr'],
]) {
if (payloadKey in payload
&& await hasColumnCached('contract_blocks', field)) {
updates[field] = payload[payloadKey] ? String(payload[payloadKey]) : null;
}
}
if ('isActive' in payload) {
updates.is_active = payload.isActive !== false;
}
if ('displayOrder' in payload && Number.isFinite(payload.displayOrder)) {
updates.display_order = Number(payload.displayOrder);
}
await db('contract_blocks').where({ id }).update(updates);
return await getBlockById(id);
}
/**
* Hard-delete an admin-authored block. System blocks refuse delete —
* the admin must `deactivate` (toggle `is_active=false`) instead.
*
* Active inclusions on existing contracts are protected by the FK
* ON DELETE RESTRICT — deleting a block that's still referenced will
* raise a DB error which we catch and surface as a clean 409.
*/
async function deleteBlock(id) {
const existing = await getBlockById(id);
if (!existing) throw new AppError('Block not found', 404);
if (existing.is_system) {
throw new AppError(
'System blocks cannot be deleted. Toggle them inactive instead so they remain available for audit on old contracts.',
409,
'SYSTEM_BLOCK_PROTECTED',
);
}
try {
await db('contract_blocks').where({ id }).del();
} catch (err) {
if (/foreign key|FOREIGN KEY|RESTRICT/i.test(err.message)) {
throw new AppError(
'This block is referenced by one or more contracts. Toggle it inactive instead of deleting.',
409,
'BLOCK_IN_USE',
);
}
throw err;
}
return { id };
}
module.exports = {
listBlocks,
getBlockById,
createBlock,
updateBlock,
deleteBlock,
ensureSystemBlocksSeeded,
ALLOWED_SECTIONS,
};
File diff suppressed because it is too large Load Diff
+329 -55
View File
@@ -42,6 +42,12 @@ const PREFILLABLE_FIELDS = [
'city',
'state',
'country_code',
'country_name',
// Locale used for portal UI AND for quote/invoice PDF rendering.
// Admin can pre-set this on the invitation so a German customer
// gets German documents from the very first invoice, without
// waiting for them to log in and pick their language.
'preferred_language',
];
/**
@@ -101,9 +107,14 @@ async function createInvitation({ email, invitedById, prefill }) {
const existingCustomer = await db('customer_accounts')
.where('email', normalisedEmail)
.first();
if (existingCustomer) {
if (existingCustomer && existingCustomer.password_hash) {
// Already-active customer with this email — duplicate, reject.
throw new ConflictError('A customer account with this email already exists', 'email');
}
// If the existing customer is PASSIVE (password_hash IS NULL), this
// is the "promote to active" path: the admin clicked "Send portal
// invitation" on a passive customer. Allow the invitation through —
// acceptInvitation handles the UPSERT into the existing row.
const pendingInvite = await db('customer_invitations')
.where('email', normalisedEmail)
@@ -158,6 +169,92 @@ async function createInvitation({ email, invitedById, prefill }) {
return { id, email: normalisedEmail, token, expiresAt };
}
/**
* Create a "passive" customer directly — no invitation, no email.
*
* Used for two flows:
* 1. Admin opens the quote/invoice editor, clicks "+ Create new
* customer", fills out the form, hits "Save as passive customer".
* The customer becomes available immediately as the recipient of
* the document the admin is working on.
* 2. Admin opens the same form and hits "Save & send portal
* invitation". The editor calls createDirect first to mint the
* customer id, then calls the send-invite route to fire the
* onboarding email. (Two separate API calls — easier to reason
* about than an atomic endpoint.)
*
* A passive customer is identified by `password_hash IS NULL`. The
* customerAuth middleware already rejects login for those (bcrypt
* compare against null returns false), so we don't need a separate
* "is_passive" column or an extra gate.
*
* Race-guarded against duplicate emails the same way createInvitation
* is — a real duplicate throws ConflictError.
*
* @param {{ email, prefill, createdByAdminId }} args
* @returns {Promise<{ id }>} The new customer's id.
*/
async function createDirect({ email, prefill, createdByAdminId }) {
const normalisedEmail = String(email || '').trim().toLowerCase();
if (!normalisedEmail) throw new ValidationError('Email is required');
const existing = await db('customer_accounts')
.where('email', normalisedEmail)
.first();
if (existing) {
throw new ConflictError('A customer account with this email already exists', 'email');
}
// Same default-locale resolution as acceptInvitation so German
// shops get German customers automatically.
let defaultPreferredLanguage = 'en';
try {
// eslint-disable-next-line global-require
const businessProfileService = require('./businessProfileService');
const { profile: bp } = await businessProfileService.getProfile();
if (bp && bp.default_locale) defaultPreferredLanguage = bp.default_locale;
} catch (_) { /* keep 'en' fallback */ }
const sanitised = sanitisePrefill(prefill) || {};
const preferredLanguage = sanitised.preferred_language || defaultPreferredLanguage;
const [inserted] = await db('customer_accounts').insert({
email: normalisedEmail,
salutation: sanitised.salutation || null,
first_name: sanitised.first_name || null,
last_name: sanitised.last_name || null,
display_name: sanitised.display_name || null,
phone: sanitised.phone || null,
company_name: sanitised.company_name || null,
vat_id: sanitised.vat_id || null,
address_line1: sanitised.address_line1 || null,
address_line2: sanitised.address_line2 || null,
postal_code: sanitised.postal_code || null,
city: sanitised.city || null,
state: sanitised.state || null,
country_code: sanitised.country_code || null,
country_name: sanitised.country_name || null,
preferred_language: preferredLanguage,
password_hash: null,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
password_changed_at: null,
created_by_admin_id: createdByAdminId || null,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = inserted?.id || inserted;
await logActivity('customer_created_passive',
{ customerId: id, email: normalisedEmail },
null,
{ type: 'admin', id: createdByAdminId || null, name: 'system' }
);
logger.info('Passive customer created', { id, email: normalisedEmail, createdByAdminId });
return { id };
}
/**
* Accept an invitation. Creates the customer_accounts row in a transaction
* and marks the invitation accepted, so a partial failure can't leave a
@@ -174,14 +271,23 @@ async function acceptInvitation({ token, name, password, profile }) {
throw new ValidationError('Invalid or expired invitation');
}
// Race-condition guard: an admin may have created the customer manually
// (future flow) between the invite link being generated and clicked.
// Race-condition guard: an admin may have created the customer
// manually (passive customer flow, migration-119-era and later)
// between the invite link being generated and clicked.
//
// Two cases:
// - existing.password_hash IS NOT NULL → real duplicate, 409
// - existing.password_hash IS NULL → passive customer being
// promoted to active. Branch to the UPSERT path further down so
// the customer's id (and all the rows that reference it —
// invoices, quotes, gallery assignments) survive promotion.
const existing = await db('customer_accounts')
.where('email', invitation.email)
.first();
if (existing) {
if (existing && existing.password_hash) {
throw new ConflictError('Email already registered', 'email');
}
const promoting = !!existing && !existing.password_hash;
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
@@ -200,47 +306,105 @@ async function acceptInvitation({ token, name, password, profile }) {
merged.display_name = String(name).trim();
}
// Default the customer's preferred_language to the business profile's
// default_locale. Migration 090 sets the schema default to 'en' which
// is a poor fit for a Swiss/DE business — by pulling from the
// configured profile we make sure German shops issue German quotes
// and invoices to their new customers automatically. Customer-typed
// value still wins (if the accept form ever exposes the picker), and
// the admin can always override later on the customer detail page.
// Lazy require to avoid a service-cycle with businessProfileService.
let defaultPreferredLanguage = 'en';
try {
// eslint-disable-next-line global-require
const businessProfileService = require('./businessProfileService');
const { profile: bp } = await businessProfileService.getProfile();
if (bp && bp.default_locale) defaultPreferredLanguage = bp.default_locale;
} catch (_) { /* keep 'en' fallback */ }
const preferredLanguage = merged.preferred_language || defaultPreferredLanguage;
const customerId = await db.transaction(async (trx) => {
const [inserted] = await trx('customer_accounts').insert({
email: invitation.email,
// Profile fields land directly on the customer row. Anything the user
// didn't set stays null.
salutation: merged.salutation || null,
first_name: merged.first_name || null,
last_name: merged.last_name || null,
display_name: merged.display_name || null,
phone: merged.phone || null,
company_name: merged.company_name || null,
vat_id: merged.vat_id || null,
address_line1: merged.address_line1 || null,
address_line2: merged.address_line2 || null,
postal_code: merged.postal_code || null,
city: merged.city || null,
state: merged.state || null,
country_code: merged.country_code || null,
password_hash: passwordHash,
is_active: formatBoolean(true),
// must_change_password is decorative today — accept-invite always
// sets a customer-chosen password, so this flag is never true and
// customerAuth doesn't read it. TODO when we ship an "admin
// pre-loads a temporary password" flow: surface a code in the
// login response (mirroring adminAuth's MUST_CHANGE_PASSWORD) and
// add a /change-password gate to customerAuth.
must_change_password: formatBoolean(false),
// Leave password_changed_at NULL on initial accept. Setting it here
// creates a millisecond/second-rounding race with the JWT issued
// by the immediate /login call: stored timestamp X.500ms can floor
// to X+1 in postgres while the JWT's iat lands at X, causing the
// customerAuth middleware's `iat < password_changed_at` check to
// reject perfectly valid tokens on the very next page reload. We
// populate password_changed_at only when an actual password change
// happens later (deactivate / reset flows).
password_changed_at: null,
created_by_admin_id: invitation.invited_by,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = inserted?.id || inserted;
let id;
if (promoting) {
// Promotion path: passive customer being claimed by the
// customer themselves via the invitation link. UPDATE the
// existing row (preserving id + all foreign-key relationships)
// instead of inserting. We merge the profile fields: anything
// the customer typed on the accept form wins; values they
// didn't touch leave the existing row untouched.
id = existing.id;
const updates = {
password_hash: passwordHash,
password_changed_at: null,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
updated_at: new Date(),
};
// Only overwrite profile fields when the merged payload
// actually carries a value — never blank out existing data
// (the customer might have left a field empty because the
// admin had pre-filled it correctly).
const overwriteIfSet = (key, col = key) => {
if (merged[key] != null && merged[key] !== '') updates[col] = merged[key];
};
overwriteIfSet('salutation');
overwriteIfSet('first_name');
overwriteIfSet('last_name');
overwriteIfSet('display_name');
overwriteIfSet('phone');
overwriteIfSet('company_name');
overwriteIfSet('vat_id');
overwriteIfSet('address_line1');
overwriteIfSet('address_line2');
overwriteIfSet('postal_code');
overwriteIfSet('city');
overwriteIfSet('state');
overwriteIfSet('country_code');
if (merged.preferred_language) updates.preferred_language = merged.preferred_language;
await trx('customer_accounts').where('id', id).update(updates);
} else {
const [inserted] = await trx('customer_accounts').insert({
email: invitation.email,
// Profile fields land directly on the customer row. Anything the user
// didn't set stays null.
salutation: merged.salutation || null,
first_name: merged.first_name || null,
last_name: merged.last_name || null,
display_name: merged.display_name || null,
phone: merged.phone || null,
company_name: merged.company_name || null,
vat_id: merged.vat_id || null,
address_line1: merged.address_line1 || null,
address_line2: merged.address_line2 || null,
postal_code: merged.postal_code || null,
city: merged.city || null,
state: merged.state || null,
country_code: merged.country_code || null,
preferred_language: preferredLanguage,
password_hash: passwordHash,
is_active: formatBoolean(true),
// must_change_password is decorative today — accept-invite always
// sets a customer-chosen password, so this flag is never true and
// customerAuth doesn't read it. TODO when we ship an "admin
// pre-loads a temporary password" flow: surface a code in the
// login response (mirroring adminAuth's MUST_CHANGE_PASSWORD) and
// add a /change-password gate to customerAuth.
must_change_password: formatBoolean(false),
// Leave password_changed_at NULL on initial accept. Setting it here
// creates a millisecond/second-rounding race with the JWT issued
// by the immediate /login call: stored timestamp X.500ms can floor
// to X+1 in postgres while the JWT's iat lands at X, causing the
// customerAuth middleware's `iat < password_changed_at` check to
// reject perfectly valid tokens on the very next page reload. We
// populate password_changed_at only when an actual password change
// happens later (deactivate / reset flows).
password_changed_at: null,
created_by_admin_id: invitation.invited_by,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
id = inserted?.id || inserted;
}
await trx('customer_invitations')
.where('id', invitation.id)
@@ -301,6 +465,22 @@ async function listCustomers({ search } = {}) {
'customer_accounts.salutation',
'customer_accounts.company_name',
'customer_accounts.is_active',
// Surfaced so the route's transformCustomer can compute the
// `isPassive` flag (passwordHash == null). The actual hash
// never leaves the API — transformCustomer drops it.
'customer_accounts.password_hash',
// Per-customer feature flags + hourly rate (migrations 092/129).
// Surfaced on the LIST endpoint so the standalone Hours-logging
// page can filter the customer dropdown to only customers with
// hours logging enabled, and read the default rate without an
// N+1 detail fetch. Without these in the SELECT,
// transformCustomer evaluates the four feature_* booleans as
// false (column absent → undefined → coerce to false).
'customer_accounts.feature_calendar',
'customer_accounts.feature_quotes',
'customer_accounts.feature_bills',
'customer_accounts.feature_hours_logging',
'customer_accounts.hourly_rate_minor',
'customer_accounts.last_login',
'customer_accounts.created_at',
db.raw('COUNT(event_customer_assignments.id) as event_count')
@@ -364,10 +544,17 @@ async function updateCustomer(id, updates, updatedByAdminId) {
'email', 'salutation', 'first_name', 'last_name', 'display_name',
'phone', 'company_name', 'billing_email', 'vat_id',
'address_line1', 'address_line2', 'postal_code', 'city', 'state',
'country_code', 'preferred_language', 'notes',
'country_code', 'country_name', 'preferred_language', 'notes',
// Per-customer feature flags (#354 follow-up). Booleans below are
// coerced via formatBoolean for SQLite compatibility.
'feature_calendar', 'feature_quotes', 'feature_bills',
'feature_calendar', 'feature_quotes', 'feature_bills', 'feature_hours_logging',
// CRM billing cadence (migration 102). 'per_event' (default) keeps
// each invoice firing on its own schedule; monthly/quarterly snap
// every scheduled invoice to billing_cycle_day of the next period.
'billing_cadence', 'billing_cycle_day',
// Hour-logging default rate (migration 129). Minor units; null
// means admin must enter a per-entry override on every entry.
'hourly_rate_minor',
];
for (const f of fields) {
if (updates[f] !== undefined) {
@@ -377,8 +564,45 @@ async function updateCustomer(id, updates, updatedByAdminId) {
allowed[f] = String(updates[f] || '').trim().toLowerCase();
} else if (f === 'country_code' && updates[f]) {
allowed[f] = String(updates[f]).trim().toUpperCase().slice(0, 2);
} else if (f === 'feature_calendar' || f === 'feature_quotes' || f === 'feature_bills') {
} else if (
f === 'feature_calendar' || f === 'feature_quotes'
|| f === 'feature_bills' || f === 'feature_hours_logging'
) {
allowed[f] = formatBoolean(updates[f]);
} else if (f === 'hourly_rate_minor') {
// Default hourly rate. Null clears it (forces per-entry
// overrides); otherwise coerce to a non-negative bigint-safe
// integer. Anything funky → null.
if (updates[f] === null || updates[f] === '') {
allowed[f] = null;
} else {
const v = parseInt(updates[f], 10);
allowed[f] = Number.isFinite(v) && v >= 0 ? v : null;
}
} else if (f === 'billing_cadence') {
// Whitelist enum. Anything else flips to 'per_event' so we
// never persist garbage that the scheduler can't interpret.
const v = String(updates[f] || '').toLowerCase();
allowed[f] = ['per_event', 'monthly', 'quarterly'].includes(v) ? v : 'per_event';
} else if (f === 'billing_cycle_day') {
// Sign carries the interpretation:
// positive 1..28 → day-of-month (clamped to month length at
// schedule time, so cycleDay=28 stays valid
// in February)
// negative -1..-15 → that many days before end of month
// (cycleDay=-3 on a 31-day month fires on
// the 28th; on a 28-day February fires on
// the 25th)
// Zero is meaningless and clamps to 1 so the column never
// stores "the 0th of the month".
const v = parseInt(updates[f], 10);
if (!Number.isFinite(v) || v === 0) {
allowed[f] = 1;
} else if (v > 0) {
allowed[f] = Math.min(28, v);
} else {
allowed[f] = Math.max(-15, v);
}
} else {
allowed[f] = updates[f];
}
@@ -579,7 +803,22 @@ async function searchCustomers(query, { limit = 10 } = {}) {
.orWhereRaw('LOWER(COALESCE(last_name, \'\')) LIKE ?', [term])
.orWhereRaw('LOWER(COALESCE(company_name, \'\')) LIKE ?', [term]);
})
.select('id', 'email', 'display_name', 'first_name', 'last_name', 'company_name')
// password_hash is required by transformCustomer to compute the
// isPassive flag (passwordHash == null = passive / admin-only).
// Omitting it caused every search result to render as "Passive —
// admin only" because `undefined == null` is true. The hash itself
// is dropped by the route's transformCustomer before leaving the API.
//
// G.2 — `feature_hours_logging` is required by the calendar's
// drag-create modal (F.6) so the CustomerPicker can render the
// "Hour logging disabled" badge. Omitting it from this SELECT
// caused the badge to appear on EVERY search result regardless
// of the actual per-customer flag, because transformCustomer
// coerces undefined → false.
.select(
'id', 'email', 'display_name', 'first_name', 'last_name', 'company_name',
'password_hash', 'feature_hours_logging',
)
.orderBy('email', 'asc')
.limit(limit);
}
@@ -970,10 +1209,25 @@ async function getCustomerSurfaceGlobals() {
}
map[r.setting_key] = v;
}
// Feature globals:
// - quotes + bills default TRUE — the customer-facing pages are
// fully built and the AND-logic with the per-customer flag is
// the real gate. The earlier hardcoded `false` made it
// impossible to surface the tabs without code changes.
// - calendar defaults FALSE — the customer-side page is still a
// coming-soon stub.
// Each is overridable via app_settings (setting_type='customer_surface').
const readBool = (key, fallback) => {
const v = map[key];
if (v === undefined) return fallback;
if (v === true || v === 1 || v === '1' || v === 't') return true;
if (v === false || v === 0 || v === '0' || v === 'f') return false;
return fallback;
};
return {
calendarEnabled: false,
quotesEnabled: false,
billsEnabled: false,
calendarEnabled: readBool('customer_feature_calendar_enabled', false),
quotesEnabled: readBool('customer_feature_quotes_enabled', true),
billsEnabled: readBool('customer_feature_bills_enabled', true),
showLogo: map.customer_show_logo !== false, // default true
showCompanyName: map.customer_show_company_name !== false, // default true
};
@@ -994,13 +1248,32 @@ async function getEffectiveFeaturesForCustomer(customerOrId) {
? await db('customer_accounts').where('id', customerOrId).first()
: customerOrId;
if (!customer) {
return { calendar: false, quotes: false, bills: false };
return { calendar: false, quotes: false, bills: false, hoursLogging: false, contracts: false };
}
const globals = await getCustomerSurfaceGlobals();
// SQLite returns booleans as 0/1; Postgres returns true/false. The
// strict `=== true` check used to falsely return `false` on SQLite,
// hiding the sidebar entry even when admin had flipped the per-
// customer toggle on. Normalise both shapes here so the Quotes /
// Invoices tabs appear consistently.
const truthy = (v) => v === true || v === 1 || v === '1' || v === 't';
// Hours logging gates on the master feature_flags row (Settings →
// Features) AND the per-customer flag. The customer_surface
// app_settings layer is admin-side-only here — no portal surface
// for hours, so we skip the third gate the bills/quotes use.
const hoursMaster = await db('feature_flags').where({ key: 'hoursLogging' }).first();
const hoursLoggingMaster = hoursMaster ? Boolean(hoursMaster.value) : true;
// Contracts (migration 130): no per-customer flag, just the global
// feature_flags row. When on, every customer with an active account
// sees the Contracts tab on their portal.
const contractsMaster = await db('feature_flags').where({ key: 'contracts' }).first();
const contractsEnabled = contractsMaster ? Boolean(contractsMaster.value) : false;
return {
calendar: globals.calendarEnabled && customer.feature_calendar === true,
quotes: globals.quotesEnabled && customer.feature_quotes === true,
bills: globals.billsEnabled && customer.feature_bills === true,
calendar: globals.calendarEnabled && truthy(customer.feature_calendar),
quotes: globals.quotesEnabled && truthy(customer.feature_quotes),
bills: globals.billsEnabled && truthy(customer.feature_bills),
hoursLogging: hoursLoggingMaster && truthy(customer.feature_hours_logging),
contracts: contractsEnabled,
};
}
@@ -1130,6 +1403,7 @@ async function applyPasswordReset({ token, password }) {
module.exports = {
createInvitation,
createDirect,
acceptInvitation,
validateInvitationToken,
listCustomers,
@@ -0,0 +1,471 @@
/**
* Customer hour-logging service (migration 129).
*
* Admin records discrete time blocks against a customer; each entry
* eventually folds into an invoice as a single line item. Two flows:
*
* 1. Monthly-mode customer + feature_hours_logging on
* → saving an entry immediately appends a line item onto the
* running monthly draft (migration 128 accumulator) and flips
* the entry to status='billed'. Admin doesn't have to remember
* to convert; the running totals on the customer detail page
* reflect the bill that will eventually go out.
*
* 2. Per-event customer + feature_hours_logging on
* → entries sit at status='unbilled' until admin clicks
* "Bill these hours" (billUnbilledEntries below). That call
* mints a standalone invoice with one line per entry.
*
* Lockout: once an entry's invoice is "armed for send" (the monthly
* scheduler has cleared is_monthly_draft + set scheduled_send_at, or
* the invoice transitioned to sent/paid/cancelled), edits + deletes
* are refused. Admin must Storno the invoice to change billed hours
* — same legal-record discipline as line items today.
*/
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { AppError } = require('../utils/errors');
const logger = require('../utils/logger');
const invoiceService = require('./invoiceService');
// ---------------------------------------------------------------------
// Pure helpers — exported under `_internal` for direct unit testing.
// ---------------------------------------------------------------------
/**
* Parse two "HH:MM" strings and return the elapsed minutes. Caller
* has already validated that start < end; this throws if either is
* malformed (defensive — UI should never send a non-conforming value).
*/
function computeDurationMinutes(start, end) {
const re = /^([01]\d|2[0-3]):([0-5]\d)$/;
if (!re.test(String(start))) throw new AppError(`Invalid start_time: ${start}`, 400);
if (!re.test(String(end))) throw new AppError(`Invalid end_time: ${end}`, 400);
const [sh, sm] = String(start).split(':').map((n) => parseInt(n, 10));
const [eh, em] = String(end).split(':').map((n) => parseInt(n, 10));
const startM = sh * 60 + sm;
const endM = eh * 60 + em;
if (endM <= startM) throw new AppError('end_time must be after start_time', 400);
return endM - startM;
}
/**
* Resolve the rate this entry should bill at. Override on the entry
* wins; otherwise we fall back to the customer's default rate. If
* neither is set we throw — saves can't go through without a rate.
*/
function resolveEffectiveRate(entry, customer) {
if (entry.hourly_rate_minor_override != null) {
return Number(entry.hourly_rate_minor_override);
}
if (customer.hourly_rate_minor != null) {
return Number(customer.hourly_rate_minor);
}
throw new AppError(
'No hourly rate: set a per-entry override or a customer default.',
400,
'HOURLY_RATE_REQUIRED',
);
}
/**
* Decide whether an entry is still editable. Pure function — callers
* pass the loaded entry + (optionally) its current invoice row.
*
* Rules:
* - Unbilled entry (no invoice_id) → always editable.
* - Linked invoice is still a monthly draft → editable (period open).
* - Linked invoice has no scheduled_send_at AND status='scheduled'
* → editable (standalone draft).
* - Linked invoice has scheduled_send_at > now AND status='scheduled'
* → editable until the scheduler arms it.
* - Anything else (armed, sent, paid, overdue, cancelled) → locked.
*/
function isEntryLocked(entry, invoice) {
if (!entry.invoice_id) return false;
if (!invoice) return false; // entry references a deleted invoice — treat as unbilled
if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) return false;
if (invoice.status !== 'scheduled') return true;
if (!invoice.scheduled_send_at) return false;
return new Date(invoice.scheduled_send_at).getTime() <= Date.now();
}
/**
* Translate an entry row into the line-item shape consumed by
* createInvoice / appendToMonthlyDraft. Format:
* "{date} {start}{end} ({hours}h): {note}"
* Note suffix omitted when entry.description is null/empty.
*/
function buildLineItemFromEntry(entry, rateMinor) {
const hours = (entry.duration_minutes / 60).toFixed(2);
// ISO date input is already YYYY-MM-DD; admin's locale formatting
// happens at PDF render time, so keep the entry description portable.
const datePart = String(entry.entry_date).slice(0, 10);
const note = (entry.description || '').trim();
const description = `${datePart} ${entry.start_time}${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`;
const qty = Number(hours);
const lineTotalMinor = Math.round(qty * rateMinor);
return {
description,
quantity: qty,
unit_price_minor: rateMinor,
discount_percent: 0,
line_total_minor: lineTotalMinor,
parent_position: null,
details_text: null,
};
}
// ---------------------------------------------------------------------
// CRUD + billing surface
// ---------------------------------------------------------------------
/**
* List entries for a customer. Optional status filter; default sort
* is newest entry_date first. Joins to invoices.invoice_number so the
* UI can render "Billed on R-2026-0019" without an N+1 round-trip.
*/
async function listEntries(customerId, { status, limit = 200, offset = 0 } = {}) {
let q = db('customer_hour_entries as h')
.leftJoin('invoices as i', 'h.invoice_id', 'i.id')
.where('h.customer_account_id', customerId);
if (status) q = q.where('h.status', status);
q = q.orderBy('h.entry_date', 'desc')
.orderBy('h.start_time', 'desc')
.orderBy('h.id', 'desc')
.limit(limit)
.offset(offset);
const rows = await q.select(
'h.*',
'i.invoice_number as invoice_number',
'i.status as invoice_status',
'i.is_monthly_draft as invoice_is_monthly_draft',
'i.scheduled_send_at as invoice_scheduled_send_at',
);
return rows;
}
/**
* Create a new entry. Routes per cadence:
* - monthly + feature_hours_logging → append to running draft, flip to billed
* - per_event → leave at unbilled, admin bills later
*/
async function createEntry(customerId, payload, adminId) {
const customer = await db('customer_accounts').where({ id: customerId }).first();
if (!customer) throw new AppError('Customer not found', 404);
// Both layers must be on: global master switch AND per-customer flag
// (matches the quotes/bills AND-logic). Migration 130 added the
// global toggle; defaults true on fresh installs.
const customerAccountsService = require('./customerAccountsService');
const eff = await customerAccountsService.getEffectiveFeaturesForCustomer(customer);
if (!eff.hoursLogging) {
throw new AppError('Hour logging is not enabled for this customer', 409, 'FEATURE_OFF');
}
const entryDate = String(payload.entryDate || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(entryDate)) {
throw new AppError('entryDate must be YYYY-MM-DD', 400);
}
const startTime = String(payload.startTime || '');
const endTime = String(payload.endTime || '');
const duration = computeDurationMinutes(startTime, endTime);
let override = null;
if (payload.hourlyRateMinorOverride !== undefined && payload.hourlyRateMinorOverride !== null
&& payload.hourlyRateMinorOverride !== '') {
const v = parseInt(payload.hourlyRateMinorOverride, 10);
if (!Number.isFinite(v) || v < 0) {
throw new AppError('hourlyRateMinorOverride must be a non-negative integer', 400);
}
override = v;
}
const description = payload.description ? String(payload.description).slice(0, 1000) : null;
// Pre-validate the rate resolves to something — fail before insert
// if neither override nor customer default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer);
return await db.transaction(async (trx) => {
const row = {
customer_account_id: customer.id,
entry_date: entryDate,
start_time: startTime,
end_time: endTime,
duration_minutes: duration,
hourly_rate_minor_override: override,
description,
status: 'unbilled',
recorded_by_admin_id: adminId,
created_at: new Date(),
updated_at: new Date(),
};
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Monthly-mode customers get the auto-append treatment.
if (customer.billing_cadence === 'monthly') {
const fullEntry = { ...row, id: entryId };
const rate = resolveEffectiveRate(fullEntry, customer);
const lineItem = buildLineItemFromEntry(fullEntry, rate);
const { invoiceId, lineItemId } = await invoiceService.appendOneLineItemToMonthlyDraft(
customer, lineItem, adminId, trx,
);
await trx('customer_hour_entries').where({ id: entryId }).update({
status: 'billed',
invoice_id: invoiceId,
invoice_line_item_id: lineItemId,
billed_at: new Date(),
updated_at: new Date(),
});
try {
await logActivity('hour_entry_logged_to_monthly_draft',
{ entryId, customerId: customer.id, invoiceId },
null, `admin:${adminId}`);
} catch (_) {}
return { id: entryId, status: 'billed', invoiceId };
}
try {
await logActivity('hour_entry_logged',
{ entryId, customerId: customer.id },
null, `admin:${adminId}`);
} catch (_) {}
return { id: entryId, status: 'unbilled' };
});
}
/**
* Update an entry. Refuses when the entry is locked (linked invoice
* has already been armed for send). Otherwise: recomputes duration
* from start/end, recomputes the linked line item if billed-but-still-
* draft, and recomputes the invoice totals so the running figures
* stay accurate.
*/
async function updateEntry(entryId, payload, adminId) {
return await db.transaction(async (trx) => {
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
if (!entry) throw new AppError('Entry not found', 404);
const invoice = entry.invoice_id
? await trx('invoices').where({ id: entry.invoice_id }).first()
: null;
if (isEntryLocked(entry, invoice)) {
throw new AppError(
'Entry locked: invoice already armed for send. Storno the invoice to change billed hours.',
409,
'ENTRY_LOCKED',
);
}
const customer = await trx('customer_accounts').where({ id: entry.customer_account_id }).first();
// Merge incoming payload onto the existing row.
const next = { ...entry };
if (payload.entryDate !== undefined) {
const ed = String(payload.entryDate || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(ed)) throw new AppError('entryDate must be YYYY-MM-DD', 400);
next.entry_date = ed;
}
if (payload.startTime !== undefined) next.start_time = String(payload.startTime || '');
if (payload.endTime !== undefined) next.end_time = String(payload.endTime || '');
if (next.start_time !== entry.start_time || next.end_time !== entry.end_time) {
next.duration_minutes = computeDurationMinutes(next.start_time, next.end_time);
}
if (payload.hourlyRateMinorOverride !== undefined) {
if (payload.hourlyRateMinorOverride === null || payload.hourlyRateMinorOverride === '') {
next.hourly_rate_minor_override = null;
} else {
const v = parseInt(payload.hourlyRateMinorOverride, 10);
if (!Number.isFinite(v) || v < 0) throw new AppError('hourlyRateMinorOverride must be non-negative', 400);
next.hourly_rate_minor_override = v;
}
}
if (payload.description !== undefined) {
next.description = payload.description ? String(payload.description).slice(0, 1000) : null;
}
next.updated_at = new Date();
// Recompute the linked line item if the entry is billed (on a
// draft — the lock check above already proved it's mutable).
if (entry.invoice_id && entry.invoice_line_item_id) {
const rate = resolveEffectiveRate(next, customer);
const newLineItem = buildLineItemFromEntry(next, rate);
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).update({
description: newLineItem.description,
quantity: newLineItem.quantity,
unit_price_minor: newLineItem.unit_price_minor,
line_total_minor: newLineItem.line_total_minor,
updated_at: new Date(),
});
// Recompute invoice totals — same shape as appendToMonthlyDraft.
const allItems = await trx('invoice_line_items').where({ invoice_id: entry.invoice_id });
let netMinor = 0;
for (const li of allItems) {
if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0);
}
const vatRate = Number(invoice.vat_rate || 0);
const vatMinor = Math.round(netMinor * vatRate / 100);
const shippingMinor = Number(invoice.shipping_amount_minor || 0);
const totalMinor = netMinor + vatMinor + shippingMinor;
await trx('invoices').where({ id: entry.invoice_id }).update({
net_amount_minor: netMinor,
vat_amount_minor: vatMinor,
total_amount_minor: totalMinor,
updated_at: new Date(),
});
}
await trx('customer_hour_entries').where({ id: entryId }).update({
entry_date: next.entry_date,
start_time: next.start_time,
end_time: next.end_time,
duration_minutes: next.duration_minutes,
hourly_rate_minor_override: next.hourly_rate_minor_override,
description: next.description,
updated_at: next.updated_at,
});
try {
await logActivity('hour_entry_updated',
{ entryId, customerId: entry.customer_account_id },
null, `admin:${adminId}`);
} catch (_) {}
return { id: entryId };
});
}
/**
* Delete an entry. Same lockout semantics as update. If the entry is
* billed on a still-mutable draft, removes the linked line item and
* recomputes invoice totals before deleting the entry row itself.
*/
async function deleteEntry(entryId, adminId) {
return await db.transaction(async (trx) => {
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
if (!entry) throw new AppError('Entry not found', 404);
const invoice = entry.invoice_id
? await trx('invoices').where({ id: entry.invoice_id }).first()
: null;
if (isEntryLocked(entry, invoice)) {
throw new AppError(
'Entry locked: invoice already armed for send. Storno the invoice to remove billed hours.',
409,
'ENTRY_LOCKED',
);
}
if (entry.invoice_line_item_id) {
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).del();
}
if (entry.invoice_id) {
const allItems = await trx('invoice_line_items').where({ invoice_id: entry.invoice_id });
let netMinor = 0;
for (const li of allItems) {
if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0);
}
const vatRate = Number(invoice.vat_rate || 0);
const vatMinor = Math.round(netMinor * vatRate / 100);
const shippingMinor = Number(invoice.shipping_amount_minor || 0);
const totalMinor = netMinor + vatMinor + shippingMinor;
await trx('invoices').where({ id: entry.invoice_id }).update({
net_amount_minor: netMinor,
vat_amount_minor: vatMinor,
total_amount_minor: totalMinor,
updated_at: new Date(),
});
}
await trx('customer_hour_entries').where({ id: entryId }).del();
try {
await logActivity('hour_entry_deleted',
{ entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id },
null, `admin:${adminId}`);
} catch (_) {}
return { deleted: true };
});
}
/**
* Per-event flow: mint a standalone invoice from all unbilled entries
* for this customer, one line per entry. Refuses when the customer is
* monthly-mode (those entries auto-billed on save, so there should be
* no unbilled rows). Returns the new invoice id.
*/
async function billUnbilledEntries(customerId, adminId) {
const customer = await db('customer_accounts').where({ id: customerId }).first();
if (!customer) throw new AppError('Customer not found', 404);
if (customer.billing_cadence === 'monthly') {
throw new AppError(
'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
409,
'CADENCE_MISMATCH',
);
}
return await db.transaction(async (trx) => {
const unbilled = await trx('customer_hour_entries')
.where({ customer_account_id: customer.id, status: 'unbilled' })
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
if (unbilled.length === 0) {
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
}
const lineItems = unbilled.map((entry, idx) => {
const rate = resolveEffectiveRate(entry, customer);
const li = buildLineItemFromEntry(entry, rate);
return { ...li, position: idx + 1 };
});
// No installment metadata — hour-billing always mints a single
// standalone invoice. createInvoice returns `{ invoiceIds: [N] }`
// since migration 140 / the spawner refactor; extract the one id.
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customer.id,
lineItems,
// Reuse the customer/business currency-fallback chain inside
// createInvoice.
}, adminId, trx);
const invoiceId = invoiceIds[0];
// Locate the newly-inserted line item ids in insertion order so
// each entry gets stamped with its specific row.
const insertedLines = await trx('invoice_line_items')
.where({ invoice_id: invoiceId })
.orderBy('position', 'asc');
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
const now = new Date();
for (let i = 0; i < unbilled.length; i += 1) {
const entry = unbilled[i];
const lineItemId = lineByPos.get(i + 1) || null;
await trx('customer_hour_entries').where({ id: entry.id }).update({
status: 'billed',
invoice_id: invoiceId,
invoice_line_item_id: lineItemId,
billed_at: now,
updated_at: now,
});
}
try {
await logActivity('hour_entries_billed',
{ customerId: customer.id, invoiceId, entryCount: unbilled.length },
null, `admin:${adminId}`);
} catch (_) {}
return { invoiceId, entriesBilled: unbilled.length };
});
}
module.exports = {
listEntries,
createEntry,
updateEntry,
deleteEntry,
billUnbilledEntries,
_internal: {
computeDurationMinutes,
resolveEffectiveRate,
isEntryLocked,
buildLineItemFromEntry,
},
};
+178
View File
@@ -0,0 +1,178 @@
/**
* dealsService — read-only lineage queries grouped by `deal_uuid`.
*
* One UUID spans every quote, contract, and invoice that belongs to
* the same customer engagement (migration 140). This module is the
* single read surface for "show me everything tied to this deal" so
* the frontend's DocumentLineageCard, internal audit traversals, and
* any future deal-scoped reports query through one helper instead of
* walking the legacy point-to-point FKs each on its own.
*
* Legacy FK columns (source_quote_id, source_contract_id,
* cancels_invoice_id, replaces_invoice_id, cancellation_storno_id,
* converted_contract_id, converted_event_id) are still populated on
* write so audit logs and PDFs that show "Cancels invoice R-XXXX" or
* "From quote Q-XXXX" continue to work — those carry SEMANTIC
* relationships (which specific row this one replaces / cancels),
* distinct from grouping. The grouping is what this service owns.
*
* The follow-up cleanup PR (already on the backlog) will drop the
* legacy FK columns once deal_uuid is proven stable in production.
*/
const { db } = require('../database/db');
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function deriveOffsetDays(invoice) {
if (!invoice.installment_trigger) return 0;
if (invoice.installment_trigger === 'after_delivery') return 0;
const sched = invoice.scheduled_send_at
? new Date(invoice.scheduled_send_at) : null;
if (!sched || Number.isNaN(sched.getTime())) return 0;
const anchor = (invoice.installment_trigger === 'before_event'
|| invoice.installment_trigger === 'after_event')
? invoice.event_date
: invoice.issue_date;
if (!anchor) return 0;
const anchorDate = new Date(anchor);
if (Number.isNaN(anchorDate.getTime())) return 0;
return Math.round((sched.getTime() - anchorDate.getTime()) / MS_PER_DAY);
}
/**
* Fetch every document — quotes, contracts, invoices — sharing the
* given `deal_uuid`. Each row carries enough state for the lineage
* UI to render a clickable entry without a second round-trip:
*
* - kind: 'quote' | 'contract' | 'invoice'
* - id, number (quote_number / contract_number / invoice_number)
* - status, currency, total_amount_minor
* - issue_date, created_at
* - kind-specific extras the renderer needs (e.g. invoice.kind for
* Storno detection)
*
* Returns an object keyed by kind:
*
* { dealUuid, quotes: [...], contracts: [...], invoices: [...] }
*
* Sorted within each group by created_at ASC — earliest doc first.
* Empty deals (no matches) return all three arrays as []; callers
* should treat that as "no related docs", not an error.
*/
async function getDealDocuments(dealUuid) {
if (!dealUuid) {
return { dealUuid: null, quotes: [], contracts: [], invoices: [] };
}
const [quotes, contracts, invoices] = await Promise.all([
db('quotes')
.where({ deal_uuid: dealUuid })
.orderBy('created_at', 'asc')
.select(
'id', 'quote_number', 'status', 'currency',
'total_amount_minor', 'issue_date', 'valid_until',
'event_name', 'event_date', 'created_at',
),
db('contracts')
.where({ deal_uuid: dealUuid })
.orderBy('created_at', 'asc')
.select(
'id', 'contract_number', 'status', 'title',
'issue_date', 'valid_until',
'event_name', 'event_date', 'created_at',
),
db('invoices')
.where({ deal_uuid: dealUuid })
.orderBy('created_at', 'asc')
.select(
'id', 'invoice_number', 'kind', 'status', 'currency',
'total_amount_minor', 'paid_amount_minor',
'issue_date', 'due_date',
'event_name', 'event_date',
// installment_trigger + scheduled_send_at let the lineage card
// derive the per-slice trigger/offset_days needed to seed the
// Edit Plan modal without a second round-trip.
'installment_index', 'installment_total', 'installment_label',
'installment_trigger', 'scheduled_send_at',
'is_monthly_draft',
'created_at',
),
]);
return {
dealUuid,
quotes: quotes.map((q) => ({
kind: 'quote',
id: q.id,
number: q.quote_number,
status: q.status,
currency: q.currency,
totalAmountMinor: q.total_amount_minor,
issueDate: q.issue_date,
validUntil: q.valid_until,
eventName: q.event_name,
eventDate: q.event_date,
createdAt: q.created_at,
})),
contracts: contracts.map((c) => ({
kind: 'contract',
id: c.id,
number: c.contract_number,
status: c.status,
title: c.title,
issueDate: c.issue_date,
validUntil: c.valid_until,
eventName: c.event_name,
eventDate: c.event_date,
createdAt: c.created_at,
})),
invoices: invoices.map((i) => ({
kind: 'invoice',
invoiceKind: i.kind, // 'invoice' | 'storno'
id: i.id,
number: i.invoice_number,
status: i.status,
currency: i.currency,
totalAmountMinor: i.total_amount_minor,
paidAmountMinor: i.paid_amount_minor,
issueDate: i.issue_date,
dueDate: i.due_date,
eventName: i.event_name,
eventDate: i.event_date,
installmentIndex: i.installment_index,
installmentTotal: i.installment_total,
installmentLabel: i.installment_label,
installmentTrigger: i.installment_trigger || null,
// Approximate the original offset_days from the resolved
// scheduled_send_at — exact round-trip would need a dedicated
// column. The Edit Plan modal uses this as a seed; admin can
// override. Anchor by trigger:
// - before_event / after_event → days from event_date
// - after_delivery → 0 (waits indefinitely)
// - quote_accepted / fixed_date → days from issue_date
installmentOffsetDays: deriveOffsetDays(i),
isMonthlyDraft: Boolean(i.is_monthly_draft),
createdAt: i.created_at,
})),
};
}
/**
* Convenience: resolve a deal_uuid from any document identifier.
* Useful for routes that receive an invoice/quote/contract id and
* want the full lineage without making the client pass the UUID
* explicitly.
*
* Returns the UUID string, or null if the row doesn't exist.
*/
async function resolveDealUuidFor(kind, id) {
const table = ({ quote: 'quotes', contract: 'contracts', invoice: 'invoices' })[kind];
if (!table) return null;
const row = await db(table).where({ id }).first('deal_uuid');
return row?.deal_uuid || null;
}
module.exports = {
getDealDocuments,
resolveDealUuidFor,
};
+76 -13
View File
@@ -109,8 +109,29 @@ async function getRecipientLanguage(email, eventId = null) {
logger.error('Error fetching event language:', error);
}
}
// Second priority: Check app_settings for general default language
// Second priority: customer_accounts.preferred_language matched by
// recipient email. Honours the customer's own preference instead of
// the app-wide default — fixes the CRM bug where every quote /
// invoice / customer email shipped in the app default language
// (German on a German-locale install) even when the customer was
// explicitly set to English. Falls through silently on miss so admin
// recipients (no customer_accounts row) still see the app default.
if (email) {
try {
const customer = await db('customer_accounts')
.where('email', String(email).toLowerCase().trim())
.select('preferred_language')
.first();
if (customer && customer.preferred_language) {
return customer.preferred_language;
}
} catch (error) {
logger.debug('Skip customer_accounts language lookup', { error: error.message });
}
}
// Third priority: Check app_settings for general default language
try {
const langSetting = await db('app_settings')
.where('setting_key', 'general_default_language')
@@ -124,7 +145,7 @@ async function getRecipientLanguage(email, eventId = null) {
logger.error('Error fetching app settings language:', error);
}
// Third priority: Check email configs for default language
// Fourth priority: Check email configs for default language
try {
const emailConfig = await db('email_configs').first();
if (emailConfig && emailConfig.default_language) {
@@ -133,8 +154,8 @@ async function getRecipientLanguage(email, eventId = null) {
} catch (error) {
logger.error('Error fetching email config language:', error);
}
// Fourth priority: Check if the email domain suggests a language
// Fifth priority: Check if the email domain suggests a language
if (email) {
const domain = email.toLowerCase();
const domainLanguageMap = [
@@ -675,13 +696,34 @@ async function sendTemplateEmail(to, templateKey, variables) {
// Process template with variables
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
// Optional plumbing — quote/invoice emails set these. Attachments
// are passed by callers as [{ filename, contentPath }] where the
// file is already written to disk; nodemailer streams it.
const ccList = Array.isArray(variables.cc)
? variables.cc.filter(Boolean)
: (typeof variables.cc === 'string' && variables.cc.trim())
? variables.cc.split(/[,;]+/).map((s) => s.trim()).filter(Boolean)
: undefined;
const attachments = Array.isArray(variables.attachments)
? variables.attachments
.filter((a) => a && (a.contentPath || a.path || a.content))
.map((a) => ({
filename: a.filename,
path: a.contentPath || a.path,
content: a.content,
contentType: a.contentType,
}))
: undefined;
// Send email
const info = await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: to,
cc: ccList,
subject: subject,
html: htmlBody,
text: textBody || htmlToText(htmlBody)
text: textBody || htmlToText(htmlBody),
attachments,
});
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
@@ -709,9 +751,17 @@ async function processEmailQueue() {
let pendingEmails = [];
try {
// Pick up emails that are pending AND either have no `scheduled_at`
// or whose scheduled_at is in the past. Used by CRM invoices to
// queue split-payment emails relative to the event date.
const now = new Date();
pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.andWhere(function() {
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
})
.orderBy('scheduled_at', 'asc')
.orderBy('created_at', 'asc')
.limit(10);
} catch (dbError) {
@@ -776,22 +826,35 @@ async function processEmailQueue() {
}
}
// Queue an email for sending
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
// Queue an email for sending. Optionally takes a 5th `options` arg:
// options.scheduledAt — Date | ISO string; row only picks up once
// this moment has passed (used by CRM split-
// payment invoices). NULL = send immediately.
// Attachments + cc travel inside `emailData` (keys: attachments, cc)
// so callers don't need a new signature for every email shape.
async function queueEmail(eventId, recipientEmail, emailType, emailData, options = {}) {
try {
// Add eventId to emailData for language detection
emailData.eventId = eventId;
await db('email_queue').insert({
const row = {
event_id: eventId,
recipient_email: recipientEmail,
email_type: emailType,
email_data: JSON.stringify(emailData),
status: 'pending',
retry_count: 0,
created_at: new Date()
});
logger.info(`Email queued: ${emailType} to ${recipientEmail}`);
created_at: new Date(),
};
if (options.scheduledAt) {
row.scheduled_at = options.scheduledAt instanceof Date
? options.scheduledAt
: new Date(options.scheduledAt);
}
await db('email_queue').insert(row);
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()})` : ''
}`);
} catch (error) {
logger.error('Error queueing email:', error);
throw error;
@@ -0,0 +1,264 @@
/**
* eventReminderService — pre-event customer reminder emails
* (migration 143).
*
* Sends ONE reminder per event N days before `event_date`. Goal: nudge
* the customer on prep — space for equipment setup, dress-code notes,
* access logistics — so the photographer arrives to a workable scene.
*
* **Wiring**
*
* `runEventReminderPass()` is invoked from the invoice scheduler's
* hourly cron tick (commit #3 of this feature). Idempotent: every send
* stamps `events.event_reminder_sent_at`; subsequent ticks skip rows
* with a non-null timestamp.
*
* **Template resolution**
*
* 1. `event_reminder_<events.event_type>` — per-type template, if
* seeded. Admin manages these via the existing email-template
* editor (no schema rule restricts what they can create here;
* whatever slug-prefixed templates exist will match).
* 2. `event_reminder_default` — catch-all, seeded by migration 143.
*
* Falls through silently when the catch-all is missing (logs a warn
* but doesn't throw — the cron must not crash the whole tick because
* of one stale install).
*
* **Override precedence per event**
*
* - `events.event_reminder_disabled = true` → skip
* - `events.event_reminder_offset_days` (nullable int) → overrides
* the global `crm_event_reminders_days_before`
* - `events.event_reminder_body_override` (text) → if set,
* replaces the template body verbatim. Subject still comes from
* the template. Useful for one-off "the venue has no loading zone,
* arrive via the rear door"-style notes.
*
* **Recipient**
*
* Only the event's primary customer (`events.customer_account_id`).
* Multi-customer assignments via `event_customer_assignments` are NOT
* notified — confirmed with maintainer 2026-05-25. Events without a
* customer_account_id or without an email on file are skipped.
*
* **Snapshot semantics**
*
* We resolve + send eagerly per tick. The current shape stamps the
* sent_at timestamp on send — we deliberately do NOT snapshot the
* resolved body onto the event row at scheduling time, because the
* candidate window is short (N days before event) and the cron picks
* the freshest template every pass until the moment of send. If a
* future "schedule N hours ahead, freeze the body, send later" model
* is needed, add a snapshot column and resolve at scheduling time.
*/
const { db } = require('../database/db');
const emailProcessor = require('./emailProcessor');
const { getAppSetting } = require('../utils/appSettings');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates');
const DEFAULT_DAYS_BEFORE = 2;
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
// One-shot guard: the "schema not migrated" warn would otherwise fire
// once per cron tick (≈ hourly) on installs that haven't applied
// migration 143 yet. Log on the first encounter only — subsequent
// ticks no-op silently.
let schemaWarnLogged = false;
/**
* Lookup the most specific available template for an event_type slug.
* Returns the template_key string. The email_processor handles missing
* template rows by failing the send; we don't fetch the row body here
* because emailProcessor.queueEmail does that lookup itself.
*/
async function resolveTemplateKey(eventType) {
if (eventType) {
const perType = `${TEMPLATE_KEY_PREFIX}${eventType}`;
const exists = await db('email_templates')
.where({ template_key: perType })
.first('id');
if (exists) return perType;
}
return TEMPLATE_KEY_DEFAULT;
}
/**
* Build the variables payload the template engine substitutes. Keep
* the keys in sync with the seeded template's `variables` JSON.
*/
function composePayload({ event, customer, daysBefore, businessName }) {
const customerName = customer.company_name
|| [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|| customer.display_name
|| customer.email
|| '';
// Event date formatted DD.MM.YYYY here for simplicity; the rendered
// email may further re-locale via the template engine when locale-
// aware formatters are introduced.
const ed = event.event_date instanceof Date ? event.event_date : new Date(event.event_date);
const day = String(ed.getUTCDate()).padStart(2, '0');
const month = String(ed.getUTCMonth() + 1).padStart(2, '0');
const year = ed.getUTCFullYear();
const eventDateFormatted = `${day}.${month}.${year}`;
return {
customer_name: customerName,
event_name: event.event_name || `Event #${event.id}`,
event_date: eventDateFormatted,
event_type: event.event_type || '',
days_before: daysBefore,
business_name: businessName || '',
};
}
/**
* One pass of the reminder loop. Idempotent. Errors on individual
* events are caught and logged so a single bad row doesn't kill the
* whole tick.
*
* Returns `{ scanned, sent, skipped }` counters for logging.
*/
async function runEventReminderPass() {
const enabled = await getAppSetting('crm_event_reminders_enabled');
if (enabled !== true && enabled !== 'true' && enabled !== 1 && enabled !== '1') {
return { scanned: 0, sent: 0, skipped: 0, disabled: true };
}
// Column-existence guards — pre-migration installs return early
// instead of throwing.
const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
if (!hasCols) {
if (!schemaWarnLogged) {
logger.warn('Event reminder pass skipped — schema not yet migrated (run migration 143). Suppressing further warnings until restart.');
schemaWarnLogged = true;
}
return { scanned: 0, sent: 0, skipped: 0 };
}
// Self-heal the seeded templates. Idempotent — only inserts missing
// rows and backfills empty translations, never overwrites edits.
// Runs once per process (module-level cache); subsequent ticks no-op.
try {
await ensureEventReminderTemplatesSeeded(db, logger);
} catch (err) {
logger.error('Event reminder template self-heal failed', { message: err.message });
}
const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
? globalDaysBefore : DEFAULT_DAYS_BEFORE;
// Pull the business name once per pass for the payload.
const profile = await db('business_profile').where({ id: 1 }).first('company_name');
const businessName = profile?.company_name || '';
// Candidate set: events with a customer, event_date in the future,
// not yet sent, not disabled per-event. We don't filter on
// event_date - days_before <= NOW() in SQL because per-event
// override `event_reminder_offset_days` may shift the trigger
// window — easier to filter in JS.
const now = new Date();
const rows = await db('events')
.leftJoin('customer_accounts', 'customer_accounts.id', 'events.customer_account_id')
.whereNotNull('events.customer_account_id')
.whereNotNull('events.event_date')
.where('events.is_active', true)
.where('events.is_archived', false)
.where('events.event_reminder_disabled', false)
.whereNull('events.event_reminder_sent_at')
.where('events.event_date', '>=', now.toISOString().slice(0, 10))
.select(
'events.id', 'events.event_name', 'events.event_type', 'events.event_date',
'events.event_reminder_offset_days',
'events.event_reminder_body_override',
'events.customer_account_id',
'customer_accounts.email as customer_email',
'customer_accounts.first_name as customer_first_name',
'customer_accounts.last_name as customer_last_name',
'customer_accounts.display_name as customer_display_name',
'customer_accounts.company_name as customer_company_name',
);
let sent = 0;
let skipped = 0;
for (const row of rows) {
try {
if (!row.customer_email) { skipped += 1; continue; }
const offsetDays = Number.isFinite(Number(row.event_reminder_offset_days))
? Number(row.event_reminder_offset_days)
: daysBeforeDefault;
// Trigger window: NOW >= event_date - offset_days.
const ed = row.event_date instanceof Date ? row.event_date : new Date(row.event_date);
const triggerAt = new Date(ed.getTime() - offsetDays * 86_400_000);
if (now < triggerAt) { skipped += 1; continue; }
const templateKey = await resolveTemplateKey(row.event_type);
const customer = {
email: row.customer_email,
first_name: row.customer_first_name,
last_name: row.customer_last_name,
display_name: row.customer_display_name,
company_name: row.customer_company_name,
};
const payload = composePayload({
event: row, customer, daysBefore: offsetDays, businessName,
});
// Per-event body override: when present, append as a synthetic
// `body_override` field. The template engine should branch on it
// (e.g. Handlebars `{{#if body_override}}{{body_override}}{{else}}…default body…{{/if}}`).
// For installs where the templates don't yet handle the branch,
// the override still rides through as a variable the admin can
// reference manually.
if (row.event_reminder_body_override) {
payload.body_override = row.event_reminder_body_override;
}
await emailProcessor.queueEmail(row.id, customer.email, templateKey, payload);
// Stamp sent_at immediately so a same-pass-re-entrancy (or a
// crash between queueEmail and the update) doesn't double-send
// on the next tick. The queueEmail call is itself idempotent at
// the queue level; we belt-and-suspenders here.
await db('events')
.where({ id: row.id })
.update({ event_reminder_sent_at: new Date() });
sent += 1;
} catch (err) {
logger.error('Event reminder send failed', {
eventId: row.id, err: err.message,
});
skipped += 1;
}
}
// Production-quiet: only log when something actually happened
// (a send or a skipped row inside the trigger window). Empty passes
// — common when there are no upcoming events — stay silent so the
// hourly cron doesn't paper the logs.
if (sent > 0) {
logger.info('Event reminder pass: sent reminders', {
scanned: rows.length, sent, skipped,
});
} else if (skipped > 0) {
// skipped > 0 with sent === 0 means at least one event WAS in the
// window but couldn't be sent (missing email, send error). Log at
// info so it's visible without being noisy on healthy passes.
logger.info('Event reminder pass: rows skipped (no-send)', {
scanned: rows.length, skipped,
});
}
return { scanned: rows.length, sent, skipped };
}
module.exports = {
runEventReminderPass,
// exported for tests
_internal: {
resolveTemplateKey,
composePayload,
},
};
+91 -2
View File
@@ -11,10 +11,49 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('./eventTypeService');
const { AppError } = require('../utils/errors');
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
/**
* Coerce + validate the (event_time_start, event_time_end, is_full_day)
* triple from a payload. Migration 137 introduced these columns on the
* events table. Contract:
* - is_full_day defaults to true when undefined (preserves legacy
* callers that don't know about the new fields).
* - is_full_day=true forces both times to null regardless of what
* was supplied (full-day events never carry HH:MM).
* - is_full_day=false requires both times in HH:MM 24h form, and
* `end > start` lexicographically (string compare is safe for the
* 5-char HH:MM format).
* Throws AppError 400 on failure. Returns a normalised
* { event_time_start, event_time_end, is_full_day } triple suitable
* for direct DB write (boolean still coerced via formatBoolean at the
* write site).
*/
function normaliseEventTimeTriple({ event_time_start, event_time_end, is_full_day }) {
const isFullDay = is_full_day === undefined ? true : parseBooleanInput(is_full_day, true);
if (isFullDay) {
return { event_time_start: null, event_time_end: null, is_full_day: true };
}
const start = parseStringInput(event_time_start);
const end = parseStringInput(event_time_end);
if (!start || !TIME_RE.test(start)) {
throw new AppError('event_time_start must be HH:MM (24h)', 400, 'EVENT_TIME_INVALID');
}
if (!end || !TIME_RE.test(end)) {
throw new AppError('event_time_end must be HH:MM (24h)', 400, 'EVENT_TIME_INVALID');
}
if (start >= end) {
throw new AppError('event_time_end must be after event_time_start', 400, 'EVENT_TIME_RANGE');
}
return { event_time_start: start, event_time_end: end, is_full_day: false };
}
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -140,11 +179,21 @@ const createEvent = async (eventData) => {
allow_user_uploads,
upload_category_id,
// Photo cap
photo_cap
photo_cap,
// Migration 137 — calendar time fields. Defaults to full-day when
// the caller (legacy create-event form) doesn't know about them.
event_time_start,
event_time_end,
is_full_day
} = eventData;
const requirePassword = parseBooleanInput(require_password, true);
const customerColumnsAvailable = await hasCustomerContactColumns();
// Validate + normalise the calendar time triple up front so we throw
// before bcrypt + folder creation if the payload is bad.
const timeTriple = normaliseEventTimeTriple({
event_time_start, event_time_end, is_full_day,
});
// Validate password if required
if (requirePassword) {
@@ -214,6 +263,15 @@ const createEvent = async (eventData) => {
photo_cap: photo_cap || null
};
// Migration 137 — calendar time fields. Guarded by hasColumnCached so
// installs that haven't applied 137 yet skip the columns silently
// (per feedback_schema_drift_guards.md / feedback_cache_hasColumn_lookups.md).
if (await hasColumnCached('events', 'is_full_day')) {
insertData.event_time_start = timeTriple.event_time_start;
insertData.event_time_end = timeTriple.event_time_end;
insertData.is_full_day = formatBoolean(timeTriple.is_full_day);
}
// Remove undefined values
Object.keys(insertData).forEach(key => {
if (insertData[key] === undefined) {
@@ -360,6 +418,33 @@ const updateEvent = async (id, updates) => {
delete updates.password;
}
// Migration 137 — calendar time fields. We re-normalise the triple
// ONLY when at least one of the three fields was supplied; otherwise
// leave the row's current values alone. is_full_day=true forces both
// times to null regardless of what was supplied.
const timeFieldsTouched = (
updates.event_time_start !== undefined
|| updates.event_time_end !== undefined
|| updates.is_full_day !== undefined
);
if (timeFieldsTouched) {
if (await hasColumnCached('events', 'is_full_day')) {
const triple = normaliseEventTimeTriple({
event_time_start: updates.event_time_start,
event_time_end: updates.event_time_end,
is_full_day: updates.is_full_day,
});
updates.event_time_start = triple.event_time_start;
updates.event_time_end = triple.event_time_end;
updates.is_full_day = formatBoolean(triple.is_full_day);
} else {
// Un-migrated install — drop the fields silently.
delete updates.event_time_start;
delete updates.event_time_end;
delete updates.is_full_day;
}
}
await db('events').where('id', id).update(updates);
return { success: true };
@@ -412,5 +497,9 @@ module.exports = {
mapEventForApi,
hasCustomerContactColumns,
generateUniqueSlug,
createEventFolders
createEventFolders,
// Calendar time triple normaliser (migration 137). Exported so the
// inline adminEvents POST/PUT (which doesn't go through createEvent)
// can share the validation contract.
normaliseEventTimeTriple
};
+8 -3
View File
@@ -118,12 +118,17 @@ function frontendBase() {
return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
}
function formatEventDate(value) {
// Render the event date for the OG preview card respecting the
// admin-configured `general_date_format` (defaults to DD.MM.YYYY when
// unset). Previously hardcoded en-US "May 20, 2026" which ignored the
// operator's locale.
async function formatEventDate(value) {
if (!value) return null;
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const { formatDate } = require('../utils/dateFormatter');
return await formatDate(d);
} catch {
return null;
}
@@ -147,7 +152,7 @@ async function buildOgMetadata(slug, requestPath) {
}
const eventName = event.event_name || 'Photo Gallery';
const eventDate = formatEventDate(event.event_date);
const eventDate = await formatEventDate(event.event_date);
const titleParts = [eventName];
if (siteName && siteName !== eventName) titleParts.push(siteName);
const title = titleParts.join(' — ');
@@ -0,0 +1,74 @@
/**
* invoiceSchedulerService — cron worker for CRM automation.
*
* Despite the name, this scheduler now drives THREE jobs:
* 1. Flush invoices whose `scheduled_send_at` has passed and status
* is still 'scheduled' — flips them to 'sent' and queues the email.
* 2. Run the overdue reminder ladder (first reminder at due_date +
* reminder_first_days, second at +second_days w/ late fee).
* 3. Pre-event customer reminders (migration 143) — sends a nudge
* N days before `event_date`. Idempotent via
* `events.event_reminder_sent_at`.
*
* Jobs 1+2 delegate to `invoiceService.runScheduledTasks()`; job 3
* to `eventReminderService.runEventReminderPass()`. The two service
* calls run sequentially inside the same tick but in independent
* try/catch blocks so a failure in one doesn't suppress the other.
*
* Wired in server.js boot path next to expirationChecker — see that
* module for the cron pattern. Runs hourly; the per-row guards inside
* each service prevent duplicate sends.
*
* The module name is kept as `invoiceSchedulerService` for backward
* compatibility with the existing server.js import; rename to
* `crmSchedulerService` is a future cleanup.
*/
const cron = require('node-cron');
const invoiceService = require('./invoiceService');
const eventReminderService = require('./eventReminderService');
const logger = require('../utils/logger');
let task = null;
async function runTick() {
try {
await invoiceService.runScheduledTasks();
} catch (err) {
logger.error('Invoice scheduler tick failed', { err: err.message });
}
try {
await eventReminderService.runEventReminderPass();
} catch (err) {
logger.error('Event reminder pass failed', { err: err.message });
}
}
function startInvoiceScheduler() {
if (task) {
logger.info('Invoice scheduler already running');
return task;
}
// Hourly at minute 11 to spread load away from other hourly jobs.
task = cron.schedule('11 * * * *', async () => {
logger.info('Invoice scheduler: tick');
await runTick();
});
logger.info('Invoice scheduler started (hourly @ :11) — invoice + event-reminder jobs');
// Run once on boot so a missed window (server restart) gets caught
// up immediately.
runTick().catch((err) => {
logger.warn('Invoice scheduler initial tick failed', { err: err.message });
});
return task;
}
function stopInvoiceScheduler() {
if (task) {
task.stop();
task = null;
logger.info('Invoice scheduler stopped');
}
}
module.exports = { startInvoiceScheduler, stopInvoiceScheduler };
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
/**
* Backend-only label map for quote / invoice PDF rendering.
*
* PDFs are generated outside React, so we can't reuse `react-i18next`.
* This is a self-contained, additive label dictionary keyed by locale.
*
* Per project convention: en + de are hand-translated; fr / nl / pt / ru
* are machine-translated and flagged in the PR description for native
* review (see MEMORY.md feedback_translation_flagging.md).
*
* Anything missing for a locale falls through to English at call site
* via `t(labels, locale, key)`.
*/
const LABELS = {
en: {
quote_title: 'Quote',
invoice_title: 'Invoice',
quote_number_label: 'Quote number',
invoice_number_label: 'Invoice number',
// Stornorechnung (cancellation invoice). Distinct from
// `invoice_title` so the renderer can swap the page title when
// `doc.kind === 'storno'`. `reference_cancels` powers the
// mandatory "Bezug: Storno zu Rechnung R-XXXX vom DATE" line
// under the title that the customer/auditor needs to trace the
// §14c-defensible reversal.
storno_title: 'Cancellation invoice',
reference_cancels: 'Cancels',
date: 'Date',
quote_number: 'Quote',
invoice_number: 'Invoice',
valid_until: 'Valid until',
due_date: 'Due',
salutation: 'Dear Sir or Madam,',
lead_in_quote: 'in accordance with our agreement, we are pleased to offer the following:',
lead_in_invoice: 'in accordance with our agreement, we are invoicing the following:',
table_pos: 'Pos.',
table_qty: 'Qty',
table_description: 'Description',
table_discount: 'Discount',
table_unit_price: 'Unit price',
table_line_total: 'Total',
totals_net: 'Net amount',
totals_shipping: 'Shipping',
totals_vat: 'VAT',
totals_late_fee: 'Late fee',
totals_grand: 'Total',
payment_conditions: 'Payment conditions',
iban_intro: 'Please transfer the amount to the following bank account:',
net_days_suffix: 'days from invoice date.',
skonto_label: 'Early payment discount',
skonto_phrase: '{percent}% discount if paid within {days} working days.',
skonto_amount_label: 'Amount with discount',
late_fee_note: 'A late fee of {amount} has been added due to overdue payment.',
installment_due: 'due',
reference_label: 'Reference',
reference_replaces: 'Replaces',
reference_dated: 'dated {date}',
page: 'Page',
of: 'of',
page_of: 'Page {current} of {total}',
epc_qr_title: 'Scan to pay (SEPA)',
epc_qr_subtitle: 'Open your banking app and scan this code to prefill the transfer.',
quote_response_intro: 'You can accept or decline this quote here:',
accept_button: 'Accept quote',
decline_button: 'Decline quote',
// Tax report (commit 3/feat-crm). Hand-translated en + de here;
// fr/nl/pt/ru are filled in by commit 5 and fall through to en
// until then.
tax_title: 'Tax report',
tax_period: 'Period',
tax_generated: 'Generated',
tax_currency: 'Currency',
tax_col_no: '#',
tax_col_date: 'Date',
tax_col_invoice: 'Invoice',
tax_col_customer: 'Customer',
tax_col_event: 'Event',
tax_col_vat_rate: 'VAT %',
tax_col_net: 'Net',
tax_col_vat: 'VAT',
tax_col_total: 'Gross',
tax_col_status: 'Status',
tax_col_skonto: 'Skonto',
tax_status_cancelled: 'Cancelled',
tax_totals_by_rate: 'Totals by VAT rate',
tax_grand_total_net: 'Total net',
tax_grand_total_vat: 'Total VAT',
tax_grand_total_gross: 'Total gross',
tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).',
tax_no_invoices: 'No invoices in this period.',
// Contracts (migration 130). Section labels stay in sync with the
// SECTIONS_ORDER enum in contractService.
contract_title: 'Contract',
contract_number_label: 'Contract no.',
section_basics: 'Basics',
section_scope: 'Scope',
section_privacy: 'Privacy',
section_commercial: 'Commercial',
section_nda: 'Confidentiality',
section_closing: 'Closing provisions',
signature_customer: 'Client',
signature_admin: 'Contractor',
signed_label_name: 'Name',
signed_label_date: 'Date',
signed_label_place: 'Place',
signed_label_signature: 'Signature',
signed_at: 'Signed at',
// Dedicated signature page at the end of every contract PDF.
// Stamp service overlays canvas signatures onto the empty boxes
// at fixed coordinates; admin / customer labels stay in-place.
signature_page_title: 'Signatures',
signature_page_prompt: 'Both parties confirm acceptance of the terms above by signing below.',
// Audit certificate — separate PDF (no longer in the contract
// body) listing timestamps, IPs, and SHA-256 hashes. Generated
// by pdfStampService.renderAuditCertificate.
audit_certificate_subject: 'Signing audit certificate',
audit_title: 'Signing audit trail',
audit_intro: 'The evidence below was recorded automatically when this contract was signed. To verify file integrity, re-hash the PDF you hold with any SHA-256 utility and compare against the digest below — if the values match, the file has not been tampered with since issuing.',
audit_contract_number: 'Contract number',
audit_issued_at: 'Issued (sent to customer)',
audit_customer_section: 'Customer signature',
audit_admin_section: 'Contractor signature',
audit_integrity_section: 'File integrity hashes',
audit_signed_by: 'Name',
audit_signed_at: 'Timestamp (UTC)',
audit_ip: 'IP address',
audit_unsigned_sha: 'Original PDF SHA-256',
audit_signed_sha: 'Signed PDF SHA-256',
audit_footer: 'Generated by picpeak. This page is part of the contract — preserve all pages together.',
},
de: {
quote_title: 'Angebot',
invoice_title: 'Rechnung',
quote_number_label: 'Angebotsnummer',
invoice_number_label: 'Rechnungsnummer',
storno_title: 'Stornorechnung',
reference_cancels: 'Storno zu',
date: 'Datum',
quote_number: 'Angebot',
invoice_number: 'Rechnung',
valid_until: 'Gültig bis',
due_date: 'Fällig am',
salutation: 'Sehr geehrte Damen und Herren,',
lead_in_quote: 'gemäss unserer Absprache bieten wir wie folgt an:',
lead_in_invoice: 'gemäss unserer Vereinbarung berechnen wir wie folgt:',
table_pos: 'Pos.',
table_qty: 'Anzahl',
table_description: 'Beschreibung',
table_discount: 'Rabatt',
table_unit_price: 'Einzelpreis',
table_line_total: 'Summe',
totals_net: 'Betrag Netto',
totals_shipping: 'Versand',
totals_vat: 'ges. MwSt.',
totals_late_fee: 'Mahngebühr',
totals_grand: 'Gesamtbetrag',
payment_conditions: 'Zahlungsbedingungen',
iban_intro: 'Der Betrag ist auf die folgende Bankverbindung zu überweisen:',
net_days_suffix: 'Tage nach Rechnungsdatum.',
skonto_label: 'Skonto',
skonto_phrase: '{percent}% Skonto bei Zahlung innerhalb von {days} Werktagen.',
skonto_amount_label: 'Betrag mit Skonto',
late_fee_note: 'Wegen Zahlungsverzug wurde eine Mahngebühr von {amount} berechnet.',
installment_due: 'fällig',
reference_label: 'Bezug',
reference_replaces: 'Ersetzt',
reference_dated: 'vom {date}',
page: 'Seite',
of: 'von',
page_of: 'Seite {current} von {total}',
epc_qr_title: 'Zum Bezahlen scannen (SEPA)',
epc_qr_subtitle: 'Öffne deine Banking-App und scanne diesen Code, um die Überweisung vorauszufüllen.',
quote_response_intro: 'Sie können dieses Angebot hier annehmen oder ablehnen:',
accept_button: 'Angebot annehmen',
decline_button: 'Angebot ablehnen',
// Steuerliste — hand-translated.
tax_title: 'Steuerliste',
tax_period: 'Zeitraum',
tax_generated: 'Erstellt am',
tax_currency: 'Währung',
tax_col_no: 'Nr.',
tax_col_date: 'Datum',
tax_col_invoice: 'Rechnung',
tax_col_customer: 'Kunde',
tax_col_event: 'Anlass',
tax_col_vat_rate: 'MwSt-Satz',
tax_col_net: 'Netto',
tax_col_vat: 'MwSt.',
tax_col_total: 'Brutto',
tax_col_status: 'Status',
tax_col_skonto: 'Skonto',
tax_status_cancelled: 'Storniert',
tax_totals_by_rate: 'Summen nach MwSt-Satz',
tax_grand_total_net: 'Gesamt Netto',
tax_grand_total_vat: 'Gesamt MwSt.',
tax_grand_total_gross: 'Gesamt Brutto',
tax_cancelled_footnote: '{count} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).',
tax_no_invoices: 'Keine Rechnungen in diesem Zeitraum.',
contract_title: 'Vertrag',
contract_number_label: 'Vertragsnummer',
section_basics: 'Vertragsgrundlagen',
section_scope: 'Leistungsumfang',
section_privacy: 'Persönlichkeitsrechte & Datenschutz',
section_commercial: 'Kaufmännisches',
section_nda: 'Vertraulichkeit',
section_closing: 'Schlussbestimmungen',
signature_customer: 'Auftraggeber',
signature_admin: 'Auftragnehmer',
signed_label_name: 'Name',
signed_label_date: 'Datum',
signed_label_place: 'Ort',
signed_label_signature: 'Unterschrift',
signed_at: 'Unterzeichnet am',
signature_page_title: 'Unterschriften',
signature_page_prompt: 'Beide Parteien bestätigen mit ihrer Unterschrift die Annahme der vorstehenden Bedingungen.',
audit_certificate_subject: 'Audit-Bescheinigung der Unterzeichnung',
audit_title: 'Audit-Trail der Unterzeichnung',
audit_intro: 'Die nachstehenden Belege wurden bei der Unterzeichnung automatisch erfasst. Zur Überprüfung der Dateiintegrität bilden Sie den SHA-256-Hash der Ihnen vorliegenden PDF-Datei und vergleichen ihn mit dem unten angegebenen Wert — bei Übereinstimmung wurde die Datei seit der Ausstellung nicht verändert.',
audit_contract_number: 'Vertragsnummer',
audit_issued_at: 'Ausgestellt (an Kunden gesendet)',
audit_customer_section: 'Unterschrift Auftraggeber',
audit_admin_section: 'Unterschrift Auftragnehmer',
audit_integrity_section: 'Datei-Integritätsprüfung',
audit_signed_by: 'Name',
audit_signed_at: 'Zeitstempel (UTC)',
audit_ip: 'IP-Adresse',
audit_unsigned_sha: 'SHA-256 Ursprungs-PDF',
audit_signed_sha: 'SHA-256 signiertes PDF',
audit_footer: 'Erstellt von picpeak. Diese Seite ist Bestandteil des Vertrags — bitte alle Seiten gemeinsam aufbewahren.',
},
fr: {
// Machine-translated, flagged for native review.
quote_title: 'Devis',
invoice_title: 'Facture',
quote_number_label: 'Numéro de devis',
invoice_number_label: 'Numéro de facture',
storno_title: 'Avoir',
reference_cancels: 'Annule',
date: 'Date',
quote_number: 'Devis',
invoice_number: 'Facture',
valid_until: 'Valable jusqu\'au',
due_date: 'Échéance',
salutation: 'Madame, Monsieur,',
lead_in_quote: 'conformément à notre accord, nous vous proposons ce qui suit :',
lead_in_invoice: 'conformément à notre accord, nous facturons ce qui suit :',
table_pos: 'Pos.',
table_qty: 'Qté',
table_description: 'Description',
table_discount: 'Rabais',
table_unit_price: 'Prix unitaire',
table_line_total: 'Total',
totals_net: 'Montant net',
totals_shipping: 'Frais d\'expédition',
totals_vat: 'TVA',
totals_late_fee: 'Frais de retard',
totals_grand: 'Total',
payment_conditions: 'Conditions de paiement',
iban_intro: 'Veuillez virer le montant sur le compte suivant :',
net_days_suffix: 'jours à compter de la date de facturation.',
skonto_label: 'Escompte',
skonto_phrase: '{percent}% d\'escompte si paiement dans les {days} jours ouvrables.',
skonto_amount_label: 'Montant avec escompte',
late_fee_note: 'Des frais de retard de {amount} ont été ajoutés.',
installment_due: 'échéance',
reference_label: 'Référence',
reference_replaces: 'Remplace',
reference_dated: 'du {date}',
page: 'Page',
of: 'sur',
page_of: 'Page {current} sur {total}',
epc_qr_title: 'Scannez pour payer (SEPA)',
epc_qr_subtitle: 'Ouvrez votre application bancaire et scannez ce code pour pré-remplir le virement.',
quote_response_intro: 'Vous pouvez accepter ou refuser ce devis ici :',
accept_button: 'Accepter le devis',
decline_button: 'Refuser le devis',
// Tax report — machine-translated, flagged for native review.
tax_title: 'Rapport fiscal',
tax_period: 'Période',
tax_generated: 'Généré le',
tax_currency: 'Devise',
tax_col_no: 'N°',
tax_col_date: 'Date',
tax_col_invoice: 'Facture',
tax_col_customer: 'Client',
tax_col_event: 'Événement',
tax_col_vat_rate: 'Taux TVA',
tax_col_net: 'Net',
tax_col_vat: 'TVA',
tax_col_total: 'Brut',
tax_col_status: 'Statut',
tax_col_skonto: 'Escompte',
tax_status_cancelled: 'Annulée',
tax_totals_by_rate: 'Totaux par taux de TVA',
tax_grand_total_net: 'Total net',
tax_grand_total_vat: 'Total TVA',
tax_grand_total_gross: 'Total brut',
tax_cancelled_footnote: '{count} facture(s) annulée(s) — montants exclus des totaux (affichés pour la continuité de la piste d\'audit).',
tax_no_invoices: 'Aucune facture sur cette période.',
},
nl: {
// Machine-translated, flagged for native review.
quote_title: 'Offerte',
invoice_title: 'Factuur',
quote_number_label: 'Offertenummer',
invoice_number_label: 'Factuurnummer',
storno_title: 'Creditfactuur',
reference_cancels: 'Annuleert',
date: 'Datum',
quote_number: 'Offerte',
invoice_number: 'Factuur',
valid_until: 'Geldig tot',
due_date: 'Vervaldatum',
salutation: 'Geachte heer/mevrouw,',
lead_in_quote: 'overeenkomstig onze afspraak doen wij u het volgende voorstel:',
lead_in_invoice: 'overeenkomstig onze afspraak factureren wij het volgende:',
table_pos: 'Pos.',
table_qty: 'Aantal',
table_description: 'Beschrijving',
table_discount: 'Korting',
table_unit_price: 'Prijs per stuk',
table_line_total: 'Totaal',
totals_net: 'Netto bedrag',
totals_shipping: 'Verzending',
totals_vat: 'BTW',
totals_late_fee: 'Aanmaningskosten',
totals_grand: 'Totaal',
payment_conditions: 'Betalingsvoorwaarden',
iban_intro: 'Gelieve het bedrag over te maken op de volgende bankrekening:',
net_days_suffix: 'dagen na factuurdatum.',
skonto_label: 'Betalingskorting',
skonto_phrase: '{percent}% korting bij betaling binnen {days} werkdagen.',
skonto_amount_label: 'Bedrag met korting',
late_fee_note: 'Wegens te late betaling is een toeslag van {amount} toegevoegd.',
installment_due: 'vervalt op',
reference_label: 'Referentie',
reference_replaces: 'Vervangt',
reference_dated: 'van {date}',
page: 'Pagina',
of: 'van',
page_of: 'Pagina {current} van {total}',
epc_qr_title: 'Scan om te betalen (SEPA)',
epc_qr_subtitle: 'Open je bank-app en scan deze code om de overschrijving in te vullen.',
quote_response_intro: 'U kunt deze offerte hier accepteren of weigeren:',
accept_button: 'Offerte accepteren',
decline_button: 'Offerte weigeren',
// Tax report — machine-translated, flagged for native review.
tax_title: 'Belastingrapport',
tax_period: 'Periode',
tax_generated: 'Gegenereerd op',
tax_currency: 'Valuta',
tax_col_no: 'Nr.',
tax_col_date: 'Datum',
tax_col_invoice: 'Factuur',
tax_col_customer: 'Klant',
tax_col_event: 'Evenement',
tax_col_vat_rate: 'Btw-tarief',
tax_col_net: 'Netto',
tax_col_vat: 'Btw',
tax_col_total: 'Bruto',
tax_col_status: 'Status',
tax_col_skonto: 'Korting',
tax_status_cancelled: 'Geannuleerd',
tax_totals_by_rate: 'Totalen per btw-tarief',
tax_grand_total_net: 'Totaal netto',
tax_grand_total_vat: 'Totaal btw',
tax_grand_total_gross: 'Totaal bruto',
tax_cancelled_footnote: '{count} geannuleerde factu(u)r(en) — bedragen uitgesloten van totalen (getoond voor continuïteit van het audit-spoor).',
tax_no_invoices: 'Geen facturen in deze periode.',
},
pt: {
// Machine-translated, flagged for native review.
quote_title: 'Orçamento',
invoice_title: 'Fatura',
quote_number_label: 'Número do orçamento',
invoice_number_label: 'Número da fatura',
storno_title: 'Nota de crédito',
reference_cancels: 'Cancela',
date: 'Data',
quote_number: 'Orçamento',
invoice_number: 'Fatura',
valid_until: 'Válido até',
due_date: 'Vencimento',
salutation: 'Prezados Senhores,',
lead_in_quote: 'conforme combinado, oferecemos o seguinte:',
lead_in_invoice: 'conforme combinado, faturamos o seguinte:',
table_pos: 'Pos.',
table_qty: 'Qtde.',
table_description: 'Descrição',
table_discount: 'Desconto',
table_unit_price: 'Preço unitário',
table_line_total: 'Total',
totals_net: 'Valor líquido',
totals_shipping: 'Envio',
totals_vat: 'IVA',
totals_late_fee: 'Taxa de atraso',
totals_grand: 'Total',
payment_conditions: 'Condições de pagamento',
iban_intro: 'Por favor transfira o valor para a seguinte conta bancária:',
net_days_suffix: 'dias após a data da fatura.',
skonto_label: 'Desconto por pagamento antecipado',
skonto_phrase: '{percent}% de desconto se pago em {days} dias úteis.',
skonto_amount_label: 'Valor com desconto',
late_fee_note: 'Uma taxa de atraso de {amount} foi adicionada.',
installment_due: 'vence em',
reference_label: 'Referência',
reference_replaces: 'Substitui',
reference_dated: 'de {date}',
page: 'Página',
of: 'de',
page_of: 'Página {current} de {total}',
epc_qr_title: 'Digitalize para pagar (SEPA)',
epc_qr_subtitle: 'Abra o seu app bancário e digitalize este código para pré-preencher a transferência.',
quote_response_intro: 'Você pode aceitar ou recusar este orçamento aqui:',
accept_button: 'Aceitar orçamento',
decline_button: 'Recusar orçamento',
// Tax report — machine-translated, flagged for native review.
tax_title: 'Relatório fiscal',
tax_period: 'Período',
tax_generated: 'Gerado em',
tax_currency: 'Moeda',
tax_col_no: 'N.º',
tax_col_date: 'Data',
tax_col_invoice: 'Fatura',
tax_col_customer: 'Cliente',
tax_col_event: 'Evento',
tax_col_vat_rate: 'Taxa IVA',
tax_col_net: 'Líquido',
tax_col_vat: 'IVA',
tax_col_total: 'Bruto',
tax_col_status: 'Estado',
tax_col_skonto: 'Desconto',
tax_status_cancelled: 'Cancelada',
tax_totals_by_rate: 'Totais por taxa de IVA',
tax_grand_total_net: 'Total líquido',
tax_grand_total_vat: 'Total IVA',
tax_grand_total_gross: 'Total bruto',
tax_cancelled_footnote: '{count} fatura(s) cancelada(s) — valores excluídos dos totais (apresentados para continuidade do rastro de auditoria).',
tax_no_invoices: 'Sem faturas neste período.',
},
ru: {
// Machine-translated, flagged for native review.
quote_title: 'Коммерческое предложение',
invoice_title: 'Счёт',
quote_number_label: 'Номер предложения',
invoice_number_label: 'Номер счёта',
storno_title: 'Сторно-счёт',
reference_cancels: 'Сторно к',
date: 'Дата',
quote_number: 'Предложение',
invoice_number: 'Счёт',
valid_until: 'Действительно до',
due_date: 'Срок оплаты',
salutation: 'Уважаемые дамы и господа!',
lead_in_quote: 'согласно нашей договорённости, предлагаем следующее:',
lead_in_invoice: 'согласно нашей договорённости, выставляем счёт на следующее:',
table_pos: 'Поз.',
table_qty: 'Кол-во',
table_description: 'Описание',
table_discount: 'Скидка',
table_unit_price: 'Цена за ед.',
table_line_total: 'Сумма',
totals_net: 'Сумма нетто',
totals_shipping: 'Доставка',
totals_vat: 'НДС',
totals_late_fee: 'Пеня за просрочку',
totals_grand: 'Итого',
payment_conditions: 'Условия оплаты',
iban_intro: 'Просим перевести сумму на следующий банковский счёт:',
net_days_suffix: 'дней с даты счёта.',
skonto_label: 'Скидка за досрочную оплату',
skonto_phrase: 'Скидка {percent}% при оплате в течение {days} рабочих дней.',
skonto_amount_label: 'Сумма со скидкой',
late_fee_note: 'Добавлена пеня за просрочку: {amount}.',
installment_due: 'к оплате',
reference_label: 'Ссылка',
reference_replaces: 'Заменяет',
reference_dated: 'от {date}',
page: 'Стр.',
of: 'из',
page_of: 'Стр. {current} из {total}',
epc_qr_title: 'Сканируйте для оплаты (SEPA)',
epc_qr_subtitle: 'Откройте банковское приложение и отсканируйте этот код, чтобы предзаполнить перевод.',
quote_response_intro: 'Вы можете принять или отклонить это предложение здесь:',
accept_button: 'Принять предложение',
decline_button: 'Отклонить предложение',
// Tax report — machine-translated, flagged for native review.
tax_title: 'Налоговый отчёт',
tax_period: 'Период',
tax_generated: 'Создан',
tax_currency: 'Валюта',
tax_col_no: '№',
tax_col_date: 'Дата',
tax_col_invoice: 'Счёт',
tax_col_customer: 'Клиент',
tax_col_event: 'Событие',
tax_col_vat_rate: 'Ставка НДС',
tax_col_net: 'Нетто',
tax_col_vat: 'НДС',
tax_col_total: 'Брутто',
tax_col_status: 'Статус',
tax_col_skonto: 'Скидка',
tax_status_cancelled: 'Аннулирован',
tax_totals_by_rate: 'Итоги по ставкам НДС',
tax_grand_total_net: 'Итого нетто',
tax_grand_total_vat: 'Итого НДС',
tax_grand_total_gross: 'Итого брутто',
tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).',
tax_no_invoices: 'Нет счетов за этот период.',
},
};
function t(locale, key, vars = {}) {
const dict = LABELS[locale] || LABELS.en;
let str = dict[key] || LABELS.en[key] || key;
for (const [k, v] of Object.entries(vars)) {
str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
}
return str;
}
module.exports = { t, LABELS };
File diff suppressed because it is too large Load Diff
+361
View File
@@ -0,0 +1,361 @@
/**
* Contract-PDF stamp service.
*
* Replaces the previous re-render-on-every-signature approach with
* the industry-standard pattern: the unsigned contract PDF is
* rendered once at send time and stays byte-immutable from then on.
* Each signature event opens that PDF with pdf-lib, overlays the
* signature PNG at the fixed coordinates defined in
* pdfService.CONTRACT_SIGNATURE_LAYOUT, then writes the result to a
* new timestamped file. Same model DocuSign / Adobe Sign / HelloSign
* use.
*
* Why this matters for audit defence:
* - The customer's signed PDF = the original PDF + their signature
* stamp + nothing else. Bytes the customer saw at signing time
* pass through unchanged into the signed file.
* - No render-code drift between sign events; later layout
* tweaks to renderContractToBuffer don't retroactively change
* what already-signed PDFs look like.
* - The audit certificate (timestamps, IPs, hashes) is a
* separate sibling document not embedded in the signed
* contract PDF so the operator can verify each independently.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const PDFKit = require('pdfkit');
const { PDFDocument } = require('pdf-lib');
const pdfService = require('./pdfService');
// Resolve these at function-call time, not at module-load time, so the
// module remains loadable even when pdfService is mocked in unit tests
// (the mock stubs only renderContractToBuffer). Each function reads
// the live values from pdfService at the top of its body.
function pdfConsts() {
return {
L: pdfService.CONTRACT_SIGNATURE_LAYOUT,
PAGE: pdfService.PAGE,
FONT_BODY: pdfService.FONT_BODY,
FONT_BOLD: pdfService.FONT_BOLD,
t: pdfService._internal && pdfService._internal.t,
// formatDate respects the `general_date_format` app setting when
// a dateFormat arg is passed; with no arg it defaults to the
// European DD.MM.YYYY shape (the operator's locale). Used for the
// "Datum: ..." line under each signature stamp.
formatDate: pdfService._internal && pdfService._internal.formatDate,
};
}
const logger = require('../utils/logger');
function sha256OfBuffer(buf) {
return crypto.createHash('sha256').update(buf).digest('hex');
}
/**
* Convert PDFKit-style coordinates (top-left origin, y increases
* downward) to pdf-lib coordinates (bottom-left origin, y increases
* upward). Both libraries use PDF's native point unit.
*/
function pdfkitToPdfLib(pageHeight, x, y, w, h) {
return {
x,
y: pageHeight - y - h,
width: w,
height: h,
};
}
/**
* Stamp a signature image onto an existing contract PDF.
*
* - `pdfBuffer` is the Buffer of the PDF we're stamping into. Either
* the originally-rendered unsigned PDF (first stamp) or a
* previously-stamped version (second stamp adds the admin's
* signature on top of the customer-stamped PDF).
* - `signaturePngPath` is the on-disk path of the canvas PNG to
* embed. The file must exist; caller already validated this.
* - `role` is 'customer' or 'admin' selects the left/right box.
* - `caption` is the typed name + ISO date string drawn under the
* image so the visual artifact matches what the unsigned PDF
* showed as empty caption rows.
*
* Returns a Buffer of the new PDF. Does NOT touch the input buffer
* or the input file.
*/
async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) {
const { L, FONT_BODY, FONT_BOLD, formatDate } = pdfConsts();
if (!Buffer.isBuffer(pdfBuffer)) {
throw new Error('stampSignature: pdfBuffer must be a Buffer');
}
if (!signaturePngPath || !fs.existsSync(signaturePngPath)) {
throw new Error(`stampSignature: signature PNG not found at ${signaturePngPath}`);
}
if (!['customer', 'admin'].includes(role)) {
throw new Error(`stampSignature: role must be 'customer' or 'admin', got '${role}'`);
}
const pdfDoc = await PDFDocument.load(pdfBuffer);
const pngBytes = fs.readFileSync(signaturePngPath);
let pngImage;
try {
pngImage = await pdfDoc.embedPng(pngBytes);
} catch (err) {
// pdf-lib throws InvalidPNGError for files that aren't valid PNG.
// Try JPEG as a fallback (the canvas could be saved as JPEG too).
try {
pngImage = await pdfDoc.embedJpg(pngBytes);
} catch (_) {
throw new Error(`stampSignature: signature file at ${signaturePngPath} is neither valid PNG nor JPEG`);
}
}
// pdf-lib pages are 0-indexed. The signature page is the last page
// of the unsigned PDF (added by renderContractToBuffer just before
// the page-number stamp).
const pages = pdfDoc.getPages();
const sigPage = pages[pages.length - 1];
const { height: pageH } = sigPage.getSize();
// Origin coordinates for the box in PDFKit space. Pick by role.
const boxX = role === 'customer' ? L.customerX : L.adminX;
const boxY = L.boxY;
const boxW = L.boxWidth;
const boxH = L.boxHeight;
// The signature image fits inside the box with 4pt padding on each
// side. We preserve the aspect ratio by scaling the image to fit,
// then centring it.
const padding = 4;
const innerW = boxW - 2 * padding;
const innerH = boxH - 2 * padding;
const imgW = pngImage.width;
const imgH = pngImage.height;
const scale = Math.min(innerW / imgW, innerH / imgH);
const drawW = imgW * scale;
const drawH = imgH * scale;
// Centre inside the inner rect.
const drawXPdfkit = boxX + padding + (innerW - drawW) / 2;
const drawYPdfkit = boxY + padding + (innerH - drawH) / 2;
const conv = pdfkitToPdfLib(pageH, drawXPdfkit, drawYPdfkit, drawW, drawH);
sigPage.drawImage(pngImage, conv);
// Caption — fill in the "Name: ___" and "Date: ___" rows under
// the box. The unsigned PDF left these empty; we overwrite by
// drawing white rectangles over the empty rows then printing the
// filled-in values on top. Same coords as the unsigned render's
// captionY = boxY + boxHeight + 6.
if (caption && (caption.name || caption.signedAt)) {
const captionYPdfkit = boxY + boxH + 6;
// Use the shared formatDate helper so the "Datum: ..." line
// matches the locale-aware DD.MM.YYYY format the rest of the
// contract PDF uses (e.g. issue-date headline). Caller may pass
// a custom dateFormat via caption.dateFormat for per-document
// overrides; without it formatDate defaults to DD.MM.YYYY.
const lines = [
`${caption.nameLabel || 'Name'}: ${caption.name || ''}`,
`${caption.dateLabel || 'Date'}: ${caption.signedAt && formatDate
? formatDate(caption.signedAt, caption.dateFormat)
: ''}`,
];
// Overdraw a white rectangle so we replace the unsigned-page's
// empty captions cleanly. PDFKit + pdf-lib both lay glyphs over
// existing content rather than replacing, so without this the
// old "Name: " would still show through.
const captionRect = pdfkitToPdfLib(pageH, boxX, captionYPdfkit - 2, boxW, 28);
sigPage.drawRectangle({ ...captionRect, color: pdfLibRgb(1, 1, 1) });
// Embed Helvetica (pdf-lib's built-in font). 9pt to match the
// unsigned render's caption size.
const StandardFonts = require('pdf-lib').StandardFonts;
const helv = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontSize = 9;
for (let i = 0; i < lines.length; i++) {
const lineY = captionYPdfkit + i * 12;
const conv2 = pdfkitToPdfLib(pageH, boxX, lineY, boxW, fontSize);
sigPage.drawText(lines[i], {
x: conv2.x,
y: conv2.y,
size: fontSize,
font: helv,
color: pdfLibRgb(0, 0, 0),
});
}
}
const outBytes = await pdfDoc.save();
return Buffer.from(outBytes);
}
// pdf-lib expects rgb() instances. Importing the helper lazily so
// the function works whether pdf-lib resolves it as a named export or
// a method on the default object across versions.
let _rgbFn = null;
function pdfLibRgb(r, g, b) {
if (!_rgbFn) {
const m = require('pdf-lib');
_rgbFn = m.rgb || ((rr, gg, bb) => ({ type: 'RGB', red: rr, green: gg, blue: bb }));
}
return _rgbFn(r, g, b);
}
/**
* Render the audit certificate a standalone single-page (or 2-page
* if it grows) PDF that records timestamps, IPs, SHA-256 hashes, and
* the actor names for every signature event on the contract.
*
* Used as a sibling document to the signed contract PDF. Both are
* attached to the contract_fully_signed email and stored on the
* contract row so either party can fetch each independently.
*
* The certificate references the signed contract PDF by hash
* verifying the certificate authentic + re-hashing the contract PDF
* is the integrity check.
*
* Returns { buffer, sha256 }.
*/
async function renderAuditCertificate({ contract, customer, admin, locale = 'de' }) {
const { PAGE, FONT_BODY, FONT_BOLD, t } = pdfConsts();
return new Promise((resolve, reject) => {
try {
const doc = new PDFKit({
size: 'A4',
bufferPages: true,
margins: {
top: PAGE.marginTop, bottom: PAGE.marginBottom,
left: PAGE.marginLeft, right: PAGE.marginRight,
},
info: {
Title: `${contract.contract_number || 'Contract'}_audit_certificate`,
Author: 'picpeak',
Subject: t(locale, 'audit_certificate_subject'),
},
});
const chunks = [];
doc.on('data', (c) => chunks.push(c));
doc.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve({ buffer, sha256: sha256OfBuffer(buffer) });
});
doc.on('error', reject);
doc._fonts = { body: FONT_BODY, bold: FONT_BOLD };
let y = PAGE.marginTop;
doc.font(doc._fonts.bold).fontSize(18).fillColor('#000');
doc.text(t(locale, 'audit_title'), PAGE.marginLeft, y, {
width: PAGE.contentWidth,
});
y = doc.y + 6;
doc.strokeColor('#888').lineWidth(0.5)
.moveTo(PAGE.marginLeft, y).lineTo(PAGE.marginLeft + PAGE.contentWidth, y).stroke();
y += 14;
doc.font(doc._fonts.body).fontSize(10).fillColor('#000');
doc.text(t(locale, 'audit_intro'), PAGE.marginLeft, y, {
width: PAGE.contentWidth, align: 'left',
});
y = doc.y + 14;
const labelW = 200;
const valueW = PAGE.contentWidth - labelW;
function row(labelKey, value) {
if (!value) return;
doc.font(doc._fonts.bold).fontSize(9).fillColor('#444');
doc.text(t(locale, labelKey), PAGE.marginLeft, y, {
width: labelW, lineBreak: false,
});
doc.font(doc._fonts.body).fontSize(9).fillColor('#000');
doc.text(String(value), PAGE.marginLeft + labelW, y, {
width: valueW, align: 'left',
});
y = Math.max(y + 12, doc.y + 4);
}
row('audit_contract_number', contract.contract_number);
row('audit_issued_at', contract.sent_at
? new Date(contract.sent_at).toISOString()
: null);
if (customer && (customer.name || customer.signedAt)) {
y += 6;
doc.font(doc._fonts.bold).fontSize(11).fillColor('#000');
doc.text(t(locale, 'audit_customer_section'), PAGE.marginLeft, y);
y = doc.y + 4;
row('audit_signed_by', customer.name);
row('audit_signed_at', customer.signedAt ? new Date(customer.signedAt).toISOString() : null);
row('audit_ip', customer.ip);
}
if (admin && (admin.name || admin.signedAt)) {
y += 6;
doc.font(doc._fonts.bold).fontSize(11).fillColor('#000');
doc.text(t(locale, 'audit_admin_section'), PAGE.marginLeft, y);
y = doc.y + 4;
row('audit_signed_by', admin.name);
row('audit_signed_at', admin.signedAt ? new Date(admin.signedAt).toISOString() : null);
row('audit_ip', admin.ip);
}
if (contract.pdf_sha256 || contract.signed_pdf_sha256) {
y += 8;
doc.font(doc._fonts.bold).fontSize(11).fillColor('#000');
doc.text(t(locale, 'audit_integrity_section'), PAGE.marginLeft, y);
y = doc.y + 4;
row('audit_unsigned_sha', contract.pdf_sha256);
row('audit_signed_sha', contract.signed_pdf_sha256);
}
y += 14;
doc.font(doc._fonts.body).fontSize(8).fillColor('#666');
doc.text(t(locale, 'audit_footer'), PAGE.marginLeft, y, {
width: PAGE.contentWidth, align: 'left',
});
doc.end();
} catch (err) {
reject(err);
}
});
}
/**
* Apply a sequence of signature stamps to a contract PDF buffer.
* Each stamp is `{ signaturePngPath, role, caption }`. Returns the
* final buffer + its SHA-256 hash.
*
* Single-pass so file IO happens once per stamp pair. Caller orders
* the array (customer first, admin second) per the desired
* provenance chain.
*/
async function stampSignatures(originalPdfBuffer, stamps) {
let buffer = originalPdfBuffer;
for (const stamp of stamps) {
if (!stamp.signaturePngPath) continue;
try {
buffer = await stampSignature({
pdfBuffer: buffer,
signaturePngPath: stamp.signaturePngPath,
role: stamp.role,
caption: stamp.caption,
});
} catch (err) {
logger.error('stampSignatures: failed to apply stamp', {
role: stamp.role,
signaturePngPath: stamp.signaturePngPath,
message: err.message,
});
// Skip the failed stamp but keep going — better to produce a
// PDF missing one signature than to lose the whole document.
}
}
return { buffer, sha256: sha256OfBuffer(buffer) };
}
module.exports = {
stampSignature,
stampSignatures,
renderAuditCertificate,
_internal: { pdfkitToPdfLib, sha256OfBuffer },
};
File diff suppressed because it is too large Load Diff
+760
View File
@@ -0,0 +1,760 @@
/**
* taxReportService period-scoped revenue listing for tax filing.
*
* Pulls every revenue-relevant invoice in [from, to] (accrual basis,
* keyed on `issue_date`) and returns rows + totals broken down by
* VAT rate. Cancelled invoices stay in the row list (DE/CH/AT audit
* trail requires a gap-free invoice-number sequence) but are excluded
* from the totals math.
*
* Late fees: the user opted to include them in the totals. We split
* each invoice's `late_fee_amount_minor` proportionally using the
* invoice's own VAT rate:
* lateFeeNet = round(late_fee_amount_minor / (1 + vat_rate/100))
* lateFeeVat = late_fee_amount_minor lateFeeNet
* and add those onto the stored `net_amount_minor` / `vat_amount_minor`
* before reporting. Invoices without a late fee math collapses to
* the stored values.
*
* Returned shape (see getTaxReport):
* {
* rows: [{ id, invoiceNumber, issueDate, currency,
* vatRate, customerLabel, eventName,
* netMinor, vatMinor, totalMinor,
* isCancelled, replacedByInvoiceNumber }, ],
* totalsByVatRate: [{ vatRate, netMinor, vatMinor, totalMinor }, ],
* grandTotalNet: Number (minor units),
* grandTotalVat: Number (minor units),
* grandTotal: Number (minor units),
* cancelledCount: Number,
* currency: String,
* period: { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' },
* }
*
* Counterpart renderers (renderTaxReportPdf / renderTaxReportCsv)
* land in commit 3 alongside the routes keeping the service pure
* data-shaping for this commit.
*/
const { db, withRetry } = require('../database/db');
const pdfService = require('./pdfService');
const businessProfileService = require('./businessProfileService');
const { getAppSetting } = require('../utils/appSettings');
const { t } = require('./pdf-i18n');
const { formatMinor, formatDate } = pdfService._internal;
// Rows we WANT to surface in the tax report. `cancelled` is included
// for audit visibility; the totals math filters it out separately.
const REPORTABLE_STATUSES = ['sent', 'paid', 'overdue', 'pending_delivery', 'cancelled'];
// D.2 — `ensureInt` consolidated into utils/numericHelpers.
const { ensureInt } = require('../utils/numericHelpers');
function ensureRate(v) {
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* Compose the customer label we show in the table. Prefers company
* name (most invoices in this workflow are B2B), falls back to
* "First Last", then display_name, then email. Mirrors how the bills
* list page picks a label so the two views feel consistent.
*/
function buildCustomerLabel(row) {
if (row.customer_company_name && String(row.customer_company_name).trim()) {
return String(row.customer_company_name).trim();
}
const first = row.customer_first_name ? String(row.customer_first_name).trim() : '';
const last = row.customer_last_name ? String(row.customer_last_name).trim() : '';
const fullName = `${first} ${last}`.trim();
if (fullName) return fullName;
if (row.customer_display_name) return String(row.customer_display_name).trim();
if (row.customer_email) return String(row.customer_email).trim();
return '';
}
/**
* Split a late-fee gross amount into (net, vat) components using the
* invoice's own VAT rate. Rounding direction matches how we render
* money throughout the system: half-to-even on the net portion,
* remainder lands in VAT so net + vat = grossInput exactly.
*
* grossUpLateFee(2500, 7.7) { net: 2321, vat: 179 } // 25.00 → 23.21 + 1.79
* grossUpLateFee(2500, 0) { net: 2500, vat: 0 } // no VAT, fee is pure net
*/
function grossUpLateFee(grossMinor, vatRatePercent) {
const fee = ensureInt(grossMinor);
if (fee <= 0) return { net: 0, vat: 0 };
const rate = ensureRate(vatRatePercent);
if (rate <= 0) return { net: fee, vat: 0 };
const net = Math.round(fee / (1 + rate / 100));
const vat = fee - net;
return { net, vat };
}
/**
* Apply the late-fee gross-up to a raw DB row and return the values
* we'll show + sum in the report. Net + VAT are the stored amounts
* PLUS the late-fee components; total stays at `total_amount_minor`
* (already includes the late fee).
*/
function computeReportedAmounts(row) {
const baseNet = ensureInt(row.net_amount_minor);
const baseVat = ensureInt(row.vat_amount_minor);
const total = ensureInt(row.total_amount_minor);
const { net: lateNet, vat: lateVat } = grossUpLateFee(row.late_fee_amount_minor, row.vat_rate);
return {
netMinor: baseNet + lateNet,
vatMinor: baseVat + lateVat,
totalMinor: total,
};
}
/**
* Resolve which replacement invoice (if any) was issued for each
* cancelled row. Used for the "Bezug → R-2026-0043" badge in the UI
* and PDF. Single batched query, no N+1.
*/
async function loadReplacementsMap(cancelledIds) {
if (!cancelledIds.length) return new Map();
const successors = await db('invoices')
.whereIn('replaces_invoice_id', cancelledIds)
.select('replaces_invoice_id', 'invoice_number');
const map = new Map();
for (const s of successors) {
map.set(s.replaces_invoice_id, s.invoice_number);
}
return map;
}
/**
* Aggregate Skonto state per invoice from `invoice_payment_log`
* (migration 126). Returns Map<invoice_id, { applied, amountMinor }>.
* An invoice is considered Skonto-applied if ANY of its payment-log
* rows carries the flag admins occasionally split the discounted
* total across multiple rows (e.g. retainer + final).
*
* Single batched query, no N+1. Empty map when the input list is
* empty so the main path can skip the lookup entirely on empty
* periods.
*/
async function loadSkontoMap(invoiceIds) {
if (!invoiceIds.length) return new Map();
const rows = await db('invoice_payment_log')
.whereIn('invoice_id', invoiceIds)
.select('invoice_id', 'skonto_applied', 'skonto_amount_minor');
const map = new Map();
for (const r of rows) {
const flag = r.skonto_applied === true || r.skonto_applied === 1;
const amt = Number(r.skonto_amount_minor || 0);
const cur = map.get(r.invoice_id) || { applied: false, amountMinor: 0 };
if (flag) cur.applied = true;
cur.amountMinor += amt;
map.set(r.invoice_id, cur);
}
return map;
}
/**
* The main entry point.
*
* getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' })
*
* `from` and `to` are inclusive ISO dates (YYYY-MM-DD). `currency` is
* required and must match `invoices.currency` exactly mixing
* currencies in one report is unsound for tax filing, so the API
* forces a single-currency view.
*/
async function getTaxReport({ from, to, currency } = {}) {
if (!from || !to) {
throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)');
}
if (!currency || typeof currency !== 'string') {
throw new Error('getTaxReport: `currency` is required');
}
const cur = currency.toUpperCase();
return await withRetry(async () => {
const dbRows = await db('invoices')
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
.leftJoin('events', 'invoices.event_id', 'events.id')
.whereBetween('invoices.issue_date', [from, to])
.where('invoices.currency', cur)
.whereIn('invoices.status', REPORTABLE_STATUSES)
.orderBy('invoices.invoice_number', 'asc')
.select(
'invoices.id',
'invoices.invoice_number',
'invoices.issue_date',
'invoices.currency',
'invoices.status',
'invoices.kind',
'invoices.vat_rate',
'invoices.net_amount_minor',
'invoices.vat_amount_minor',
'invoices.total_amount_minor',
'invoices.late_fee_amount_minor',
'invoices.replaces_invoice_id',
'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',
// Prefer the invoice's inline snapshot (migration 123) so
// renames on the events table don't retroactively change
// historical tax reports; fall back to events.event_name for
// legacy rows where the snapshot is still null.
db.raw('COALESCE(invoices.event_name, events.event_name) AS event_name'),
);
// Find replacement invoice numbers for any cancelled rows so the
// UI can render "Bezug → R-XXXX" without an extra round-trip.
const cancelledIds = dbRows.filter((r) => r.status === 'cancelled').map((r) => r.id);
const replacedByMap = await loadReplacementsMap(cancelledIds);
// Skonto aggregate (migration 126). One invoice can have multiple
// payment-log rows (partial → top-up → top-up → final); we surface
// the row as "paid with Skonto" if ANY of its log rows carries the
// flag, and sum the discount across all such rows. Done as a
// separate query so the main SELECT doesn't need a GROUP BY (which
// would force every selected column into the GROUP under strict
// Postgres semantics).
const skontoByInvoiceId = await loadSkontoMap(dbRows.map((r) => r.id));
// Bucket totals by VAT rate. Use a string key so 7.7 and 7.70
// collapse to the same bucket regardless of how the DB rounds.
const byRate = new Map();
let grandTotalNet = 0;
let grandTotalVat = 0;
let grandTotal = 0;
let cancelledCount = 0;
const rows = dbRows.map((r) => {
const reported = computeReportedAmounts(r);
const isCancelled = r.status === 'cancelled';
if (isCancelled) {
cancelledCount += 1;
} else {
grandTotalNet += reported.netMinor;
grandTotalVat += reported.vatMinor;
grandTotal += reported.totalMinor;
const rateKey = String(ensureRate(r.vat_rate).toFixed(2));
const bucket = byRate.get(rateKey) || {
vatRate: ensureRate(r.vat_rate),
netMinor: 0, vatMinor: 0, totalMinor: 0,
};
bucket.netMinor += reported.netMinor;
bucket.vatMinor += reported.vatMinor;
bucket.totalMinor += reported.totalMinor;
byRate.set(rateKey, bucket);
}
const skonto = skontoByInvoiceId.get(r.id) || { applied: false, amountMinor: 0 };
return {
id: r.id,
invoiceNumber: r.invoice_number,
issueDate: r.issue_date,
currency: r.currency,
status: r.status,
// kind + isReissue drive the lineage badges in the tax-tab
// table (parity with the admin invoices list). isCancelled
// already gates the "Cancelled" badge; isReissue gates a
// "Reissue" badge on invoices created via Cancel & reissue.
kind: r.kind || 'invoice',
isCancelled,
isReissue: !isCancelled && r.replaces_invoice_id != null,
replacedByInvoiceNumber: isCancelled ? (replacedByMap.get(r.id) || null) : null,
vatRate: ensureRate(r.vat_rate),
customerLabel: buildCustomerLabel(r),
eventName: r.event_name || '',
netMinor: reported.netMinor,
vatMinor: reported.vatMinor,
totalMinor: reported.totalMinor,
// Skonto aggregate (migration 126). `skontoApplied` flags
// any row in this invoice's payment log as Skonto-applied;
// `skontoAmountMinor` is the summed discount across all such
// rows. Both surfaced so the report consumer (UI / PDF / CSV)
// can render the column without re-querying the log.
skontoApplied: skonto.applied,
skontoAmountMinor: skonto.amountMinor,
};
});
const totalsByVatRate = Array.from(byRate.values()).sort((a, b) => a.vatRate - b.vatRate);
return {
rows,
totalsByVatRate,
grandTotalNet,
grandTotalVat,
grandTotal,
cancelledCount,
currency: cur,
period: { from, to },
};
});
}
// ---------------------------------------------------------------------
// PDF + CSV renderers
// ---------------------------------------------------------------------
/**
* Pull the issuer block + date format that the renderers need. Mirrors
* the slice that invoiceService.buildInvoiceRenderContext builds for
* the regular invoice/quote PDFs so the letterhead looks identical.
*/
async function loadRenderContext(locale) {
const { profile } = await businessProfileService.getProfile();
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 renderer default */ }
const issuer = profile ? {
companyName: profile.company_name,
addressLine1: profile.address_line1,
addressLine2: profile.address_line2,
postalCode: profile.postal_code,
city: profile.city,
state: profile.state,
countryCode: profile.country_code,
countryName: profile.country_name || null,
phone: profile.phone, mobile: profile.mobile, email: profile.email, website: profile.website,
footerLine: profile.footer_line,
vatId: profile.vat_id,
logoPath: profile.logo_path,
pdfFontTtfPath: profile.pdf_font_ttf_path,
pdfFontFamily: profile.pdf_font_family || null,
showLogo: profile.pdf_show_logo == null ? true
: (profile.pdf_show_logo === true || profile.pdf_show_logo === 1 || profile.pdf_show_logo === '1'),
showCompanyName: profile.pdf_show_company_name == null ? true
: (profile.pdf_show_company_name === true || profile.pdf_show_company_name === 1 || profile.pdf_show_company_name === '1'),
logoHeight: profile.pdf_logo_height == null ? 56 : Number(profile.pdf_logo_height),
companyNameInline: profile.pdf_company_name_inline === true || profile.pdf_company_name_inline === 1 || profile.pdf_company_name_inline === '1',
// Folding marks would clutter a tax-report (no envelope window in
// play); always suppress regardless of the profile setting.
foldingMarks: 'none',
} : {};
return { issuer, dateFormat, locale: locale || profile?.default_locale || 'de' };
}
// Page layout for the tax-report table. Sized for A4 landscape (762pt
// content width). Sums to ~759 leaving ~3pt slack for the right margin.
//
// "Status" column lives at the far right so the cancelled marker
// doesn't crowd the invoice number. The invoice column itself stays
// uncluttered with just "R-2026-0001" — easier to scan for an
// auditor looking at the sequence.
const TAX_TABLE_COLS = [
{ key: 'idx', labelKey: 'tax_col_no', width: 26, align: 'right' },
{ key: 'date', labelKey: 'tax_col_date', width: 60, align: 'left' },
{ key: 'invoice', labelKey: 'tax_col_invoice', width: 100, align: 'left' },
{ key: 'customer', labelKey: 'tax_col_customer', width: 132, align: 'left' },
{ key: 'event', labelKey: 'tax_col_event', width: 95, align: 'left' },
{ key: 'vatRate', labelKey: 'tax_col_vat_rate', width: 42, align: 'right' },
{ key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' },
{ key: 'vat', labelKey: 'tax_col_vat', width: 60, align: 'right' },
{ key: 'total', labelKey: 'tax_col_total', width: 80, align: 'right' },
// Skonto column (migration 126) — blank for non-Skonto rows so the
// column reads quietly until it has data. Shrunk neighbouring text
// columns slightly to make space without going over the landscape
// content width.
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 56, align: 'right' },
{ key: 'status', labelKey: 'tax_col_status', width: 58, align: 'left' },
];
function colX(leftMargin, index) {
let x = leftMargin;
for (let i = 0; i < index; i += 1) x += TAX_TABLE_COLS[i].width;
return x;
}
function drawTaxTableHeader(doc, leftMargin, y, locale, fonts) {
doc.font(fonts.bold).fontSize(8.5).fillColor('#000');
for (let i = 0; i < TAX_TABLE_COLS.length; i += 1) {
const col = TAX_TABLE_COLS[i];
doc.text(t(locale, col.labelKey), colX(leftMargin, i) + 2, y, {
width: col.width - 4, align: col.align,
});
}
const headerBottom = y + 14;
doc.moveTo(leftMargin, headerBottom)
.lineTo(leftMargin + TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0), headerBottom)
.lineWidth(0.6).strokeColor('#000').stroke();
return headerBottom + 4;
}
function formatVatRate(rate, locale) {
// 7.7 → "7.7 %" in en, "7,7 %" in de. Two decimals stripped for
// tidiness when zero (8.10 → "8.1 %").
const n = Number(rate || 0);
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB';
const formatted = new Intl.NumberFormat(intlLocale, {
minimumFractionDigits: 0, maximumFractionDigits: 2,
}).format(n);
return `${formatted} %`;
}
function rowCellValues(row, idx, locale, dateFormat) {
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB';
return {
idx: String(idx),
date: formatDate(row.issueDate, dateFormat),
invoice: row.invoiceNumber, // no inline "(Cancelled)" — keep the column tidy; status is its own column
customer: row.customerLabel || '',
event: row.eventName || '',
vatRate: formatVatRate(row.vatRate, locale),
net: formatMinor(row.netMinor, row.currency, intlLocale),
vat: formatMinor(row.vatMinor, row.currency, intlLocale),
total: formatMinor(row.totalMinor, row.currency, intlLocale),
skonto: row.skontoApplied
? formatMinor(row.skontoAmountMinor, row.currency, intlLocale)
: '',
status: row.isCancelled ? t(locale, 'tax_status_cancelled') : '',
};
}
/**
* Render the tax report as a PDF buffer.
*
* renderTaxReportPdf({ from, to, currency, locale }) Promise<Buffer>
*
* Currency is required and used to scope the data (same contract as
* getTaxReport). Locale defaults to the business profile's default.
*/
async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
const report = await getTaxReport({ from, to, currency });
const renderCtx = await loadRenderContext(locale);
const useLocale = renderCtx.locale;
const intlLocale = useLocale === 'de' ? 'de-CH' : 'en-GB';
const { doc, page, fonts } = pdfService.createBaseDocument({
orientation: 'landscape',
issuer: renderCtx.issuer,
info: {
Title: `${t(useLocale, 'tax_title')} ${report.period.from}${report.period.to}`,
Author: renderCtx.issuer.companyName || 'picpeak',
},
});
return await new Promise((resolve, reject) => {
try {
const chunks = [];
doc.on('data', (c) => chunks.push(c));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const leftMargin = page.marginLeft;
// Issuer block: top-right, same width pattern as the existing
// invoice/quote letterhead (180pt) so the branding feels
// consistent across all admin-facing PDFs.
const issuerWidth = 180;
const issuerX = page.width - page.marginRight - issuerWidth;
const issuerY = page.marginTop + 4;
const issuerEndY = pdfService.drawIssuerBlock(
doc, renderCtx.issuer, issuerX, issuerY, issuerWidth, useLocale
);
// Title block on the left.
doc.font(fonts.bold).fontSize(18).fillColor('#000')
.text(t(useLocale, 'tax_title'), leftMargin, page.marginTop + 4, {
width: page.contentWidth - issuerWidth - 20, align: 'left',
});
doc.font(fonts.body).fontSize(10).fillColor('#333');
const periodLine = `${t(useLocale, 'tax_period')}: ${formatDate(report.period.from, renderCtx.dateFormat)} ${formatDate(report.period.to, renderCtx.dateFormat)}`;
doc.text(periodLine, leftMargin, page.marginTop + 30, {
width: page.contentWidth - issuerWidth - 20, align: 'left',
});
doc.text(`${t(useLocale, 'tax_currency')}: ${report.currency}`,
leftMargin, page.marginTop + 46, {
width: page.contentWidth - issuerWidth - 20, align: 'left',
});
// Table starts below whichever block (issuer or title) ends lower.
let y = Math.max(issuerEndY, page.marginTop + 70) + 14;
y = drawTaxTableHeader(doc, leftMargin, y, useLocale, fonts);
doc.fontSize(8.5);
const tableBottomLimit = page.height - page.marginBottom - 110; // leave room for totals
const tableWidth = TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0);
if (report.rows.length === 0) {
doc.font(fonts.body).fontSize(10).fillColor('#555')
.text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, {
width: tableWidth, align: 'center',
});
y += 24;
}
// Row height is now DYNAMIC — computed per row as the max
// rendered height across every cell at its column width. This
// means a cell that wraps to two lines (long customer label,
// multi-line event name, "Storniert" tag in a narrow status
// column) makes the whole row taller instead of overlapping
// the row below. The minimum keeps tight rows readable.
const ROW_MIN_HEIGHT = 14;
const ROW_VERTICAL_PADDING = 4; // space between text and the separator line
const safeStr = (v) => (v == null ? '' : String(v));
// Measure how tall a value would render in the given column.
// Numeric / aligned cells use `lineBreak: false` so they never
// wrap (they're either ints or money strings whose width we
// budget for) — only text cells (customer, event, invoice,
// status) opt into natural wrapping.
const isWrappable = (col) => ['invoice', 'customer', 'event', 'status'].includes(col.key);
const measureCellHeight = (value, col) => {
const s = safeStr(value);
if (!s) return 0;
const opts = isWrappable(col)
? { width: col.width - 4, align: col.align }
: { width: col.width - 4, align: col.align, lineBreak: false };
// `doc.heightOfString` reads the current font + fontSize, so
// we set the body font + 8.5pt before each row's measurement
// pass and the values stay consistent with the actual draw.
return doc.heightOfString(s, opts);
};
for (let i = 0; i < report.rows.length; i += 1) {
const row = report.rows[i];
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat);
// Set the font BEFORE measuring so heightOfString reads the
// exact rendering state we'll use for doc.text below.
doc.font(fonts.body).fontSize(8.5);
let textHeight = ROW_MIN_HEIGHT - ROW_VERTICAL_PADDING;
for (const col of TAX_TABLE_COLS) {
const h = measureCellHeight(cells[col.key], col);
if (h > textHeight) textHeight = h;
}
const rowH = Math.ceil(textHeight) + ROW_VERTICAL_PADDING;
// Page break check uses the actual row height we're about to
// draw, not the old hard-coded constant — long rows can't
// sneak past the bottom margin. Pass margins explicitly so the
// new page inherits the same 40pt frame as page 1 — without
// this, PDFKit's addPage falls back to its 72pt default and
// the footer-Y math (`page.height - page.marginBottom - 12`)
// ends up positioned for a margin the page doesn't actually
// have, which is what made the page-number footer drift onto
// the wrong row of subsequent pages.
if (y + rowH > tableBottomLimit) {
doc.addPage({
size: 'A4', layout: 'landscape',
margins: {
top: page.marginTop, bottom: page.marginBottom,
left: page.marginLeft, right: page.marginRight,
},
});
y = page.marginTop;
y = drawTaxTableHeader(doc, leftMargin, y, useLocale, fonts);
doc.font(fonts.body).fontSize(8.5);
}
doc.fillColor(row.isCancelled ? '#888' : '#000');
for (let c = 0; c < TAX_TABLE_COLS.length; c += 1) {
const col = TAX_TABLE_COLS[c];
const opts = isWrappable(col)
? { width: col.width - 4, align: col.align }
: { width: col.width - 4, align: col.align, lineBreak: false };
doc.text(safeStr(cells[col.key]), colX(leftMargin, c) + 2, y, opts);
}
// Light separator under each row, drawn at the dynamic
// bottom edge — not at a fixed offset.
doc.moveTo(leftMargin, y + rowH - 1)
.lineTo(leftMargin + tableWidth, y + rowH - 1)
.lineWidth(0.3).strokeColor('#e0e0e0').stroke();
y += rowH;
}
// Totals block. Lives in the right half of the page so it
// doesn't fight with the cancelled footnote on the left.
//
// Estimate the totals block height up-front: header (16) +
// 13pt per VAT bucket row + divider (8) + three grand-total
// rows (39) + a 12pt cushion for the footer below. If that
// doesn't fit on the current page, force a new page now —
// otherwise PDFKit auto-paginates mid-totals, creating phantom
// pages whose footer ends up at unexpected Y positions on the
// subsequent bufferedPageRange loop.
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12;
const footerReserve = 24; // 12 above + 12 of page-number text room
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
doc.addPage({
size: 'A4', layout: 'landscape',
margins: {
top: page.marginTop, bottom: page.marginBottom,
left: page.marginLeft, right: page.marginRight,
},
});
y = page.marginTop;
}
const totalsTop = y + 12;
const totalsBoxWidth = 360;
const totalsX = page.width - page.marginRight - totalsBoxWidth;
doc.font(fonts.bold).fontSize(10).fillColor('#000')
.text(t(useLocale, 'tax_totals_by_rate'), totalsX, totalsTop, {
width: totalsBoxWidth, align: 'left',
});
let ty = totalsTop + 16;
doc.font(fonts.body).fontSize(9);
for (const bucket of report.totalsByVatRate) {
const labelLeft = `${formatVatRate(bucket.vatRate, useLocale)}`;
doc.text(labelLeft, totalsX, ty, { width: 80, align: 'left' });
doc.text(formatMinor(bucket.netMinor, report.currency, intlLocale),
totalsX + 80, ty, { width: 90, align: 'right' });
doc.text(formatMinor(bucket.vatMinor, report.currency, intlLocale),
totalsX + 175, ty, { width: 90, align: 'right' });
doc.text(formatMinor(bucket.totalMinor, report.currency, intlLocale),
totalsX + 270, ty, { width: 90, align: 'right' });
ty += 13;
}
// Divider above grand totals.
doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2)
.lineWidth(0.6).strokeColor('#000').stroke();
ty += 6;
doc.font(fonts.bold);
doc.text(t(useLocale, 'tax_grand_total_net'), totalsX, ty, { width: 170, align: 'left' });
doc.text(formatMinor(report.grandTotalNet, report.currency, intlLocale),
totalsX + 175, ty, { width: 90, align: 'right' });
ty += 13;
doc.text(t(useLocale, 'tax_grand_total_vat'), totalsX, ty, { width: 170, align: 'left' });
doc.text(formatMinor(report.grandTotalVat, report.currency, intlLocale),
totalsX + 175, ty, { width: 90, align: 'right' });
ty += 13;
doc.text(t(useLocale, 'tax_grand_total_gross'), totalsX, ty, { width: 170, align: 'left' });
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale),
totalsX + 270, ty, { width: 90, align: 'right' });
// Cancelled footnote (bottom-left). Only when there are any.
if (report.cancelledCount > 0) {
doc.font(fonts.body).fontSize(8).fillColor('#555')
.text(
t(useLocale, 'tax_cancelled_footnote', { count: report.cancelledCount }),
leftMargin, totalsTop,
{ width: page.contentWidth - totalsBoxWidth - 20, align: 'left' }
);
}
// Page x of N footer (bottom-right). Done after all body
// rendering via PDFKit's bufferPages so we know the final count
// before stamping. Resets fill colour + font so the stamp looks
// identical on every page regardless of where rendering ended.
const range = doc.bufferedPageRange();
for (let pageIdx = 0; pageIdx < range.count; pageIdx += 1) {
doc.switchToPage(range.start + pageIdx);
const pageLabel = t(useLocale, 'page_of', {
current: pageIdx + 1, total: range.count,
});
// Position the page label just ABOVE the bottom margin —
// keeping the baseline inside the content area prevents
// PDFKit's layout engine from auto-paginating when the
// 8pt-tall text wouldn't fit between the requested y and
// the bottom of the page. The previous +6 offset pushed the
// y into the margin, which made PDFKit add a fresh blank
// page for every label, doubling the page count. Mirror the
// safe `- 12` offset used by the invoice/quote renderer in
// pdfService.renderDocument().
doc.font(fonts.body).fontSize(8).fillColor('#888')
.text(pageLabel,
page.width - page.marginRight - 160,
page.height - page.marginBottom - 12,
{ width: 160, align: 'right', lineBreak: false });
}
doc.end();
} catch (err) {
reject(err);
}
});
}
/**
* Render the tax report as a CSV string. Header row in the admin's
* locale; numbers use a dot decimal separator (universal for CSV
* import into Excel/Numbers/accounting software) so we don't have to
* thread locale-specific formatting into the export.
*
* renderTaxReportCsv({ from, to, currency, locale })
* Promise<{ content, filename, contentType }>
*/
async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
const report = await getTaxReport({ from, to, currency });
const useLocale = locale || 'en';
const headers = [
t(useLocale, 'tax_col_no'),
t(useLocale, 'tax_col_date'),
t(useLocale, 'tax_col_invoice'),
t(useLocale, 'tax_col_customer'),
t(useLocale, 'tax_col_event'),
t(useLocale, 'tax_col_vat_rate'),
`${t(useLocale, 'tax_col_net')} (${report.currency})`,
`${t(useLocale, 'tax_col_vat')} (${report.currency})`,
`${t(useLocale, 'tax_col_total')} (${report.currency})`,
t(useLocale, 'tax_status_cancelled'),
// Migration 126 — Skonto export. `tax_col_skonto` is the discount
// amount in major units; admin's accountant reconciles the line.
`${t(useLocale, 'tax_col_skonto')} (${report.currency})`,
];
const escape = (cell) => {
const s = cell === null || cell === undefined ? '' : String(cell);
// RFC 4180: wrap in quotes when the value contains comma, quote,
// or newline. We always wrap, simpler + bulletproof for Excel.
return `"${s.replace(/"/g, '""')}"`;
};
const minorToDotDecimal = (m) => ((Number(m) || 0) / 100).toFixed(2);
const lines = [headers.map(escape).join(',')];
report.rows.forEach((row, i) => {
lines.push([
i + 1,
row.issueDate,
row.invoiceNumber,
row.customerLabel,
row.eventName,
Number(row.vatRate).toFixed(2),
minorToDotDecimal(row.netMinor),
minorToDotDecimal(row.vatMinor),
minorToDotDecimal(row.totalMinor),
row.isCancelled ? '1' : '0',
row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '',
].map(escape).join(','));
});
// Trailing totals row: blank cells + grand totals at the end so
// the column alignment matches the data rows when opened in Excel.
lines.push('');
lines.push([
'', '', '',
t(useLocale, 'tax_grand_total_gross'),
'', '',
minorToDotDecimal(report.grandTotalNet),
minorToDotDecimal(report.grandTotalVat),
minorToDotDecimal(report.grandTotal),
'', '',
].map(escape).join(','));
const content = lines.join('\r\n') + '\r\n';
const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`;
return { content, filename, contentType: 'text/csv; charset=utf-8' };
}
module.exports = {
getTaxReport,
renderTaxReportPdf,
renderTaxReportCsv,
// Exposed for unit tests.
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate },
};
+38
View File
@@ -0,0 +1,38 @@
/**
* Small helper to read app_settings rows.
*
* The picpeak codebase has TWO settings services:
* - `src/services/settingsService.js` queries a `settings` table that
* doesn't actually exist on most deployments (legacy SQLite-era
* name). Calling getSetting() from there raises
* "relation \"settings\" does not exist" on Postgres.
* - The canonical store is `app_settings`, accessed inline by every
* other service (shareLinkService, customerAccountsService,
* authSecurity, dateFormatter, ).
*
* The CRM services use this helper instead of settingsService so the
* crm_* keys seeded by migration 102 are actually readable.
*/
const { db } = require('../database/db');
/**
* Read a single app_settings row by key. Returns the parsed value or
* `defaultValue` when the key doesn't exist.
*
* `setting_value` is always JSON-stringified at write time
* (see migration 102 + the /admin/settings/general route), so we
* JSON.parse on the way out. Falls back to the raw string on
* malformed JSON so legacy text values still work.
*/
async function getAppSetting(key, defaultValue = null) {
const row = await db('app_settings').where({ setting_key: key }).first();
if (!row || row.setting_value == null) return defaultValue;
try {
return JSON.parse(row.setting_value);
} catch (_) {
return row.setting_value;
}
}
module.exports = { getAppSetting };
+35
View File
@@ -0,0 +1,35 @@
/**
* clientIp resolve the originating client IP for audit-trail
* recording (contract signing, quote responses, payment-check
* actions, etc.).
*
* **Why this helper exists:** the public-facing routes used to read
* `req.headers['x-forwarded-for']` directly and take the first
* comma-segment as the source IP. That bypasses Express's `trust
* proxy` safety net entirely — any direct (non-proxied) POST to the
* signing endpoint can spoof the audit IP by setting the header,
* which defeats the legal-evidence promise of the contract feature.
*
* **Correct path:** trust ONLY `req.ip`, and rely on
* `app.set('trust proxy', ...)` in `server.js` to populate it
* correctly. Express's trust-proxy machinery is the only thing that
* knows which upstream hops are trustworthy. The default in
* `server.js` (`'loopback, linklocal, uniquelocal'`) is correct for
* picpeak's standard deployment (nginx in front, Docker network);
* operators with unusual topologies override via `TRUST_PROXY` env.
*
* **Returns:** the resolved IPv4/IPv6 string, or `null` when Express
* couldn't determine one (very rare happens with abusive raw
* sockets / malformed connections).
*
* **Storage:** call sites still gate persistence on a separate
* privacy setting (e.g. `crm_contracts_store_ip`). This helper only
* concerns itself with *which* IP to record, not *whether* to
* record one.
*/
function clientIpForAudit(req) {
if (!req) return null;
return req.ip || null;
}
module.exports = { clientIpForAudit };
+22 -1
View File
@@ -97,6 +97,27 @@ async function formatDate(date, language = 'en') {
}
}
/**
* Sync DD.MM.YYYY formatter used by quote / invoice / contract render
* contexts. Unlike `formatDate` above, this never consults app_settings
* it's intended for fixed-format use inside templates already rendered
* for a specific document type. Three services used to ship a local
* copy each; this is the single source.
*
* - falsy input empty string (template's {{#if ...}} block hides)
* - invalid date the original value coerced to String (defensive
* passthrough; matches the prior behaviour of the three local copies)
*/
function formatShortDate(value) {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
return `${dd}.${mm}.${d.getFullYear()}`;
}
module.exports = {
formatDate
formatDate,
formatShortDate,
};
+93
View File
@@ -0,0 +1,93 @@
/**
* documentSequences atomic gap-free sequence generator for CRM
* document numbers (invoices, quotes, contracts, future doc kinds).
*
* **Contract**: `claimNextSequence(kind, year, [trx])` returns the
* next integer in the (kind, year) series. Atomic against concurrent
* callers: two simultaneous claims for the same row return strictly
* increasing values, no collisions, no gaps.
*
* **How the atomicity works**
*
* Postgres: a single `UPDATE … SET current_value = current_value + 1
* WHERE kind = ? AND year = ? RETURNING current_value` holds a row
* lock for the duration of the statement; concurrent callers
* serialize on the lock.
*
* SQLite: knex does not expose `BEGIN IMMEDIATE` declaratively, but
* SQLite's default journal mode (or WAL) gives us per-row serialization
* via the transaction. We wrap the UPDATE + re-SELECT in a transaction
* which acquires the write lock; concurrent transactions queue.
*
* **First-claim path** (no row yet for the (kind, year))
*
* Migration 132 seeded rows for every existing year via MAX(...)
* backfill. New years need an INSERT on first use. We do an
* INSERT-OR-IGNORE then UPDATE RETURNING. Both steps are inside
* the same transaction so the year row is guaranteed to exist when
* the UPDATE fires.
*/
const { db } = require('../database/db');
const { AppError } = require('./errors');
/**
* Claim the next sequence value for (kind, year). Returns the new
* integer. Throws AppError on DB failure; caller composes the
* formatted document number from this integer via formatNumberInTemplate.
*
* @param {string} kind 'invoice' | 'quote' | 'contract' | ...
* @param {number} year 4-digit year
* @param {object} [trx] optional knex transaction. When supplied the
* claim joins the caller's transaction so the
* sequence increment and the row INSERT can
* commit-or-roll-back together. Otherwise we
* run our own micro-transaction.
*/
async function claimNextSequence(kind, year, trx) {
if (!kind || typeof kind !== 'string') {
throw new AppError('claimNextSequence: kind required', 500);
}
const yr = parseInt(year, 10);
if (!Number.isFinite(yr)) {
throw new AppError('claimNextSequence: invalid year', 500);
}
const exec = async (q) => {
// Step 1: ensure the (kind, year) row exists. INSERT...ON CONFLICT
// DO NOTHING is the Postgres-native form; SQLite supports the same
// syntax (3.24+). knex's `onConflict('...').ignore()` paves over
// the differences.
await q('document_sequences')
.insert({
kind, year: yr, current_value: 0,
created_at: new Date(), updated_at: new Date(),
})
.onConflict(['kind', 'year']).ignore();
// Step 2: atomic claim. We do UPDATE … (no RETURNING because
// knex's returning() is uneven across drivers) then re-SELECT.
// The transaction wrapper (or the caller's trx) keeps the two
// statements on the same row lock, so concurrent claimers
// serialize.
await q('document_sequences')
.where({ kind, year: yr })
.increment('current_value', 1)
.update({ updated_at: new Date() });
const row = await q('document_sequences')
.where({ kind, year: yr })
.select('current_value')
.first();
if (!row) {
throw new AppError(`claimNextSequence: row vanished for ${kind}/${yr}`, 500);
}
return row.current_value;
};
if (trx) {
return await exec(trx);
}
return await db.transaction(async (innerTrx) => exec(innerTrx));
}
module.exports = { claimNextSequence };
+134
View File
@@ -0,0 +1,134 @@
/**
* IBAN validation per ISO 13616.
*
* Three checks:
* 1. Format 2 uppercase letters (country) + 2 digits (check) +
* alphanumeric BBAN.
* 2. Length each country fixes a total IBAN length. We accept any
* country whose ISO code we recognise; unknown country codes
* fall back to a generic 1534 char range (ISO 13616 caps every
* IBAN at 34 chars).
* 3. Mod-97 checksum rearrange the IBAN so the first four chars
* land at the end, expand letters to digits (A=10..Z=35), the
* result modulo 97 MUST equal 1. Catches single-digit typos and
* digit transpositions with high probability.
*
* The validator is pure (no IO, no DB, no network) and returns a
* structured result so callers can surface a precise reason to the
* user.
*
* What this does NOT do:
* - Confirm the bank itself exists (would require an external
* directory or a bank-routing API out of scope here).
* - Validate the BBAN's internal structure beyond length + charset
* (country-specific BBAN rules are not enforced).
*
* Usage:
* const { valid, normalized, reason } = validateIban(' ch 93 0076 2011 6238 5295 7 ');
* if (!valid) throw new Error(reason);
* // normalized === 'CH9300762011623852957'
*/
// IBAN length per ISO country code (ISO 13616, public registry).
// Anything not in this table falls through to the 1534 range check.
// Source: SWIFT IBAN Registry. Update when new countries are added.
const IBAN_LENGTHS = {
AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28,
BA: 20, BE: 16, BG: 22, BH: 22, BR: 29, BY: 28,
CH: 21, CR: 22, CY: 28, CZ: 24,
DE: 22, DK: 18, DO: 28,
EE: 20, EG: 29, ES: 24,
FI: 18, FO: 18, FR: 27,
GB: 22, GE: 22, GI: 23, GL: 18, GR: 27, GT: 28,
HR: 21, HU: 28,
IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27,
JO: 30,
KW: 30, KZ: 20,
LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25,
MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30,
NL: 18, NO: 15,
PK: 24, PL: 28, PS: 29, PT: 25,
QA: 29,
RO: 24, RS: 22,
SA: 24, SC: 31, SE: 24, SI: 19, SK: 24, SM: 27, ST: 25, SV: 28,
TL: 23, TN: 24, TR: 26,
UA: 29,
VA: 22, VG: 24,
XK: 20,
};
/**
* Rearrange + numerify the IBAN per ISO 13616 then take mod 97.
* The whole-string-as-BigInt approach is acceptable here: max IBAN
* length is 34 chars numerified length is at most ~68 digits.
* Native BigInt is plenty fast for one-off validation.
*/
function mod97(iban) {
const rearranged = iban.slice(4) + iban.slice(0, 4);
let expanded = '';
for (const ch of rearranged) {
if (ch >= '0' && ch <= '9') {
expanded += ch;
} else if (ch >= 'A' && ch <= 'Z') {
// A=10, B=11, ..., Z=35
expanded += String(ch.charCodeAt(0) - 55);
} else {
return -1; // invalid char — caller treats as failed checksum
}
}
// Standard chunked mod-97 to avoid BigInt allocation cost.
let remainder = 0;
for (const digit of expanded) {
remainder = (remainder * 10 + Number(digit)) % 97;
}
return remainder;
}
/**
* Normalise + validate an IBAN string.
*
* @param {unknown} input Raw user-typed value. Spaces and lowercase
* letters are tolerated and stripped/uppercased
* before checking.
* @returns {{
* valid: boolean,
* normalized: string, // empty when input wasn't a string
* reason?: 'EMPTY' // nothing useful supplied
* | 'FORMAT' // failed the structural regex
* | 'LENGTH' // wrong length for the country
* | 'CHECKSUM' // mod-97 didn't equal 1
* }}
*/
function validateIban(input) {
if (input == null) return { valid: false, normalized: '', reason: 'EMPTY' };
const raw = String(input).replace(/\s+/g, '').toUpperCase();
if (!raw) return { valid: false, normalized: '', reason: 'EMPTY' };
// ISO 13616: starts with 2 letters (country) + 2 digits (check) +
// 11..30 chars of alphanumeric BBAN. Total length 15..34.
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(raw)) {
return { valid: false, normalized: raw, reason: 'FORMAT' };
}
const country = raw.slice(0, 2);
const expectedLen = IBAN_LENGTHS[country];
if (expectedLen != null) {
if (raw.length !== expectedLen) {
return { valid: false, normalized: raw, reason: 'LENGTH' };
}
} else if (raw.length < 15 || raw.length > 34) {
return { valid: false, normalized: raw, reason: 'LENGTH' };
}
if (mod97(raw) !== 1) {
return { valid: false, normalized: raw, reason: 'CHECKSUM' };
}
return { valid: true, normalized: raw };
}
module.exports = {
validateIban,
// Exposed for unit tests.
_internal: { mod97, IBAN_LENGTHS },
};
+34
View File
@@ -0,0 +1,34 @@
/**
* Shared numeric coercion helpers used across the CRM services.
*
* Previously: 4 copies of `ensureInt` + 2 copies of `ensureNumber` lived
* across quoteService, invoiceService, contractService, and
* taxReportService. Each copy was identical apart from `Number.isFinite`
* vs `!Number.isNaN` converging on the same answer in practice
* because `parseInt`/`Number` never produce `Infinity` from string input.
*
* One canonical pair lives here so future numeric coercion concerns
* (e.g. BigInt safety, locale-aware decimals) are addressed in one place.
*/
/**
* Coerce a value to a non-NaN integer, defaulting to 0 on garbage.
* Matches the legacy `ensureInt` semantics across all four services.
*/
function ensureInt(value) {
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : 0;
}
/**
* Coerce a value to a finite Number, defaulting to `fallback` (0) on
* null/undefined/empty string/NaN. Matches the legacy `ensureNumber`
* shape used by quote + invoice line-item math.
*/
function ensureNumber(value, fallback = 0) {
if (value === null || value === undefined || value === '') return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
module.exports = { ensureInt, ensureNumber };
+79
View File
@@ -0,0 +1,79 @@
/**
* Build a consistent filesystem-safe filename for quote / invoice
* PDFs. Format:
*
* <docNumber>_<customerLabel>.pdf
*
* - docNumber: the invoice/quote number as printed
* - customerLabel: customer.company_name || full person name ||
* display_name || email-local-part || 'customer'
*
* Both segments are sanitised: spaces '-', non-ASCII letters
* preserved, slashes/colons/quotes stripped, length capped so the
* combined filename stays under the typical 255-byte filesystem
* limit (we cap each side at 80 chars, which is generous for both
* pieces).
*
* Used by:
* - Content-Disposition headers on every admin + customer PDF
* endpoint
* - The PDF's internal `Title` metadata (Chrome's PDF viewer
* uses this as the default name when saving from a blob URL,
* where Content-Disposition can't reach)
*/
function sanitiseSegment(input, maxLen = 80) {
if (!input) return '';
let s = String(input).trim();
// Replace OS-hostile characters with '-'.
s = s.replace(/[/\\:*?"<>|]+/g, '-');
// Collapse whitespace runs into a single '-'.
s = s.replace(/\s+/g, '-');
// Collapse repeat dashes.
s = s.replace(/-+/g, '-');
// Trim leading/trailing dashes + dots.
s = s.replace(/^[-.]+|[-.]+$/g, '');
if (s.length > maxLen) s = s.slice(0, maxLen);
return s;
}
/**
* Resolve a label representing the customer for the filename. Tries
* company name first (most useful for filing), then full person
* name, then display name, then the email's local part, finally
* 'customer' as a generic fallback.
*
* @param {object} customer customer_accounts row (snake_case)
* @returns {string} sanitised label segment
*/
function customerLabel(customer) {
if (!customer) return 'customer';
const company = (customer.company_name || '').trim();
if (company) return sanitiseSegment(company);
const fullName = [customer.first_name, customer.last_name]
.map((v) => (v || '').trim()).filter(Boolean).join(' ');
if (fullName) return sanitiseSegment(fullName);
const display = (customer.display_name || '').trim();
if (display) return sanitiseSegment(display);
const email = (customer.email || '').trim();
if (email) return sanitiseSegment(email.split('@')[0]);
return 'customer';
}
/**
* Build the final filename. Always ends with `.pdf`. When the
* document number is missing (e.g. preview of an unsaved row),
* substitutes a sensible fallback.
*
* @param {object} args
* - docNumber: 'R-2026-0001' / 'Q-2026-0042' / null for previews
* - customer: customer_accounts row
* - fallback: prefix when docNumber is null ('invoice-preview' etc.)
*/
function buildPdfFilename({ docNumber, customer, fallback = 'document' }) {
const numberSeg = sanitiseSegment(docNumber) || sanitiseSegment(fallback) || 'document';
const custSeg = customerLabel(customer);
return `${numberSeg}_${custSeg}.pdf`;
}
module.exports = { buildPdfFilename, sanitiseSegment, customerLabel };
+157
View File
@@ -0,0 +1,157 @@
/**
* publicTokenGuards shared validators for the public token tables
* (`contract_action_tokens`, `quote_action_tokens`). Centralises the
* checks that every public-facing route MUST run before doing work,
* so future routes can't accidentally skip a guard.
*
* What this enforces:
* 1. **Existence** 404 when the token doesn't match a row.
* 2. **Expiry** 410 when `expires_at` is in the past
* OR is NULL (defensive: NULL = expired,
* not "valid forever" historical bug).
* 3. **One-shot semantics** when `requireUnused: true`, 409 if
* `used_at` is already set. Prevents
* replay of leaked tokens on the upload
* path. The sign path historically allowed
* re-signing for in-browser flows; opt in
* per call site.
* 4. **Attempt throttling** non-existent tokens increment a per-IP
* counter; the IP is locked out for 15 min
* after 20 invalid attempts. Mitigates the
* token-prefix brute force route that
* standard rate-limiters don't catch
* (large token space, low miss rate per
* IP, but distributed crawlers add up).
*
* Returns the validated token row on success. Sends the appropriate
* HTTP response and returns `null` on failure the caller must check
* for null and `return` immediately.
*/
const { db } = require('../database/db');
const { clientIpForAudit } = require('./clientIp');
const logger = require('./logger');
// In-memory bad-attempt counter. Per-process; cleared on restart.
// Keyed by IP. Each entry: { count, firstAt }. We could persist this
// in app_settings or a dedicated table, but in-memory is simpler and
// good enough for the threat (distributed brute force is the only
// case where IP locking helps anyway, and that needs more than one
// IP to be effective).
const BAD_ATTEMPT_LIMIT = 20;
const BAD_ATTEMPT_WINDOW_MS = 15 * 60 * 1000;
const badAttempts = new Map();
function recordBadAttempt(ip) {
if (!ip) return;
const now = Date.now();
const entry = badAttempts.get(ip);
if (!entry || (now - entry.firstAt) > BAD_ATTEMPT_WINDOW_MS) {
badAttempts.set(ip, { count: 1, firstAt: now });
return;
}
entry.count += 1;
}
function isIpLocked(ip) {
if (!ip) return false;
const entry = badAttempts.get(ip);
if (!entry) return false;
if ((Date.now() - entry.firstAt) > BAD_ATTEMPT_WINDOW_MS) {
badAttempts.delete(ip);
return false;
}
return entry.count >= BAD_ATTEMPT_LIMIT;
}
/**
* Validate a public action token. Returns the token row on success,
* sends a response + returns null on failure.
*
* @param {object} req Express request (for IP)
* @param {object} res Express response (to send errors)
* @param {object} opts
* @param {string} opts.tableName 'contract_action_tokens' | 'quote_action_tokens'
* @param {string} opts.token 64-hex token string
* @param {boolean} [opts.requireUnused] refuse when used_at is set (default false)
*/
async function loadActionToken(req, res, opts) {
const { tableName, token, requireUnused = false } = opts;
const ip = clientIpForAudit(req);
if (isIpLocked(ip)) {
res.status(429).json({
error: 'Too many invalid token attempts. Try again in 15 minutes.',
code: 'TOKEN_LOOKUP_LOCKED',
});
return null;
}
const row = await db(tableName).where({ token }).first();
if (!row) {
recordBadAttempt(ip);
res.status(404).json({ error: 'Not found' });
return null;
}
// Defensive: NULL expires_at counts as expired. Historical bug —
// old seed rows could land without an expiry value, granting
// permanent unauthenticated access. We refuse rather than guess.
if (!row.expires_at) {
logger.warn('publicTokenGuards: token has NULL expires_at — refusing', {
tableName, tokenPrefix: token.slice(0, 12),
});
res.status(410).json({ error: 'This link has expired', code: 'TOKEN_NO_EXPIRY' });
return null;
}
if (new Date(row.expires_at).getTime() < Date.now()) {
res.status(410).json({ error: 'This link has expired', code: 'TOKEN_EXPIRED' });
return null;
}
if (requireUnused && row.used_at) {
res.status(409).json({
error: 'This link has already been used',
code: 'TOKEN_ALREADY_USED',
});
return null;
}
return row;
}
/**
* Pre-multer guard for upload routes. Runs the same validation as
* loadActionToken but DOES NOT mutate state it just rejects bad
* tokens before multer reads the request body and writes to disk.
* Without this, a captured/expired token can DoS disk by spamming
* uploads that get rejected post-write.
*
* Wired in as middleware before `multer.single(...)`.
*/
function preMulterTokenGuard(tableName) {
return async (req, res, next) => {
try {
const token = req.params.token;
if (!token || !/^[a-f0-9]{64}$/i.test(token)) {
return res.status(400).json({ error: 'Invalid token format' });
}
const row = await loadActionToken(req, res, { tableName, token, requireUnused: true });
if (!row) return; // loadActionToken already responded
// Attach for downstream handler — saves a duplicate DB lookup.
req.publicTokenRow = row;
next();
} catch (err) {
logger.error('preMulterTokenGuard: unexpected error', { err: err.message });
return res.status(500).json({ error: 'Internal error' });
}
};
}
module.exports = {
loadActionToken,
preMulterTokenGuard,
// Exported for tests + future routes that need the same lock
// surface (e.g. payment-check actions).
_internal: { recordBadAttempt, isIpLocked, badAttempts },
};
+193
View File
@@ -0,0 +1,193 @@
/**
* resolveLogoFile resolve a logo to an absolute existing file path.
*
* The CRM PDFs accept a logo from three different sources and each
* source may store the path in a different shape (absolute multer
* path, relative URL, bare filename, etc.). Rather than have the PDF
* renderer guess, we build an exhaustive candidate list HERE, return
* the first existing PNG/JPEG file, and log everything we tried when
* we come up empty.
*
* Why this lives in utils:
* - both invoiceService and quoteService need the same resolution
* - the renderer (pdfService) should NOT touch the DB or the
* filesystem-discovery rules; it just calls doc.image(path)
*
* Sources we accept (in priority order):
* 1. business_profile.logo_path explicit per-CRM logo
* 2. app_settings.branding_logo_path absolute multer path from
* Settings Branding (preferred already absolute)
* 3. app_settings.branding_logo_url URL path from the same
* branding upload (fallback for older installs)
*
* For each source we try multiple candidate disk paths:
* - The raw value as an absolute path (if absolute)
* - STORAGE_PATH joined with the value (stripped of leading "/")
* - STORAGE_PATH/uploads/logos/<basename>
* - STORAGE_PATH/branding/<basename>
* - CWD/storage joined with the value (last-ditch for older
* docker-compose configs that didn't set STORAGE_PATH)
*
* Format filter:
* PDFKit only natively decodes PNG + JPEG. SVG/WebP/GIF/TIFF files
* are silently skipped here (with a warn log) so the rest of the
* PDF still renders rather than crashing on an unsupported format.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { getStoragePath } = require('../config/storage');
const { getAppSetting } = require('./appSettings');
const logger = require('./logger');
const SUPPORTED_EXT = /\.(png|jpe?g)$/i;
// Formats PDFKit can't embed directly but `sharp` can rasterise into
// PNG for us. We transparently convert + cache.
const CONVERTIBLE_EXT = /\.(svg|webp|gif|tif|tiff|avif|heif|heic)$/i;
function generateCandidates(raw, storageRoot) {
const value = String(raw || '').trim();
if (!value) return [];
const stripped = value.replace(/^\/+/, '');
const baseName = path.basename(value);
// Build candidate set; dedup at the end so we don't stat the same
// file twice when the inputs overlap.
const candidates = [
path.isAbsolute(value) ? value : null,
path.join(storageRoot, stripped),
path.join(storageRoot, 'uploads', 'logos', baseName),
path.join(storageRoot, 'branding', baseName),
path.join(process.cwd(), 'storage', stripped),
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
path.join(process.cwd(), 'storage', 'branding', baseName),
].filter(Boolean);
return [...new Set(candidates)];
}
function pickExisting(candidates) {
for (const c of candidates) {
try {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
} catch (_) { /* ignore */ }
}
return null;
}
/**
* Rasterise a non-PNG/JPEG source into PNG so PDFKit can embed it.
* The output is cached under STORAGE_PATH/cache/logo-png/, keyed by
* the source path + mtime + size re-uploading the SVG invalidates
* the cache automatically without us having to clean up old entries.
*
* Returns the absolute cached PNG path on success, or null when
* `sharp` fails (corrupt SVG, unsupported feature inside the SVG,
* etc.). The caller logs + falls back to the name-only branch.
*/
async function rasteriseToPng(sourcePath, storageRoot) {
let sharp;
try {
sharp = require('sharp');
} catch (err) {
logger.warn('PDF logo rasterisation skipped — sharp not installed', { err: err.message });
return null;
}
try {
const stat = fs.statSync(sourcePath);
const cacheDir = path.join(storageRoot, 'cache', 'logo-png');
fs.mkdirSync(cacheDir, { recursive: true });
// Content-addressed cache: sha1(src path + mtime ns + size).
// Including mtime means re-uploading the source invalidates the
// cache entry naturally.
const key = crypto.createHash('sha1')
.update(`${sourcePath}|${stat.mtimeMs}|${stat.size}`)
.digest('hex');
const cachedPath = path.join(cacheDir, `${key}.png`);
if (fs.existsSync(cachedPath)) {
return cachedPath;
}
// density: 384 gives a crisp render even when the SVG embeds at
// a small intrinsic size (PDFKit's `fit` will downscale, never
// upscale). 512px wide is more than enough for a letterhead
// logo.
await sharp(sourcePath, { density: 384 })
.resize({ width: 512, withoutEnlargement: false })
.png()
.toFile(cachedPath);
logger.info('PDF logo rasterised to PNG', { source: sourcePath, cached: cachedPath });
return cachedPath;
} catch (err) {
logger.warn('PDF logo rasterisation failed', {
source: sourcePath, err: err.message,
});
return null;
}
}
/**
* @param {object} profile the business_profile row (or null)
* @returns {Promise<string|null>} absolute path to a usable PNG/JPEG, or null
*/
async function resolveLogoFile(profile) {
const storageRoot = getStoragePath();
const raws = [];
const profileLogoPath = (profile?.logo_path || '').toString().trim();
if (profileLogoPath) raws.push({ source: 'business_profile.logo_path', value: profileLogoPath });
try {
const brandingDisk = await getAppSetting('branding_logo_path');
if (brandingDisk && typeof brandingDisk === 'string' && brandingDisk.trim()) {
raws.push({ source: 'branding_logo_path', value: brandingDisk.trim() });
}
} catch (_) { /* ignore */ }
try {
const brandingUrl = await getAppSetting('branding_logo_url');
if (brandingUrl && typeof brandingUrl === 'string' && brandingUrl.trim()) {
raws.push({ source: 'branding_logo_url', value: brandingUrl.trim() });
}
} catch (_) { /* ignore */ }
if (raws.length === 0) return null;
for (const { source, value } of raws) {
const candidates = generateCandidates(value, storageRoot);
const found = pickExisting(candidates);
if (!found) continue;
if (SUPPORTED_EXT.test(found)) {
logger.info('Resolved PDF logo', { source, configured: value, resolved: found });
return found;
}
if (CONVERTIBLE_EXT.test(found)) {
// SVG / WebP / GIF / TIFF / AVIF — rasterise to PNG so PDFKit
// can embed it. Cached under STORAGE_PATH/cache/logo-png/ so
// repeated PDF renders don't re-encode the same source.
const rasterised = await rasteriseToPng(found, storageRoot);
if (rasterised) {
logger.info('Resolved PDF logo via rasterisation', {
source, configured: value, original: found, resolved: rasterised,
});
return rasterised;
}
logger.warn('PDF logo rasterisation produced no file; trying next source', {
source, configured: value, found,
});
continue;
}
// Unknown extension — try anyway, PDFKit may still accept it.
logger.warn('PDF logo has unusual extension; attempting to render as-is', {
source, configured: value, found,
});
return found;
}
logger.warn('PDF logo not found on disk after trying all sources', {
storageRoot,
sources: raws.map(({ source, value }) => ({
source, value, candidates: generateCandidates(value, storageRoot),
})),
});
return null;
}
module.exports = { resolveLogoFile };
+124
View File
@@ -0,0 +1,124 @@
/**
* safePath path-containment helpers for the contract / quote / invoice
* PDF surfaces.
*
* **Why this exists**
*
* The audit (#25, #31) flagged that several routes pipe `fs.createReadStream`
* on a path read directly from the DB (`contracts.pdf_path`,
* `contracts.signed_pdf_path`) and that `attachSignedPdfUpload` accepts
* a route-supplied filePath with no containment assertion. The
* defence-in-depth concern: if a path ever got into the DB pointing
* outside the legitimate storage roots (via a future migration bug,
* a hand-edited row, or a SQL-injection regression elsewhere), the
* stream would happily read /etc/passwd or any other readable file
* for the requesting admin.
*
* Today the DB paths are written by the service layer and never
* accept caller input directly, so the practical exposure is low
* but a 4-line containment check at the read boundary makes the
* invariant explicit and protects against future drift.
*
* **Approach**
*
* `assertPathInside(absoluteFilePath, allowedRoots)` resolves both
* sides to canonical absolute paths via `fs.realpathSync` and
* verifies the file path starts with one of the allowed root strings
* followed by a path separator (so /storage-evil/ doesn't pass when
* /storage/ is allowed). Throws `AppError 403` on violation.
*
* `realpathSync` resolves symlinks, defeating the obvious attack
* (symlink in storage root /etc/passwd). It throws on missing
* files, which is fine callers already exists-check before stream
* via `fs.existsSync`. We re-throw missing-file errors as
* AppError 404 to keep the response shape consistent.
*
* **What the contract surface uses**
*
* Two roots:
* 1. `<cwd>/storage/business-docs/contract/<year>/` system-stamped
* PDFs (immutable as-sent + signed copies).
* 2. `<STORAGE_PATH or cwd/storage>/uploads/contracts/signed/`
* wet-upload PDFs (admin or customer-supplied).
*
* Both roots are constants from the operator's perspective; legitimate
* paths always live under one of them.
*/
const fs = require('fs');
const path = require('path');
const { AppError } = require('./errors');
/**
* Resolve the canonical (symlink-followed) absolute path. Throws
* AppError 404 when the file is missing on disk; caller handles
* the 404 response.
*/
function realpathOr404(absPath) {
try {
return fs.realpathSync(absPath);
} catch (err) {
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
throw new AppError('File missing on disk', 404, 'FILE_MISSING');
}
throw err;
}
}
/**
* Assert that `filePath` resolves to a location inside one of
* `allowedRoots`. Throws AppError 403 on violation.
*
* Both inputs are resolved through realpath so symlinks in either
* direction are followed before comparison. `allowedRoots` that
* don't themselves exist are silently dropped from the check (a
* deployment with both quote and contract roots may have the
* contract root missing on first boot, for example) at least one
* root MUST exist for the check to allow the path.
*/
function assertPathInside(filePath, allowedRoots) {
if (!filePath) throw new AppError('No path provided', 400);
const resolvedFile = realpathOr404(filePath);
const resolvedRoots = [];
for (const root of allowedRoots) {
if (!root) continue;
try {
const r = fs.realpathSync(root);
// Append a separator so /storage/foo doesn't match /storage/foo-evil.
resolvedRoots.push(r.endsWith(path.sep) ? r : r + path.sep);
} catch (_) {
// Root doesn't exist yet — fall through. Next iteration may resolve.
}
}
if (resolvedRoots.length === 0) {
// Defensive: refuse rather than allowing free access when no root
// exists. Should only happen on a half-provisioned install.
throw new AppError('No allowed storage roots configured', 500, 'NO_STORAGE_ROOTS');
}
const ok = resolvedRoots.some((root) =>
resolvedFile === root.slice(0, -1) || resolvedFile.startsWith(root)
);
if (!ok) {
throw new AppError('Refusing to serve a file outside the storage roots', 403, 'PATH_OUTSIDE_STORAGE');
}
return resolvedFile;
}
/**
* Convenience helper that builds the standard contract PDF roots
* (system-stamped + wet-upload) and delegates to assertPathInside.
* Use from contract PDF stream / read sites.
*/
function assertContractPdfPath(filePath) {
const cwd = process.cwd();
const storageRoot = process.env.STORAGE_PATH || path.join(cwd, 'storage');
return assertPathInside(filePath, [
path.join(cwd, 'storage', 'business-docs', 'contract'),
path.join(storageRoot, 'uploads', 'contracts', 'signed'),
]);
}
module.exports = {
assertPathInside,
assertContractPdfPath,
};
+96
View File
@@ -0,0 +1,96 @@
/**
* schemaCache process-local memoisation for `db.schema.hasColumn`.
*
* **Why this exists**
*
* The CRM services on `feat/crm` are riddled with `hasColumn` guards
* because the schema has been drifting fast (every doc-feature
* migration adds a column that older installs may not yet have).
* Each call hits information_schema (Postgres) or sqlite_master
* (SQLite). On hot paths `recordCustomerSignature`,
* `recordAdminCountersignature`, `getContractById`, the monthly
* billing pass we issue 48 hasColumn checks per request, all
* for columns whose presence cannot change at runtime.
*
* The audit flagged this as a perf medium. Caching is safe because:
*
* 1. Schema-changing operations (migrations, `ALTER TABLE`) only
* run at boot via `run-migrations-safe.js`, BEFORE any service
* module accepts traffic. The cache is populated lazily after
* boot finishes, so the entries reflect post-migration state.
*
* 2. The Node process is the only schema authority. There's no
* sibling process sneaking in `ALTER TABLE` while we serve
* requests.
*
* 3. If a future migration path needs to run mid-flight, it can
* call `invalidateSchemaCache()` after the schema change.
*
* **What we cache**
*
* Just the boolean answer to `(table, column)`. A miss means the
* column doesn't exist on this install; a hit means it does. The
* cache key is `${table}.${column}`. There's no TTL the entry is
* valid for the lifetime of the Node process.
*
* **What we DON'T cache**
*
* Negative results from `hasTable` failures (the table simply isn't
* there) those go through the underlying call each time. That
* scenario is exceptional (table truly missing during a half-applied
* migration window) and we want it to surface, not get masked by a
* stale cache.
*
* **API**
*
* const { hasColumnCached, invalidateSchemaCache } = require('../utils/schemaCache');
* if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
* ...
* }
*
* Drop-in replacement for `db.schema.hasColumn(...)` calls. The
* existing helper signature returns a Promise<boolean> so async
* call-sites need no shape change.
*/
const { db } = require('../database/db');
const cache = new Map();
async function hasColumnCached(table, column) {
const key = `${table}.${column}`;
if (cache.has(key)) return cache.get(key);
// Resolve via the underlying schema API. We deliberately don't
// catch errors here — if the call throws (e.g. DB connection lost
// mid-boot), the error surfaces to the caller exactly as it would
// have without the cache.
const present = await db.schema.hasColumn(table, column);
cache.set(key, present);
return present;
}
/**
* Drop every cached entry. Call this after a runtime schema change
* (rare only the dev tooling does this today). Safe to call any
* time; the next hasColumnCached lookup will re-resolve.
*/
function invalidateSchemaCache() {
cache.clear();
}
/**
* Drop entries for a single table. Useful when only one table was
* altered and other tables' caches are still valid.
*/
function invalidateSchemaCacheForTable(table) {
const prefix = `${table}.`;
for (const k of cache.keys()) {
if (k.startsWith(prefix)) cache.delete(k);
}
}
module.exports = {
hasColumnCached,
invalidateSchemaCache,
invalidateSchemaCacheForTable,
};