From 840df525817f1bcfa65dbf1fd47e4adbe349c13c Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 2 Jun 2026 01:15:40 +0200
Subject: [PATCH 01/59] fix(crm): respect general_date_format on all admin date
inputs
Admin date inputs were inconsistent: raw on event
creation and the bill editor rendered in the browser locale (en-US users
saw MM/DD/YYYY regardless of Settings -> General), while the historical-
invoice import modal used a private LocalizedDateField that displayed the
configured format but showed a text box plus a tiny native date stub
side-by-side ("two date fields, looks corrupted").
Extract a single shared LocalizedDateInput that displays/parses in the
configured general_date_format on every browser and opens the native
picker via a calendar icon button (showPicker on a visually-hidden native
input), so there is one date field, not two. Wire it into event creation,
the bill editor (event/issue/due dates), the import modal, and the tax-
report range filters (dropping the Chromium-only lang={dateInputLang}
workaround there).
---
.../components/common/LocalizedDateInput.tsx | 168 ++++++++++++++++++
frontend/src/components/common/index.ts | 1 +
frontend/src/pages/admin/CreateEventPage.tsx | 8 +-
.../src/pages/admin/bills/BillEditorPage.tsx | 10 +-
.../src/pages/admin/bills/BillsListPage.tsx | 116 +-----------
.../src/pages/admin/clients/TaxReportPage.tsx | 22 +--
6 files changed, 188 insertions(+), 137 deletions(-)
create mode 100644 frontend/src/components/common/LocalizedDateInput.tsx
diff --git a/frontend/src/components/common/LocalizedDateInput.tsx b/frontend/src/components/common/LocalizedDateInput.tsx
new file mode 100644
index 00000000..03fe0003
--- /dev/null
+++ b/frontend/src/components/common/LocalizedDateInput.tsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { clsx } from 'clsx';
+import { Calendar } from 'lucide-react';
+import { useLocalizedDate } from '../../hooks/useLocalizedDate';
+
+/**
+ * Date input that displays + accepts values in the admin-configured
+ * format from Settings → General (`general_date_format`), independent
+ * of the browser locale. Stores + emits ISO (YYYY-MM-DD) so the rest
+ * of the form / API surface keeps the canonical shape.
+ *
+ * A native `` always renders in the browser's own
+ * locale (en-US users see MM/DD/YYYY) no matter what the app is
+ * configured for, so it can't be used directly. This component shows
+ * a plain text input in the configured format and parses on blur. A
+ * calendar icon button opens the native date picker (via showPicker())
+ * off a visually-hidden native input, giving the click-to-pick
+ * affordance without rendering a second visible date box.
+ */
+interface LocalizedDateInputProps {
+ label?: string;
+ value: string;
+ onChange: (iso: string) => void;
+ error?: string;
+ /** Forwarded to the native picker so min/max date constraints work. */
+ min?: string;
+ max?: string;
+ disabled?: boolean;
+}
+
+export const LocalizedDateInput: React.FC = ({
+ label,
+ value,
+ onChange,
+ error,
+ min,
+ max,
+ disabled,
+}) => {
+ const { dateFormat } = useLocalizedDate();
+ const nativeRef = React.useRef(null);
+ const inputId = React.useId();
+
+ // Normalise the configured format down to the four shapes the parser
+ // understands. Defaults to DD.MM.YYYY (the operator's primary locale)
+ // when unknown.
+ const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => {
+ const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase();
+ if (f.startsWith('mm/dd')) return 'MM/DD/YYYY';
+ if (f.startsWith('yyyy')) return 'YYYY-MM-DD';
+ if (f.includes('/')) return 'DD/MM/YYYY';
+ return 'DD.MM.YYYY';
+ })();
+ const placeholder = normalisedFormat.toLowerCase();
+
+ // ISO → display
+ const toDisplay = (iso: string): string => {
+ if (!iso) return '';
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
+ if (!m) return iso;
+ const [, y, mo, d] = m;
+ switch (normalisedFormat) {
+ case 'MM/DD/YYYY': return `${mo}/${d}/${y}`;
+ case 'YYYY-MM-DD': return `${y}-${mo}-${d}`;
+ case 'DD/MM/YYYY': return `${d}/${mo}/${y}`;
+ case 'DD.MM.YYYY':
+ default: return `${d}.${mo}.${y}`;
+ }
+ };
+
+ // display → ISO (accepts variant separators leniently)
+ const toIso = (raw: string): string => {
+ const s = raw.trim();
+ if (!s) return '';
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
+ const parts = s.split(/[./-]/);
+ if (parts.length !== 3) return '';
+ const [a, b, c] = parts;
+ let y: string, mo: string, d: string;
+ if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) {
+ [y, mo, d] = [a, b, c];
+ } else if (normalisedFormat === 'MM/DD/YYYY') {
+ [mo, d, y] = [a, b, c];
+ } else {
+ [d, mo, y] = [a, b, c];
+ }
+ if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
+ return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
+ };
+
+ const [text, setText] = React.useState(toDisplay(value));
+ React.useEffect(() => {
+ setText(toDisplay(value));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value]);
+
+ const openPicker = () => {
+ const el = nativeRef.current;
+ if (!el) return;
+ try {
+ el.showPicker();
+ } catch {
+ // showPicker throws on unsupported browsers / outside a user
+ // gesture — the text field stays fully usable for typing.
+ }
+ };
+
+ return (
+
+ {label && (
+
+ )}
+
+ setText(e.target.value)}
+ onBlur={() => {
+ const iso = toIso(text);
+ if (iso) {
+ onChange(iso);
+ setText(toDisplay(iso));
+ } else if (!text.trim()) {
+ onChange('');
+ }
+ }}
+ className={clsx('input pr-10', error && 'border-red-500 focus-visible:ring-red-500')}
+ aria-invalid={error ? 'true' : 'false'}
+ aria-describedby={error ? `${inputId}-error` : undefined}
+ />
+
+ {/* Visually hidden native picker — its only job is to provide
+ the calendar popup the icon button triggers. Value stays in
+ ISO so it's always parseable. */}
+ onChange(e.target.value)}
+ tabIndex={-1}
+ aria-hidden="true"
+ className="sr-only"
+ />
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+};
diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts
index 46702452..dc5984ea 100644
--- a/frontend/src/components/common/index.ts
+++ b/frontend/src/components/common/index.ts
@@ -1,6 +1,7 @@
export { Button } from './Button';
export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input';
+export { LocalizedDateInput } from './LocalizedDateInput';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading';
export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary';
diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx
index 99cd4422..e5df23f7 100644
--- a/frontend/src/pages/admin/CreateEventPage.tsx
+++ b/frontend/src/pages/admin/CreateEventPage.tsx
@@ -15,7 +15,7 @@ import {
import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
-import { Button, Input, Card, PasswordGenerator } from '../../components/common';
+import { Button, Input, Card, PasswordGenerator, LocalizedDateInput } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -587,13 +587,11 @@ export const CreateEventPage: React.FC = () => {
leftIcon={}
/>
- setFormData(prev => ({ ...prev, event_date: iso }))}
error={errors.event_date}
- leftIcon={}
/>
diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx
index ff6d0258..38edc81e 100644
--- a/frontend/src/pages/admin/bills/BillEditorPage.tsx
+++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react';
-import { Button, Card, Loading, Input } from '../../../components/common';
+import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
import { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service';
import { quotesService } from '../../../services/quotes.service';
import { contractsService } from '../../../services/contracts.service';
@@ -488,8 +488,8 @@ export const BillEditorPage: React.FC = () => {
From db2c482ae9d1049960df443c1e224a86b6e8ff2f Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 2 Jun 2026 01:28:00 +0200
Subject: [PATCH 02/59] feat(crm): country dropdown + name guard for customer
create/edit
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.
Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).
Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.
i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
---
.../110_normalize_country_code_fl_to_li.js | 40 ++++++++++
backend/src/routes/adminCustomers.js | 11 +++
.../components/admin/InlineCustomerCreate.tsx | 42 ++++++----
.../src/components/common/CountrySelect.tsx | 77 +++++++++++++++++++
frontend/src/components/common/index.ts | 1 +
frontend/src/constants/countries.ts | 31 ++++++++
frontend/src/i18n/locales/de.json | 5 +-
frontend/src/i18n/locales/en.json | 5 +-
.../src/pages/admin/CustomerDetailPage.tsx | 16 ++--
9 files changed, 202 insertions(+), 26 deletions(-)
create mode 100644 backend/migrations/core/110_normalize_country_code_fl_to_li.js
create mode 100644 frontend/src/components/common/CountrySelect.tsx
create mode 100644 frontend/src/constants/countries.ts
diff --git a/backend/migrations/core/110_normalize_country_code_fl_to_li.js b/backend/migrations/core/110_normalize_country_code_fl_to_li.js
new file mode 100644
index 00000000..26cba940
--- /dev/null
+++ b/backend/migrations/core/110_normalize_country_code_fl_to_li.js
@@ -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() {};
diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js
index 6f178d66..dc95bc9f 100644
--- a/backend/src/routes/adminCustomers.js
+++ b/backend/src/routes/adminCustomers.js
@@ -245,6 +245,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({
diff --git a/frontend/src/components/admin/InlineCustomerCreate.tsx b/frontend/src/components/admin/InlineCustomerCreate.tsx
index 41ad4b47..1bca4d5c 100644
--- a/frontend/src/components/admin/InlineCustomerCreate.tsx
+++ b/frontend/src/components/admin/InlineCustomerCreate.tsx
@@ -23,7 +23,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Send, X } from 'lucide-react';
-import { Button, Input } from '../common';
+import { Button, CountrySelect, Input } from '../common';
import {
customerAdminService,
type CustomerAccountDetail,
@@ -136,28 +136,42 @@ export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mod
staleTime: 5 * 60 * 1000,
});
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
+ const profileCountryCode = profileSnapshot?.profile?.countryCode || '';
- // Seed preferredLanguage with the profile default once the profile
- // arrives (only if the field is still empty so we don't clobber
- // explicit user input).
+ // Seed preferredLanguage + countryCode with the profile defaults once
+ // the profile arrives (only if the field is still empty so we don't
+ // clobber explicit user input).
React.useEffect(() => {
- if (profileDefaultLocale && !form.preferredLanguage) {
- setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
- }
+ setForm((prev) => {
+ const next = { ...prev };
+ if (profileDefaultLocale && !prev.preferredLanguage) next.preferredLanguage = profileDefaultLocale;
+ if (profileCountryCode && !prev.countryCode) next.countryCode = profileCountryCode.toUpperCase();
+ return next;
+ });
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [profileDefaultLocale]);
+ }, [profileDefaultLocale, profileCountryCode]);
const setField = (key: keyof FormState) =>
(e: React.ChangeEvent) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
- const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email);
+ const hasEmail = !!form.email && /\S+@\S+\.\S+/.test(form.email);
+ // At least one human-readable identifier so the record isn't a
+ // nameless row that's impossible to recognise in lists later.
+ const hasName = !!(form.companyName.trim() || form.displayName.trim()
+ || form.firstName.trim() || form.lastName.trim());
+ const isValid = hasEmail && hasName;
const handleSave = async (mode: 'passive' | 'invite') => {
- if (!isValid) {
+ if (!hasEmail) {
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
return;
}
+ if (!hasName) {
+ toast.error(t('customers.create.nameRequired',
+ 'Enter at least a company name or a contact name.'));
+ return;
+ }
setBusy(mode);
try {
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
@@ -298,12 +312,10 @@ export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mod
value={form.state}
onChange={setField('state')}
/>
- setForm((prev) => ({ ...prev, countryCode: code }))}
/>
diff --git a/frontend/src/components/common/CountrySelect.tsx b/frontend/src/components/common/CountrySelect.tsx
new file mode 100644
index 00000000..8ed61382
--- /dev/null
+++ b/frontend/src/components/common/CountrySelect.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { clsx } from 'clsx';
+import { useTranslation } from 'react-i18next';
+import { countryLabel, sortedCountryOptions } from '../../constants/countries';
+
+/**
+ * Country picker whose option labels are localized country names but
+ * whose stored/emitted value is always the ISO 3166-1 alpha-2 code.
+ * Labels come from `Intl.DisplayNames` in the active UI language, so the
+ * list stays locale-aware without a hand-maintained translation map.
+ *
+ * A value that isn't in the curated list (e.g. legacy data) is preserved
+ * as its own option so editing an existing record never silently drops it.
+ */
+interface CountrySelectProps {
+ label?: string;
+ value: string;
+ onChange: (code: string) => void;
+ error?: string;
+ disabled?: boolean;
+ /** Label for the empty option; defaults to a translated placeholder. */
+ placeholder?: string;
+}
+
+export const CountrySelect: React.FC = ({
+ label,
+ value,
+ onChange,
+ error,
+ disabled,
+ placeholder,
+}) => {
+ const { i18n, t } = useTranslation();
+ const lang = i18n.language || 'en';
+ const selectId = React.useId();
+
+ const options = sortedCountryOptions(lang);
+ const current = (value || '').trim().toUpperCase();
+ const hasCurrent = options.some((o) => o.code === current);
+
+ return (
+
{/* Free-text country name override (migration 107). When
left empty the PDF renderer falls back to the locale-
- aware lookup on the abbreviation; useful when the
- abbreviation isn't an ISO code (e.g. "FL" for
- Liechtenstein, which is "LI" in ISO). */}
+ aware lookup on the ISO code. Kept for the rare case
+ where an operator wants a custom display name that
+ differs from the standard localized label. */}
{t('customers.detail.countryName', 'Country (full name)')}
Date: Tue, 2 Jun 2026 01:36:52 +0200
Subject: [PATCH 03/59] fix(crm): anchor imported invoice dates to issue_date,
not import time
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The invoice-import endpoint stamped sent_at and paid_at with the moment
of import (new Date()) instead of the document's historical dates. The
CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a
year-old paid invoice imported today wrongly counted toward the rolling
window. The dashboard windowing is correct (cash-basis "received in the
window") — the bug was the wrong paid_at on imported rows.
POST /admin/invoices/import now anchors sent_at to issue_date and
paid_at to issue_date (or an optional new paidAt param when the admin
knows the real payment date), never to import time.
Migration 111 backfills rows imported under the old behaviour: for every
invoice with imported_pdf_path set, sent_at/paid_at are reset to
issue_date. The old code never captured a real payment date, so
issue_date is the only sensible anchor. Idempotent and scoped strictly
to imported rows, so picpeak-issued invoices are untouched.
paid_at/sent_at are operational timestamps, not the invoice's immutable
legal content, so correcting the import-time error is safe under the
§14/§11 UStG immutability rule.
---
.../111_backfill_imported_invoice_dates.js | 47 +++++++++++++++++++
backend/src/routes/adminInvoices.js | 14 +++++-
2 files changed, 59 insertions(+), 2 deletions(-)
create mode 100644 backend/migrations/core/111_backfill_imported_invoice_dates.js
diff --git a/backend/migrations/core/111_backfill_imported_invoice_dates.js b/backend/migrations/core/111_backfill_imported_invoice_dates.js
new file mode 100644
index 00000000..47068067
--- /dev/null
+++ b/backend/migrations/core/111_backfill_imported_invoice_dates.js
@@ -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() {};
diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js
index c8f4e7c5..dd077a10 100644
--- a/backend/src/routes/adminInvoices.js
+++ b/backend/src/routes/adminInvoices.js
@@ -417,6 +417,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',
@@ -431,6 +433,7 @@ router.post(
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) => {
@@ -470,6 +473,13 @@ router.post(
const status = req.body.status || 'sent';
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';
@@ -488,14 +498,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).
From 522cf2aa4a58a3126ce08c780ba46d4b53149f23 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 2 Jun 2026 01:52:45 +0200
Subject: [PATCH 04/59] fix(invoices): auto-track due date from send date +
payment term
Due date now derives from (scheduled send date else issue date) plus the
selected Net-days template, both in the editor and on save. The bill
editor renders it read-only with an Override toggle for manual entry;
existing invoices preserve their stored due date. Backend adds a single
resolveNetDays resolver that honors the split payment-net-days template
(previously only the legacy FK was read) and the
crm_payment_default_net_days setting, used by createInvoice and the
installment-spawn path alike.
---
backend/src/services/invoiceService.js | 70 ++++++++++++++-----
frontend/src/i18n/locales/de.json | 2 +
frontend/src/i18n/locales/en.json | 2 +
.../src/pages/admin/bills/BillEditorPage.tsx | 49 ++++++++++++-
4 files changed, 106 insertions(+), 17 deletions(-)
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index ab02c247..19b3f818 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -104,6 +104,46 @@ function computeDueDate(scheduledSendAt, netDays = 30) {
return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000);
}
+/**
+ * Resolve the net-days a new invoice's due date should be anchored to.
+ * Single source of truth so the editor (split picker), legacy callers,
+ * and quote→invoice conversion all land on the same number. Priority:
+ *
+ * 1. `payload.netDays` — explicit caller override (installment spawn
+ * passes the snapshot's net_days here).
+ * 2. Split picker (migration 124): payment_net_days_templates.net_days
+ * via `payload.paymentNetDaysTemplateId`. This is what the bill
+ * editor actually sends; the old code only read the legacy FK and
+ * so silently ignored Net 60 / 90 selections.
+ * 3. Legacy single FK: payment_term_templates.net_days via
+ * `payload.paymentTermTemplateId`.
+ * 4. The `crm_payment_default_net_days` setting (admin-configured).
+ * 5. 30 — historical hard default.
+ */
+async function resolveNetDays(payload, trx = db) {
+ if (payload && payload.netDays != null && payload.netDays !== '') {
+ const n = ensureInt(payload.netDays);
+ if (n) return n;
+ }
+ if (payload && payload.paymentNetDaysTemplateId) {
+ const probe = await trx('payment_net_days_templates')
+ .where({ id: payload.paymentNetDaysTemplateId })
+ .select('net_days')
+ .first();
+ if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
+ }
+ if (payload && payload.paymentTermTemplateId) {
+ const probe = await trx('payment_term_templates')
+ .where({ id: payload.paymentTermTemplateId })
+ .select('net_days')
+ .first();
+ if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
+ }
+ const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
+ if (setting) return setting;
+ return 30;
+}
+
/**
* Resolve the deal_uuid for a new invoice row (migration 140). Priority:
*
@@ -642,18 +682,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 +962,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 = [];
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 415999bf..a6062a2c 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3512,6 +3512,8 @@
"total": "Gesamt",
"sourceQuote": "Vom Angebot",
"issueDate": "Ausgestellt am",
+ "dueDateOverrideOn": "Manuelles Fälligkeitsdatum — Häkchen entfernen, um es automatisch aus Versanddatum + Zahlungsziel zu berechnen",
+ "dueDateOverrideOff": "Automatisch aus Versanddatum + Zahlungsziel — ankreuzen, um es manuell zu setzen",
"scheduledSendAt": "Geplanter Versand (optional)",
"installment": "Rate",
"paid": "Bezahlt",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 306f7e5f..45da5bbe 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3557,6 +3557,8 @@
"field": {
"issueDate": "Issued",
"dueDate": "Due",
+ "dueDateOverrideOn": "Manual due date — untick to auto-set from send date + payment term",
+ "dueDateOverrideOff": "Auto from send date + payment term — tick to set manually",
"scheduledSendAt": "Scheduled send",
"installment": "Installment",
"total": "Total",
diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx
index 38edc81e..8426117d 100644
--- a/frontend/src/pages/admin/bills/BillEditorPage.tsx
+++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx
@@ -48,6 +48,12 @@ export const BillEditorPage: React.FC = () => {
const [currency, setCurrency] = useState('CHF');
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
const [dueDate, setDueDate] = useState('');
+ // Due date is normally view-only: it auto-tracks (send date else issue
+ // date) + the selected Net-days template, so the payment clock starts
+ // on the day the invoice actually goes out. Flipping this lets the
+ // admin type a different date by hand; we keep it pinned so the auto
+ // effect below stops clobbering their value.
+ const [dueDateOverridden, setDueDateOverridden] = useState(false);
const [scheduledSendAt, setScheduledSendAt] = useState('');
// null = inherit profile default at render time. 'none' / 'swiss' /
// 'epc' = explicit per-invoice override. (Existing invoices that
@@ -124,6 +130,10 @@ export const BillEditorPage: React.FC = () => {
setCurrency(inv.currency);
setIssueDate(inv.issueDate);
setDueDate(inv.dueDate);
+ // The invoice already carries a due date — preserve it rather than
+ // letting the auto effect recompute and surprise the admin. They
+ // can untick "Override" to re-enable auto-tracking.
+ setDueDateOverridden(true);
setScheduledSendAt(inv.scheduledSendAt ? inv.scheduledSendAt.slice(0, 16) : '');
// Preserve null when the saved invoice has no explicit format —
// it inherits the profile default at render time.
@@ -317,6 +327,25 @@ export const BillEditorPage: React.FC = () => {
setPaymentTimingTemplateId((prev) => prev ?? defaultTiming.id);
}, [isEdit, netDaysTemplates, timingTemplates, appSettings]);
+ // Auto-track the due date off (scheduled send date else issue date) +
+ // the selected Net-days template, mirroring the backend's
+ // computeDueDate. The clock starts the day the invoice goes out, so
+ // scheduling a future send pushes the due date out with it. Skipped
+ // once the admin overrides the field by hand. Date math is in UTC to
+ // match the backend (which parses the YYYY-MM-DD base as UTC midnight).
+ useEffect(() => {
+ if (dueDateOverridden) return;
+ const base = (scheduledSendAt ? scheduledSendAt.slice(0, 10) : issueDate) || '';
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(base)) return;
+ const tpl = netDaysTemplates?.templates?.find((t) => t.id === paymentNetDaysTemplateId);
+ const netDays = tpl?.netDays != null
+ ? Number(tpl.netDays)
+ : Number(appSettings?.crm_payment_default_net_days) || 30;
+ const d = new Date(`${base}T00:00:00Z`);
+ d.setUTCDate(d.getUTCDate() + netDays);
+ setDueDate(d.toISOString().slice(0, 10));
+ }, [dueDateOverridden, scheduledSendAt, issueDate, paymentNetDaysTemplateId, netDaysTemplates, appSettings]);
+
const buildPayload = (): InvoiceCreatePayload => ({
customerAccountId: customerId || 0,
currency,
@@ -501,7 +530,25 @@ export const BillEditorPage: React.FC = () => {
{t('bills.section.details', 'Details')}
-
+
+
+
+ setDueDateOverridden(e.target.checked)}
+ className="rounded border-neutral-300 dark:border-neutral-600"
+ />
+ {dueDateOverridden
+ ? t('bills.field.dueDateOverrideOn', 'Manual due date — untick to auto-set from send date + payment term')
+ : t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
+
+
setScheduledSendAt(e.target.value)} />
From 7c39a30957a59c379b6abe6702798923488a9021 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 2 Jun 2026 08:38:07 +0200
Subject: [PATCH 05/59] feat(bills): link event label to its event detail page
The Anlass / event name on the invoice detail page and the bills list
now links through to /admin/events/:id when the invoice references a
real event row. The list link stops propagation so it doesn't trigger
the row's invoice navigation. Falls back to plain text when the invoice
carries only a free-text event snapshot. Customer portal unchanged
(no admin route access).
---
frontend/src/pages/admin/bills/BillDetailPage.tsx | 9 ++++++++-
frontend/src/pages/admin/bills/BillsListPage.tsx | 8 +++++++-
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx
index cee6b480..cfdd0ece 100644
--- a/frontend/src/pages/admin/bills/BillDetailPage.tsx
+++ b/frontend/src/pages/admin/bills/BillDetailPage.tsx
@@ -386,7 +386,14 @@ export const BillDetailPage: React.FC = () => {
From 45c7cc80ab68d00d3dc9b84dcf5f29e5d918aef5 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 2 Jun 2026 08:54:23 +0200
Subject: [PATCH 06/59] fix(invoices): anchor issue date + Skonto window to the
actual send date
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A scheduled invoice's issue_date was stamped at creation, so a long-
scheduled invoice printed a stale date by the time it shipped — the
relative Skonto window ("pay within N working days") and the net-days
due date were then counted from the authoring day, not the send day.
sendInvoice now stamps issue_date = send date on the first send and
re-derives the due date from it, preserving a manual due-date override.
Adds resolveNetDaysForRow to read net days from the persisted snapshot.
Deselecting Skonto before the scheduled send already propagates (the
scheduler re-reads the row fresh and the render context honours
skonto_disabled); no change needed there.
---
backend/src/services/invoiceService.js | 52 ++++++++++++++++++++++++++
1 file changed, 52 insertions(+)
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 19b3f818..356f98c5 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -144,6 +144,25 @@ async function resolveNetDays(payload, trx = db) {
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:
*
@@ -1904,6 +1923,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);
From d788a6cde50a4146123bc7efadca314d6916c2a1 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 2 Jun 2026 09:00:05 +0200
Subject: [PATCH 07/59] feat(crm): per-customer Skonto opt-out
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds customer_accounts.skonto_disabled (migration 112) so a customer
that negotiated "no early-payment discount" can be flagged once instead
of ticking the per-invoice toggle on every invoice. resolveSkontoPercent
ForInvoice and the PDF render context both honour it, extending the
resolution chain to customer → invoice → snapshot → quote → global.
Checkbox added to the customer detail Billing card (en + de).
---
.../core/112_add_customer_skonto_disabled.js | 34 +++++++++++++++++++
backend/src/routes/adminCustomers.js | 6 ++++
.../src/services/customerAccountsService.js | 4 +++
backend/src/services/invoiceService.js | 17 ++++++++--
frontend/src/i18n/locales/de.json | 2 ++
frontend/src/i18n/locales/en.json | 2 ++
.../src/pages/admin/CustomerDetailPage.tsx | 22 +++++++++++-
.../src/services/customerAdmin.service.ts | 6 ++++
8 files changed, 90 insertions(+), 3 deletions(-)
create mode 100644 backend/migrations/core/112_add_customer_skonto_disabled.js
diff --git a/backend/migrations/core/112_add_customer_skonto_disabled.js b/backend/migrations/core/112_add_customer_skonto_disabled.js
new file mode 100644
index 00000000..89982547
--- /dev/null
+++ b/backend/migrations/core/112_add_customer_skonto_disabled.js
@@ -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');
+ });
+};
diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js
index dc95bc9f..4b2b8606 100644
--- a/backend/src/routes/adminCustomers.js
+++ b/backend/src/routes/adminCustomers.js
@@ -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,
@@ -394,6 +398,8 @@ router.put('/:id', [
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']),
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 })
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
+ // Per-customer Skonto opt-out (migration 112).
+ body('skonto_disabled').optional().isBoolean(),
], handleAsync(async (req, res) => {
validateRequest(req);
const customer = await customerAccountsService.updateCustomer(
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
index 9b32c759..5a11c6c7 100644
--- a/backend/src/services/customerAccountsService.js
+++ b/backend/src/services/customerAccountsService.js
@@ -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') {
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 356f98c5..83467b9d 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -1690,8 +1690,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;
}
@@ -2667,6 +2669,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;
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index a6062a2c..d292050a 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3134,6 +3134,8 @@
"quarterly": "Quartalsweise",
"cycleDay": "Stichtag",
"cycleDayHint": "1..28 = Tag im Monat. Negativ -1..-15 für „N Tage vor Monatsende“ (so löst -3 in einem 31-Tage-Monat am 28. aus).",
+ "skontoDisabled": "Kein Skonto für diesen Kunden",
+ "skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden – unabhängig von Vorlage oder globalen Standardwerten.",
"triggerNow": "Rechnung jetzt ausstellen",
"triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
"triggerHint": "Überspringt den Stichtag und stellt den aktuellen Entwurf sofort aus. Wird abgelehnt, wenn für die aktuelle Periode nichts erfasst wurde.",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 45da5bbe..97077185 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3134,6 +3134,8 @@
"quarterly": "Quarterly",
"cycleDay": "Cycle day",
"cycleDayHint": "1..28 = day of month. Use negative -1..-15 for \"N days before month end\" (so -3 fires on the 28th of a 31-day month).",
+ "skontoDisabled": "No Skonto for this customer",
+ "skontoDisabledHint": "Disables the early-payment discount on all of this customer’s invoices, regardless of template or global defaults.",
"triggerNow": "Trigger invoice now",
"triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.",
"triggerHint": "Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.",
diff --git a/frontend/src/pages/admin/CustomerDetailPage.tsx b/frontend/src/pages/admin/CustomerDetailPage.tsx
index dafbf18a..b56bf2d7 100644
--- a/frontend/src/pages/admin/CustomerDetailPage.tsx
+++ b/frontend/src/pages/admin/CustomerDetailPage.tsx
@@ -40,7 +40,7 @@ type EditableFields =
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
- | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay';
+ | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND
@@ -140,6 +140,7 @@ export const CustomerDetailPage: React.FC = () => {
hourlyRateMinor: customer.hourlyRateMinor ?? null,
billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1,
+ skontoDisabled: customer.skontoDisabled ?? false,
} as any);
}
}, [customer, form]);
@@ -738,6 +739,25 @@ export const CustomerDetailPage: React.FC = () => {
)}
+ {/* Per-customer Skonto opt-out (migration 112). For B2B
+ customers who negotiated "no early-payment discount" — set
+ once instead of ticking the per-invoice toggle every time. */}
+
+ setForm((prev) => ({ ...prev, skontoDisabled: e.target.checked } as any))}
+ className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600"
+ />
+
+ {t('customers.billing.skontoDisabled', 'No Skonto for this customer')}
+
+ {t('customers.billing.skontoDisabledHint',
+ 'Disables the early-payment discount on all of this customer’s invoices, regardless of template or global defaults.')}
+
+
+
+
{/* Preview of the open monthly draft (migration 128). Shows
every line item queued for the customer's current billing
period so admin sees exactly what "Trigger invoice now"
diff --git a/frontend/src/services/customerAdmin.service.ts b/frontend/src/services/customerAdmin.service.ts
index 2602d9af..9ecdbded 100644
--- a/frontend/src/services/customerAdmin.service.ts
+++ b/frontend/src/services/customerAdmin.service.ts
@@ -60,6 +60,10 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
*/
billingCadence?: 'per_event' | 'monthly' | 'quarterly';
billingCycleDay?: number;
+ /** 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?: boolean;
notes: string | null;
events: Array<{
id: number;
@@ -158,6 +162,8 @@ export const customerAdminService = {
// CRM billing cadence (migration 102 + 128).
billingCadence: 'billing_cadence',
billingCycleDay: 'billing_cycle_day',
+ // Per-customer Skonto opt-out (migration 112).
+ skontoDisabled: 'skonto_disabled',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
From 06b4522deceb06ed1d90e188a62b82fc7e0e91d6 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Tue, 2 Jun 2026 09:27:10 +0200
Subject: [PATCH 08/59] feat(billing): manual cadence + fix admin date inputs
ignoring date-format setting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Manual billing cadence
Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
the monthly draft accumulator — invoices and billed hours pile onto one running
draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
never matches. The draft ships only when an admin clicks "Trigger invoice now".
No migration: billing_cadence is a free-form string column gated by validators.
- adminCustomers.js: allow 'manual' in billing_cadence validator
- invoiceService.js: route manual through accumulator; NULL periods + placeholder
issue/due date in getOrCreateMonthlyDraft
- customerHoursService.js: manual auto-appends hours to running draft;
billUnbilledEntries refuses manual (CADENCE_MISMATCH)
- CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
NULL-period-safe draft preview + trigger button, manual-specific copy
- customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
- en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
draftPreview.titleManual
Date-format fixes
Replace raw (browser-locale) with LocalizedDateInput so
these admin surfaces honor the general_date_format setting:
- ContractEditorPage.tsx (issue / valid-until / event dates)
- QuoteEditorPage.tsx (event / valid-until dates)
- EventDetailsPage.tsx (expiry date)
- HoursSection.tsx (entry date)
---
backend/src/routes/adminCustomers.js | 2 +-
backend/src/services/customerHoursService.js | 15 +++--
backend/src/services/invoiceService.js | 40 ++++++++-----
.../src/components/admin/HoursSection.tsx | 5 +-
frontend/src/i18n/locales/de.json | 6 +-
frontend/src/i18n/locales/en.json | 6 +-
.../src/pages/admin/CustomerDetailPage.tsx | 58 ++++++++++++-------
frontend/src/pages/admin/EventDetailsPage.tsx | 7 +--
.../admin/contracts/ContractEditorPage.tsx | 8 +--
.../pages/admin/quotes/QuoteEditorPage.tsx | 10 ++--
.../src/services/customerAdmin.service.ts | 12 ++--
11 files changed, 104 insertions(+), 65 deletions(-)
diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js
index 4b2b8606..026af794 100644
--- a/backend/src/routes/adminCustomers.js
+++ b/backend/src/routes/adminCustomers.js
@@ -395,7 +395,7 @@ 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).
diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js
index e07644e7..d35065fa 100644
--- a/backend/src/services/customerHoursService.js
+++ b/backend/src/services/customerHoursService.js
@@ -202,8 +202,10 @@ 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 lineItem = buildLineItemFromEntry(fullEntry, rate);
@@ -387,15 +389,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',
);
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index 83467b9d..bc863f0c 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -293,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.
@@ -302,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
@@ -341,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',
@@ -355,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.
@@ -677,15 +687,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] : [] };
}
diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx
index 13414230..62536a2a 100644
--- a/frontend/src/components/admin/HoursSection.tsx
+++ b/frontend/src/components/admin/HoursSection.tsx
@@ -17,7 +17,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Clock } from 'lucide-react';
-import { Button, Card } from '../common';
+import { Button, Card, LocalizedDateInput } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
@@ -224,8 +224,7 @@ export const HoursSection: React.FC = ({
{t('customers.hours.form.date', 'Date')}
- setEntryDate(e.target.value)} className="input w-full" />
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index d292050a..7313a736 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3127,22 +3127,26 @@
},
"billing": {
"section": "Abrechnungsrhythmus",
- "hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird.",
+ "hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird. Manuell: Positionen sammeln sich genauso, aber die Rechnung wird erst versendet, wenn Sie sie auslösen.",
"cadence": "Abrechnungsrhythmus",
"perEvent": "Per Event",
"monthly": "Monatlich",
"quarterly": "Quartalsweise",
+ "manual": "Manuell (nur auf Auslösung)",
"cycleDay": "Stichtag",
"cycleDayHint": "1..28 = Tag im Monat. Negativ -1..-15 für „N Tage vor Monatsende“ (so löst -3 in einem 31-Tage-Monat am 28. aus).",
"skontoDisabled": "Kein Skonto für diesen Kunden",
"skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden – unabhängig von Vorlage oder globalen Standardwerten.",
"triggerNow": "Rechnung jetzt ausstellen",
"triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
+ "triggerConfirmManual": "Gesammelte Rechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
"triggerHint": "Überspringt den Stichtag und stellt den aktuellen Entwurf sofort aus. Wird abgelehnt, wenn für die aktuelle Periode nichts erfasst wurde.",
+ "triggerHintManual": "Stellt den aktuellen Entwurf sofort aus. Entwürfe mit manuellem Rhythmus werden nie automatisch versendet – dies ist der einzige Weg, sie zu versenden. Wird abgelehnt, wenn nichts erfasst wurde.",
"triggered": "Monatsrechnung ausgestellt: {{number}}",
"triggerError": "Monatsrechnung konnte nicht ausgelöst werden.",
"draftPreview": {
"title": "Offen für die Rechnung dieses Monats",
+ "titleManual": "Offen – wird auf manuelle Auslösung versendet",
"periodRange": "{{number}} · {{from}} – {{to}}"
}
},
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 97077185..9037f9f4 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3127,22 +3127,26 @@
},
"billing": {
"section": "Billing cadence",
- "hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day.",
+ "hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day. Manual: items accumulate the same way, but the bill ships only when you trigger it.",
"cadence": "Billing cadence",
"perEvent": "Per event",
"monthly": "Monthly",
"quarterly": "Quarterly",
+ "manual": "Manual (trigger only)",
"cycleDay": "Cycle day",
"cycleDayHint": "1..28 = day of month. Use negative -1..-15 for \"N days before month end\" (so -3 fires on the 28th of a 31-day month).",
"skontoDisabled": "No Skonto for this customer",
"skontoDisabledHint": "Disables the early-payment discount on all of this customer’s invoices, regardless of template or global defaults.",
"triggerNow": "Trigger invoice now",
"triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.",
+ "triggerConfirmManual": "Issue this customer's accumulated bill now? The customer receives the email immediately.",
"triggerHint": "Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.",
+ "triggerHintManual": "Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.",
"triggered": "Monthly bill issued: {{number}}",
"triggerError": "Could not trigger the monthly bill.",
"draftPreview": {
"title": "Pending in this month's bill",
+ "titleManual": "Pending — ships on manual trigger",
"periodRange": "{{number}} · {{from}} – {{to}}"
}
},
diff --git a/frontend/src/pages/admin/CustomerDetailPage.tsx b/frontend/src/pages/admin/CustomerDetailPage.tsx
index b56bf2d7..431e842b 100644
--- a/frontend/src/pages/admin/CustomerDetailPage.tsx
+++ b/frontend/src/pages/admin/CustomerDetailPage.tsx
@@ -77,7 +77,7 @@ export const CustomerDetailPage: React.FC = () => {
queryKey: ['admin-customer-monthly-draft', customerId],
queryFn: () => customerAdminService.getMonthlyDraft(customerId),
enabled: Number.isFinite(customerId) && customerId > 0
- && (customer?.billingCadence === 'monthly'),
+ && (customer?.billingCadence === 'monthly' || customer?.billingCadence === 'manual'),
});
const monthlyDraft = monthlyDraftRes?.draft || null;
@@ -716,9 +716,10 @@ export const CustomerDetailPage: React.FC = () => {
+
)}
- {/* Manual trigger — issue the running monthly draft NOW
- instead of waiting for the cadence-day scheduler tick.
- Only shown for monthly-mode customers (per-event has no
- draft to arm; the equivalent action there is "Bill these
- hours" on the standalone Hours-logging page). */}
- {form.billingCadence === 'monthly' && (
+ {/* Manual trigger — issue the running draft NOW. For monthly
+ customers this bypasses the cadence-day scheduler tick; for
+ manual-cadence customers it's the ONLY way the draft ships
+ (the scheduler never auto-flushes a manual draft). Per-event
+ has no draft to arm; the equivalent action there is "Bill
+ these hours" on the standalone Hours-logging page. */}
+ {(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && (
- {t('customers.billing.triggerHint',
- 'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
+ {form.billingCadence === 'manual'
+ ? t('customers.billing.triggerHintManual',
+ 'Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.')
+ : t('customers.billing.triggerHint',
+ 'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
- {t('customers.field.hourlyRateHint',
- 'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
- { currency: profileDefaultCurrency })}
-
+ {t('customers.field.hourlyRateHint',
+ 'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
+ { currency: profileDefaultCurrency })}
+
+ {t('customers.hours.noRate.body',
+ 'Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.')}
+
+
+
+ {t('customers.hours.noRate.setForCustomer', 'Set a rate for this customer')}
+
+
+ {t('customers.hours.noRate.setInstallDefault', 'Set an install-wide default')}
+
+