ab6bad17c9
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).
183 lines
6.2 KiB
JavaScript
183 lines
6.2 KiB
JavaScript
/**
|
||
* Unit tests for the pure helpers in customerHoursService (migration
|
||
* 129). The CRUD paths themselves are exercised end-to-end via the
|
||
* admin/customers routes during manual QA; this file covers the
|
||
* deterministic logic so regressions in the rate / duration / lock
|
||
* resolution show up before they hit a real invoice.
|
||
*/
|
||
|
||
const path = require('path');
|
||
const servicePath = path.join(__dirname, '..', '..', 'src', 'services', 'customerHoursService');
|
||
|
||
// The service imports invoiceService which pulls in the DB. We don't
|
||
// need either for the pure helpers — stub the DB layer so the
|
||
// require chain doesn't try to connect to anything.
|
||
jest.mock('../../src/database/db', () => ({
|
||
db: jest.fn(),
|
||
logActivity: jest.fn(),
|
||
withRetry: (fn) => fn(),
|
||
}));
|
||
jest.mock('../../src/services/invoiceService', () => ({}));
|
||
|
||
const { _internal } = require(servicePath);
|
||
const { computeDurationMinutes, resolveEffectiveRate, isEntryLocked, buildLineItemFromEntry } = _internal;
|
||
|
||
describe('computeDurationMinutes', () => {
|
||
it('returns minute count for a basic window', () => {
|
||
expect(computeDurationMinutes('09:00', '11:30')).toBe(150);
|
||
});
|
||
|
||
it('handles single-minute precision', () => {
|
||
expect(computeDurationMinutes('09:30', '11:00')).toBe(90);
|
||
expect(computeDurationMinutes('14:15', '14:30')).toBe(15);
|
||
});
|
||
|
||
it('rejects malformed input', () => {
|
||
expect(() => computeDurationMinutes('9:00', '11:00')).toThrow(/Invalid start_time/);
|
||
expect(() => computeDurationMinutes('09:00', '25:00')).toThrow(/Invalid end_time/);
|
||
});
|
||
|
||
it('rejects zero or negative duration', () => {
|
||
expect(() => computeDurationMinutes('09:00', '09:00')).toThrow(/must be after/);
|
||
expect(() => computeDurationMinutes('11:00', '09:00')).toThrow(/must be after/);
|
||
});
|
||
});
|
||
|
||
describe('resolveEffectiveRate', () => {
|
||
it('prefers the per-entry override when set', () => {
|
||
expect(resolveEffectiveRate(
|
||
{ hourly_rate_minor_override: 20000 },
|
||
{ hourly_rate_minor: 15000 },
|
||
)).toBe(20000);
|
||
});
|
||
|
||
it('falls back to the customer default when no override', () => {
|
||
expect(resolveEffectiveRate(
|
||
{ hourly_rate_minor_override: null },
|
||
{ hourly_rate_minor: 15000 },
|
||
)).toBe(15000);
|
||
});
|
||
|
||
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.
|
||
expect(resolveEffectiveRate(
|
||
{ hourly_rate_minor_override: 0 },
|
||
{ hourly_rate_minor: 15000 },
|
||
)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('isEntryLocked', () => {
|
||
it('unbilled entry → not locked', () => {
|
||
expect(isEntryLocked({ invoice_id: null }, null)).toBe(false);
|
||
});
|
||
|
||
it('monthly draft → not locked (still accumulating)', () => {
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: true, status: 'scheduled', scheduled_send_at: null },
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('standalone draft with no send time → not locked', () => {
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: null },
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('future-scheduled draft → not locked', () => {
|
||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: future },
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('armed (scheduled_send_at in the past, status still scheduled) → locked', () => {
|
||
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status: 'scheduled', scheduled_send_at: past },
|
||
)).toBe(true);
|
||
});
|
||
|
||
it('sent / paid / overdue / cancelled → locked', () => {
|
||
for (const status of ['sent', 'paid', 'overdue', 'cancelled']) {
|
||
expect(isEntryLocked(
|
||
{ invoice_id: 42 },
|
||
{ id: 42, is_monthly_draft: false, status, scheduled_send_at: null },
|
||
)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('entry references a deleted invoice (null) → treat as unbilled', () => {
|
||
expect(isEntryLocked({ invoice_id: 42 }, null)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('buildLineItemFromEntry', () => {
|
||
const baseEntry = {
|
||
entry_date: '2026-05-20',
|
||
start_time: '09:00',
|
||
end_time: '11:30',
|
||
duration_minutes: 150,
|
||
description: 'Editing wedding photos',
|
||
};
|
||
|
||
it('formats the description per spec', () => {
|
||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h): Editing wedding photos');
|
||
});
|
||
|
||
it('omits the colon when no description', () => {
|
||
const li = buildLineItemFromEntry({ ...baseEntry, description: null }, 15000);
|
||
expect(li.description).toBe('2026-05-20 09:00–11:30 (2.50h)');
|
||
});
|
||
|
||
it('quantity is decimal hours with 2 places', () => {
|
||
const li = buildLineItemFromEntry(baseEntry, 15000);
|
||
expect(li.quantity).toBeCloseTo(2.5, 5);
|
||
});
|
||
|
||
it('line_total rounds correctly for non-clean durations', () => {
|
||
// 15 minutes at CHF 100/h = CHF 25.00 = 2500 minor
|
||
const li = buildLineItemFromEntry(
|
||
{ ...baseEntry, start_time: '14:00', end_time: '14:15', duration_minutes: 15 },
|
||
10000,
|
||
);
|
||
expect(li.line_total_minor).toBe(2500);
|
||
});
|
||
|
||
it('zero-rate line items produce a zero total without exploding', () => {
|
||
const li = buildLineItemFromEntry(baseEntry, 0);
|
||
expect(li.line_total_minor).toBe(0);
|
||
expect(li.unit_price_minor).toBe(0);
|
||
});
|
||
});
|