Merge pull request #603 from Luca-Timo/feat/crm-improvements

CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
This commit is contained in:
Paul Nothaft
2026-06-04 22:09:08 +02:00
committed by GitHub
104 changed files with 4685 additions and 629 deletions
+18 -1
View File
@@ -43,7 +43,16 @@ RUN npm install -g npm@10
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
# PDFKit + the web UI use) are registered with fontconfig further down so the
# logo's text renders in its actual typeface, not a fallback.
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation && \
fc-cache -f
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -55,6 +64,14 @@ COPY --chown=nodejs:nodejs . .
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
# rasterises an SVG logo its <text> renders in the actual brand typeface
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
# internal family name and recurses into the per-family subdirectories.
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
fc-cache -f /app/assets/fonts
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
@@ -58,13 +58,30 @@ describe('resolveEffectiveRate', () => {
)).toBe(15000);
});
it('throws when both override and customer rate are unset', () => {
it('throws when override, customer rate, AND install default are all unset', () => {
expect(() => resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
null,
)).toThrow(/No hourly rate/);
});
it('falls back to the install-wide default when override + customer rate are unset', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
12000,
)).toBe(12000);
});
it('customer rate wins over the install-wide default', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
12000,
)).toBe(15000);
});
it('treats override=0 as "explicitly zero" (not null)', () => {
// Override === 0 is unusual but legal — pro bono blocks, internal
// tracking. Must NOT fall through to the customer default.
@@ -0,0 +1,238 @@
/**
* Unit tests for the per-weekday business-hours floor (migration 114).
* Exercises the pure snap logic against a fixed IANA zone so the results
* don't drift with the CI box's local timezone.
*
* All scenarios use Europe/Zurich (the regulatory-scope default).
*/
const {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal,
} = require('../../src/utils/businessHours');
const TZ = 'Europe/Zurich';
// MonFri 09:0018:00, weekend closed. Plain string-block storage shape.
const STANDARD = {
'1': [{ start: '09:00', end: '18:00' }],
'2': [{ start: '09:00', end: '18:00' }],
'3': [{ start: '09:00', end: '18:00' }],
'4': [{ start: '09:00', end: '18:00' }],
'5': [{ start: '09:00', end: '18:00' }],
'6': [],
'7': [],
};
// MonFri with a lunch break (09:0012:00, 13:0018:00).
const LUNCH = {
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'6': [],
'7': [],
};
const cfg = (schedule, overrides = {}) => ({
enabled: true,
timezone: TZ,
schedule,
...overrides,
});
// Build a UTC instant from a Zurich wall-clock so the assertions read in
// local terms. Reuses the module's own converter (covered separately).
function zurich(y, mo, d, hh, mi) {
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
}
function partsOf(date) {
const p = _internal.getZonedParts(date, TZ);
return [p.y, p.mo, p.d, p.hh, p.mi];
}
describe('parseHHMM / minutesToHHMM', () => {
it('parses valid times to minutes', () => {
expect(parseHHMM('09:00')).toBe(540);
expect(parseHHMM('00:00')).toBe(0);
expect(parseHHMM('23:59')).toBe(1439);
});
it('rejects malformed input', () => {
expect(parseHHMM('9:00')).toBeNull();
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('')).toBeNull();
expect(parseHHMM(null)).toBeNull();
});
it('round-trips minutesToHHMM', () => {
expect(minutesToHHMM(540)).toBe('09:00');
expect(minutesToHHMM(0)).toBe('00:00');
expect(minutesToHHMM(1439)).toBe('23:59');
});
});
describe('normaliseSchedule', () => {
it('parses, sorts, and drops invalid blocks', () => {
const out = normaliseSchedule({
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
});
expect(out['1']).toEqual([
{ start: '09:00', end: '12:00' },
{ start: '13:00', end: '18:00' },
]);
expect(out['2']).toEqual([]);
expect(out['3']).toEqual([]);
expect(out['7']).toEqual([]);
});
it('accepts [start,end] pair blocks and a JSON string', () => {
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
});
it('garbage input → all-empty week', () => {
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
});
});
describe('snapToBusinessHours — single window (MonFri 09:0018:00)', () => {
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
// 2026-06-02 is a Tuesday.
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('weekday inside window (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
it('Sunday 14:00 → Monday 09:00', () => {
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
// 2026-06-05 is a Friday.
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
const input = zurich(2026, 6, 2, 9, 0);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — lunch break (09:0012:00, 13:0018:00)', () => {
it('morning block (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('afternoon block (Tue 17:59) → unchanged', () => {
const input = zurich(2026, 6, 2, 17, 59);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('after close (Tue 19:00) → next day 09:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — per-day differing hours', () => {
const PERDAY = {
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
'2': [], // Tue closed
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
'4': [], '5': [], '6': [], '7': [],
};
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
});
describe('snapToBusinessHours — passthrough cases', () => {
it('floor disabled → unchanged even when outside hours', () => {
const input = zurich(2026, 6, 2, 2, 11);
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
.toBe(input.getTime());
});
it('empty schedule → unchanged (nothing to snap to)', () => {
const empty = normaliseSchedule(null);
const input = zurich(2026, 6, 7, 14, 0);
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
});
it('non-Date / invalid input is passed through untouched', () => {
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
const bad = new Date('not-a-date');
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
});
});
describe('_internal round-trips', () => {
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
const p = _internal.getZonedParts(d, TZ);
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
});
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
});
});
@@ -0,0 +1,40 @@
/**
* Migration: normalize the Liechtenstein country code from the
* colloquial vehicle-plate code `FL` to the ISO 3166-1 alpha-2 code
* `LI`.
*
* Background: the customer create/edit UI used to accept a free-text
* 2-char country code and the placeholder suggested `FL` for
* Liechtenstein. That code isn't ISO — the PDF renderer's locale-aware
* lookup (services/pdfService.js countryName) and the new country
* dropdown both key on ISO, so `FL` rows render as the bare code
* instead of "Liechtenstein". The dropdown now stores `LI`; this
* migration brings existing rows in line so they display correctly and
* match new records.
*
* Scope: customer_accounts.country_code and business_profile.country_code.
* Case-insensitive so a hand-entered `fl` is caught too. The free-text
* country_name override column is left untouched — it exists precisely
* for operators who want a custom display string.
*
* Idempotent: re-runs only touch rows still holding FL, so a second run
* is a no-op.
*/
async function normalizeColumn(knex, table) {
if (!(await knex.schema.hasTable(table))) return;
if (!(await knex.schema.hasColumn(table, 'country_code'))) return;
await knex(table)
.whereRaw('UPPER(country_code) = ?', ['FL'])
.update({ country_code: 'LI' });
}
exports.up = async function(knex) {
await normalizeColumn(knex, 'customer_accounts');
await normalizeColumn(knex, 'business_profile');
};
// Irreversible by design: once normalized to the ISO code there's no
// way to know which `LI` rows were originally `FL`, and reverting would
// reintroduce the non-ISO value the rest of the system can't read.
exports.down = async function() {};
@@ -0,0 +1,47 @@
/**
* Migration: backfill historical send/payment dates on already-imported
* invoices.
*
* Background: the invoice-import endpoint (POST /admin/invoices/import)
* used to stamp `sent_at` and `paid_at` with the moment of import
* (`new Date()`) rather than the document's own historical dates. The
* CRM dashboard's "Revenue · last 30 days" card keys on `paid_at`, so a
* year-old paid invoice imported today wrongly counted toward the
* rolling window. The route now anchors both timestamps to `issue_date`
* (with an optional explicit `paidAt`); this migration brings the rows
* imported under the old behaviour in line.
*
* Scope: rows with `imported_pdf_path` set — i.e. historical documents,
* never invoices issued by picpeak itself. For those, no real payment
* date was ever captured (the column held the import timestamp), so the
* issue date is the best available anchor. Note: `paid_at`/`sent_at` are
* operational timestamps, not part of the invoice's immutable legal
* content — correcting an import-time bug on them doesn't alter the
* issued document.
*
* Idempotent: re-runs just re-assign the same issue_date value.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
const cols = ['imported_pdf_path', 'issue_date', 'sent_at', 'paid_at'];
for (const c of cols) {
if (!(await knex.schema.hasColumn('invoices', c))) return;
}
// sent_at → issue_date for every imported row that has one.
await knex('invoices')
.whereNotNull('imported_pdf_path')
.whereNotNull('sent_at')
.update({ sent_at: knex.ref('issue_date') });
// paid_at → issue_date for imported rows that recorded a payment.
await knex('invoices')
.whereNotNull('imported_pdf_path')
.whereNotNull('paid_at')
.update({ paid_at: knex.ref('issue_date') });
};
// Irreversible by design: the original import-time stamps were wrong
// data, and there's no record of them to restore.
exports.down = async function() {};
@@ -0,0 +1,34 @@
/**
* Migration: per-customer Skonto opt-out.
*
* Background: invoices already carry a per-invoice `skonto_disabled`
* flag (migration 126). For B2B customers who negotiated "no early-
* payment discount" as a standing contract term, the admin had to tick
* that toggle on every single invoice. This adds a customer-level flag
* so the opt-out is set once and applies to all of that customer's
* invoices. The resolver chain becomes:
* customer.skonto_disabled → invoice.skonto_disabled →
* invoice snapshot → source-quote snapshot → global default.
*
* Default false so existing customers keep inheriting whatever Skonto
* the template / global default offers — no behaviour change on upgrade
* (see migration-preserve-existing-state guidance).
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
if (await knex.schema.hasColumn('customer_accounts', 'skonto_disabled')) return;
await knex.schema.alterTable('customer_accounts', (table) => {
table.boolean('skonto_disabled').notNullable().defaultTo(false);
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
if (!(await knex.schema.hasColumn('customer_accounts', 'skonto_disabled'))) return;
await knex.schema.alterTable('customer_accounts', (table) => {
table.dropColumn('skonto_disabled');
});
};
@@ -0,0 +1,35 @@
/**
* Migration: install-wide default hourly rate.
*
* Background: hour entries resolve a billing rate through
* entry.hourly_rate_minor_override → customer.hourly_rate_minor.
* When a customer had neither set, saving an entry hard-failed with
* HOURLY_RATE_REQUIRED — a confusing save-time error on the hours page.
* This adds an install-wide fallback so a single global rate covers
* every customer who hasn't been given an individual one. The chain
* becomes:
* entry override → customer rate → business_profile default → (CTA).
*
* Stored in minor units (matches customer_accounts.hourly_rate_minor).
* Nullable, default NULL: existing installs keep today's behaviour
* (no implicit rate) until the admin sets one — no surprise rate gets
* applied on upgrade (migration-preserve-existing-state guidance).
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor')) return;
await knex.schema.alterTable('business_profile', (table) => {
table.bigInteger('default_hourly_rate_minor');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor'))) return;
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('default_hourly_rate_minor');
});
};
@@ -0,0 +1,61 @@
/**
* Migration: configurable business hours on the business profile.
*
* Adds two columns to the singleton business_profile row (id=1):
*
* business_hours TEXT — JSON, per-ISO-weekday opening
* blocks. Shape:
* {"1":[{"start":"09:00","end":"12:00"},
* {"start":"13:00","end":"18:00"}],
* ...,"7":[]}
* Keys are ISO weekdays 1=Mon … 7=Sun;
* a day with no blocks is closed.
* Multiple blocks per day model lunch
* breaks (Google-style).
* scheduled_email_floor_enabled BOOLEAN default TRUE — master switch for
* holding scheduled emails until the
* next open block.
*
* business_hours defaults to NULL (no hours configured). A null / empty
* schedule makes the scheduled-email floor a no-op, so existing installs
* keep today's behaviour — emails send at their requested instant until
* the admin actually defines opening hours (migration-preserve-state).
*
* The timezone the blocks are interpreted in is the EXISTING
* business_profile.timezone column (added earlier for the admin calendar)
* — no new tz column. The whole business-hours definition lives on the
* business profile, which is where the admin edits it.
*
* Idempotent: each column guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'business_hours'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.text('business_hours');
});
}
if (!(await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.boolean('scheduled_email_floor_enabled').notNullable().defaultTo(true);
});
}
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('scheduled_email_floor_enabled');
});
}
if (await knex.schema.hasColumn('business_profile', 'business_hours')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('business_hours');
});
}
};
@@ -0,0 +1,31 @@
/**
* Migration: admin decline-quote reason.
*
* Background: admins can now decline a quote on the customer's behalf
* ("customer told us by phone they're not going ahead") instead of
* waiting for the public response link. This stores an optional free-text
* reason alongside the existing `declined_at` timestamp so the quote
* detail page can show WHY it was declined.
*
* Nullable, no default — existing declined rows simply carry no reason,
* which is exactly how customer-side declines already look. No behaviour
* change on upgrade.
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'decline_reason')) return;
await knex.schema.alterTable('quotes', (table) => {
table.text('decline_reason');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'decline_reason'))) return;
await knex.schema.alterTable('quotes', (table) => {
table.dropColumn('decline_reason');
});
};
@@ -0,0 +1,37 @@
/**
* Migration: backfill paid_amount_minor for imported PAID invoices that
* stored 0.
*
* Background: the historical-invoice import only sent paidAmountMinor when
* the admin separately filled the "paid amount" field. Left blank (easy to
* miss — the total was already entered), it stored paid_amount_minor = 0
* even with status='paid'. The dashboard revenue windows sum
* paid_amount_minor (not total), so those paid imports contributed NOTHING
* to revenue. The import route now defaults a blank paid amount to the
* total; this fixes the rows already created before that change.
*
* Scope: imported (imported_pdf_path set) + status='paid' + paid_amount_minor
* 0/null → set paid_amount_minor = total_amount_minor (fully paid). Operational
* payment field, not immutable legal content (same reasoning as migration 111).
*
* Idempotent: re-running sets the same value; rows already > 0 are untouched.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (!(await knex.schema.hasColumn('invoices', 'imported_pdf_path'))) return;
if (!(await knex.schema.hasColumn('invoices', 'paid_amount_minor'))) return;
await knex('invoices')
.whereNotNull('imported_pdf_path')
.where('status', 'paid')
.andWhere(function() {
this.where('paid_amount_minor', 0).orWhereNull('paid_amount_minor');
})
.update({ paid_amount_minor: knex.raw('total_amount_minor') });
};
exports.down = async function() {
// Irreversible data backfill — we can't tell which rows we changed apart
// from legitimately-full payments. No-op.
};
+46
View File
@@ -559,6 +559,52 @@ app.get('/robots.txt', async (req, res) => {
}
});
// Dynamic favicon endpoints. Browsers — notably Safari — request
// /favicon.ico and /apple-touch-icon*.png directly at the site root and are
// unreliable about honouring JS-injected <link rel="icon"> tags. Serving the
// admin's configured branding favicon here makes it work without client-side
// JS (and survive aggressive favicon caches). Falls back to the bundled asset
// shipped with the frontend build when no custom favicon is set.
app.get(
['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'],
async (req, res) => {
try {
const { getAppSetting } = require('./src/utils/appSettings');
const raw = await getAppSetting('branding_favicon_url', null);
const url = (raw && String(raw).trim()) || null;
if (url) {
// External URL — can't stream the bytes, so redirect (best effort).
if (/^https?:\/\//i.test(url)) return res.redirect(302, url);
// Local upload → stream the file bytes DIRECTLY rather than 302'ing.
// Safari does NOT reliably follow a redirect for favicon requests
// (it falls back to the HTML <link>, i.e. the bundled default),
// whereas Firefox/Chrome do — so a 302 worked everywhere except
// Safari. sendFile sets the right content-type from the extension.
const rel = String(url).replace(/^\/+/, '').replace(/^uploads\//, '');
const uploadsRoot = path.resolve(path.join(storagePath, 'uploads'));
const resolved = path.resolve(path.join(uploadsRoot, rel));
// Path containment — never serve outside the uploads dir.
if (resolved.startsWith(uploadsRoot + path.sep) && fs.existsSync(resolved)) {
// This route streams the file directly, bypassing the secureStatic
// middleware — so re-apply its SVG hardening here. An admin-uploaded
// SVG favicon could contain <script>; served at the top-level
// /favicon.ico origin without CSP that would be stored XSS. Keep in
// sync with secureStatic.js.
if (/\.svg$/i.test(resolved)) {
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
res.setHeader('X-Content-Type-Options', 'nosniff');
}
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.sendFile(resolved);
}
}
} catch (error) {
logger.warn('Favicon lookup failed; serving bundled default', { error: error.message });
}
return res.redirect(302, '/favicon-32x32.png');
}
);
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
// watchdog) detect a silent process restart between two checks.
app.get('/health', async (req, res) => {
+16 -1
View File
@@ -31,7 +31,22 @@ function secureStatic(basePath, options = {}) {
// Disable directory listing for security
index: false,
// Don't allow dotfiles
dotfiles: 'deny'
dotfiles: 'deny',
setHeaders: (resp, filePath) => {
// Preserve any caller-provided header logic (e.g. font caching).
if (typeof options.setHeaders === 'function') options.setHeaders(resp, filePath);
// SVGs are admin-uploadable (logos, favicon). Served from our
// own origin, a malicious SVG opened directly could run embedded
// <script>/on* handlers (stored XSS). A restrictive CSP lets the
// browser RENDER the vector but blocks all script execution —
// so we keep real SVGs (scalable) instead of rasterising them.
// `default-src 'none'` already implies script-src 'none';
// style-src + img-src(data:) keep normal SVG rendering working.
if (/\.svg$/i.test(filePath)) {
resp.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
resp.setHeader('X-Content-Type-Options', 'nosniff');
}
}
});
return staticMiddleware(req, res, next);
@@ -142,6 +142,10 @@ function transformProfile(p) {
taxId: p.tax_id || '',
vatLabel: p.vat_label || 'MwSt.',
vatRateDefault: p.vat_rate_default == null ? null : Number(p.vat_rate_default),
// Install-wide fallback hourly rate (migration 113), minor units.
// null = no global default; the hours page then requires a per-
// customer or per-entry rate.
defaultHourlyRateMinor: p.default_hourly_rate_minor == null ? null : Number(p.default_hourly_rate_minor),
defaultCurrency: p.default_currency || 'CHF',
defaultLocale: p.default_locale || 'de',
defaultQrFormat: p.default_qr_format || 'none',
@@ -163,11 +167,34 @@ function transformProfile(p) {
// 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,
// Migration 114 — per-ISO-weekday opening hours (object keyed
// "1".."7"). Stored as JSON TEXT; parse to an object for the API.
// null/blank = no hours configured.
businessHours: parseBusinessHours(p.business_hours),
// Migration 114 — master switch for the scheduled-email floor.
// Defaults true (column is NOT NULL default true).
scheduledEmailFloorEnabled: p.scheduled_email_floor_enabled == null
? true
: (p.scheduled_email_floor_enabled === true
|| p.scheduled_email_floor_enabled === 1
|| p.scheduled_email_floor_enabled === '1'),
createdAt: p.created_at,
updatedAt: p.updated_at,
};
}
/** Parse the stored business_hours JSON to an object, or null. */
function parseBusinessHours(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'object') return raw; // pg jsonb path (column is text today)
try {
const obj = JSON.parse(raw);
return obj && typeof obj === 'object' ? obj : null;
} catch (_) {
return null;
}
}
function transformBank(b) {
if (!b) return null;
return {
@@ -346,6 +373,11 @@ router.put(
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 }),
// Migration 113 — install-wide default hourly rate, minor units.
// nullable so the admin can clear it; values: 'falsy' would drop a
// legitimate 0 (which we treat as "explicitly free"), so use the
// nullable form and let the service coerce.
body('defaultHourlyRateMinor').optional({ nullable: true }).isInt({ min: 0 }),
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']),
@@ -371,6 +403,15 @@ router.put(
// "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 }),
// Migration 114 — per-weekday opening hours. Object keyed "1".."7" or
// null to clear. Shape is validated + sanitised in the service layer
// (normaliseSchedule); here we only reject obviously-wrong types.
body('businessHours').optional({ nullable: true }).custom((v) => {
if (v === null || typeof v === 'object') return true;
throw new Error('businessHours must be an object or null');
}),
// Migration 114 — scheduled-email floor master switch.
body('scheduledEmailFloorEnabled').optional().isBoolean(),
],
handleAsync(async (req, res) => {
validateRequest(req);
@@ -393,6 +434,7 @@ router.put(
taxId: 'tax_id',
vatLabel: 'vat_label',
vatRateDefault: 'vat_rate_default',
defaultHourlyRateMinor: 'default_hourly_rate_minor',
defaultCurrency: 'default_currency',
defaultLocale: 'default_locale',
defaultQrFormat: 'default_qr_format',
@@ -408,6 +450,9 @@ router.put(
pdfQuoteShowSkonto: 'pdf_quote_show_skonto',
// Migration 137 — admin calendar timezone.
timezone: 'timezone',
// Migration 114 — business hours + scheduled-email floor switch.
businessHours: 'business_hours',
scheduledEmailFloorEnabled: 'scheduled_email_floor_enabled',
};
for (const [api, db] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
+2 -2
View File
@@ -286,7 +286,7 @@ router.get(
query('status').optional().isString(),
query('customerAccountId').optional().isInt({ min: 1 }),
query('q').optional().isString(),
query('sort').optional().isIn(['newest', 'oldest', 'customer_asc']),
query('sort').optional().isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc']),
query('page').optional().isInt({ min: 1 }),
query('pageSize').optional().isInt({ min: 1, max: 200 }),
],
@@ -302,7 +302,7 @@ router.get(
const pageSize = parseInt(req.query.pageSize, 10) || 25;
const result = await contractService.listContracts({
filters,
sort: req.query.sort || 'newest',
sort: req.query.sort || 'issue_desc',
page,
pageSize,
});
+30 -1
View File
@@ -67,6 +67,10 @@ function transformCustomer(c) {
// 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,
// Per-customer Skonto opt-out (migration 112). When true, none of
// this customer's invoices qualify for an early-payment discount,
// regardless of template / global defaults.
skontoDisabled: c.skonto_disabled === true || c.skonto_disabled === 1,
lastLogin: c.last_login,
createdAt: c.created_at,
updatedAt: c.updated_at,
@@ -245,6 +249,17 @@ router.post('/', [
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 }),
// At least one human-readable identifier so the record isn't a
// nameless row that's impossible to recognise in lists later.
body('prefill').custom((prefill) => {
const p = prefill || {};
const hasName = ['company_name', 'display_name', 'first_name', 'last_name']
.some((k) => typeof p[k] === 'string' && p[k].trim());
if (!hasName) {
throw new Error('At least a company name or a contact name is required');
}
return true;
}),
], handleAsync(async (req, res) => {
validateRequest(req);
const { id } = await customerAccountsService.createDirect({
@@ -380,9 +395,11 @@ router.put('/:id', [
// 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_cadence').optional().isIn(['per_event', 'monthly', 'quarterly', 'manual']),
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)'),
// Per-customer Skonto opt-out (migration 112).
body('skonto_disabled').optional().isBoolean(),
], handleAsync(async (req, res) => {
validateRequest(req);
const customer = await customerAccountsService.updateCustomer(
@@ -518,6 +535,18 @@ router.put('/:id/events', [
// surface.
// ---------------------------------------------------------------------
// Aggregate landing view for /admin/clients/hours — every customer with
// open (unbilled) hours + the open monetary amount. Registered before
// the /:id/hour-entries routes; the literal first segment ("hour-entries")
// can't collide with the int-validated :id pattern.
router.get('/hour-entries/unbilled-summary', [
adminAuth,
requirePermission('customers.view'),
], handleAsync(async (req, res) => {
const summary = await customerHoursService.getUnbilledSummaryByCustomer();
successResponse(res, { summary });
}));
router.get('/:id/hour-entries', [
adminAuth,
requirePermission('customers.view'),
+12 -5
View File
@@ -429,11 +429,18 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
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.
// Revenue windows: sum of `paid_amount_minor` for invoices marked
// PAID whose payment date (`paid_at`) falls inside the window
// cash-basis recognition (revenue counts when the money arrives).
// Using paid_amount (not total) so partial payments are tracked
// accurately. Stornos excluded — never status='paid' in normal flow,
// the guard is defensive.
//
// `paid_at` is admin-controllable, so an old/imported invoice lands
// in the right window without any special-casing here: the mark-paid
// dialog takes an optional payment date (backdate to when the money
// actually arrived) and the historical-import route anchors paid_at
// to the invoice's issue_date.
const winSum = async (cutoff) => {
const row = await db('invoices')
.where('status', 'paid')
+120 -2
View File
@@ -1,10 +1,10 @@
const express = require('express');
const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator');
const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { wrapEmailHtml } = require('../services/emailProcessor');
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
const router = express.Router();
// Get email configuration
@@ -94,6 +94,16 @@ router.post('/config', [
await db('email_configs').insert(configData);
}
// Refresh the cached transporter so the new SMTP settings take effect
// immediately. Without this, a previously-initialised transporter stays
// cached (the queue processor only re-inits when it's null), so changing
// the email account had no effect until a backend restart — emails kept
// failing against the old/empty config. initializeTransporter catches its
// own errors and returns null, so this never throws; an invalid config
// simply leaves the transporter null (surfaced via the Test-email button).
const { initializeTransporter } = require('../services/emailProcessor');
await initializeTransporter(true);
// Log activity
await logActivity('email_config_updated',
{ smtp_host, from_email },
@@ -248,6 +258,114 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
}
});
// Flush the email queue now. Sends every pending email immediately,
// bypassing the business-hours floor (`scheduled_at`) — the escape hatch
// for "drain the queue before I take the server down for an update".
router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (req, res) => {
try {
const summary = await processEmailQueue({ ignoreSchedule: true, limit: 1000 });
try {
await logActivity('email_queue_flushed',
{ processed: summary.processed, sent: summary.sent, failed: summary.failed },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
} catch (_) { /* activity logging is best-effort */ }
res.json({ message: 'Email queue flushed', ...summary });
} catch (error) {
console.error('Flush email queue error:', error);
res.status(500).json({ error: 'Failed to flush email queue', details: error.message });
}
});
// Read-only "Sent emails" feed — paginated view of email_queue with
// filters (status, type, recipient search, date range). email_data is
// deliberately NOT returned (it can carry attachment paths / PII); the
// list only needs the envelope + delivery state. event_id is joined to
// events so the UI can link back to the source gallery when present.
router.get('/queue', adminAuth, requirePermission('email.view'), [
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('from').optional({ values: 'falsy' }).isISO8601(),
query('to').optional({ values: 'falsy' }).isISO8601(),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25;
const applyFilters = (qb) => {
if (req.query.status) qb.where('email_queue.status', req.query.status);
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
if (req.query.q) {
const term = `%${String(req.query.q).trim()}%`;
qb.where(function () {
this.where('email_queue.recipient_email', 'like', term)
.orWhere('email_queue.email_type', 'like', term);
});
}
return qb;
};
const [{ count }] = await applyFilters(db('email_queue')).count({ count: '*' });
const total = parseInt(count, 10) || 0;
const rows = await applyFilters(
db('email_queue')
.leftJoin('events', 'events.id', 'email_queue.event_id')
.select(
'email_queue.id',
'email_queue.recipient_email',
'email_queue.email_type',
'email_queue.status',
'email_queue.created_at',
'email_queue.scheduled_at',
'email_queue.sent_at',
'email_queue.error_message',
'email_queue.retry_count',
'email_queue.event_id',
'events.event_name as event_name',
'events.slug as event_slug'
)
)
.orderBy('email_queue.created_at', 'desc')
.limit(pageSize)
.offset((page - 1) * pageSize);
const items = rows.map((r) => ({
id: r.id,
recipientEmail: r.recipient_email,
emailType: r.email_type,
status: r.status,
createdAt: r.created_at,
scheduledAt: r.scheduled_at,
sentAt: r.sent_at,
errorMessage: r.error_message,
retryCount: r.retry_count,
eventId: r.event_id,
eventName: r.event_name || null,
eventSlug: r.event_slug || null,
}));
res.json({
items,
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
});
} catch (error) {
console.error('List email queue error:', error);
res.status(500).json({ error: 'Failed to load email queue', details: error.message });
}
});
// Helper: parse variables JSON safely
function parseVariables(template) {
try {
+32 -5
View File
@@ -240,6 +240,9 @@ const INVOICE_BODY_VALIDATORS = [
body('skontoDisabled').optional().isBoolean(),
// Inline event snapshot (migration 123). Mirrors quotes — kept
// optional because standalone invoices may not have an event yet.
// eventId links the invoice to a gallery event (FK) when created from
// the event detail page; persisted by createInvoice (event_id).
body('eventId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
@@ -319,7 +322,7 @@ router.get(
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('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
],
@@ -336,7 +339,7 @@ router.get(
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
q: req.query.q,
},
sort: req.query.sort || 'newest',
sort: req.query.sort || 'issue_desc',
page: req.query.page ? parseInt(req.query.page, 10) : 1,
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
});
@@ -417,6 +420,8 @@ router.post(
// currency 3-letter ISO (optional, default profile/CHF)
// status 'sent' | 'paid' | 'overdue' (default 'sent')
// paidAmountMinor int (optional, for status='paid')
// paidAt ISO date (optional — the real historical payment
// date; defaults to issueDate, never import time)
// language string (optional, default 'de')
router.post(
'/import',
@@ -425,12 +430,15 @@ router.post(
[
body('customerAccountId').isInt({ min: 1 }),
body('invoiceNumber').isString().isLength({ min: 1, max: 64 }),
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
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('paidAt').optional({ values: 'falsy' }).isISO8601(),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
],
handleAsync(async (req, res) => {
@@ -466,10 +474,24 @@ router.post(
}
const totalMinor = parseInt(req.body.totalAmountMinor, 10);
const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0;
const status = req.body.status || 'sent';
// A paid import with no explicit paid amount means FULLY paid — default
// paid_amount_minor to the total. The dashboard revenue windows sum
// paid_amount_minor (not total), so a blank paid amount used to store 0
// and the paid invoice contributed nothing to revenue.
const explicitPaid = req.body.paidAmountMinor != null && String(req.body.paidAmountMinor) !== '';
const paidMinor = explicitPaid
? (parseInt(req.body.paidAmountMinor, 10) || 0)
: (status === 'paid' ? totalMinor : 0);
const issueDate = req.body.issueDate;
const dueDate = req.body.dueDate || issueDate;
// Imported docs are historical: their real send/payment dates are
// the document's own dates, NOT the moment of import. Stamping
// import-time here put year-old paid invoices inside the dashboard's
// rolling "Revenue · last 30 days" window (which keys on paid_at).
// Anchor to the historical date; let the admin override paid_at when
// they know the exact payment date.
const paidAt = req.body.paidAt || issueDate;
const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase();
const language = req.body.language || customer.preferred_language || 'de';
@@ -477,7 +499,12 @@ router.post(
invoice_number: req.body.invoiceNumber,
customer_account_id: customer.id,
source_quote_id: null,
// No FK link on import — the event may predate picpeak. Store the
// free-text snapshot only, mirroring createInvoice's event_name /
// event_date columns (migration 107).
event_id: null,
event_name: req.body.eventName || null,
event_date: req.body.eventDate || null,
language,
currency,
issue_date: issueDate,
@@ -488,14 +515,14 @@ router.post(
installment_trigger: null,
status,
scheduled_send_at: null,
sent_at: status !== 'scheduled' ? new Date() : null,
sent_at: new Date(issueDate),
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,
paid_at: status === 'paid' ? new Date(paidAt) : 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).
+21 -2
View File
@@ -105,6 +105,7 @@ function transformQuote(q) {
responseLockedAt: q.response_locked_at,
acceptedAt: q.accepted_at,
declinedAt: q.declined_at,
declineReason: q.decline_reason ?? null,
convertedEventId: q.converted_event_id,
// Migration 130 lineage. Null until quoteService.createFromQuote
// sets it. Surfaced so QuoteDetailPage can render a "Linked
@@ -257,7 +258,7 @@ router.get(
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('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc', 'value_asc', 'value_desc']),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
],
@@ -272,7 +273,7 @@ router.get(
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',
sort: req.query.sort || 'issue_desc',
page: req.query.page ? parseInt(req.query.page, 10) : 1,
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
});
@@ -450,6 +451,24 @@ router.post(
})
);
// Admin "decline on behalf" — flips a draft/sent/expired quote to
// `declined` without the customer's public link. For "they said no by
// phone" workflows. Optional free-text reason persisted on the row.
router.post(
'/:id/decline',
requirePermission('quotes.manage'),
[
param('id').isInt({ min: 1 }),
body('reason').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.adminDeclineQuote(id, req.admin.id, req.body.reason);
return successResponse(res, result, 200, 'Quote declined');
})
);
router.post(
'/:id/convert',
requirePermission('quotes.manage'),
+51 -9
View File
@@ -73,10 +73,11 @@ const faviconStorage = multer.diskStorage({
const faviconUpload = multer({
storage: faviconStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
const name = file.originalname.toLowerCase();
// For ICO files, we can't use the standard validateFileType
if (file.mimetype === 'image/png') {
if (validateFileType(file.originalname, file.mimetype, ['image/png'])) {
@@ -84,12 +85,16 @@ const faviconUpload = multer({
} else {
cb(new Error('Invalid PNG file'));
}
} else if (allowedMimeTypes.includes(file.mimetype) &&
(file.originalname.toLowerCase().endsWith('.ico') ||
file.originalname.toLowerCase().endsWith('.png'))) {
} else if (file.mimetype === 'image/svg+xml' && name.endsWith('.svg')) {
// SVG favicons are supported by modern browsers and are crisp at any
// size. Served SVGs are CSP-locked (no script execution) by the
// secureStatic middleware, so an admin-uploaded SVG is render-only.
cb(null, true);
} else if (allowedMimeTypes.includes(file.mimetype) &&
(name.endsWith('.ico') || name.endsWith('.png'))) {
cb(null, true);
} else {
cb(new Error('Favicon must be PNG or ICO format'));
cb(new Error('Favicon must be PNG, ICO, or SVG format'));
}
}
});
@@ -544,9 +549,16 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
return res.status(400).json({ error: 'No logo file uploaded' });
}
// ?variant=dark stores a separate dark-mode logo (branding_logo_*_dark);
// anything else is the default (light) logo. Consumers pick the dark
// variant when the active theme is dark, falling back to the light one.
const isDark = req.query.variant === 'dark' || req.body.variant === 'dark';
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
// Get old logo to delete
const oldLogoSetting = await db('app_settings')
.where('setting_key', 'branding_logo_path')
.where('setting_key', pathKey)
.first();
if (oldLogoSetting && oldLogoSetting.setting_value) {
@@ -568,7 +580,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
await db('app_settings')
.insert({
setting_key: 'branding_logo_path',
setting_key: pathKey,
setting_value: JSON.stringify(logoPath),
setting_type: 'branding',
updated_at: new Date()
@@ -582,7 +594,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
// Save public URL
await db('app_settings')
.insert({
setting_key: 'branding_logo_url',
setting_key: urlKey,
setting_value: JSON.stringify(publicPath),
setting_type: 'branding',
updated_at: new Date()
@@ -603,6 +615,36 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
}
});
// Remove a logo. ?variant=dark clears the dark-mode logo
// (branding_logo_*_dark); otherwise the default logo. Best-effort file
// unlink, then blanks the url + path settings.
router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const isDark = req.query.variant === 'dark';
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
if (pathSetting && pathSetting.setting_value) {
try {
let p = pathSetting.setting_value;
if (p.startsWith('"')) p = JSON.parse(p);
await fs.unlink(p);
} catch (error) {
console.error('Failed to delete logo file:', error);
}
}
await db('app_settings')
.whereIn('setting_key', [pathKey, urlKey])
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
res.json({ message: 'Logo removed' });
} catch (error) {
console.error('Logo delete error:', error);
res.status(500).json({ error: 'Failed to remove logo' });
}
});
// Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
try {
+78
View File
@@ -23,6 +23,7 @@ const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { verifyDocumentArtefacts } = require('../services/backupIntegrityService');
const { getCoverageReport } = require('../services/backupCoverageService');
const { db } = require('../database/db');
const router = express.Router();
@@ -84,4 +85,81 @@ router.get(
}),
);
/**
* GET /api/admin/system-health/failures
*
* Surfaces background failures that would otherwise go unnoticed. v1
* covers stuck/failed outbound emails: rows the queue processor has
* given up on (status='failed') or exhausted its retries on
* (status='pending' AND retry_count >= 3 — the processor only picks up
* retry_count < 3). Trigger: a 14h window where 'quote_sent' template
* errors left invoices unsent with no admin-visible signal.
*/
router.get(
'/failures',
requirePermission('settings.view'),
handleAsync(async (req, res) => {
const stuckEmails = await db('email_queue')
.where(function () {
this.where('status', 'failed')
.orWhere(function () {
this.where('status', 'pending').andWhere('retry_count', '>=', 3);
});
})
.orderBy('created_at', 'desc')
.limit(200)
.select('id', 'recipient_email', 'email_type', 'status', 'retry_count', 'error_message', 'created_at');
return successResponse(res, {
stuckEmails: stuckEmails.map((r) => ({
id: r.id,
recipientEmail: r.recipient_email,
emailType: r.email_type,
status: r.status,
retryCount: r.retry_count,
errorMessage: r.error_message,
createdAt: r.created_at,
})),
counts: { stuckEmails: stuckEmails.length },
});
}),
);
/**
* POST /failures/email/:id/retry — re-queue a stuck email (status back to
* pending, retry_count reset, error cleared, scheduled_at cleared so the
* 60s processor picks it up on its next pass).
*/
router.post(
'/failures/email/:id/retry',
requirePermission('settings.edit'),
handleAsync(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const updated = await db('email_queue').where({ id }).update({
status: 'pending',
retry_count: 0,
error_message: null,
scheduled_at: null,
});
if (!updated) return res.status(404).json({ error: 'Email not found' });
return successResponse(res, { retried: true });
}),
);
/**
* DELETE /failures/email/:id — dismiss a stuck email (remove the row so
* it stops surfacing). Use when the failure is understood + won't be sent.
*/
router.delete(
'/failures/email/:id',
requirePermission('settings.edit'),
handleAsync(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
await db('email_queue').where({ id }).del();
return successResponse(res, { dismissed: true });
}),
);
module.exports = router;
+20 -1
View File
@@ -24,6 +24,15 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
const { validateFileType } = require('../utils/fileSecurityUtils');
const contractService = require('../services/contractService');
const { getAppSetting } = require('../utils/appSettings');
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
// or bare `uploads/...` filename) into a URL the public page can load.
function normalizeBrandingLogoUrl(raw) {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
}
const { clientIpForAudit } = require('../utils/clientIp');
const { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards');
const { db } = require('../database/db');
@@ -69,7 +78,7 @@ const signedPdfUpload = multer({
* The IP / signature image paths are NEVER exposed publicly even after
* signing — they're audit evidence.
*/
function publicContractView(contract, inclusions, customer, profile, locale) {
function publicContractView(contract, inclusions, customer, profile, locale, brandingLogoUrl, brandingLogoUrlDark) {
const orderedSections = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
const blocksBySection = {};
for (const s of orderedSections) blocksBySection[s] = [];
@@ -143,6 +152,12 @@ function publicContractView(contract, inclusions, customer, profile, locale) {
city: profile.city,
email: profile.email,
website: profile.website,
// Light + dark branding logos (Settings → Branding), so the public
// sign page renders the logo that reads in its resolved colour mode.
// Mirrors publicQuotes — the print-only business_profile.logo_path is
// intentionally NOT used here.
logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
} : null,
};
}
@@ -168,12 +183,16 @@ router.get(
// 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 brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
const view = publicContractView(
data.contract,
data.inclusions,
customer,
profile,
data.contract.language || 'de',
brandingLogoUrl,
brandingLogoUrlDark,
);
view.allowPdfUpload = allowPdfUpload;
view.requireDrawnSignature = requireDrawnSignature;
+10 -6
View File
@@ -50,16 +50,20 @@ router.get(
const { getAppSetting } = require('../utils/appSettings');
const profile = await db('business_profile').where({ id: 1 }).first();
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
const toUrl = (raw) => {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
};
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\//, '')}`;
})(),
// Light + dark branding logos — the page picks per its colour mode.
logoUrl: toUrl(brandingLogoUrl),
logoUrlDark: toUrl(brandingLogoUrlDark),
} : null;
return successResponse(res, { invoice: view, issuer });
+17 -9
View File
@@ -23,6 +23,15 @@ const { loadActionToken } = require('../utils/publicTokenGuards');
const router = express.Router();
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
// or bare `uploads/...` filename) into a URL the public page can load.
function normalizeBrandingLogoUrl(raw) {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
}
// Rate-limit: 30 token previews per IP per minute, 10 responses.
const previewLimiter = rateLimit({
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
@@ -31,7 +40,7 @@ const respondLimiter = rateLimit({
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
});
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl) {
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark) {
return {
quoteNumber: quote.quote_number,
status: quote.status,
@@ -98,13 +107,11 @@ function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosTe
// 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\//, '')}`;
})(),
// version that doesn't read in dark mode. Both light + dark
// branding URLs are surfaced so the page can pick the one that
// matches its resolved colour mode (see usePublicDarkMode).
logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
} : null,
};
}
@@ -137,9 +144,10 @@ router.get(
// — 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);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
return successResponse(res, {
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl),
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark),
});
})
);
+1
View File
@@ -59,6 +59,7 @@ router.get('/', async (req, res) => {
branding_watermark_size: settingsObject.branding_watermark_size || 15,
branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_url || '',
branding_logo_url_dark: settingsObject.branding_logo_url_dark || '',
branding_logo_size: settingsObject.branding_logo_size || 'medium',
branding_logo_max_height: settingsObject.branding_logo_max_height || 48,
branding_logo_position: settingsObject.branding_logo_position || 'left',
@@ -17,6 +17,7 @@ const { db, withRetry } = require('../database/db');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { normaliseSchedule } = require('../utils/businessHours');
const ALLOWED_PROFILE_FIELDS = [
'company_name',
@@ -41,6 +42,10 @@ const ALLOWED_PROFILE_FIELDS = [
'tax_id',
'vat_label',
'vat_rate_default',
// Install-wide fallback hourly rate (migration 113), minor units.
// Last link in the hour-entry rate chain after the per-entry
// override and the per-customer default.
'default_hourly_rate_minor',
'default_currency',
'default_locale',
'default_qr_format',
@@ -75,6 +80,11 @@ const ALLOWED_PROFILE_FIELDS = [
// by the calendar UI to render timed blocks in the operator's
// working tz. Admin-only; never exposed via publicSettings.
'timezone',
// Per-ISO-weekday opening hours (migration 114). JSON TEXT; drives the
// scheduled-email floor and is interpreted in `timezone`.
'business_hours',
// Master switch for the scheduled-email business-hours floor (mig 114).
'scheduled_email_floor_enabled',
];
const ALLOWED_BANK_FIELDS = [
@@ -161,6 +171,32 @@ function sanitiseProfilePayload(payload) {
? Math.max(24, Math.min(200, n))
: 56;
}
// Install-wide default hourly rate (minor units). Empty / null clears
// it back to "no global default"; otherwise coerce to a non-negative
// integer so a stray decimal can't land sub-cent values in the column.
if (updates.default_hourly_rate_minor !== undefined) {
if (updates.default_hourly_rate_minor === null || updates.default_hourly_rate_minor === '') {
updates.default_hourly_rate_minor = null;
} else {
const n = parseInt(updates.default_hourly_rate_minor, 10);
updates.default_hourly_rate_minor = Number.isFinite(n) && n >= 0 ? n : null;
}
}
// Per-weekday opening hours. Accept the API object (or a JSON string),
// run it through the shared validator (drops bad blocks, sorts, fills
// all 7 days), and persist the canonical JSON string. An explicit
// null / '' clears the schedule back to "no hours configured".
if (updates.business_hours !== undefined) {
if (updates.business_hours === null || updates.business_hours === '') {
updates.business_hours = null;
} else {
updates.business_hours = JSON.stringify(normaliseSchedule(updates.business_hours));
}
}
if (updates.scheduled_email_floor_enabled !== undefined) {
updates.scheduled_email_floor_enabled = formatBoolean(Boolean(updates.scheduled_email_floor_enabled));
}
return updates;
}
+12 -1
View File
@@ -621,7 +621,7 @@ async function buildRenderContext(contract, inclusions) {
// Public API
// ---------------------------------------------------------------------
async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('contracts')
.leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id')
@@ -658,11 +658,22 @@ async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize
case 'oldest':
query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc');
break;
case 'issue_asc':
query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc');
break;
case 'issue_desc':
query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc');
break;
case 'customer_asc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('contracts.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('contracts.id', 'desc');
break;
case 'newest':
default:
query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc');
@@ -555,6 +555,9 @@ async function updateCustomer(id, updates, updatedByAdminId) {
// Hour-logging default rate (migration 129). Minor units; null
// means admin must enter a per-entry override on every entry.
'hourly_rate_minor',
// Per-customer Skonto opt-out (migration 112). Boolean, coerced
// via formatBoolean below for SQLite compatibility.
'skonto_disabled',
];
for (const f of fields) {
if (updates[f] !== undefined) {
@@ -567,6 +570,7 @@ async function updateCustomer(id, updates, updatedByAdminId) {
} else if (
f === 'feature_calendar' || f === 'feature_quotes'
|| f === 'feature_bills' || f === 'feature_hours_logging'
|| f === 'skonto_disabled'
) {
allowed[f] = formatBoolean(updates[f]);
} else if (f === 'hourly_rate_minor') {
+128 -16
View File
@@ -25,6 +25,7 @@
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const invoiceService = require('./invoiceService');
@@ -50,24 +51,52 @@ function computeDurationMinutes(start, end) {
}
/**
* 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.
* Resolve the rate this entry should bill at. Resolution chain:
* 1. per-entry override
* 2. per-customer default rate
* 3. install-wide default rate (business_profile, migration 113)
* Only when all three are unset do we throw — the hours UI surfaces a
* "set a rate" CTA off the back of HOURLY_RATE_REQUIRED rather than a
* raw error. `installDefaultMinor` is loaded once per request by the
* caller (see getInstallDefaultRateMinor) and passed in so this stays
* a pure function.
*/
function resolveEffectiveRate(entry, customer) {
function resolveEffectiveRate(entry, customer, installDefaultMinor = null) {
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);
}
if (installDefaultMinor != null) {
return Number(installDefaultMinor);
}
throw new AppError(
'No hourly rate: set a per-entry override or a customer default.',
'No hourly rate: set a per-entry override, a customer default, or an install-wide default rate.',
400,
'HOURLY_RATE_REQUIRED',
);
}
/**
* Read the install-wide default hourly rate (minor units) off the
* singleton business_profile row. Returns null when unset OR when the
* column doesn't exist yet (pre-migration-113 install) — callers then
* fall through to the HOURLY_RATE_REQUIRED path. Accepts an optional
* transaction so it joins the caller's atomic unit.
*/
async function getInstallDefaultRateMinor(trx) {
const conn = trx || db;
if (!(await hasColumnCached('business_profile', 'default_hourly_rate_minor'))) {
return null;
}
const row = await conn('business_profile').where({ id: 1 })
.first('default_hourly_rate_minor');
return row && row.default_hourly_rate_minor != null
? Number(row.default_hourly_rate_minor)
: null;
}
/**
* Decide whether an entry is still editable. Pure function — callers
* pass the loaded entry + (optionally) its current invoice row.
@@ -181,9 +210,14 @@ async function createEntry(customerId, payload, adminId) {
}
const description = payload.description ? String(payload.description).slice(0, 1000) : null;
// Install-wide fallback rate (migration 113) — the last link in the
// resolution chain. Loaded once and reused for the pre-validate and
// the accumulator append below.
const installDefaultMinor = await getInstallDefaultRateMinor();
// 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);
// if neither override, customer default, nor install default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer, installDefaultMinor);
return await db.transaction(async (trx) => {
const row = {
@@ -202,10 +236,12 @@ async function createEntry(customerId, payload, adminId) {
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') {
// Accumulator-mode customers (monthly + manual) get the auto-append
// treatment — the entry lands on the running draft instead of staying
// unbilled. Manual differs only in that its draft never auto-flushes.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
const fullEntry = { ...row, id: entryId };
const rate = resolveEffectiveRate(fullEntry, customer);
const rate = resolveEffectiveRate(fullEntry, customer, installDefaultMinor);
const lineItem = buildLineItemFromEntry(fullEntry, rate);
const { invoiceId, lineItemId } = await invoiceService.appendOneLineItemToMonthlyDraft(
customer, lineItem, adminId, trx,
@@ -286,7 +322,8 @@ async function updateEntry(entryId, payload, adminId) {
// 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 installDefaultMinor = await getInstallDefaultRateMinor(trx);
const rate = resolveEffectiveRate(next, customer, installDefaultMinor);
const newLineItem = buildLineItemFromEntry(next, rate);
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).update({
description: newLineItem.description,
@@ -387,15 +424,16 @@ async function deleteEntry(entryId, adminId) {
/**
* 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.
* in an accumulator mode (monthly / manual) — those entries auto-billed
* onto the running draft 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') {
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
throw new AppError(
'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
'Accumulator-mode customers (monthly / manual) auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
409,
'CADENCE_MISMATCH',
);
@@ -409,8 +447,9 @@ async function billUnbilledEntries(customerId, adminId) {
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
}
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
const lineItems = unbilled.map((entry, idx) => {
const rate = resolveEffectiveRate(entry, customer);
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
const li = buildLineItemFromEntry(entry, rate);
return { ...li, position: idx + 1 };
});
@@ -456,12 +495,85 @@ async function billUnbilledEntries(customerId, adminId) {
});
}
/**
* Landing aggregate for /admin/clients/hours: one row per customer that
* currently carries unbilled hour entries, with the open hours + open
* monetary amount. In practice only per-event customers surface here —
* monthly/manual cadences auto-append each entry onto the running draft
* at save time (status flips straight to 'billed'), so they never leave
* unbilled rows behind. Each entry's amount resolves through the usual
* override → customer-rate → install-default chain; if an entry has no
* resolvable rate it still counts toward hours/entries but the row is
* flagged rateResolvable=false so the UI can prompt for a rate rather
* than silently undercounting. Sorted by open amount desc.
*/
async function getUnbilledSummaryByCustomer() {
const installDefaultMinor = await getInstallDefaultRateMinor();
const rows = await db('customer_hour_entries as h')
.join('customer_accounts as c', 'h.customer_account_id', 'c.id')
.where('h.status', 'unbilled')
.select(
'h.customer_account_id',
'h.duration_minutes',
'h.hourly_rate_minor_override',
'c.hourly_rate_minor as customer_hourly_rate_minor',
'c.company_name',
'c.display_name',
'c.first_name',
'c.last_name',
'c.email',
'c.password_hash',
'c.billing_cadence',
);
const byCustomer = new Map();
for (const r of rows) {
let agg = byCustomer.get(r.customer_account_id);
if (!agg) {
agg = {
customerAccountId: r.customer_account_id,
companyName: r.company_name || null,
displayName: r.display_name || null,
firstName: r.first_name || null,
lastName: r.last_name || null,
email: r.email || null,
// passive = no portal password set, same rule as the customer
// list / picker (adminCustomers transform).
isPassive: r.password_hash == null,
billingCadence: r.billing_cadence || null,
entryCount: 0,
totalMinutes: 0,
openAmountMinor: 0,
rateResolvable: true,
};
byCustomer.set(r.customer_account_id, agg);
}
agg.entryCount += 1;
const minutes = Number(r.duration_minutes || 0);
agg.totalMinutes += minutes;
let rateMinor = null;
if (r.hourly_rate_minor_override != null) rateMinor = Number(r.hourly_rate_minor_override);
else if (r.customer_hourly_rate_minor != null) rateMinor = Number(r.customer_hourly_rate_minor);
else if (installDefaultMinor != null) rateMinor = installDefaultMinor;
if (rateMinor == null) {
agg.rateResolvable = false;
} else {
agg.openAmountMinor += Math.round((minutes / 60) * rateMinor);
}
}
return Array.from(byCustomer.values())
.sort((a, b) => b.openAmountMinor - a.openAmountMinor);
}
module.exports = {
listEntries,
getUnbilledSummaryByCustomer,
createEntry,
updateEntry,
deleteEntry,
billUnbilledEntries,
getInstallDefaultRateMinor,
_internal: {
computeDurationMinutes,
resolveEffectiveRate,
+124 -21
View File
@@ -2,6 +2,11 @@ const nodemailer = require('nodemailer');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const {
snapToBusinessHours,
normaliseSchedule,
} = require('../utils/businessHours');
const { hasColumnCached } = require('../utils/schemaCache');
let transporter = null;
let lastConfigHash = null;
@@ -735,10 +740,22 @@ async function sendTemplateEmail(to, templateKey, variables) {
}
}
// Process email queue
async function processEmailQueue() {
// Process email queue.
//
// Options:
// ignoreSchedule when true, send every pending email regardless of its
// `scheduled_at` floor (used by the admin "send now" flush
// before maintenance/updates). The scheduled interval run
// leaves it false so future-dated emails keep waiting.
// limit max emails per pass. The flush raises this to drain the
// whole queue in a single pass (no re-query, so a failing
// email isn't retried in a tight loop within one flush).
//
// Returns { processed, sent, failed }.
async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
logger.info('Email queue processor: Checking for pending emails...');
const result = { processed: 0, sent: 0, failed: 0 };
try {
// Try to initialize transporter if it's null (in case it failed at startup)
if (!transporter) {
@@ -746,37 +763,46 @@ async function processEmailQueue() {
transporter = await initializeTransporter();
if (!transporter) {
logger.warn('Email transporter could not be initialized, skipping queue processing');
return;
return result;
}
}
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() {
const query = db('email_queue')
.where('status', 'pending');
if (!ignoreSchedule) {
// Automatic runs: respect the retry cap (don't hammer a failing
// address) AND the schedule (business-hours floor / future send).
query.where('retry_count', '<', 3).andWhere(function() {
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
})
});
}
// A manual "send now" (ignoreSchedule) deliberately bypasses BOTH the
// schedule and the retry cap: the admin is forcing a retry, typically
// right after fixing SMTP. Without this, emails that failed 3× during
// an SMTP outage are stuck "pending" forever with no way to resend.
pendingEmails = await query
.orderBy('scheduled_at', 'asc')
.orderBy('created_at', 'asc')
.limit(10);
.limit(limit);
} catch (dbError) {
logger.error('Failed to query email queue:', dbError);
return;
return result;
}
if (pendingEmails.length === 0) {
logger.info('Email queue processor: No pending emails found');
return;
return result;
}
logger.info(`Processing ${pendingEmails.length} emails from queue`);
result.processed = pendingEmails.length;
for (const email of pendingEmails) {
try {
const emailData = typeof email.email_data === 'string'
@@ -796,9 +822,11 @@ async function processEmailQueue() {
status: 'sent',
sent_at: new Date()
});
result.sent += 1;
logger.info(`Email ${email.id} sent successfully`);
} catch (error) {
result.failed += 1;
// Increment retry count
try {
await db('email_queue')
@@ -825,12 +853,66 @@ async function processEmailQueue() {
} catch (error) {
logger.error('Error processing email queue:', error);
}
return result;
}
// Load + normalise the business-hours config used by queueEmail. The
// definition lives on the singleton business_profile row (migration 114):
// business_hours JSON, per-ISO-weekday opening blocks
// scheduled_email_floor_enabled master on/off switch
// timezone IANA zone the blocks are read in
// Any failure (column missing on a half-migrated install, bad data) or an
// unconfigured schedule degrades to `enabled: false` so a queued email is
// never lost — it just sends at its original time.
async function getScheduledEmailConfig() {
try {
if (!(await hasColumnCached('business_profile', 'business_hours'))) {
return { enabled: false };
}
const hasToggle = await hasColumnCached('business_profile', 'scheduled_email_floor_enabled');
const cols = ['business_hours', 'timezone'];
if (hasToggle) cols.push('scheduled_email_floor_enabled');
const profile = await db('business_profile').where({ id: 1 }).first(cols);
if (!profile) return { enabled: false };
const enabled = hasToggle
? (profile.scheduled_email_floor_enabled === true
|| profile.scheduled_email_floor_enabled === 1
|| profile.scheduled_email_floor_enabled === '1')
: true;
if (!enabled) return { enabled: false };
const schedule = normaliseSchedule(profile.business_hours);
let timezone = (profile.timezone || '').trim();
if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
// Reject a bogus tz before it reaches Intl in the snap helper.
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
} catch (_) {
timezone = 'UTC';
}
return { enabled: true, timezone, schedule };
} catch (err) {
logger.warn(`Business-hours config unavailable, skipping email floor: ${err.message}`);
return { enabled: false };
}
}
// 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.
// options.respectBusinessHours — when true, snap the send time to the
// next open business-hours block (from "now").
// Use for automated/relationship mail (dunning
// reminders, gallery-expiry warnings) so we don't
// ping customers overnight. No-op when the floor
// is off / business hours unconfigured / already
// inside a block. Leave it off for transactional
// + admin-initiated mail so those send instantly.
// 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 = {}) {
@@ -846,15 +928,36 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
retry_count: 0,
created_at: new Date(),
};
if (options.scheduledAt) {
row.scheduled_at = options.scheduledAt instanceof Date
? options.scheduledAt
: new Date(options.scheduledAt);
let snappedFrom = null;
// Base time to schedule from:
// - explicit options.scheduledAt (CRM split-payment invoices), OR
// - "now" when the caller opts into the business-hours floor via
// options.respectBusinessHours — automated / relationship mail
// like dunning reminders + gallery-expiry warnings, so we don't
// ping the customer at 02:00.
// Both snap to the next open business-hours block. No-op when the
// floor is disabled, business hours are unconfigured, or the instant
// already lands inside a block. Transactional / admin-initiated mail
// (invoice_sent, storno, invitations, password resets) passes neither
// option and sends immediately.
const baseTime = options.scheduledAt
? (options.scheduledAt instanceof Date ? options.scheduledAt : new Date(options.scheduledAt))
: (options.respectBusinessHours ? new Date() : null);
if (baseTime) {
const cfg = await getScheduledEmailConfig();
const snapped = snapToBusinessHours(baseTime, cfg);
if (snapped.getTime() !== baseTime.getTime()) snappedFrom = baseTime;
// Persist a future scheduled_at for an explicit scheduledAt always;
// for the respectBusinessHours floor only when it actually moved the
// time forward (inside hours → leave null → processor sends at once).
if (options.scheduledAt || snappedFrom) row.scheduled_at = snapped;
}
await db('email_queue').insert(row);
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()})` : ''
row.scheduled_at ? ` (scheduled ${row.scheduled_at.toISOString()}${
snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : ''
})` : ''
}`);
} catch (error) {
logger.error('Error queueing email:', error);
+2 -1
View File
@@ -85,7 +85,8 @@ async function queueExpirationWarning(event) {
expiry_date: event.expires_at,
gallery_link: shareUrl,
gallery_password: '{{password_security_message}}'
});
// Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true });
logger.info(`Queued expiration warning for event ${event.slug}`);
}
+159 -33
View File
@@ -104,6 +104,65 @@ function computeDueDate(scheduledSendAt, netDays = 30) {
return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000);
}
/**
* Resolve the net-days a new invoice's due date should be anchored to.
* Single source of truth so the editor (split picker), legacy callers,
* and quoteinvoice conversion all land on the same number. Priority:
*
* 1. `payload.netDays` explicit caller override (installment spawn
* passes the snapshot's net_days here).
* 2. Split picker (migration 124): payment_net_days_templates.net_days
* via `payload.paymentNetDaysTemplateId`. This is what the bill
* editor actually sends; the old code only read the legacy FK and
* so silently ignored Net 60 / 90 selections.
* 3. Legacy single FK: payment_term_templates.net_days via
* `payload.paymentTermTemplateId`.
* 4. The `crm_payment_default_net_days` setting (admin-configured).
* 5. 30 historical hard default.
*/
async function resolveNetDays(payload, trx = db) {
if (payload && payload.netDays != null && payload.netDays !== '') {
const n = ensureInt(payload.netDays);
if (n) return n;
}
if (payload && payload.paymentNetDaysTemplateId) {
const probe = await trx('payment_net_days_templates')
.where({ id: payload.paymentNetDaysTemplateId })
.select('net_days')
.first();
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
}
if (payload && payload.paymentTermTemplateId) {
const probe = await trx('payment_term_templates')
.where({ id: payload.paymentTermTemplateId })
.select('net_days')
.first();
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
}
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
if (setting) return setting;
return 30;
}
/**
* Net-days for an already-persisted invoice row (no payload). Reads the
* snapshot's net_days, then the crm_payment_default_net_days setting,
* then 30. Used at send time to re-anchor the due date when the issue
* date is stamped. Mirrors resolveNetDays' tail.
*/
async function resolveNetDaysForRow(invoice) {
const snap = typeof invoice.payment_term_snapshot === 'string'
? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })()
: invoice.payment_term_snapshot;
if (snap && snap.net_days != null) {
const n = ensureInt(snap.net_days);
if (n) return n;
}
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
if (setting) return setting;
return 30;
}
/**
* Resolve the deal_uuid for a new invoice row (migration 140). Priority:
*
@@ -234,6 +293,13 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Manual cadence has no billing cycle: the draft accumulates
// indefinitely and ships ONLY via the admin "Trigger invoice now"
// gesture, so it carries NO period_end. The scheduler's auto-flush
// filter is `monthly_period_end <= today`, which a NULL period_end
// can never satisfy — keeping manual drafts out of the cron path.
const isManual = customer.billing_cadence === 'manual';
// Resolve period_end: prefer the cadence in the current month, but
// if it has already passed, roll to next month so the new draft
// gathers items toward the NEXT bill.
@@ -243,8 +309,11 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
const nextMonth = today.getMonth() + 1;
target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay);
}
const periodStart = new Date(target.getFullYear(), target.getMonth(), 1);
const periodEnd = target;
const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1);
const periodEnd = isManual ? null : target;
// Placeholder issue/due date for the empty draft row — recomputed at
// issuance time. Manual drafts have no period_end, so fall back to today.
const placeholderDate = (periodEnd || today).toISOString().slice(0, 10);
// Look up any existing open draft for this customer. We deliberately
// do NOT filter by monthly_period_end here — only one draft can be
@@ -282,8 +351,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
event_id: null,
language,
currency,
issue_date: periodEnd.toISOString().slice(0, 10),
due_date: periodEnd.toISOString().slice(0, 10), // recomputed at issuance time
issue_date: placeholderDate,
due_date: placeholderDate, // recomputed at issuance time
installment_index: 0,
installment_total: 1,
status: 'scheduled',
@@ -296,8 +365,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
business_bank_account_id: bank?.id || null,
qr_format: null,
is_monthly_draft: true,
monthly_period_start: periodStart.toISOString().slice(0, 10),
monthly_period_end: periodEnd.toISOString().slice(0, 10),
monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null,
monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null,
// Migration 140 — each monthly-draft cycle is its own deal (no
// quote/contract chain). Fresh UUID at creation; subsequent line
// appends just mutate this same row, so the uuid sticks.
@@ -341,7 +410,7 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
// Public API
// ---------------------------------------------------------------------
async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('invoices')
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
@@ -408,6 +477,8 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize
// reflects when the row landed in the DB. id is the tiebreaker
// for rows that share a created_at second.
case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break;
case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break;
case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break;
case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break;
case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break;
case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break;
@@ -417,6 +488,11 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('invoices.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('invoices.id', 'desc');
break;
case 'newest':
default:
query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc');
@@ -618,15 +694,19 @@ async function createInvoice(payload, adminId, trx = db) {
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
ensureCustomerCanBill(customer);
// Monthly-billing intercept (migration 128). For customers in
// billing_cadence='monthly' mode every createInvoice call APPENDS
// line items onto the running monthly-draft instead of minting a
// Accumulator intercept (migration 128). For customers in
// billing_cadence='monthly' OR 'manual' mode every createInvoice call
// APPENDS line items onto a single running draft instead of minting a
// fresh invoice. Admin sees the editor flow exactly as before; the
// returned id is the draft's id so the UI can redirect to the
// accumulator. `_skipMonthlyRouting` is the escape hatch used by
// internal helpers that need to mint a non-draft row (e.g. the
// accumulator itself, or future test fixtures).
if (customer.billing_cadence === 'monthly' && !payload._skipMonthlyRouting) {
// accumulator. The two modes differ only in WHEN the draft ships:
// 'monthly' auto-flushes on the cadence day (scheduler), 'manual'
// never auto-flushes (no period_end) and ships only via the admin
// "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape
// hatch used by internal helpers that need to mint a non-draft row
// (e.g. the accumulator itself, or future test fixtures).
if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual')
&& !payload._skipMonthlyRouting) {
const draft = await appendToMonthlyDraft(payload, customer, adminId, trx);
return { invoiceIds: draft?.id ? [draft.id] : [] };
}
@@ -642,18 +722,13 @@ async function createInvoice(payload, adminId, trx = db) {
// used `invoiceNumber` here.
const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10);
const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null;
// Resolve the selected payment-term template's net_days BEFORE
// computing the due date so Net 60 / 90 templates actually push
// the due date out. Falls back to 30 when no template is set
// (matches the historical default).
let resolvedNetDays = 30;
if (payload.paymentTermTemplateId) {
const probe = await trx('payment_term_templates')
.where({ id: payload.paymentTermTemplateId })
.select('net_days')
.first();
if (probe && probe.net_days != null) resolvedNetDays = ensureInt(probe.net_days) || 30;
}
// Resolve net_days BEFORE computing the due date so Net 60 / 90
// selections actually push the due date out. resolveNetDays honors
// the split picker FK the editor sends, the legacy single FK, and
// the crm_payment_default_net_days setting (see helper). The clock
// starts on the SEND date when the invoice is scheduled, otherwise
// the issue date — so a future send pushes the due date out too.
const resolvedNetDays = await resolveNetDays(payload, trx);
const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays)
.toISOString().slice(0, 10);
@@ -927,10 +1002,13 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre
}
// netDays drives the due-date offset on every scheduled invoice
// created here. Defaults to 30 when the caller doesn't pass one;
// callers in quoteService now pass the converting quote's
// payment-term net_days so Net 60 / 90 templates flow through.
const resolvedNetDays = ensureInt(netDays) || 30;
// created here. Callers in quoteService pass the converting quote's
// payment-term net_days so Net 60 / 90 templates flow through; when
// absent we fall back to the crm_payment_default_net_days setting
// (then 30) rather than silently using 30, matching createInvoice.
const resolvedNetDays = ensureInt(netDays)
|| ensureInt(await getAppSetting('crm_payment_default_net_days'))
|| 30;
const total = installments.length;
const acceptanceTime = new Date();
const invoiceIds = [];
@@ -1633,8 +1711,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
// but still printed the discount row on the PDF. Zero out both
// fields here so pdfService.drawPaymentBlock's
// `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays`
// guard suppresses the row.
if (invoice.skonto_disabled) {
// guard suppresses the row. The per-customer opt-out (migration 112)
// is honoured here too — a customer flagged skonto_disabled never
// prints the discount row, mirroring resolveSkontoPercentForInvoice.
if (invoice.skonto_disabled || customer?.skonto_disabled) {
paymentTerm.skontoPercent = null;
paymentTerm.skontoWithinDays = null;
}
@@ -1866,6 +1946,39 @@ async function sendInvoice(id, adminId) {
invoice.language = customer.preferred_language;
}
// Stamp the issue date at the moment the invoice actually goes out.
// A scheduled invoice's issue_date is provisional — set to the
// authoring day at creation — but the legal issue date is when it
// ships. Anchoring it here keeps the printed invoice date, the Skonto
// window (a relative "pay within N working days" counted from that
// date) and the net-days due date all consistent with the send date.
// Only on the first send (status 'scheduled'); 'sent' / 'overdue'
// rows are immutable legal records and keep their stamped date.
if (invoice.status === 'scheduled') {
const sendDateIso = new Date().toISOString().slice(0, 10);
const netDays = await resolveNetDaysForRow(invoice);
// Re-anchor the due date too, but only when it was machine-set: if
// the stored due_date still equals the auto formula off the OLD
// base (scheduled_send_at, else the old issue_date), the admin never
// hand-edited it and we slide it to the new issue date. A divergent
// value means a manual override (the editor's "Override due date"
// toggle) — leave it untouched.
const oldBase = invoice.scheduled_send_at
? new Date(invoice.scheduled_send_at)
: new Date(invoice.issue_date);
const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10);
const storedDue = invoice.due_date
? new Date(invoice.due_date).toISOString().slice(0, 10)
: null;
const updates = { issue_date: sendDateIso, updated_at: new Date() };
if (storedDue && storedDue === oldAutoDue) {
updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10);
}
await db('invoices').where({ id }).update(updates);
invoice.issue_date = updates.issue_date;
if (updates.due_date) invoice.due_date = updates.due_date;
}
const ctx = await buildInvoiceRenderContext(invoice, lineItems);
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
@@ -2537,7 +2650,9 @@ async function applyReminder(invoice, lineItems, level, adminId) {
contentPath: pdfPath,
contentType: 'application/pdf',
}],
});
// Dunning reminders are relationship mail — hold to business hours so
// the customer isn't pinged overnight (no-op unless hours configured).
}, { respectBusinessHours: true });
try {
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },
@@ -2577,6 +2692,17 @@ async function resolveSkontoPercentForInvoice(invoice) {
// installments that shouldn't qualify for the discount even when
// the global default offers it.
if (invoice.skonto_disabled) return null;
// Per-customer opt-out (migration 112) — a customer that negotiated
// "no Skonto" as a contract term never qualifies, so the admin
// doesn't have to tick the per-invoice toggle on every invoice.
// Falls through customer → invoice → snapshot → quote → global.
if (invoice.customer_account_id) {
const cust = await db('customer_accounts')
.where({ id: invoice.customer_account_id })
.select('skonto_disabled')
.first();
if (cust && cust.skonto_disabled) return null;
}
const parseSnap = (raw) => {
if (!raw) return null;
if (typeof raw === 'object') return raw;
+77 -1
View File
@@ -39,6 +39,7 @@ const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
const pdfService = require('./pdfService');
const emailProcessor = require('./emailProcessor');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const { hasColumnCached } = require('../utils/schemaCache');
const fs = require('fs');
const path = require('path');
@@ -333,7 +334,7 @@ function ensureCustomerFeatureEnabled(customer, feature) {
* Filters: { status[], customerAccountId, from, to, q }
* Sort: 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc'
*/
async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('quotes')
.leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id')
@@ -385,11 +386,22 @@ async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize =
case 'oldest':
query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc');
break;
case 'issue_asc':
query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc');
break;
case 'issue_desc':
query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc');
break;
case 'customer_asc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('quotes.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('quotes.id', 'desc');
break;
case 'value_asc':
query = query.orderBy('quotes.total_amount_minor', 'asc');
break;
@@ -1162,6 +1174,69 @@ async function adminAcceptQuote(id, adminId) {
return { status: 'accepted', lockedAt: responseLockedAt };
}
/**
* Admin "decline on behalf of customer" records the quote as
* `declined` directly, bypassing the public token + response window.
* Used when the customer says no by phone/email and the admin wants the
* pipeline reflected without asking them to click the decline link.
*
* Mirrors adminAcceptQuote's guards: refuses quotes that are already
* terminal (`accepted`, `declined`, `converted`) those would overwrite
* history. Allowed from `draft` / `sent` / `expired`.
*
* `reason` is optional free text persisted to `quotes.decline_reason`
* (migration 115) and surfaced on the quote detail page.
*
* Any outstanding accept/decline tokens are invalidated so the customer
* can't flip the quote back to accepted via a still-live emailed link.
*/
async function adminDeclineQuote(id, adminId, reason = null) {
const quote = await db('quotes').where({ id }).first();
if (!quote) throw new AppError('Quote not found', 404);
if (quote.status === 'declined') {
throw new AppError('Quote already declined', 409, 'QUOTE_ALREADY_DECLINED');
}
if (quote.status === 'accepted') {
throw new AppError('Quote already accepted; duplicate it to start a fresh round.', 409, 'QUOTE_ALREADY_ACCEPTED');
}
if (quote.status === 'converted') {
throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED');
}
const now = new Date();
const cleanReason = typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 5000) : null;
const hasReasonColumn = await hasColumnCached('quotes', 'decline_reason');
await db.transaction(async (trx) => {
const updates = {
status: 'declined',
responded_at: quote.responded_at || now,
// Close the public response window immediately so a customer link
// can't toggle the quote afterwards (recordResponse rejects once
// now > response_locked_at).
response_locked_at: now,
declined_at: now,
accepted_at: null,
updated_at: now,
};
if (hasReasonColumn) updates.decline_reason = cleanReason;
await trx('quotes').where({ id }).update(updates);
// Burn any unused tokens for this quote — defense in depth alongside
// the closed response window above.
await trx('quote_action_tokens')
.where({ quote_id: id })
.whereNull('used_at')
.update({ used_at: now, used_action: 'declined' });
});
try {
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
} catch (_) {}
return { status: 'declined', declinedAt: now };
}
/**
* Convert an accepted quote to an event + scheduled invoices.
* Wraps everything in a transaction so a half-finished conversion
@@ -1775,6 +1850,7 @@ module.exports = {
duplicateQuote,
recordResponse,
adminAcceptQuote,
adminDeclineQuote,
convertToEvent,
convertToInvoiceOnly,
+237
View File
@@ -0,0 +1,237 @@
/**
* Business hours + scheduled-email floor (migration 114).
*
* Pure, dependency-free time math. picpeak doesn't pull in a date library,
* so timezone handling leans on Intl.DateTimeFormat, which every supported
* Node build ships with full IANA data for.
*
* The schedule is per-ISO-weekday with any number of opening blocks, so a
* day can carry a lunch break (e.g. 09:0012:00 + 13:0018:00) or differ
* from its neighbours the Google-business-hours model. Shape:
*
* { "1": [{ start: "09:00", end: "12:00" }, { start: "13:00", end: "18:00" }],
* "2": [...], ..., "6": [], "7": [] }
*
* ISO weekday numbering throughout: 1=Mon 7=Sun. A weekday with no
* blocks is closed.
*
* Snap rule (nearest upcoming block-open; confirmed with the maintainer):
* - floor disabled / empty schedule unchanged
* - instant falls inside any block unchanged
* - instant before a later block same day that block's open
* (covers before-first-open AND lunch gaps)
* - otherwise first block of the next
* open day, at its open
*/
/**
* Wall-clock components of `date` as observed in IANA zone `tz`.
* Returns { y, mo, d, hh, mi, ss } with mo 1-12, hh 0-23.
*/
function getZonedParts(date, tz) {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const map = {};
for (const part of dtf.formatToParts(date)) {
if (part.type !== 'literal') map[part.type] = part.value;
}
let hh = parseInt(map.hour, 10);
// Some engines render midnight as "24" under hour12:false; normalise.
if (hh === 24) hh = 0;
return {
y: parseInt(map.year, 10),
mo: parseInt(map.month, 10),
d: parseInt(map.day, 10),
hh,
mi: parseInt(map.minute, 10),
ss: parseInt(map.second, 10) || 0,
};
}
/** ISO weekday (1=Mon … 7=Sun) for a plain calendar date. */
function isoWeekday(y, mo, d) {
const dow = new Date(Date.UTC(y, mo - 1, d)).getUTCDay(); // 0=Sun … 6=Sat
return dow === 0 ? 7 : dow;
}
/** Offset (ms) between zone `tz` wall-clock and UTC at instant `utcMs`. */
function tzOffsetMs(utcMs, tz) {
const p = getZonedParts(new Date(utcMs), tz);
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
return asUtc - utcMs;
}
/**
* Convert a wall-clock time (interpreted in zone `tz`) to a UTC instant.
* Two-pass offset resolution so it stays correct across DST boundaries.
*/
function zonedWallClockToUtc(y, mo, d, hh, mi, tz) {
const naiveUtc = Date.UTC(y, mo - 1, d, hh, mi, 0);
let result = naiveUtc - tzOffsetMs(naiveUtc, tz);
result = naiveUtc - tzOffsetMs(result, tz);
return new Date(result);
}
/** "HH:MM" → minutes from midnight, or null when malformed. */
function parseHHMM(value) {
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value || ''));
if (!m) return null;
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}
/** minutes from midnight → "HH:MM" (24h, zero-padded). */
function minutesToHHMM(minutes) {
const hh = Math.floor(minutes / 60);
const mi = minutes % 60;
return `${String(hh).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
}
/** Add `n` calendar days to a {y,mo,d}, returning the same shape. */
function addDays(y, mo, d, n) {
const dt = new Date(Date.UTC(y, mo - 1, d + n));
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
}
/**
* Validate + clean a raw weekly schedule into the canonical storage shape.
*
* Accepts the JSON object (or a JSON string), keyed by ISO weekday. Each
* day's value is an array of blocks; a block may be {start,end} or a
* [start,end] pair. Invalid blocks (bad HH:MM, end<=start) are dropped;
* blocks are sorted by start. Days are emitted as string keys "1".."7"
* with an array value (possibly empty = closed).
*
* Returns { "1": [{start,end}], ..., "7": [] } never throws.
*/
function normaliseSchedule(raw) {
let obj = raw;
if (typeof raw === 'string') {
try { obj = JSON.parse(raw); } catch (_) { obj = null; }
}
const out = {};
for (let iso = 1; iso <= 7; iso += 1) out[String(iso)] = [];
if (!obj || typeof obj !== 'object') return out;
for (let iso = 1; iso <= 7; iso += 1) {
const dayRaw = obj[String(iso)] !== undefined ? obj[String(iso)] : obj[iso];
if (!Array.isArray(dayRaw)) continue;
const blocks = [];
for (const b of dayRaw) {
let startStr;
let endStr;
if (Array.isArray(b)) {
[startStr, endStr] = b;
} else if (b && typeof b === 'object') {
startStr = b.start;
endStr = b.end;
}
const startMin = parseHHMM(startStr);
const endMin = parseHHMM(endStr);
if (startMin == null || endMin == null || endMin <= startMin) continue;
blocks.push({ start: minutesToHHMM(startMin), end: minutesToHHMM(endMin), startMin, endMin });
}
blocks.sort((a, b) => a.startMin - b.startMin);
out[String(iso)] = blocks.map((b) => ({ start: b.start, end: b.end }));
}
return out;
}
/** True when at least one weekday carries at least one opening block. */
function hasAnyBlocks(schedule) {
if (!schedule || typeof schedule !== 'object') return false;
for (let iso = 1; iso <= 7; iso += 1) {
const day = schedule[String(iso)];
if (Array.isArray(day) && day.length > 0) return true;
}
return false;
}
/** Blocks for an ISO weekday as sorted {startMin,endMin}, parsed fresh. */
function blocksForDay(schedule, iso) {
const day = schedule[String(iso)];
if (!Array.isArray(day)) return [];
const out = [];
for (const b of day) {
const startMin = parseHHMM(b && b.start);
const endMin = parseHHMM(b && b.end);
if (startMin == null || endMin == null || endMin <= startMin) continue;
out.push({ startMin, endMin });
}
out.sort((a, b) => a.startMin - b.startMin);
return out;
}
/**
* Snap a Date to the configured business-hours window.
*
* @param {Date} date the requested send instant
* @param {Object} cfg
* @param {boolean} cfg.enabled
* @param {string} cfg.timezone IANA zone (resolved by caller; never "")
* @param {Object} cfg.schedule per-ISO-weekday blocks (see module docs)
* @returns {Date} the (possibly unchanged) send instant
*/
function snapToBusinessHours(date, cfg) {
if (!cfg || !cfg.enabled) return date;
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return date;
const { timezone, schedule } = cfg;
if (!hasAnyBlocks(schedule)) return date; // nothing to snap to
const p = getZonedParts(date, timezone);
const iso = isoWeekday(p.y, p.mo, p.d);
const tMin = p.hh * 60 + p.mi;
const today = blocksForDay(schedule, iso);
for (const block of today) {
// Inside an open block → leave the instant untouched.
if (tMin >= block.startMin && tMin < block.endMin) return date;
}
// Before a later block today (covers before-first-open AND lunch gaps):
// snap up to the nearest block whose open is still ahead.
for (const block of today) {
if (tMin < block.startMin) {
return zonedWallClockToUtc(
p.y, p.mo, p.d, Math.floor(block.startMin / 60), block.startMin % 60, timezone
);
}
}
// After the last block today, or a closed day: walk forward to the first
// open block of the next open day. Bounded at 14 days as a safety stop;
// hasAnyBlocks above guarantees the loop terminates well within that.
let cur = { y: p.y, mo: p.mo, d: p.d };
for (let i = 1; i <= 14; i += 1) {
cur = addDays(cur.y, cur.mo, cur.d, 1);
const dayBlocks = blocksForDay(schedule, isoWeekday(cur.y, cur.mo, cur.d));
if (dayBlocks.length > 0) {
const open = dayBlocks[0].startMin;
return zonedWallClockToUtc(
cur.y, cur.mo, cur.d, Math.floor(open / 60), open % 60, timezone
);
}
}
return date;
}
module.exports = {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal: {
getZonedParts,
isoWeekday,
zonedWallClockToUtc,
addDays,
blocksForDay,
},
};
+13 -4
View File
@@ -41,6 +41,12 @@ const { getStoragePath } = require('../config/storage');
const { getAppSetting } = require('./appSettings');
const logger = require('./logger');
// Bump this whenever the SVG→PNG rendering environment changes in a way that
// changes output, to invalidate previously-cached rasterisations.
// v2 (2026-06): backend image now ships fonts (fontconfig + brand fonts),
// so SVG logos with live <text> render their text instead of tofu boxes.
const RASTER_VERSION = 'v2-fonts';
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.
@@ -96,11 +102,14 @@ async function rasteriseToPng(sourcePath, storageRoot) {
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.
// Content-addressed cache: sha1(renderer version + src path + mtime ns
// + size). Including mtime means re-uploading the source invalidates the
// cache entry naturally. RASTER_VERSION is bumped whenever the rendering
// environment changes in a way that affects output (e.g. installing fonts
// so SVG <text> stops rendering as tofu) — bumping it invalidates every
// previously-cached PNG without having to clear the cache dir by hand.
const key = crypto.createHash('sha1')
.update(`${sourcePath}|${stat.mtimeMs}|${stat.size}`)
.update(`${RASTER_VERSION}|${sourcePath}|${stat.mtimeMs}|${stat.size}`)
.digest('hex');
const cachedPath = path.join(cacheDir, `${key}.png`);
if (fs.existsSync(cachedPath)) {