feat(crm): per-customer Skonto opt-out

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).
This commit is contained in:
Luca
2026-06-02 09:00:05 +02:00
parent 45c7cc80ab
commit d788a6cde5
8 changed files with 90 additions and 3 deletions
@@ -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');
});
};
+6
View File
@@ -67,6 +67,10 @@ function transformCustomer(c) {
// per-entry override on every logged block. // per-entry override on every logged block.
featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1, featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1,
hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null, 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, lastLogin: c.last_login,
createdAt: c.created_at, createdAt: c.created_at,
updatedAt: c.updated_at, updatedAt: c.updated_at,
@@ -394,6 +398,8 @@ router.put('/:id', [
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']), body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']),
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 }) 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)'), .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) => { ], handleAsync(async (req, res) => {
validateRequest(req); validateRequest(req);
const customer = await customerAccountsService.updateCustomer( const customer = await customerAccountsService.updateCustomer(
@@ -555,6 +555,9 @@ async function updateCustomer(id, updates, updatedByAdminId) {
// Hour-logging default rate (migration 129). Minor units; null // Hour-logging default rate (migration 129). Minor units; null
// means admin must enter a per-entry override on every entry. // means admin must enter a per-entry override on every entry.
'hourly_rate_minor', '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) { for (const f of fields) {
if (updates[f] !== undefined) { if (updates[f] !== undefined) {
@@ -567,6 +570,7 @@ async function updateCustomer(id, updates, updatedByAdminId) {
} else if ( } else if (
f === 'feature_calendar' || f === 'feature_quotes' f === 'feature_calendar' || f === 'feature_quotes'
|| f === 'feature_bills' || f === 'feature_hours_logging' || f === 'feature_bills' || f === 'feature_hours_logging'
|| f === 'skonto_disabled'
) { ) {
allowed[f] = formatBoolean(updates[f]); allowed[f] = formatBoolean(updates[f]);
} else if (f === 'hourly_rate_minor') { } else if (f === 'hourly_rate_minor') {
+15 -2
View File
@@ -1690,8 +1690,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
// but still printed the discount row on the PDF. Zero out both // but still printed the discount row on the PDF. Zero out both
// fields here so pdfService.drawPaymentBlock's // fields here so pdfService.drawPaymentBlock's
// `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays` // `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays`
// guard suppresses the row. // guard suppresses the row. The per-customer opt-out (migration 112)
if (invoice.skonto_disabled) { // 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.skontoPercent = null;
paymentTerm.skontoWithinDays = null; paymentTerm.skontoWithinDays = null;
} }
@@ -2667,6 +2669,17 @@ async function resolveSkontoPercentForInvoice(invoice) {
// installments that shouldn't qualify for the discount even when // installments that shouldn't qualify for the discount even when
// the global default offers it. // the global default offers it.
if (invoice.skonto_disabled) return null; 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) => { const parseSnap = (raw) => {
if (!raw) return null; if (!raw) return null;
if (typeof raw === 'object') return raw; if (typeof raw === 'object') return raw;
+2
View File
@@ -3134,6 +3134,8 @@
"quarterly": "Quartalsweise", "quarterly": "Quartalsweise",
"cycleDay": "Stichtag", "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).", "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", "triggerNow": "Rechnung jetzt ausstellen",
"triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.", "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.", "triggerHint": "Überspringt den Stichtag und stellt den aktuellen Entwurf sofort aus. Wird abgelehnt, wenn für die aktuelle Periode nichts erfasst wurde.",
+2
View File
@@ -3134,6 +3134,8 @@
"quarterly": "Quarterly", "quarterly": "Quarterly",
"cycleDay": "Cycle day", "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).", "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 customers invoices, regardless of template or global defaults.",
"triggerNow": "Trigger invoice now", "triggerNow": "Trigger invoice now",
"triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.", "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.", "triggerHint": "Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.",
@@ -40,7 +40,7 @@ type EditableFields =
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state' | 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes' | 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay'; | 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date // `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND // formatter. It honors the admin's `general_date_format` setting AND
@@ -140,6 +140,7 @@ export const CustomerDetailPage: React.FC = () => {
hourlyRateMinor: customer.hourlyRateMinor ?? null, hourlyRateMinor: customer.hourlyRateMinor ?? null,
billingCadence: customer.billingCadence ?? 'per_event', billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1, billingCycleDay: customer.billingCycleDay ?? 1,
skontoDisabled: customer.skontoDisabled ?? false,
} as any); } as any);
} }
}, [customer, form]); }, [customer, form]);
@@ -738,6 +739,25 @@ export const CustomerDetailPage: React.FC = () => {
)} )}
</div> </div>
{/* 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. */}
<label className="mt-4 flex items-start gap-2 text-sm text-theme">
<input
type="checkbox"
checked={!!form.skontoDisabled}
onChange={(e) => setForm((prev) => ({ ...prev, skontoDisabled: e.target.checked } as any))}
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600"
/>
<span>
{t('customers.billing.skontoDisabled', 'No Skonto for this customer')}
<span className="block text-xs text-muted-theme">
{t('customers.billing.skontoDisabledHint',
'Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.')}
</span>
</span>
</label>
{/* Preview of the open monthly draft (migration 128). Shows {/* Preview of the open monthly draft (migration 128). Shows
every line item queued for the customer's current billing every line item queued for the customer's current billing
period so admin sees exactly what "Trigger invoice now" period so admin sees exactly what "Trigger invoice now"
@@ -60,6 +60,10 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
*/ */
billingCadence?: 'per_event' | 'monthly' | 'quarterly'; billingCadence?: 'per_event' | 'monthly' | 'quarterly';
billingCycleDay?: number; 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; notes: string | null;
events: Array<{ events: Array<{
id: number; id: number;
@@ -158,6 +162,8 @@ export const customerAdminService = {
// CRM billing cadence (migration 102 + 128). // CRM billing cadence (migration 102 + 128).
billingCadence: 'billing_cadence', billingCadence: 'billing_cadence',
billingCycleDay: 'billing_cycle_day', billingCycleDay: 'billing_cycle_day',
// Per-customer Skonto opt-out (migration 112).
skontoDisabled: 'skonto_disabled',
}; };
for (const [k, v] of Object.entries(payload)) { for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v; if (k in map) snake[map[k]] = v;