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.
|
||||
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(
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user