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
+2
View File
@@ -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.",
+2
View File
@@ -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 customers 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.",
@@ -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 = () => {
)}
</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
every line item queued for the customer's current billing
period so admin sees exactly what "Trigger invoice now"
@@ -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;