Files
app/src/services/invoices.service.ts
BOHA 40a859f5e1 feat(invoices)!: Obsah sections, internal-only notes, unified per-page PDF header+footer
Invoices now mirror the issued-orders document model:

- New invoice_sections table (CZ/EN rich-text "Obsah") edited via the
  shared SectionsEditor, printed inline right after the items on the
  PDF. Full-replace on update, same transaction as items.
- Printed notes dropped: the notes column is removed (migration merges
  existing content into internal_notes first); the form field is now
  "Interni poznamky", never printed. Legacy payloads sending notes are
  silently stripped.
- Form cleanup: Cislo faktury and Vystavil fields removed (number lives
  in the header, issued_by auto-fills); page header title restyled to
  the orders/offers pattern (number span + status chip).
- Unified per-page PDF header for the red-accent family: shared
  buildPdfHeaderTemplate in pdf-shared (22mm logo, red heading, red
  rule) rendered by a Puppeteer headerTemplate on EVERY page of both
  invoices and issued orders (incl. the /file fallback render); body
  headers are print-hidden. htmlToPdf gained the headerTemplate option.
- Footer parity: invoices get the per-page "Vystavil + Strana X z Y"
  footer; the invoice bottom block (notice + QR/VAT recap + Prevzal)
  is break-inside: avoid so a page break can never split it.
- @page margins now match the template space (32mm top, 18mm bottom) -
  Chromium lays out by CSS @page margins, which also fixes issued
  orders' content running into the 18mm footer zone on full pages.

BREAKING CHANGE: invoices.notes column dropped (data merged into
internal_notes); deploy must run prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:02:37 +02:00

