refactor: extract invoices business logic into invoices.service.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,50 +1,28 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import prisma from '../../config/database';
|
|
||||||
import { requirePermission } from '../../middleware/auth';
|
import { requirePermission } from '../../middleware/auth';
|
||||||
import { logAudit } from '../../services/audit';
|
import { logAudit } from '../../services/audit';
|
||||||
import { success, error, parseId } from '../../utils/response';
|
import { success, error, parseId } from '../../utils/response';
|
||||||
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
import { parsePagination, buildPaginationMeta } from '../../utils/pagination';
|
||||||
import { generateInvoiceNumber } from '../../services/numbering.service';
|
|
||||||
import { parseBody } from '../../schemas/common';
|
import { parseBody } from '../../schemas/common';
|
||||||
import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema';
|
import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema';
|
||||||
|
import {
|
||||||
// Status transition rules matching PHP
|
markOverdueInvoices,
|
||||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
listInvoices,
|
||||||
issued: ['paid'],
|
getNextInvoiceNumberFormatted,
|
||||||
overdue: ['paid'],
|
getInvoiceStats,
|
||||||
paid: [],
|
getOrderDataForInvoice,
|
||||||
};
|
getInvoice,
|
||||||
|
createInvoice,
|
||||||
const ALLOWED_SORT_FIELDS = ['id', 'invoice_number', 'status', 'issue_date', 'due_date', 'currency'];
|
updateInvoice,
|
||||||
|
deleteInvoice,
|
||||||
interface InvoiceItemInput { description?: string; quantity?: number; unit?: string; unit_price?: number; vat_rate?: number; position?: number }
|
} from '../../services/invoices.service';
|
||||||
|
|
||||||
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 default async function invoicesRoutes(fastify: FastifyInstance): Promise<void> {
|
export default async function invoicesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
|
// Auto-update overdue invoices on GET requests only (matches PHP behavior)
|
||||||
fastify.addHook('onRequest', async (request) => {
|
fastify.addHook('onRequest', async (request) => {
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
try {
|
await markOverdueInvoices();
|
||||||
await prisma.invoices.updateMany({
|
|
||||||
where: { status: 'issued', due_date: { lt: new Date() } },
|
|
||||||
data: { status: 'overdue' },
|
|
||||||
});
|
|
||||||
} catch { /* silent */ }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/invoices
|
// GET /api/admin/invoices
|
||||||
@@ -52,188 +30,51 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise<
|
|||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const { page, limit, skip, order, search } = parsePagination(query);
|
const { page, limit, skip, order, search } = parsePagination(query);
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const result = await listInvoices({
|
||||||
if (query.status) where.status = String(query.status);
|
page,
|
||||||
if (query.customer_id) where.customer_id = Number(query.customer_id);
|
limit,
|
||||||
if (search) {
|
skip,
|
||||||
where.OR = [
|
sort: String(query.sort || ''),
|
||||||
{ invoice_number: { contains: search } },
|
order,
|
||||||
{ customers: { name: { contains: search } } },
|
search,
|
||||||
{ customers: { company_id: { contains: search } } },
|
status: query.status ? String(query.status) : undefined,
|
||||||
];
|
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||||
}
|
|
||||||
|
|
||||||
const sortField = ALLOWED_SORT_FIELDS.includes(String(query.sort || '')) ? String(query.sort) : 'id';
|
|
||||||
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 reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
|
return reply.send({ success: true, data: result.data, pagination: buildPaginationMeta(result.total, page, limit) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/invoices/next-number
|
// GET /api/admin/invoices/next-number
|
||||||
fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => {
|
fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => {
|
||||||
// Match PHP: prefix = YY + invoice_type_code from company_settings
|
const result = await getNextInvoiceNumberFormatted();
|
||||||
const settings = await prisma.company_settings.findFirst({ select: { invoice_type_code: true } });
|
return success(reply, result);
|
||||||
const typeCode = settings?.invoice_type_code || '81';
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const yy = String(year).slice(-2);
|
|
||||||
const prefix = `${yy}${typeCode}`;
|
|
||||||
|
|
||||||
// Atomic numbering via number_sequences table
|
|
||||||
const nextNum = await generateInvoiceNumber(year);
|
|
||||||
const number = `${prefix}${String(nextNum).padStart(4, '0')}`;
|
|
||||||
return success(reply, { number, next_number: number });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/invoices/stats
|
// GET /api/admin/invoices/stats
|
||||||
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||||
const query = request.query as Record<string, unknown>;
|
const query = request.query as Record<string, unknown>;
|
||||||
const now = new Date();
|
const month = query.month ? Number(query.month) : undefined;
|
||||||
const year = Number(query.year) || now.getFullYear();
|
const year = query.year ? Number(query.year) : undefined;
|
||||||
const month = Number(query.month) || (now.getMonth() + 1);
|
const stats = await getInvoiceStats(month, year);
|
||||||
|
return success(reply, stats);
|
||||||
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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper: compute invoice total WITH VAT (matching PHP)
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: aggregate by currency → CurrencyAmount[]
|
|
||||||
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 = (invoices: typeof allInvoices) => {
|
|
||||||
let total = 0;
|
|
||||||
for (const inv of invoices) {
|
|
||||||
total += invoiceTotalWithVat(inv); // Simplified: no real FX conversion
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
|
|
||||||
// VAT by currency
|
|
||||||
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;
|
|
||||||
|
|
||||||
return success(reply, {
|
|
||||||
paid_month: aggregateByCurrency(paidInvoices),
|
|
||||||
paid_month_czk: sumCzk(paidInvoices),
|
|
||||||
paid_month_count: paidInvoices.length,
|
|
||||||
awaiting: aggregateByCurrency(awaitingInvoices),
|
|
||||||
awaiting_czk: sumCzk(awaitingInvoices),
|
|
||||||
awaiting_count: awaitingInvoices.length,
|
|
||||||
overdue: aggregateByCurrency(overdueInvoices),
|
|
||||||
overdue_czk: sumCzk(overdueInvoices),
|
|
||||||
overdue_count: overdueInvoices.length,
|
|
||||||
vat_month: vatAmounts,
|
|
||||||
vat_month_czk: Math.round(vatCzk * 100) / 100,
|
|
||||||
month,
|
|
||||||
year,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/invoices/order-data/:id
|
// GET /api/admin/invoices/order-data/:id
|
||||||
fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
|
fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => {
|
||||||
const orderId = parseId(request.params.id, reply);
|
const orderId = parseId(request.params.id, reply);
|
||||||
if (orderId === null) return;
|
if (orderId === null) return;
|
||||||
const order = await prisma.orders.findUnique({
|
const result = await getOrderDataForInvoice(orderId);
|
||||||
where: { id: orderId },
|
if (!result) return error(reply, 'Objednávka nenalezena', 404);
|
||||||
include: {
|
return success(reply, result);
|
||||||
customers: true,
|
|
||||||
order_items: { orderBy: { position: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!order) return error(reply, 'Objednávka nenalezena', 404);
|
|
||||||
const { order_items, customers, ...rest } = order;
|
|
||||||
return success(reply, { ...rest, items: order_items, customer_name: customers?.name || null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/invoices/:id
|
// GET /api/admin/invoices/:id
|
||||||
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const invoice = await prisma.invoices.findUnique({
|
const invoice = await getInvoice(id);
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
customers: true,
|
|
||||||
invoice_items: { orderBy: { position: 'asc' } },
|
|
||||||
orders: { select: { id: true, order_number: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!invoice) return error(reply, 'Faktura nenalezena', 404);
|
if (!invoice) return error(reply, 'Faktura nenalezena', 404);
|
||||||
const { invoice_items, ...rest } = invoice;
|
return success(reply, invoice);
|
||||||
return success(reply, {
|
|
||||||
...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] || [],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/admin/invoices
|
// POST /api/admin/invoices
|
||||||
@@ -242,43 +83,7 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise<
|
|||||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
|
|
||||||
const invoice = await prisma.invoices.create({
|
const invoice = await createInvoice(body);
|
||||||
data: {
|
|
||||||
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena faktura ${invoice.invoice_number}` });
|
await logAudit({ request, authData: request.authData, action: 'create', entityType: 'invoice', entityId: invoice.id, description: `Vytvořena faktura ${invoice.invoice_number}` });
|
||||||
// Return both invoice_id and id for frontend compatibility
|
// Return both invoice_id and id for frontend compatibility
|
||||||
@@ -293,75 +98,14 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise<
|
|||||||
if ('error' in parsed) return error(reply, parsed.error, 400);
|
if ('error' in parsed) return error(reply, parsed.error, 400);
|
||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
|
|
||||||
const existing = await prisma.invoices.findUnique({ where: { id } });
|
const result = await updateInvoice(id, body);
|
||||||
if (!existing) return error(reply, 'Faktura nenalezena', 404);
|
|
||||||
|
|
||||||
const currentStatus = existing.status as string;
|
if ('error' in result) {
|
||||||
|
if (result.error === 'not_found') return error(reply, 'Faktura nenalezena', 404);
|
||||||
// Handle status transition
|
if (result.error === 'invalid_transition') return error(reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400);
|
||||||
if (body.status !== undefined && body.status !== currentStatus) {
|
|
||||||
const newStatus = String(body.status);
|
|
||||||
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
|
||||||
if (!allowed.includes(newStatus)) {
|
|
||||||
return error(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${(result as any).invoice_number}` });
|
||||||
|
|
||||||
// Only allow full editing in 'issued' state
|
|
||||||
const isDraft = currentStatus === 'issued';
|
|
||||||
if (isDraft) {
|
|
||||||
const strFields = ['currency', 'payment_method', 'constant_symbol', 'bank_name', 'bank_swift', 'bank_iban', 'bank_account', 'issued_by'];
|
|
||||||
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) 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,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${existing.invoice_number}` });
|
|
||||||
return success(reply, { id }, 200, 'Faktura byla aktualizována');
|
return success(reply, { id }, 200, 'Faktura byla aktualizována');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,10 +113,9 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise<
|
|||||||
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
|
fastify.delete<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.delete') }, async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
const existing = await prisma.invoices.findUnique({ where: { id } });
|
const existing = await deleteInvoice(id);
|
||||||
if (!existing) return error(reply, 'Faktura nenalezena', 404);
|
if (!existing) return error(reply, 'Faktura nenalezena', 404);
|
||||||
|
|
||||||
await prisma.invoices.delete({ where: { id } });
|
|
||||||
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána faktura ${existing.invoice_number}` });
|
await logAudit({ request, authData: request.authData, action: 'delete', entityType: 'invoice', entityId: id, description: `Smazána faktura ${existing.invoice_number}` });
|
||||||
return success(reply, null, 200, 'Faktura smazána');
|
return success(reply, null, 200, 'Faktura smazána');
|
||||||
});
|
});
|
||||||
|
|||||||
349
src/services/invoices.service.ts
Normal file
349
src/services/invoices.service.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
import { generateInvoiceNumber } from './numbering.service';
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export { generateInvoiceNumber as getNextInvoiceNumber } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvoices(params: ListInvoicesParams) {
|
||||||
|
const { page, limit, skip, sort, order, search, status, customer_id } = 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 (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 async function getNextInvoiceNumberFormatted() {
|
||||||
|
const settings = await prisma.company_settings.findFirst({ select: { invoice_type_code: true } });
|
||||||
|
const typeCode = settings?.invoice_type_code || '81';
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const yy = String(year).slice(-2);
|
||||||
|
const prefix = `${yy}${typeCode}`;
|
||||||
|
|
||||||
|
const nextNum = await generateInvoiceNumber(year);
|
||||||
|
const number = `${prefix}${String(nextNum).padStart(4, '0')}`;
|
||||||
|
return { number, next_number: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: compute invoice total WITH VAT (matching PHP)
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: aggregate by currency
|
||||||
|
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 = (invoices: typeof allInvoices) => {
|
||||||
|
let total = 0;
|
||||||
|
for (const inv of invoices) {
|
||||||
|
total += invoiceTotalWithVat(inv); // Simplified: no real FX conversion
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
|
||||||
|
// VAT by currency
|
||||||
|
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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid_month: aggregateByCurrency(paidInvoices),
|
||||||
|
paid_month_czk: sumCzk(paidInvoices),
|
||||||
|
paid_month_count: paidInvoices.length,
|
||||||
|
awaiting: aggregateByCurrency(awaitingInvoices),
|
||||||
|
awaiting_czk: sumCzk(awaitingInvoices),
|
||||||
|
awaiting_count: awaitingInvoices.length,
|
||||||
|
overdue: aggregateByCurrency(overdueInvoices),
|
||||||
|
overdue_czk: sumCzk(overdueInvoices),
|
||||||
|
overdue_count: overdueInvoices.length,
|
||||||
|
vat_month: vatAmounts,
|
||||||
|
vat_month_czk: Math.round(vatCzk * 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 invoice = await prisma.invoices.create({
|
||||||
|
data: {
|
||||||
|
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
|
||||||
|
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,
|
||||||
|
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() };
|
||||||
|
|
||||||
|
// Only allow full editing in 'issued' state
|
||||||
|
const isDraft = currentStatus === 'issued';
|
||||||
|
if (isDraft) {
|
||||||
|
const strFields = ['currency', 'payment_method', 'constant_symbol', 'bank_name', 'bank_swift', 'bank_iban', 'bank_account', 'issued_by'];
|
||||||
|
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) 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 } });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user