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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 32–36 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;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 },
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 15–34 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 15–34 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 },
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 4–8 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,
|
||||
};
|
||||
Reference in New Issue
Block a user