feat(hours): install-wide default rate + inline missing-rate CTA

Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
This commit is contained in:
Luca
2026-06-02 11:33:45 +02:00
parent d9251c0850
commit ab6bad17c9
10 changed files with 295 additions and 44 deletions
@@ -142,6 +142,10 @@ function transformProfile(p) {
taxId: p.tax_id || '',
vatLabel: p.vat_label || 'MwSt.',
vatRateDefault: p.vat_rate_default == null ? null : Number(p.vat_rate_default),
// Install-wide fallback hourly rate (migration 113), minor units.
// null = no global default; the hours page then requires a per-
// customer or per-entry rate.
defaultHourlyRateMinor: p.default_hourly_rate_minor == null ? null : Number(p.default_hourly_rate_minor),
defaultCurrency: p.default_currency || 'CHF',
defaultLocale: p.default_locale || 'de',
defaultQrFormat: p.default_qr_format || 'none',
@@ -346,6 +350,11 @@ router.put(
body('taxId').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('vatLabel').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('vatRateDefault').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
// Migration 113 — install-wide default hourly rate, minor units.
// nullable so the admin can clear it; values: 'falsy' would drop a
// legitimate 0 (which we treat as "explicitly free"), so use the
// nullable form and let the service coerce.
body('defaultHourlyRateMinor').optional({ nullable: true }).isInt({ min: 0 }),
body('defaultCurrency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('defaultLocale').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('defaultQrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
@@ -393,6 +402,7 @@ router.put(
taxId: 'tax_id',
vatLabel: 'vat_label',
vatRateDefault: 'vat_rate_default',
defaultHourlyRateMinor: 'default_hourly_rate_minor',
defaultCurrency: 'default_currency',
defaultLocale: 'default_locale',
defaultQrFormat: 'default_qr_format',
@@ -41,6 +41,10 @@ const ALLOWED_PROFILE_FIELDS = [
'tax_id',
'vat_label',
'vat_rate_default',
// Install-wide fallback hourly rate (migration 113), minor units.
// Last link in the hour-entry rate chain after the per-entry
// override and the per-customer default.
'default_hourly_rate_minor',
'default_currency',
'default_locale',
'default_qr_format',
@@ -161,6 +165,17 @@ function sanitiseProfilePayload(payload) {
? Math.max(24, Math.min(200, n))
: 56;
}
// Install-wide default hourly rate (minor units). Empty / null clears
// it back to "no global default"; otherwise coerce to a non-negative
// integer so a stray decimal can't land sub-cent values in the column.
if (updates.default_hourly_rate_minor !== undefined) {
if (updates.default_hourly_rate_minor === null || updates.default_hourly_rate_minor === '') {
updates.default_hourly_rate_minor = null;
} else {
const n = parseInt(updates.default_hourly_rate_minor, 10);
updates.default_hourly_rate_minor = Number.isFinite(n) && n >= 0 ? n : null;
}
}
return updates;
}
+47 -10
View File
@@ -25,6 +25,7 @@
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const invoiceService = require('./invoiceService');
@@ -50,24 +51,52 @@ function computeDurationMinutes(start, end) {
}
/**
* Resolve the rate this entry should bill at. Override on the entry
* wins; otherwise we fall back to the customer's default rate. If
* neither is set we throw — saves can't go through without a rate.
* Resolve the rate this entry should bill at. Resolution chain:
* 1. per-entry override
* 2. per-customer default rate
* 3. install-wide default rate (business_profile, migration 113)
* Only when all three are unset do we throw — the hours UI surfaces a
* "set a rate" CTA off the back of HOURLY_RATE_REQUIRED rather than a
* raw error. `installDefaultMinor` is loaded once per request by the
* caller (see getInstallDefaultRateMinor) and passed in so this stays
* a pure function.
*/
function resolveEffectiveRate(entry, customer) {
function resolveEffectiveRate(entry, customer, installDefaultMinor = null) {
if (entry.hourly_rate_minor_override != null) {
return Number(entry.hourly_rate_minor_override);
}
if (customer.hourly_rate_minor != null) {
return Number(customer.hourly_rate_minor);
}
if (installDefaultMinor != null) {
return Number(installDefaultMinor);
}
throw new AppError(
'No hourly rate: set a per-entry override or a customer default.',
'No hourly rate: set a per-entry override, a customer default, or an install-wide default rate.',
400,
'HOURLY_RATE_REQUIRED',
);
}
/**
* Read the install-wide default hourly rate (minor units) off the
* singleton business_profile row. Returns null when unset OR when the
* column doesn't exist yet (pre-migration-113 install) — callers then
* fall through to the HOURLY_RATE_REQUIRED path. Accepts an optional
* transaction so it joins the caller's atomic unit.
*/
async function getInstallDefaultRateMinor(trx) {
const conn = trx || db;
if (!(await hasColumnCached('business_profile', 'default_hourly_rate_minor'))) {
return null;
}
const row = await conn('business_profile').where({ id: 1 })
.first('default_hourly_rate_minor');
return row && row.default_hourly_rate_minor != null
? Number(row.default_hourly_rate_minor)
: null;
}
/**
* Decide whether an entry is still editable. Pure function — callers
* pass the loaded entry + (optionally) its current invoice row.
@@ -181,9 +210,14 @@ async function createEntry(customerId, payload, adminId) {
}
const description = payload.description ? String(payload.description).slice(0, 1000) : null;
// Install-wide fallback rate (migration 113) — the last link in the
// resolution chain. Loaded once and reused for the pre-validate and
// the accumulator append below.
const installDefaultMinor = await getInstallDefaultRateMinor();
// Pre-validate the rate resolves to something — fail before insert
// if neither override nor customer default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer);
// if neither override, customer default, nor install default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer, installDefaultMinor);
return await db.transaction(async (trx) => {
const row = {
@@ -207,7 +241,7 @@ async function createEntry(customerId, payload, adminId) {
// unbilled. Manual differs only in that its draft never auto-flushes.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
const fullEntry = { ...row, id: entryId };
const rate = resolveEffectiveRate(fullEntry, customer);
const rate = resolveEffectiveRate(fullEntry, customer, installDefaultMinor);
const lineItem = buildLineItemFromEntry(fullEntry, rate);
const { invoiceId, lineItemId } = await invoiceService.appendOneLineItemToMonthlyDraft(
customer, lineItem, adminId, trx,
@@ -288,7 +322,8 @@ async function updateEntry(entryId, payload, adminId) {
// Recompute the linked line item if the entry is billed (on a
// draft — the lock check above already proved it's mutable).
if (entry.invoice_id && entry.invoice_line_item_id) {
const rate = resolveEffectiveRate(next, customer);
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
const rate = resolveEffectiveRate(next, customer, installDefaultMinor);
const newLineItem = buildLineItemFromEntry(next, rate);
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).update({
description: newLineItem.description,
@@ -412,8 +447,9 @@ async function billUnbilledEntries(customerId, adminId) {
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
}
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
const lineItems = unbilled.map((entry, idx) => {
const rate = resolveEffectiveRate(entry, customer);
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
const li = buildLineItemFromEntry(entry, rate);
return { ...li, position: idx + 1 };
});
@@ -465,6 +501,7 @@ module.exports = {
updateEntry,
deleteEntry,
billUnbilledEntries,
getInstallDefaultRateMinor,
_internal: {
computeDurationMinutes,
resolveEffectiveRate,