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:
@@ -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');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 customer’s 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 customer’s 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user