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>
736 lines
24 KiB
TypeScript
736 lines
24 KiB
TypeScript
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:00–02: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:00–02: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;
|
||
}
|