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. */} + + {/* 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;