Files
app/src/services/invoices.service.ts
BOHA 07cb428287 1.5.2
- feat: order confirmation PDF generation with VAT support
- feat: order confirmation modal with custom item editing
- fix: attendance negative duration clamping and switchProject timing
- fix: Quill editor locked to Tahoma 14px, PDF heading sizes
- fix: invoice/offer PDF font consistency (Tahoma enforcement)
- fix: invoice alert cron improvements
- fix: NAS financials manager edge cases
- refactor: numbering service with unique sequence constraints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:23:10 +02:00

464 lines
14 KiB
TypeScript

import prisma from "../config/database";
import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
releaseInvoiceNumber,
} from "./numbering.service";
// Status transition rules matching PHP
const VALID_TRANSITIONS: Record<string, string[]> = {
issued: ["paid"],
overdue: ["paid"],
paid: [],
};
const ALLOWED_SORT_FIELDS = [
"id",
"invoice_number",
"status",
"issue_date",
"due_date",
"currency",
];
interface InvoiceItemInput {
description?: string;
quantity?: number;
unit?: string;
unit_price?: number;
vat_rate?: number;
position?: number;
}
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 = items.reduce(
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);
const vatAmount = applyVat
? items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return (
s +
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100)
);
}, 0)
: 0;
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 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 allInvoices = await prisma.invoices.findMany({
include: { invoice_items: true },
});
const invoiceTotalWithVat = (inv: (typeof allInvoices)[0]) => {
const sub = inv.invoice_items.reduce(
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);
const vat = inv.apply_vat
? inv.invoice_items.reduce((s, i) => {
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
return (
s +
base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100)
);
}, 0)
: 0;
return sub + vat;
};
const aggregateByCurrency = (invoices: typeof allInvoices) => {
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: typeof allInvoices) => {
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 monthInvoices = allInvoices.filter((inv) => {
const issueDate = inv.issue_date ? new Date(inv.issue_date) : null;
return issueDate && issueDate >= monthStart && issueDate <= monthEnd;
});
const paidInvoices = monthInvoices.filter((i) => i.status === "paid");
const awaitingInvoices = allInvoices.filter((i) => i.status === "issued");
const overdueInvoices = allInvoices.filter((i) => i.status === "overdue");
const vatMap: Record<string, number> = {};
for (const inv of monthInvoices) {
if (!inv.apply_vat) continue;
const cur = inv.currency || "CZK";
for (const item of inv.invoice_items) {
const base =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const vat =
base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100);
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,
}));
let vatCzk = 0;
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
// VAT also needs conversion
let vatCzkConverted = 0;
for (const [cur, amount] of Object.entries(vatMap)) {
vatCzkConverted += await toCzk(amount, cur);
}
return {
paid_month: aggregateByCurrency(paidInvoices),
paid_month_czk: await sumCzk(paidInvoices),
paid_month_count: paidInvoices.length,
awaiting: aggregateByCurrency(awaitingInvoices),
awaiting_czk: await sumCzk(awaitingInvoices),
awaiting_count: awaitingInvoices.length,
overdue: aggregateByCurrency(overdueInvoices),
overdue_czk: await sumCzk(overdueInvoices),
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,
};
}
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: Record<string, any>) {
const invoiceNumber =
body.invoice_number !== undefined && body.invoice_number !== null
? String(body.invoice_number)
: (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: body.status ? String(body.status) : "issued",
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate ? 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 as InvoiceItemInput[]).map((item, i) => ({
invoice_id: invoice.id,
description: 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: Record<string, any>) {
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 'issued' and 'overdue' states
const isDraft = currentStatus === "issued" || currentStatus === "overdue";
if (isDraft) {
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 issued/overdue
if (currentStatus === "issued" || currentStatus === "overdue") {
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;
await prisma.invoices.update({ where: { id }, data });
// Only allow items update in draft state
if (isDraft && 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 as InvoiceItemInput[]).map((item, i) => ({
invoice_id: id,
description: 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 { id, invoice_number: 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 } });
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseInvoiceNumber(year);
return existing;
}