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:
@@ -58,13 +58,30 @@ describe('resolveEffectiveRate', () => {
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('throws when both override and customer rate are unset', () => {
|
||||
it('throws when override, customer rate, AND install default are all unset', () => {
|
||||
expect(() => resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
null,
|
||||
)).toThrow(/No hourly rate/);
|
||||
});
|
||||
|
||||
it('falls back to the install-wide default when override + customer rate are unset', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: null },
|
||||
12000,
|
||||
)).toBe(12000);
|
||||
});
|
||||
|
||||
it('customer rate wins over the install-wide default', () => {
|
||||
expect(resolveEffectiveRate(
|
||||
{ hourly_rate_minor_override: null },
|
||||
{ hourly_rate_minor: 15000 },
|
||||
12000,
|
||||
)).toBe(15000);
|
||||
});
|
||||
|
||||
it('treats override=0 as "explicitly zero" (not null)', () => {
|
||||
// Override === 0 is unusual but legal — pro bono blocks, internal
|
||||
// tracking. Must NOT fall through to the customer default.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Migration: install-wide default hourly rate.
|
||||
*
|
||||
* Background: hour entries resolve a billing rate through
|
||||
* entry.hourly_rate_minor_override → customer.hourly_rate_minor.
|
||||
* When a customer had neither set, saving an entry hard-failed with
|
||||
* HOURLY_RATE_REQUIRED — a confusing save-time error on the hours page.
|
||||
* This adds an install-wide fallback so a single global rate covers
|
||||
* every customer who hasn't been given an individual one. The chain
|
||||
* becomes:
|
||||
* entry override → customer rate → business_profile default → (CTA).
|
||||
*
|
||||
* Stored in minor units (matches customer_accounts.hourly_rate_minor).
|
||||
* Nullable, default NULL: existing installs keep today's behaviour
|
||||
* (no implicit rate) until the admin sets one — no surprise rate gets
|
||||
* applied on upgrade (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('business_profile'))) return;
|
||||
if (await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor')) return;
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.bigInteger('default_hourly_rate_minor');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('business_profile'))) return;
|
||||
if (!(await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor'))) return;
|
||||
await knex.schema.alterTable('business_profile', (table) => {
|
||||
table.dropColumn('default_hourly_rate_minor');
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user