diff --git a/docs/architecture/data-and-migrations.md b/docs/architecture/data-and-migrations.md index 183f46c1a..c07db751b 100644 --- a/docs/architecture/data-and-migrations.md +++ b/docs/architecture/data-and-migrations.md @@ -112,6 +112,20 @@ After migrations and with `foreign_keys` re-enabled, startup runs: non-daily reset periods are tracked dynamically. - `autoRepairPaymentDetails` and `autoRepairDefaultPrinter` - the two targeted data repairs. +## Order and bill numbers + +`generateOrderNumber` and `generateBillNumber` in `main/db.ts` draw from the `sequences` table +(`name`, `date` bucket) and format `[prefix-][date-or-period-]NNNN`. `orders.order_number` and +`bills.bill_number` are `UNIQUE`, so two rules keep every number issuable: + +- A series that resets (daily orders; daily, monthly, or financial-year bills) always carries its + date or period, whatever the include toggle says; only a non-resetting series may omit it. The + `PUT /api/settings/order-numbering` route stores the effective flag, and `GET` reports it, so the + settings screen shows the include toggle only for a non-resetting series. +- A candidate that is already issued (a mid-day setting change, a reused prefix, restored or + imported rows) lifts the counter past the highest number issued under the same head instead of + failing the sale. The allocation throws only if three attempts cannot find a free number. + ## Backup and restore | Operation | Entry point | Notes | diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index 8ece8dd1e..8ce4e10b7 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -3649,7 +3649,14 @@ export default function SettingsPage() { invoice_financial_year_start_month: orderNumberForm.invoiceFinancialYearStartMonth, invoice_financial_year_start_day: orderNumberForm.invoiceFinancialYearStartDay, }); - const saved = { ...orderNumberForm, prefix, invoicePrefix }; + const saved = { + ...orderNumberForm, + prefix, + invoicePrefix, + includeDate: orderNumberForm.includeDate || orderNumberForm.resetDaily, + invoiceIncludePeriod: + orderNumberForm.invoiceIncludePeriod || orderNumberForm.invoiceResetPeriod !== 'never', + }; setOrderNumberForm(saved); setSavedOrderNumberForm(saved); if (!silent) toast.success(t('orderNumberingSaved')); diff --git a/frontend/src/components/settings/GeneralSettingsTab.tsx b/frontend/src/components/settings/GeneralSettingsTab.tsx index a80951610..cb4a18701 100644 --- a/frontend/src/components/settings/GeneralSettingsTab.tsx +++ b/frontend/src/components/settings/GeneralSettingsTab.tsx @@ -637,7 +637,7 @@ export function GeneralSettingsTab({ {[ orderNumberForm.prefix, - orderNumberForm.includeDate ? sampleOrderDate : '', + orderNumberForm.includeDate || orderNumberForm.resetDaily ? sampleOrderDate : '', '0001', ] .filter(Boolean) @@ -648,24 +648,26 @@ export function GeneralSettingsTab({
-
-
- {t('orderNumberIncludeDate')} -

{t('orderNumberIncludeDateHint')}

+ {!orderNumberForm.resetDaily && ( +
+
+ {t('orderNumberIncludeDate')} +

{t('orderNumberIncludeDateHint')}

+
+ { + markHydrationTouched('includeDate'); + setOrderNumberForm((p) => ({ ...p, includeDate: v })); + } + : () => {} + } + />
- { - markHydrationTouched('includeDate'); - setOrderNumberForm((p) => ({ ...p, includeDate: v })); - } - : () => {} - } - /> -
+ )}
{t('orderNumberResetDaily')} @@ -722,7 +724,8 @@ export function GeneralSettingsTab({ {[ orderNumberForm.invoicePrefix, - orderNumberForm.invoiceIncludePeriod + orderNumberForm.invoiceIncludePeriod || + orderNumberForm.invoiceResetPeriod !== 'never' ? invoicePreviewSegment( orderNumberForm.invoiceResetPeriod, orderNumberForm.invoiceFinancialYearStartMonth, @@ -809,28 +812,30 @@ export function GeneralSettingsTab({ )}
-
-
-
- {t('invoiceNumberIncludePeriod')} -

- {t('invoiceNumberIncludePeriodHint')} -

+ {orderNumberForm.invoiceResetPeriod === 'never' && ( +
+
+
+ {t('invoiceNumberIncludePeriod')} +

+ {t('invoiceNumberIncludePeriodHint')} +

+
+ { + markHydrationTouched('invoiceIncludePeriod'); + setOrderNumberForm((p) => ({ ...p, invoiceIncludePeriod: v })); + } + : () => {} + } + />
- { - markHydrationTouched('invoiceIncludePeriod'); - setOrderNumberForm((p) => ({ ...p, invoiceIncludePeriod: v })); - } - : () => {} - } - />
-
+ )}
diff --git a/main/db.ts b/main/db.ts index ea2643b41..817782763 100644 --- a/main/db.ts +++ b/main/db.ts @@ -8752,29 +8752,90 @@ function requireTenantTimezone(): string { }).timezone; } +/** A series that restarts every day repeats its counter, so the date is what keeps each number unique. */ +export function orderNumberIncludesDate(includeDate: boolean, resetDaily: boolean): boolean { + return includeDate || resetDaily; +} + +/** A series that restarts every period repeats its counter, so the period is what keeps each number unique. */ +export function invoiceNumberIncludesPeriod(includePeriod: boolean, resetPeriod: string): boolean { + return includePeriod || resetPeriod !== 'never'; +} + +// Longer suffixes are foreign numbers; lifting the counter to them could exhaust its integer range. +const MAX_COUNTER_DIGITS = 9; + +/** + * Draws the next counter value for `bucket` and formats it as `NNNN`. A candidate that is + * already issued (settings changed mid-day, a reused prefix, restored or imported rows) lifts the + * counter past the highest number issued under the same head instead of failing the sale. + */ +function allocateUniqueNumber( + sequence: string, + bucket: string, + table: 'orders' | 'bills', + column: 'order_number' | 'bill_number', + headParts: string[], +): string { + const head = headParts.length ? `${headParts.join('-')}-` : ''; + const isIssued = db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?`); + const highestIssued = db.prepare(` + SELECT MAX(CAST(substr(${column}, ?) AS INTEGER)) AS highest + FROM ${table} + WHERE ${column} GLOB ? AND substr(${column}, ?) NOT GLOB '*[^0-9]*' + AND length(${column}) - ? <= ${MAX_COUNTER_DIGITS} + `); + const liftCounter = db.prepare( + 'UPDATE sequences SET current_value = MAX(current_value, ?) WHERE name = ? AND date = ?', + ); + + for (let attempt = 0; attempt < 3; attempt++) { + const candidate = head + String(getNextSequence(sequence, bucket)).padStart(4, '0'); + if (!isIssued.get(candidate)) return candidate; + const row = highestIssued.get( + head.length + 1, + `${head}[0-9]*`, + head.length + 1, + head.length, + ) as { highest: number | null } | undefined; + liftCounter.run(row?.highest ?? 0, sequence, bucket); + } + throw new Error(`Could not allocate a unique ${column}`); +} + export function generateOrderNumber(): string { const prefix = sanitizedNumberPrefix(getSettingValue('order_number_prefix'), 'ORD'); - const includeDate = getSettingValue('order_number_include_date') !== 'false'; const resetDaily = getSettingValue('order_number_reset_daily') !== 'false'; + const includeDate = orderNumberIncludesDate( + getSettingValue('order_number_include_date') !== 'false', + resetDaily, + ); const timezone = requireTenantTimezone(); // Per-day bucket when reset daily, otherwise a single bucket. - const bucket = resetDaily ? dateStampInTimezone(timezone) : 'ALL'; - const next = getNextSequence('orders', bucket); - - const dateSegment = includeDate ? dateStampInTimezone(timezone) : ''; - return [prefix, dateSegment, String(next).padStart(4, '0')].filter(Boolean).join('-'); + const dateStamp = dateStampInTimezone(timezone); + const bucket = resetDaily ? dateStamp : 'ALL'; + return allocateUniqueNumber( + 'orders', + bucket, + 'orders', + 'order_number', + [prefix, includeDate ? dateStamp : ''].filter(Boolean), + ); } export function generateBillNumber(): string { const prefix = sanitizedNumberPrefix(getSettingValue('invoice_number_prefix'), 'INV'); - const includePeriod = getSettingValue('invoice_number_include_period') !== 'false'; const configuredPeriod = getSettingValue('invoice_number_reset_period') || 'daily'; const resetPeriod: InvoiceResetPeriod = ['never', 'daily', 'monthly', 'financial_year'].includes( configuredPeriod, ) ? (configuredPeriod as InvoiceResetPeriod) : 'daily'; + const includePeriod = invoiceNumberIncludesPeriod( + getSettingValue('invoice_number_include_period') !== 'false', + resetPeriod, + ); const timezone = requireTenantTimezone(); const fyStart = clampFinancialYearStart( getSettingValue('invoice_financial_year_start_month'), @@ -8787,10 +8848,13 @@ export function generateBillNumber(): string { fyStart.day, ); const bucket = resetPeriod === 'never' ? 'ALL' : periodSegment; - const next = getNextSequence('bills', bucket); - return [prefix, includePeriod ? periodSegment : '', String(next).padStart(4, '0')] - .filter(Boolean) - .join('-'); + return allocateUniqueNumber( + 'bills', + bucket, + 'bills', + 'bill_number', + [prefix, includePeriod ? periodSegment : ''].filter(Boolean), + ); } export function now(): string { diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 05f129fc0..c17ffe5bd 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -1,7 +1,14 @@ import { Router, Request, Response, RequestHandler } from 'express'; import expressRateLimit from 'express-rate-limit'; import { randomUUID } from 'crypto'; -import { getDatabase, getSettingValue, now, parseBackupPaginationQuery } from '../db'; +import { + getDatabase, + getSettingValue, + invoiceNumberIncludesPeriod, + now, + orderNumberIncludesDate, + parseBackupPaginationQuery, +} from '../db'; import { CloudRequestError, cloudSync, @@ -874,18 +881,26 @@ router.delete( // ─── Order numbering settings (must come BEFORE /:key wildcard) ───────────── +const INVOICE_RESET_PERIODS = new Set(['never', 'daily', 'monthly', 'financial_year']); + function orderNumberingShape(s: Record) { + const resetPeriod = INVOICE_RESET_PERIODS.has(s.invoice_number_reset_period) + ? s.invoice_number_reset_period + : 'daily'; + const orderResetDaily = s.order_number_reset_daily !== 'false'; return { order_number_prefix: s.order_number_prefix ?? 'ORD', - order_number_include_date: s.order_number_include_date !== 'false', - order_number_reset_daily: s.order_number_reset_daily !== 'false', + order_number_include_date: orderNumberIncludesDate( + s.order_number_include_date !== 'false', + orderResetDaily, + ), + order_number_reset_daily: orderResetDaily, invoice_number_prefix: s.invoice_number_prefix ?? 'INV', - invoice_number_include_period: s.invoice_number_include_period !== 'false', - invoice_number_reset_period: ['never', 'daily', 'monthly', 'financial_year'].includes( - s.invoice_number_reset_period, - ) - ? s.invoice_number_reset_period - : 'daily', + invoice_number_include_period: invoiceNumberIncludesPeriod( + s.invoice_number_include_period !== 'false', + resetPeriod, + ), + invoice_number_reset_period: resetPeriod, invoice_financial_year_start_month: parseBoundedInt( s.invoice_financial_year_start_month, 1, @@ -898,7 +913,6 @@ function orderNumberingShape(s: Record) { // Alphanumeric only to avoid collisions with automatic hyphen separators. const ORDER_NUMBER_PREFIX_PATTERN = /^[A-Za-z0-9]{0,12}$/; -const INVOICE_RESET_PERIODS = new Set(['never', 'daily', 'monthly', 'financial_year']); function parseBoundedInt(value: unknown, min: number, max: number, fallback: number): number { const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); @@ -979,16 +993,25 @@ router.put( } const db = getDatabase(); - upsertSettings(db, { - order_number_prefix, - order_number_include_date: boolFlag(order_number_include_date), - order_number_reset_daily: boolFlag(order_number_reset_daily), - invoice_number_prefix, - invoice_number_include_period: boolFlag(invoice_number_include_period), - invoice_number_reset_period, - invoice_financial_year_start_month, - invoice_financial_year_start_day, - }); + db.transaction(() => { + upsertSettings(db, { + order_number_prefix, + order_number_include_date: boolFlag(order_number_include_date), + order_number_reset_daily: boolFlag(order_number_reset_daily), + invoice_number_prefix, + invoice_number_include_period: boolFlag(invoice_number_include_period), + invoice_number_reset_period, + invoice_financial_year_start_month, + invoice_financial_year_start_day, + }); + // Persist the effective flags so a series that must carry its date or period never + // stores a contradictory "off" that would resurface when the reset option changes. + const effective = orderNumberingShape(getAllSettings(db)); + upsertSettings(db, { + order_number_include_date: String(effective.order_number_include_date), + invoice_number_include_period: String(effective.invoice_number_include_period), + }); + })(); res.json(orderNumberingShape(getAllSettings(db))); } catch (error: any) { console.error('[API] Internal error:', error); diff --git a/tests/order-numbering-settings.test.ts b/tests/order-numbering-settings.test.ts index 51a5ffd03..3cb8e6377 100644 --- a/tests/order-numbering-settings.test.ts +++ b/tests/order-numbering-settings.test.ts @@ -30,7 +30,11 @@ Module._load = function (requestName: string, parent: unknown, isMain: boolean) }; const { - initTestDb, closeDatabase, assertEqual, getResults, seedOwnerUser, + initTestDb, + closeDatabase, + assertEqual, + getResults, + seedOwnerUser, } = require('./helpers/test-setup'); async function main() { @@ -55,40 +59,134 @@ async function main() { const defaults = await request(app).get('/api/settings/order-numbering'); assertEqual(defaults.status, 200, 'returns 200'); assertEqual(defaults.body.invoice_number_prefix, 'INV', 'default invoice prefix is INV'); - assertEqual(defaults.body.invoice_number_include_period, true, 'default invoice period is included'); - assertEqual(defaults.body.invoice_number_reset_period, 'daily', 'default invoice reset is daily'); - assertEqual(defaults.body.invoice_financial_year_start_month, 4, 'default FY start month is April'); + assertEqual( + defaults.body.invoice_number_include_period, + true, + 'default invoice period is included', + ); + assertEqual( + defaults.body.invoice_number_reset_period, + 'daily', + 'default invoice reset is daily', + ); + assertEqual( + defaults.body.invoice_financial_year_start_month, + 4, + 'default FY start month is April', + ); assertEqual(defaults.body.invoice_financial_year_start_day, 1, 'default FY start day is 1'); console.log('\n2. PUT /settings/order-numbering persists invoice numbering'); - const updated = await request(app) - .put('/api/settings/order-numbering') - .send({ - invoice_number_prefix: 'BILL', - invoice_number_include_period: false, - invoice_number_reset_period: 'financial_year', - invoice_financial_year_start_month: 7, - invoice_financial_year_start_day: 15, - }); + const updated = await request(app).put('/api/settings/order-numbering').send({ + invoice_number_prefix: 'BILL', + invoice_number_include_period: false, + invoice_number_reset_period: 'financial_year', + invoice_financial_year_start_month: 7, + invoice_financial_year_start_day: 15, + }); assertEqual(updated.status, 200, 'returns 200'); assertEqual(updated.body.invoice_number_prefix, 'BILL', 'invoice prefix updates'); - assertEqual(updated.body.invoice_number_include_period, false, 'invoice period toggle updates'); - assertEqual(updated.body.invoice_number_reset_period, 'financial_year', 'invoice reset period updates'); + assertEqual( + updated.body.invoice_number_include_period, + true, + 'a resetting series always keeps its period', + ); + assertEqual( + updated.body.invoice_number_reset_period, + 'financial_year', + 'invoice reset period updates', + ); assertEqual(updated.body.invoice_financial_year_start_month, 7, 'FY month updates'); assertEqual(updated.body.invoice_financial_year_start_day, 15, 'FY day updates'); + console.log('\n2b. Contradictory include flags are normalized and persisted'); + const dailyNoPeriod = await request(app).put('/api/settings/order-numbering').send({ + invoice_number_include_period: false, + invoice_number_reset_period: 'daily', + order_number_include_date: false, + order_number_reset_daily: true, + }); + assertEqual( + dailyNoPeriod.body.invoice_number_include_period, + true, + 'daily invoice series keeps its period', + ); + assertEqual( + dailyNoPeriod.body.order_number_include_date, + true, + 'daily order series keeps its date', + ); + const storedFlags = db + .prepare( + "SELECT key, value FROM settings WHERE key IN ('invoice_number_include_period', 'order_number_include_date')", + ) + .all() as any[]; + assertEqual( + storedFlags.every((r: any) => r.value === 'true'), + true, + 'effective flags are stored, not the contradictory off', + ); + + const neverNoPeriod = await request(app).put('/api/settings/order-numbering').send({ + invoice_number_include_period: false, + invoice_number_reset_period: 'never', + order_number_include_date: false, + order_number_reset_daily: false, + }); + assertEqual( + neverNoPeriod.body.invoice_number_include_period, + false, + 'non-resetting invoice series may omit the period', + ); + assertEqual( + neverNoPeriod.body.order_number_include_date, + false, + 'non-resetting order series may omit the date', + ); + + db.prepare( + "UPDATE settings SET value = 'false' WHERE key IN ('invoice_number_include_period', 'order_number_include_date')", + ).run(); + db.prepare( + "UPDATE settings SET value = 'daily' WHERE key = 'invoice_number_reset_period'", + ).run(); + db.prepare("UPDATE settings SET value = 'true' WHERE key = 'order_number_reset_daily'").run(); + const legacy = await request(app).get('/api/settings/order-numbering'); + assertEqual( + legacy.body.invoice_number_include_period, + true, + 'GET reports the effective period for legacy stored values', + ); + assertEqual( + legacy.body.order_number_include_date, + true, + 'GET reports the effective date for legacy stored values', + ); + console.log('\n3. PUT /settings/order-numbering rejects invalid invoice values'); - const badPrefix = await request(app).put('/api/settings/order-numbering').send({ invoice_number_prefix: 'TOO LONG PREFIX' }); + const badPrefix = await request(app) + .put('/api/settings/order-numbering') + .send({ invoice_number_prefix: 'TOO LONG PREFIX' }); assertEqual(badPrefix.status, 400, 'rejects invalid invoice prefix'); - const dashPrefix = await request(app).put('/api/settings/order-numbering').send({ invoice_number_prefix: 'FAC-' }); + const dashPrefix = await request(app) + .put('/api/settings/order-numbering') + .send({ invoice_number_prefix: 'FAC-' }); assertEqual(dashPrefix.status, 400, 'rejects invoice prefix containing a dash'); - const underscorePrefix = await request(app).put('/api/settings/order-numbering').send({ order_number_prefix: 'ORD_' }); + const underscorePrefix = await request(app) + .put('/api/settings/order-numbering') + .send({ order_number_prefix: 'ORD_' }); assertEqual(underscorePrefix.status, 400, 'rejects order prefix containing an underscore'); - const badPeriod = await request(app).put('/api/settings/order-numbering').send({ invoice_number_reset_period: 'quarterly' }); + const badPeriod = await request(app) + .put('/api/settings/order-numbering') + .send({ invoice_number_reset_period: 'quarterly' }); assertEqual(badPeriod.status, 400, 'rejects invalid reset period'); - const badMonth = await request(app).put('/api/settings/order-numbering').send({ invoice_financial_year_start_month: 13 }); + const badMonth = await request(app) + .put('/api/settings/order-numbering') + .send({ invoice_financial_year_start_month: 13 }); assertEqual(badMonth.status, 400, 'rejects invalid FY month'); - const badDay = await request(app).put('/api/settings/order-numbering').send({ invoice_financial_year_start_day: 0 }); + const badDay = await request(app) + .put('/api/settings/order-numbering') + .send({ invoice_financial_year_start_day: 0 }); assertEqual(badDay.status, 400, 'rejects invalid FY day'); console.log('\n' + '='.repeat(50)); diff --git a/tests/sequence-generation.test.ts b/tests/sequence-generation.test.ts index 9de8c2c0c..db0d4bedc 100644 --- a/tests/sequence-generation.test.ts +++ b/tests/sequence-generation.test.ts @@ -17,13 +17,17 @@ const os = require('os'); const path = require('path'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-seq-')); Module._load = function (request: string, parent: unknown, isMain: boolean) { - if (request === 'electron') return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + if (request === 'electron') + return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; return originalLoad.apply(this, arguments as any); }; const { - initTestDb, getResults, closeDatabase, - assert, assertEqual, + initTestDb, + getResults, + closeDatabase, + assert, + assertEqual, } = require('./helpers/test-setup'); const { generateOrderNumber, generateBillNumber, dateStampInTimezone } = require('../main/db'); @@ -37,7 +41,9 @@ async function main() { try { // ── Test 1: sequences table has composite primary key ────────────── console.log('\n1. Sequences table has composite primary key (name, date)'); - const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='sequences'").get(); + const tableInfo = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='sequences'") + .get(); const ddl = tableInfo?.sql?.replace(/\s+/g, ' ').trim() || ''; assert(ddl.includes('PRIMARY KEY (name, date)'), 'Table has PRIMARY KEY (name, date)'); @@ -99,18 +105,30 @@ async function main() { // ── Test 10: Invoice numbers can reset monthly ──────────────────── console.log('\n10. Bill numbers can reset monthly'); - db.prepare("UPDATE settings SET value = 'monthly' WHERE key = 'invoice_number_reset_period'").run(); + db.prepare( + "UPDATE settings SET value = 'monthly' WHERE key = 'invoice_number_reset_period'", + ).run(); db.prepare('DELETE FROM sequences WHERE name = ?').run('bills'); const monthly = generateBillNumber(); - assertEqual(monthly, `INV-${orderToday.slice(0, 6)}-0001`, 'Monthly bill number uses YYYYMM bucket'); + assertEqual( + monthly, + `INV-${orderToday.slice(0, 6)}-0001`, + 'Monthly bill number uses YYYYMM bucket', + ); const monthlyRow = db.prepare("SELECT date FROM sequences WHERE name = 'bills'").get() as any; assertEqual(monthlyRow?.date, orderToday.slice(0, 6), 'Monthly sequence bucket is YYYYMM'); // ── Test 11: Invoice numbers can reset by financial year ─────────── console.log('\n11. Bill numbers can reset by financial year'); - db.prepare("UPDATE settings SET value = 'financial_year' WHERE key = 'invoice_number_reset_period'").run(); - db.prepare("UPDATE settings SET value = '4' WHERE key = 'invoice_financial_year_start_month'").run(); - db.prepare("UPDATE settings SET value = '1' WHERE key = 'invoice_financial_year_start_day'").run(); + db.prepare( + "UPDATE settings SET value = 'financial_year' WHERE key = 'invoice_number_reset_period'", + ).run(); + db.prepare( + "UPDATE settings SET value = '4' WHERE key = 'invoice_financial_year_start_month'", + ).run(); + db.prepare( + "UPDATE settings SET value = '1' WHERE key = 'invoice_financial_year_start_day'", + ).run(); db.prepare('DELETE FROM sequences WHERE name = ?').run('bills'); const parts = orderToday.match(/^(\d{4})(\d{2})(\d{2})$/); const year = Number(parts?.[1]); @@ -119,12 +137,20 @@ async function main() { const fyStart = month > 4 || (month === 4 && day >= 1) ? year : year - 1; const fySegment = `FY${fyStart}-${String((fyStart + 1) % 100).padStart(2, '0')}`; const financialYear = generateBillNumber(); - assertEqual(financialYear, `INV-${fySegment}-0001`, 'Financial-year bill number uses FY segment'); + assertEqual( + financialYear, + `INV-${fySegment}-0001`, + 'Financial-year bill number uses FY segment', + ); // ── Test 12: Invoice numbers can run without resets or period text ─ console.log('\n12. Bill numbers can run without resets or period text'); - db.prepare("UPDATE settings SET value = 'never' WHERE key = 'invoice_number_reset_period'").run(); - db.prepare("UPDATE settings SET value = 'false' WHERE key = 'invoice_number_include_period'").run(); + db.prepare( + "UPDATE settings SET value = 'never' WHERE key = 'invoice_number_reset_period'", + ).run(); + db.prepare( + "UPDATE settings SET value = 'false' WHERE key = 'invoice_number_include_period'", + ).run(); db.prepare('DELETE FROM sequences WHERE name = ?').run('bills'); const noReset = generateBillNumber(); assertEqual(noReset, 'INV-0001', 'No-reset bill number omits period text when configured'); @@ -134,12 +160,149 @@ async function main() { // ── Test 13: A pre-existing prefix saved with a trailing dash is ── // ── sanitized so it doesn't double up with the auto separator ───── console.log('\n13. Bill number sanitizes a pre-existing "FAC-" prefix (no double dash)'); - db.prepare("UPDATE settings SET value = 'daily' WHERE key = 'invoice_number_reset_period'").run(); - db.prepare("UPDATE settings SET value = 'true' WHERE key = 'invoice_number_include_period'").run(); + db.prepare( + "UPDATE settings SET value = 'daily' WHERE key = 'invoice_number_reset_period'", + ).run(); + db.prepare( + "UPDATE settings SET value = 'true' WHERE key = 'invoice_number_include_period'", + ).run(); db.prepare("UPDATE settings SET value = 'FAC-' WHERE key = 'invoice_number_prefix'").run(); db.prepare('DELETE FROM sequences WHERE name = ?').run('bills'); const facDash = generateBillNumber(); - assertEqual(facDash, `FAC-${orderToday}-0001`, 'Trailing dash in stored prefix is stripped, not doubled'); + assertEqual( + facDash, + `FAC-${orderToday}-0001`, + 'Trailing dash in stored prefix is stripped, not doubled', + ); + + // ── Test 14: Daily reset keeps the date even when it is toggled off ─ + console.log( + '\n14. Daily-reset numbers stay unique across a day rollover without prefix or date', + ); + const setSetting = (key: string, value: string) => + db + .prepare( + 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ) + .run(key, value); + setSetting('invoice_number_prefix', ''); + setSetting('invoice_number_include_period', 'false'); + setSetting('invoice_number_reset_period', 'daily'); + setSetting('order_number_prefix', ''); + setSetting('order_number_include_date', 'false'); + setSetting('order_number_reset_daily', 'true'); + db.prepare("DELETE FROM sequences WHERE name IN ('bills', 'orders')").run(); + + const RealDate = Date; + const dayOne = generateBillNumber(); + const orderDayOne = generateOrderNumber(); + const shifted = RealDate.now() + 24 * 60 * 60 * 1000; + (globalThis as any).Date = class extends RealDate { + constructor(...args: any[]) { + super(...(args.length ? (args as [any]) : [shifted])); + } + static now() { + return shifted; + } + }; + let dayTwo: string; + let orderDayTwo: string; + try { + dayTwo = generateBillNumber(); + orderDayTwo = generateOrderNumber(); + } finally { + (globalThis as any).Date = RealDate; + } + assertEqual(dayOne, `${orderToday}-0001`, 'Day one bill number carries the date'); + assert(dayTwo !== dayOne, 'Day two bill number differs from day one'); + assert(/^\d{8}-0001$/.test(dayTwo), 'Day two bill number restarts at 0001 under its own date'); + assert(orderDayTwo !== orderDayOne, 'Day two order number differs from day one'); + assert( + /^\d{8}-0001$/.test(orderDayTwo), + 'Day two order number restarts at 0001 under its own date', + ); + + // ── Test 15: Mid-day setting switches and reused numbers never collide ─ + console.log('\n15. Already-issued numbers lift the counter instead of failing the sale'); + const insertOrder = db.prepare('INSERT INTO orders (order_number) VALUES (?)'); + const anchorOrderId = Number(insertOrder.run('ANCHOR').lastInsertRowid); + const insertBill = db.prepare( + 'INSERT INTO bills (bill_number, order_id, subtotal, total) VALUES (?, ?, 0, 0)', + ); + const issueBill = (n: string) => insertBill.run(n, anchorOrderId); + + // Daily series switched to a lifetime counter on the same day. + setSetting('invoice_number_prefix', 'INV'); + setSetting('invoice_number_include_period', 'true'); + setSetting('invoice_number_reset_period', 'daily'); + db.prepare("DELETE FROM sequences WHERE name = 'bills'").run(); + db.prepare('DELETE FROM bills').run(); + const issuedToday = [generateBillNumber(), generateBillNumber(), generateBillNumber()]; + for (const n of issuedToday) issueBill(n); + setSetting('invoice_number_reset_period', 'never'); + const afterSwitch = generateBillNumber(); + assertEqual( + afterSwitch, + `INV-${orderToday}-0004`, + 'Same-day switch to a lifetime counter continues after issued numbers', + ); + issueBill(afterSwitch); + + // Empty prefix and no period (legacy daily numbers 0001..0012) moved to a lifetime counter. + setSetting('invoice_number_prefix', ''); + setSetting('invoice_number_include_period', 'false'); + db.prepare('DELETE FROM bills').run(); + for (let i = 1; i <= 12; i++) issueBill(String(i).padStart(4, '0')); + db.prepare("DELETE FROM sequences WHERE name = 'bills'").run(); + assertEqual(generateBillNumber(), '0013', 'Lifetime counter resumes after legacy bare numbers'); + + // A stale or missing counter on the same day never reissues a number. + setSetting('invoice_number_prefix', 'INV'); + setSetting('invoice_number_include_period', 'true'); + setSetting('invoice_number_reset_period', 'daily'); + db.prepare('DELETE FROM bills').run(); + issueBill(`INV-${orderToday}-0001`); + issueBill(`INV-${orderToday}-0002`); + db.prepare("DELETE FROM sequences WHERE name = 'bills'").run(); + assertEqual( + generateBillNumber(), + `INV-${orderToday}-0003`, + 'Missing counter row skips numbers already issued today', + ); + + // An imported number with an oversized suffix is ignored when lifting the counter. + setSetting('invoice_number_prefix', 'INV'); + setSetting('invoice_number_include_period', 'true'); + setSetting('invoice_number_reset_period', 'daily'); + db.prepare('DELETE FROM bills').run(); + issueBill(`INV-${orderToday}-0001`); + issueBill(`INV-${orderToday}-99999999999999999999`); + db.prepare("DELETE FROM sequences WHERE name = 'bills'").run(); + assertEqual( + generateBillNumber(), + `INV-${orderToday}-0002`, + 'Oversized imported suffix does not lift the counter', + ); + assertEqual( + generateBillNumber(), + `INV-${orderToday}-0003`, + 'Counter keeps advancing after an oversized imported suffix', + ); + + // Order numbers: daily series switched to a lifetime counter on the same day. + setSetting('order_number_prefix', 'ORD'); + setSetting('order_number_include_date', 'true'); + setSetting('order_number_reset_daily', 'true'); + db.prepare("DELETE FROM sequences WHERE name = 'orders'").run(); + db.prepare('DELETE FROM bills').run(); + db.prepare('DELETE FROM orders').run(); + for (let i = 0; i < 3; i++) insertOrder.run(generateOrderNumber()); + setSetting('order_number_reset_daily', 'false'); + assertEqual( + generateOrderNumber(), + `ORD-${orderToday}-0004`, + 'Order counter continues after issued numbers', + ); // ── Summary ─────────────────────────────────────────────────────── console.log('\n' + '='.repeat(50));