diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index 3e65611..a707549 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -1,50 +1,28 @@ import { FastifyInstance } from 'fastify'; -import prisma from '../../config/database'; import { requirePermission } from '../../middleware/auth'; import { logAudit } from '../../services/audit'; import { success, error, parseId } from '../../utils/response'; import { parsePagination, buildPaginationMeta } from '../../utils/pagination'; -import { generateInvoiceNumber } from '../../services/numbering.service'; import { parseBody } from '../../schemas/common'; import { CreateInvoiceSchema, UpdateInvoiceSchema } from '../../schemas/invoices.schema'; - -// Status transition rules matching PHP -const VALID_TRANSITIONS: Record = { - 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 } - -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, - }; -} +import { + markOverdueInvoices, + listInvoices, + getNextInvoiceNumberFormatted, + getInvoiceStats, + getOrderDataForInvoice, + getInvoice, + createInvoice, + updateInvoice, + deleteInvoice, +} from '../../services/invoices.service'; export default async function invoicesRoutes(fastify: FastifyInstance): Promise { // Auto-update overdue invoices on GET requests only (matches PHP behavior) fastify.addHook('onRequest', async (request) => { if (request.method !== 'GET') return; - try { - await prisma.invoices.updateMany({ - where: { status: 'issued', due_date: { lt: new Date() } }, - data: { status: 'overdue' }, - }); - } catch { /* silent */ } + await markOverdueInvoices(); }); // GET /api/admin/invoices @@ -52,188 +30,51 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise< const query = request.query as Record; const { page, limit, skip, order, search } = parsePagination(query); - const where: Record = {}; - if (query.status) where.status = String(query.status); - if (query.customer_id) where.customer_id = Number(query.customer_id); - if (search) { - where.OR = [ - { invoice_number: { contains: search } }, - { customers: { name: { contains: search } } }, - { customers: { company_id: { contains: search } } }, - ]; - } - - const sortField = ALLOWED_SORT_FIELDS.includes(String(query.sort || '')) ? String(query.sort) : 'id'; - const orderBy: Record = { [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, - }; + const result = await listInvoices({ + page, + limit, + skip, + sort: String(query.sort || ''), + order, + search, + status: query.status ? String(query.status) : undefined, + customer_id: query.customer_id ? Number(query.customer_id) : undefined, }); - 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 fastify.get('/next-number', { preHandler: requirePermission('invoices.create') }, async (_request, reply) => { - // Match PHP: prefix = YY + invoice_type_code from company_settings - 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}`; - - // 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 }); + const result = await getNextInvoiceNumberFormatted(); + return success(reply, result); }); // GET /api/admin/invoices/stats fastify.get('/stats', { preHandler: requirePermission('invoices.view') }, async (request, reply) => { const query = request.query as Record; - const now = new Date(); - const year = Number(query.year) || now.getFullYear(); - const month = Number(query.month) || (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 → CurrencyAmount[] - const aggregateByCurrency = (invoices: typeof allInvoices) => { - const map: Record = {}; - 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 = {}; - 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, - }); + const month = query.month ? Number(query.month) : undefined; + const year = query.year ? Number(query.year) : undefined; + const stats = await getInvoiceStats(month, year); + return success(reply, stats); }); // GET /api/admin/invoices/order-data/:id fastify.get<{ Params: { id: string } }>('/order-data/:id', { preHandler: requirePermission('invoices.create') }, async (request, reply) => { const orderId = parseId(request.params.id, reply); if (orderId === null) return; - const order = await prisma.orders.findUnique({ - where: { id: orderId }, - include: { - 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 }); + const result = await getOrderDataForInvoice(orderId); + if (!result) return error(reply, 'Objednávka nenalezena', 404); + return success(reply, result); }); // GET /api/admin/invoices/:id fastify.get<{ Params: { id: string } }>('/:id', { preHandler: requirePermission('invoices.view') }, async (request, reply) => { const id = parseId(request.params.id, reply); if (id === null) return; - const invoice = await prisma.invoices.findUnique({ - where: { id }, - include: { - customers: true, - invoice_items: { orderBy: { position: 'asc' } }, - orders: { select: { id: true, order_number: true } }, - }, - }); + const invoice = await getInvoice(id); if (!invoice) return error(reply, 'Faktura nenalezena', 404); - const { invoice_items, ...rest } = 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] || [], - }); + return success(reply, invoice); }); // 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); const body = parsed.data; - 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, - })), - }); - } + const invoice = await createInvoice(body); 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 @@ -293,75 +98,14 @@ export default async function invoicesRoutes(fastify: FastifyInstance): Promise< if ('error' in parsed) return error(reply, parsed.error, 400); const body = parsed.data; - const existing = await prisma.invoices.findUnique({ where: { id } }); - if (!existing) return error(reply, 'Faktura nenalezena', 404); + const result = await updateInvoice(id, body); - 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(reply, `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, 400); - } + if ('error' in result) { + if (result.error === 'not_found') return error(reply, 'Faktura nenalezena', 404); + if (result.error === 'invalid_transition') return error(reply, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, 400); } - const data: Record = { 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, - })), - }); - }); - } - - await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${existing.invoice_number}` }); + await logAudit({ request, authData: request.authData, action: 'update', entityType: 'invoice', entityId: id, description: `Upravena faktura ${(result as any).invoice_number}` }); 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) => { const id = parseId(request.params.id, reply); 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); - 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}` }); return success(reply, null, 200, 'Faktura smazána'); }); diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts new file mode 100644 index 0000000..247ac83 --- /dev/null +++ b/src/services/invoices.service.ts @@ -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 = { + 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 = {}; + 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 = { [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 = {}; + 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 = {}; + 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) { + 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) { + 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 = { 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; +}