feat(crm): toggle for VAT on late fees (jurisdiction-dependent)
Mahngebühr VAT differs by country (CH: liable; DE/AT: not), so it's now a toggle (crm_invoices_late_fee_vat_enabled, seeded into migration 143 in place since it isn't deployed yet — no compensation migration). When on, VAT is added on top of the net fee at the org's default rate (business_profile.vat_rate_default). Gated so it's a NO-OP when the org doesn't charge VAT (default rate 0/unset) — i.e. enabling the toggle on a non-VAT org adds nothing, as required. Settings UI: a self-documenting checkbox. The fee is treated as net + VAT-on-top; the tax-report VAT breakdown for the fee is part of the deferred dunning-document rework. tsc 0, build green, 9/9 workflow tests.
This commit is contained in:
@@ -19,6 +19,10 @@ exports.up = async function (knex) {
|
||||
const seeds = [
|
||||
{ setting_key: 'crm_invoices_late_fee_type', setting_value: JSON.stringify('flat'), setting_type: 'crm' },
|
||||
{ setting_key: 'crm_invoices_late_fee_percent', setting_value: JSON.stringify(0), setting_type: 'crm' },
|
||||
// VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's
|
||||
// a toggle. Default OFF (preserve current no-VAT behaviour). No-op anyway
|
||||
// when the org doesn't charge VAT (business_profile.vat_rate_default = 0).
|
||||
{ setting_key: 'crm_invoices_late_fee_vat_enabled', setting_value: JSON.stringify(false), setting_type: 'crm' },
|
||||
];
|
||||
for (const s of seeds) {
|
||||
const exists = await knex('app_settings').where({ setting_key: s.setting_key }).first();
|
||||
@@ -29,6 +33,6 @@ exports.up = async function (knex) {
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('app_settings'))) return;
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent'])
|
||||
.whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent', 'crm_invoices_late_fee_vat_enabled'])
|
||||
.del();
|
||||
};
|
||||
|
||||
@@ -2655,11 +2655,27 @@ async function sendReminder(id, levelOverride, adminId) {
|
||||
async function resolvePerReminderFeeMinor(invoice) {
|
||||
if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0;
|
||||
const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat';
|
||||
let fee;
|
||||
if (type === 'percent') {
|
||||
const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0;
|
||||
return Math.max(0, Math.round(Number(invoice.total_amount_minor || 0) * pct / 100));
|
||||
fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100);
|
||||
} else {
|
||||
fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
|
||||
}
|
||||
return Math.max(0, ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500);
|
||||
fee = Math.max(0, fee);
|
||||
|
||||
// VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's
|
||||
// toggle-gated. It also no-ops when the ORG doesn't charge VAT — the org's
|
||||
// default rate (business_profile.vat_rate_default) is 0/unset — so enabling
|
||||
// the toggle on a non-VAT org adds nothing. (The fee amount is treated as net;
|
||||
// VAT is added on top. The tax-report VAT breakdown for the fee is part of the
|
||||
// deferred dunning-document rework.)
|
||||
if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) === true && fee > 0) {
|
||||
const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default');
|
||||
const rate = Number(profile?.vat_rate_default) || 0;
|
||||
if (rate > 0) fee += Math.round(fee * rate / 100);
|
||||
}
|
||||
return fee;
|
||||
}
|
||||
|
||||
async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
|
||||
@@ -34,6 +34,7 @@ const SETTING_KEYS = [
|
||||
'crm_invoices_late_fee_type',
|
||||
'crm_invoices_late_fee_minor',
|
||||
'crm_invoices_late_fee_percent',
|
||||
'crm_invoices_late_fee_vat_enabled',
|
||||
'crm_invoices_late_fee_label',
|
||||
'crm_invoices_skonto_business_days',
|
||||
'crm_invoices_skonto_percent_default',
|
||||
@@ -249,6 +250,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
<p className="font-medium">{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}</p>
|
||||
<p className="mt-1">{t('crmSettings.lateFeeAgb.body', 'Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen.')}</p>
|
||||
</div>
|
||||
{checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<Input type="number" min={1} max={365}
|
||||
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
|
||||
|
||||
Reference in New Issue
Block a user