- 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>
464 lines
14 KiB
TypeScript
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;
|
|
}
|