736 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
releaseInvoiceNumber,
assignInvoiceNumber,
} from "./numbering.service";
// Status transition rules matching PHP.
// draft -> issued is the finalize step (consumes + assigns the invoice number).
const VALID_TRANSITIONS: Record<string, string[]> = {
draft: ["issued"],
issued: ["paid"],
overdue: ["paid"],
paid: [],
};
const ALLOWED_SORT_FIELDS = [
"id",
"invoice_number",
"status",
"issue_date",
"due_date",
"currency",
];
interface InvoiceItemInput {
description?: string | null;
item_description?: string | null;
quantity?: number;
unit?: string | null;
unit_price?: number;
vat_rate?: number;
position?: number;
}
interface InvoiceSectionInput {
title?: string | null;
title_cz?: string | null;
content?: string | null;
}
/**
* Body shape accepted by createInvoice/updateInvoice. All fields optional and
* loosely typed because the payload arrives validated-but-coerced from the Zod
* route layer (numbers may still be string-from-form). Kept permissive so the
* existing String()/Number() coercions below stay correct; replaces the old
* `Record<string, any>`.
*/
export interface InvoiceInput {
invoice_number?: string | number | null;
order_id?: number | string | null;
customer_id?: number | string | null;
status?: string;
currency?: string;
vat_rate?: number | string | null;
apply_vat?: boolean | number | string;
payment_method?: string | null;
constant_symbol?: string | null;
bank_name?: string | null;
bank_swift?: string | null;
bank_iban?: string | null;
bank_account?: string | null;
issue_date?: string | null;
due_date?: string | null;
tax_date?: string | null;
issued_by?: string | null;
billing_text?: string | null;
language?: string;
internal_notes?: string | null;
paid_date?: string | null;
items?: InvoiceItemInput[];
sections?: InvoiceSectionInput[];
[key: string]: unknown;
}
/**
* Single rounding-correct money/VAT core. All three former implementations
* (computeInvoiceTotals, invoiceTotalWithVat, the stats VAT loop) funnel through
* this so the subtotal/accumulation/rounding logic cannot diverge again.
*
* Each caller resolves its own per-line VAT *rate* before calling (the three
* paths historically differ in how a line rate of 0 is treated — that nuance
* is preserved by keeping rate resolution caller-side via `resolveRate`).
*
* - `subtotal` = Σ(qty × unit_price) over lines.
* - `vat` = Σ of per-line VAT. When `roundPerLine` is true each line's VAT is
* rounded to 2 decimals BEFORE accumulation (invoice totals); when false the
* raw per-line VAT is accumulated and the caller rounds the sum (stats).
*
* Numeric outputs are byte-for-byte identical to the previous three code paths.
*/
function computeMoney<T>(
lines: T[],
applyVat: boolean | null,
getBase: (line: T) => number,
resolveRate: (line: T) => number,
roundPerLine: boolean,
): { subtotal: number; vat: number } {
let subtotal = 0;
let vat = 0;
for (const line of lines) {
const base = getBase(line);
subtotal += base;
if (applyVat) {
const lineVat = base * (resolveRate(line) / 100);
vat += roundPerLine ? Math.round(lineVat * 100) / 100 : lineVat;
}
}
return { subtotal, vat };
}
interface InvoiceFilterParams {
search?: string;
status?: string;
customer_id?: number;
month?: number;
year?: number;
}
interface ListInvoicesParams extends InvoiceFilterParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
search: string;
}
export interface CurrencyAmount {
amount: number;
currency: string;
}
/**
* Shared `where` builder for the issued-invoices list and the per-currency
* list-totals aggregation, so both stay in sync (any new filter applies to
* both). month/year filter on issue_date (matching the list).
*/
function buildInvoiceWhere(
params: InvoiceFilterParams,
): Record<string, unknown> {
const { status, customer_id, month, year, search } = params;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (month && year) {
// issue_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back — the filter included the previous month's last day and
// DROPPED invoices issued on the selected month's last day.
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
where.issue_date = { gte: from, lt: to };
}
if (search) {
where.OR = [
{ invoice_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
];
}
return where;
}
function computeInvoiceTotals(
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
applyVat: boolean | null,
defaultVatRate: unknown,
) {
const { subtotal, vat: vatAmount } = computeMoney(
items,
applyVat,
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
(i) =>
i.vat_rate != null && i.vat_rate !== ""
? Number(i.vat_rate)
: defaultVatRate != null
? Number(defaultVatRate)
: 21,
true, // round each line's VAT before accumulation
);
return {
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
};
}
export async function markOverdueInvoices() {
try {
// due_date is @db.Date (UTC midnight of the calendar day). Compare
// against UTC midnight of the LOCAL today — a bare new Date() has
// yesterday's UTC date during the 00:0002:00 Prague window, so the
// overdue flip lagged a day there. Due TODAY = not yet overdue.
const today = utcMidnightOfLocalDay();
await prisma.invoices.updateMany({
where: { status: "issued", due_date: { lt: today } },
data: { status: "overdue" },
});
// Reverse: if due_date was changed to today/future, set back to issued
await prisma.invoices.updateMany({
where: { status: "overdue", due_date: { gte: today } },
data: { status: "issued" },
});
} catch (err) {
console.error("markOverdueInvoices failed:", err);
}
}
export async function listInvoices(params: ListInvoicesParams) {
const { page, limit, skip, sort, order } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = buildInvoiceWhere(params);
const orderBy: Record<string, string> = { [sortField]: order };
const [invoices, total] = await Promise.all([
prisma.invoices.findMany({
where,
skip,
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
invoice_items: true,
orders: { select: { id: true, order_number: true } },
},
}),
prisma.invoices.count({ where }),
]);
const enriched = invoices.map((inv) => {
const totals = computeInvoiceTotals(
inv.invoice_items,
inv.apply_vat,
inv.vat_rate,
);
const { invoice_items, ...rest } = inv;
return {
...rest,
items: invoice_items,
customer_name: inv.customers?.name || null,
order_number: inv.orders?.order_number || null,
...totals,
};
});
return { data: enriched, total, page, limit };
}
/**
* Sum issued-invoice TOTAL (incl. VAT) per currency across the WHOLE filtered
* set (not a single page). Reuses `buildInvoiceWhere` so the filters track the
* list, and `computeInvoiceTotals` so the per-invoice math matches the
* list/detail exactly. Returns one entry per currency, rounded to 2dp,
* zero/empty totals dropped.
*
* NOTE: This is SEPARATE from `getInvoiceStats` (the monthly KPI cards). That
* function is untouched — this only sums the list's visible rows per currency.
*/
export async function getInvoiceListTotals(
params: InvoiceFilterParams,
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildInvoiceWhere(params);
const invoices = await prisma.invoices.findMany({
where,
include: { invoice_items: true },
});
const byCurrency: Record<string, number> = {};
for (const inv of invoices) {
const { total } = computeInvoiceTotals(
inv.invoice_items,
inv.apply_vat,
inv.vat_rate,
);
const cur = inv.currency || "CZK";
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
}
const totals: CurrencyAmount[] = Object.entries(byCurrency)
.map(([currency, amount]) => ({
currency,
amount: Math.round(amount * 100) / 100,
}))
.filter((t) => t.amount > 0);
return { totals };
}
export {
generateInvoiceNumber as getNextInvoiceNumberFormatted,
previewInvoiceNumber as getNextInvoiceNumberPreview,
} from "./numbering.service";
export function invoiceTotalWithVat(inv: {
apply_vat: boolean | null;
vat_rate: { toNumber(): number } | null;
currency: string | null;
invoice_items: Array<{
quantity: { toNumber(): number } | null;
unit_price: { toNumber(): number } | null;
vat_rate: { toNumber(): number } | null;
}>;
}) {
const { subtotal, vat } = computeMoney(
inv.invoice_items,
inv.apply_vat,
(i) =>
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0),
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
// the invoice default, then 21.
(i) =>
Number(i.vat_rate?.toNumber()) || Number(inv.vat_rate?.toNumber()) || 21,
true, // round each line's VAT before accumulation
);
return subtotal + vat;
}
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const now = new Date();
const year = queryYear || now.getFullYear();
const month = queryMonth || now.getMonth() + 1;
// issue_date is @db.Date (compared by UTC date part) → UTC-midnight period
// boundaries, half-open [gte, lt) ranges. Local-midnight bounds included
// the previous period's boundary day in the stats.
const monthStart = new Date(Date.UTC(year, month - 1, 1));
const nextMonthStart = new Date(Date.UTC(year, month, 1));
const startOfYear = new Date(Date.UTC(year, 0, 1));
const startOfNextYear = new Date(Date.UTC(year + 1, 0, 1));
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
prisma.invoices.findMany({
where: {
// Drafts are not real financial documents — exclude them so their
// VAT/amounts never inflate the monthly stats (vat_month etc.).
status: { not: "draft" },
issue_date: { gte: monthStart, lt: nextMonthStart },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "issued",
issue_date: { gte: startOfYear, lt: startOfNextYear },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "overdue",
issue_date: { gte: startOfYear, lt: startOfNextYear },
},
include: { invoice_items: true },
}),
]);
const aggregateByCurrency = (
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
) => {
const map: Record<string, number> = {};
for (const inv of invoices) {
const cur = inv.currency || "CZK";
map[cur] = (map[cur] || 0) + invoiceTotalWithVat(inv);
}
return Object.entries(map)
.filter(([, v]) => v > 0)
.map(([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}));
};
const sumCzk = async (
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
) => {
let total = 0;
for (const inv of invoices) {
const amount = invoiceTotalWithVat(inv);
total += await toCzk(amount, inv.currency || "CZK");
}
return Math.round(total * 100) / 100;
};
const paidInvoices = monthInvoices.filter((i) => i.status === "paid");
// VAT for the month, per currency. Uses the shared money core with
// roundPerLine=false (the stats path historically accumulates RAW per-line
// VAT and rounds only the final sum — preserved byte-for-byte).
const vatMap: Record<string, number> = {};
for (const inv of monthInvoices) {
if (!inv.apply_vat) continue;
const cur = inv.currency || "CZK";
const { vat } = computeMoney(
inv.invoice_items,
inv.apply_vat,
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
false, // accumulate raw per-line VAT; round only the per-currency sum
);
vatMap[cur] = (vatMap[cur] || 0) + vat;
}
const vatAmounts = Object.entries(vatMap)
.filter(([, v]) => v > 0)
.map(([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}));
// VAT also needs CZK conversion — run each currency's toCzk concurrently.
const vatCzkConverted = (
await Promise.all(
Object.entries(vatMap).map(([cur, amount]) => toCzk(amount, cur)),
)
).reduce((s, v) => s + v, 0);
// These aggregates/conversions are independent — run them concurrently
// instead of serially awaiting each in the return literal.
const [paidMonthCzk, awaitingCzk, overdueCzk] = await Promise.all([
sumCzk(paidInvoices),
sumCzk(awaitingInvoices),
sumCzk(overdueInvoices),
]);
return {
paid_month: aggregateByCurrency(paidInvoices),
paid_month_czk: paidMonthCzk,
paid_month_count: paidInvoices.length,
awaiting: aggregateByCurrency(awaitingInvoices),
awaiting_czk: awaitingCzk,
awaiting_count: awaitingInvoices.length,
overdue: aggregateByCurrency(overdueInvoices),
overdue_czk: overdueCzk,
overdue_count: overdueInvoices.length,
vat_month: vatAmounts,
vat_month_czk: Math.round(vatCzkConverted * 100) / 100,
month,
year,
};
}
export async function getOrderDataForInvoice(orderId: number) {
const order = await prisma.orders.findUnique({
where: { id: orderId },
// This result is spread into the order-data API response — never include
// the PO attachment blob (served only by GET /orders/:id/attachment).
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
if (!order) return null;
const { order_items, customers, ...rest } = order;
return {
...rest,
items: order_items,
customer_name: customers?.name || null,
};
}
// NOTE: returns `null` (not `{ error, status }`) on not-found — intentional and
// load-bearing: routes/admin/invoices.ts checks `if (!invoice) 404`. Returning a
// truthy `{ error }` object here would bypass that 404 guard, so this stays null.
export async function getInvoice(id: number) {
const invoice = await prisma.invoices.findUnique({
where: { id },
include: {
customers: true,
invoice_items: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
invoice_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
orders: { select: { id: true, order_number: true } },
},
});
if (!invoice) return null;
const { invoice_items, invoice_sections, ...rest } = invoice;
return {
...rest,
items: invoice_items,
sections: invoice_sections,
customer: invoice.customers,
customer_name: invoice.customers?.name || null,
order_number: invoice.orders?.order_number || null,
valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [],
};
}
export async function createInvoice(body: InvoiceInput) {
const status = body.status ? String(body.status) : "draft";
// Deferred numbering: a draft carries NO invoice_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignInvoiceNumber on draft->issued).
// A caller-supplied number, or an invoice created already-finalized, numbers now.
const explicit =
body.invoice_number !== undefined && body.invoice_number !== null
? String(body.invoice_number)
: null;
const invoiceNumber =
explicit ??
(status === "draft" ? null : (await generateInvoiceNumber()).number);
// Header + items + sections are ONE write — a failure mid-way must not
// leave a headerless/partial invoice behind.
const invoice = await prisma.$transaction(async (tx) => {
const created = await tx.invoices.create({
data: {
invoice_number: invoiceNumber,
order_id: body.order_id ? Number(body.order_id) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
payment_method: body.payment_method
? String(body.payment_method)
: null,
constant_symbol: body.constant_symbol
? String(body.constant_symbol)
: null,
bank_name: body.bank_name ? String(body.bank_name) : null,
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
bank_account: body.bank_account ? String(body.bank_account) : null,
issue_date: body.issue_date ? new Date(String(body.issue_date)) : null,
due_date: body.due_date ? new Date(String(body.due_date)) : null,
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
billing_text: body.billing_text ? String(body.billing_text) : null,
language: body.language ? String(body.language) : "cs",
internal_notes: body.internal_notes
? String(body.internal_notes)
: null,
},
});
if (Array.isArray(body.items)) {
await tx.invoice_items.createMany({
data: body.items.map((item, i) => ({
invoice_id: created.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.invoice_sections.createMany({
data: body.sections.map((s, i) => ({
invoice_id: created.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: i,
})),
});
}
return created;
});
return invoice;
}
export async function updateInvoice(id: number, body: InvoiceInput) {
const existing = await prisma.invoices.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const };
const currentStatus = existing.status as string;
// Handle status transition
if (body.status !== undefined && body.status !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return { error: "invalid_transition" as const, currentStatus, newStatus };
}
}
const data: Record<string, unknown> = { modified_at: new Date() };
// Allow full editing in 'draft', 'issued' and 'overdue' states.
const editable =
currentStatus === "draft" ||
currentStatus === "issued" ||
currentStatus === "overdue";
if (editable) {
const strFields = [
"currency",
"payment_method",
"constant_symbol",
"bank_name",
"bank_swift",
"bank_iban",
"bank_account",
"issued_by",
"billing_text",
"language",
];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined)
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined)
data.apply_vat =
body.apply_vat === true ||
body.apply_vat === 1 ||
body.apply_vat === "1";
if (body.issue_date !== undefined)
data.issue_date = body.issue_date
? new Date(String(body.issue_date))
: null;
if (body.due_date !== undefined)
data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
if (body.tax_date !== undefined)
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
}
// Internal notes editable in draft/issued/overdue (never printed)
if (editable) {
if (body.internal_notes !== undefined)
data.internal_notes = body.internal_notes
? String(body.internal_notes)
: null;
}
// Status change
if (body.status !== undefined) {
data.status = String(body.status);
// Auto-set paid_date when transitioning to paid. paid_date is @db.Date
// (truncated to the UTC date part) — utcMidnightOfLocalDay keeps the
// LOCAL calendar day even during the 00:0002:00 Prague window.
if (String(body.status) === "paid" && !existing.paid_date) {
data.paid_date = utcMidnightOfLocalDay();
}
}
if (body.paid_date !== undefined && currentStatus !== "paid")
data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
// Finalize transition draft -> issued: consume + write the official invoice
// number ATOMICALLY with the status change, so there is never a window where
// the invoice is "issued" but still has a null invoice_number.
// assignInvoiceNumber is idempotent (won't re-number an already-numbered row).
const finalizing =
currentStatus === "draft" &&
body.status !== undefined &&
String(body.status) === "issued";
let assignedNumber: string | null = null;
if (finalizing) {
await prisma.$transaction(async (tx) => {
await tx.invoices.update({ where: { id }, data });
assignedNumber = await assignInvoiceNumber(id, tx);
});
} else {
await prisma.invoices.update({ where: { id }, data });
}
// Allow items/sections full-replace while editable (draft/issued/overdue) —
// both in ONE transaction so a failure can't leave a half-replaced document.
const replaceItems = editable && Array.isArray(body.items);
const replaceSections = editable && Array.isArray(body.sections);
if (replaceItems || replaceSections) {
await prisma.$transaction(async (tx) => {
if (replaceItems) {
await tx.invoice_items.deleteMany({ where: { invoice_id: id } });
await tx.invoice_items.createMany({
data: body.items!.map((item, i) => ({
invoice_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
}
if (replaceSections) {
await tx.invoice_sections.deleteMany({ where: { invoice_id: id } });
await tx.invoice_sections.createMany({
data: body.sections!.map((s, i) => ({
invoice_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: i,
})),
});
}
});
}
// Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its invoice_number is still the pre-finalize null).
return {
id,
invoice_number: assignedNumber ?? existing.invoice_number,
};
}
export async function deleteInvoice(id: number) {
const existing = await prisma.invoices.findUnique({ where: { id } });
if (!existing) return null;
await prisma.invoices.delete({ where: { id } });
// Derive the sequence year from the invoice number ("<seq>/<YYYY>"). Guard the
// split: a non-standard number (no "/" or a non-4-digit / non-numeric part)
// falls back to the current year rather than releasing a bogus sequence.
const yearPart = existing.invoice_number?.split("/")[1];
const parsedYear =
yearPart && /^\d{4}$/.test(yearPart) ? Number(yearPart) : NaN;
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear;
await releaseInvoiceNumber(year, existing.invoice_number ?? undefined);
return existing;
}