Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/architecture/data-and-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
83 changes: 44 additions & 39 deletions frontend/src/components/settings/GeneralSettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ export function GeneralSettingsTab({
<Ltr>
{[
orderNumberForm.prefix,
orderNumberForm.includeDate ? sampleOrderDate : '',
orderNumberForm.includeDate || orderNumberForm.resetDaily ? sampleOrderDate : '',
'0001',
]
.filter(Boolean)
Expand All @@ -648,24 +648,26 @@ export function GeneralSettingsTab({
</div>

<div className="mt-5 pt-5 border-t border-border space-y-3">
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-foreground">{t('orderNumberIncludeDate')}</span>
<p className="text-xs text-muted-foreground">{t('orderNumberIncludeDateHint')}</p>
{!orderNumberForm.resetDaily && (
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-foreground">{t('orderNumberIncludeDate')}</span>
<p className="text-xs text-muted-foreground">{t('orderNumberIncludeDateHint')}</p>
</div>
<Toggle
value={orderNumberForm.includeDate}
label={t('orderNumberIncludeDate')}
onChange={
isAdmin
? (v) => {
markHydrationTouched('includeDate');
setOrderNumberForm((p) => ({ ...p, includeDate: v }));
}
: () => {}
}
/>
</div>
<Toggle
value={orderNumberForm.includeDate}
label={t('orderNumberIncludeDate')}
onChange={
isAdmin
? (v) => {
markHydrationTouched('includeDate');
setOrderNumberForm((p) => ({ ...p, includeDate: v }));
}
: () => {}
}
/>
</div>
)}
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-foreground">{t('orderNumberResetDaily')}</span>
Expand Down Expand Up @@ -722,7 +724,8 @@ export function GeneralSettingsTab({
<Ltr>
{[
orderNumberForm.invoicePrefix,
orderNumberForm.invoiceIncludePeriod
orderNumberForm.invoiceIncludePeriod ||
orderNumberForm.invoiceResetPeriod !== 'never'
? invoicePreviewSegment(
orderNumberForm.invoiceResetPeriod,
orderNumberForm.invoiceFinancialYearStartMonth,
Expand Down Expand Up @@ -809,28 +812,30 @@ export function GeneralSettingsTab({
)}
</div>

<div className="mt-5 pt-5 border-t border-border">
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-foreground">{t('invoiceNumberIncludePeriod')}</span>
<p className="text-xs text-muted-foreground">
{t('invoiceNumberIncludePeriodHint')}
</p>
{orderNumberForm.invoiceResetPeriod === 'never' && (
<div className="mt-5 pt-5 border-t border-border">
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-foreground">{t('invoiceNumberIncludePeriod')}</span>
<p className="text-xs text-muted-foreground">
{t('invoiceNumberIncludePeriodHint')}
</p>
</div>
<Toggle
value={orderNumberForm.invoiceIncludePeriod}
label={t('invoiceNumberIncludePeriod')}
onChange={
isAdmin
? (v) => {
markHydrationTouched('invoiceIncludePeriod');
setOrderNumberForm((p) => ({ ...p, invoiceIncludePeriod: v }));
}
: () => {}
}
/>
</div>
<Toggle
value={orderNumberForm.invoiceIncludePeriod}
label={t('invoiceNumberIncludePeriod')}
onChange={
isAdmin
? (v) => {
markHydrationTouched('invoiceIncludePeriod');
setOrderNumberForm((p) => ({ ...p, invoiceIncludePeriod: v }));
}
: () => {}
}
/>
</div>
</div>
)}
</div>
</div>

Expand Down
86 changes: 75 additions & 11 deletions main/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>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]*'
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
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'),
Expand All @@ -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 {
Expand Down
63 changes: 43 additions & 20 deletions main/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, string>) {
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,
Expand All @@ -898,7 +913,6 @@ function orderNumberingShape(s: Record<string, string>) {

// 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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading