Files
app/src/services/invoices.service.ts

618 lines
20 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 { 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;
}
/**
* 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;
notes?: string | null;
internal_notes?: string | null;
paid_date?: string | null;
items?: InvoiceItemInput[];
[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 ListInvoicesParams {
page: number;
limit: number;
skip: number;
sort: string;
order: "asc" | "desc";
search: string;
status?: string;
customer_id?: number;
month?: number;
year?: number;
}
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 {
await prisma.invoices.updateMany({
where: { status: "issued", due_date: { lt: new Date() } },
data: { status: "overdue" },
});
// Reverse: if due_date was changed to future, set back to issued
await prisma.invoices.updateMany({
where: { status: "overdue", due_date: { gte: new Date() } },
data: { status: "issued" },
});
} catch (err) {
console.error("markOverdueInvoices failed:", err);
}
}
export async function listInvoices(params: ListInvoicesParams) {
const {
page,
limit,
skip,
sort,
order,
search,
status,
customer_id,
month,
year,
} = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(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 } } },
];
}
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 };
}
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;
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59);
const startOfYear = new Date(year, 0, 1);
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
prisma.invoices.findMany({
where: {
issue_date: { gte: monthStart, lte: monthEnd },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "issued",
issue_date: { gte: startOfYear, lte: endOfYear },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "overdue",
issue_date: { gte: startOfYear, lte: endOfYear },
},
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 },
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" } },
orders: { select: { id: true, order_number: true } },
},
});
if (!invoice) return null;
const { invoice_items, ...rest } = invoice;
return {
...rest,
items: invoice_items,
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);
const invoice = await prisma.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",
notes: body.notes ? String(body.notes) : null,
internal_notes: body.internal_notes ? String(body.internal_notes) : null,
},
});
if (Array.isArray(body.items)) {
await prisma.invoice_items.createMany({
data: body.items.map((item, i) => ({
invoice_id: invoice.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,
})),
});
}
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;
}
// Notes editable in draft/issued/overdue
if (editable) {
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
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
if (String(body.status) === "paid" && !existing.paid_date) {
data.paid_date = new Date();
}
}
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 update while editable (draft/issued/overdue).
if (editable && Array.isArray(body.items)) {
await prisma.$transaction(async (tx) => {
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,
})),
});
});
}
// 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;
}