import { eq, and, desc, like, lt, sql, gte, lte, inArray, ne } from 'drizzle-orm'; import { db } from '@/lib/db'; import { invoices, invoiceLineItems, invoiceExpenses, expenses, } from '@/lib/db/schema/financial'; import { files } from '@/lib/db/schema/documents'; import { ports } from '@/lib/db/schema/ports'; import { systemSettings } from '@/lib/db/schema/system'; import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; import { withTransaction } from '@/lib/db/utils'; import { NotFoundError, ConflictError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { logger } from '@/lib/logger'; import { generatePdf } from '@/lib/pdf/generate'; import { invoiceTemplate, buildInvoiceInputs } from '@/lib/pdf/templates/invoice-template'; import { minioClient, buildStoragePath } from '@/lib/minio'; import { getQueue } from '@/lib/queue'; import { env } from '@/lib/env'; import type { CreateInvoiceInput, UpdateInvoiceInput, RecordPaymentInput, ListInvoicesInput, } from '@/lib/validators/invoices'; // AuditMeta type expected by service functions export interface ServiceAuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── Auto-numbering (BR-041) ─────────────────────────────────────────────── async function generateInvoiceNumber(portId: string, tx: typeof db): Promise { const lockKey = `invoice_${portId}`; await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`); const now = new Date(); const prefix = `INV-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; const [existing] = await tx .select({ invoiceNumber: invoices.invoiceNumber }) .from(invoices) .where( and(eq(invoices.portId, portId), like(invoices.invoiceNumber, `${prefix}-%`)), ) .orderBy(desc(invoices.invoiceNumber)) .limit(1); let seq = 1; if (existing) { const parts = existing.invoiceNumber.split('-'); seq = parseInt(parts[parts.length - 1] ?? '0', 10) + 1; } return `${prefix}-${String(seq).padStart(3, '0')}`; } // ─── List ───────────────────────────────────────────────────────────────── export async function listInvoices(portId: string, query: ListInvoicesInput) { const filters = []; if (query.status) { filters.push(eq(invoices.status, query.status)); } if (query.clientName) { filters.push(like(invoices.clientName, `%${query.clientName}%`)); } if (query.dateFrom) { filters.push(gte(invoices.dueDate, query.dateFrom)); } if (query.dateTo) { filters.push(lte(invoices.dueDate, query.dateTo)); } return buildListQuery({ table: invoices, portIdColumn: invoices.portId, portId, idColumn: invoices.id, updatedAtColumn: invoices.updatedAt, filters, page: query.page, pageSize: query.limit, searchColumns: [invoices.clientName, invoices.invoiceNumber], searchTerm: query.search, includeArchived: query.includeArchived, archivedAtColumn: invoices.archivedAt, sort: query.sort ? { column: invoices[query.sort as keyof typeof invoices] as any, direction: query.order, } : undefined, }); } // ─── Get by ID ──────────────────────────────────────────────────────────── export async function getInvoiceById(id: string, portId: string) { const invoice = await db.query.invoices.findFirst({ where: and(eq(invoices.id, id), eq(invoices.portId, portId)), }); if (!invoice) throw new NotFoundError('Invoice'); const lineItems = await db .select() .from(invoiceLineItems) .where(eq(invoiceLineItems.invoiceId, id)) .orderBy(invoiceLineItems.sortOrder); const linkedExpenses = await db .select({ expense: expenses }) .from(invoiceExpenses) .innerJoin(expenses, eq(expenses.id, invoiceExpenses.expenseId)) .where(eq(invoiceExpenses.invoiceId, id)); return { ...invoice, lineItems, linkedExpenses: linkedExpenses.map((r) => r.expense), }; } // ─── Create (BR-041, BR-042, BR-045) ───────────────────────────────────── export async function createInvoice( portId: string, data: CreateInvoiceInput, meta: ServiceAuditMeta, ) { const invoice = await withTransaction(async (tx) => { const invoiceNumber = await generateInvoiceNumber(portId, tx); // Calculate subtotal from line items const lineItemsData = data.lineItems ?? []; const subtotal = lineItemsData.reduce( (sum, li) => sum + li.quantity * li.unitPrice, 0, ); // BR-042: net10 discount — read from systemSettings let discountPct = 0; if (data.paymentTerms === 'net10') { const [setting] = await tx .select({ value: systemSettings.value }) .from(systemSettings) .where( and( eq(systemSettings.key, 'invoice_net10_discount'), eq(systemSettings.portId, portId), ), ) .limit(1); if (setting) { discountPct = Number(setting.value) || 2; } else { discountPct = 2; } } const discountAmount = (subtotal * discountPct) / 100; const feeAmount = 0; // No fee by default const feePct = 0; const total = subtotal - discountAmount + feeAmount; // BR-045: Verify expenses aren't already linked to a non-draft invoice const expenseIds = data.expenseIds ?? []; if (expenseIds.length > 0) { const alreadyLinked = await tx .select({ expenseId: invoiceExpenses.expenseId }) .from(invoiceExpenses) .innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId)) .where( and( inArray(invoiceExpenses.expenseId, expenseIds), sql`${invoices.status} != 'draft'`, ), ) .limit(1); if (alreadyLinked.length > 0) { throw new ConflictError( 'One or more expenses are already linked to a non-draft invoice', ); } } const [newInvoice] = await tx .insert(invoices) .values({ portId, invoiceNumber, clientName: data.clientName, billingEmail: data.billingEmail ?? null, billingAddress: data.billingAddress ?? null, dueDate: data.dueDate, paymentTerms: data.paymentTerms ?? 'net30', currency: data.currency ?? 'USD', subtotal: String(subtotal), discountPct: String(discountPct), discountAmount: String(discountAmount), feePct: String(feePct), feeAmount: String(feeAmount), total: String(total), status: 'draft', paymentStatus: 'unpaid', notes: data.notes ?? null, createdBy: meta.userId, }) .returning(); if (!newInvoice) throw new Error('Insert failed'); // Insert line items if (lineItemsData.length > 0) { await tx.insert(invoiceLineItems).values( lineItemsData.map((li, idx) => ({ invoiceId: newInvoice.id, description: li.description, quantity: String(li.quantity), unitPrice: String(li.unitPrice), total: String(li.quantity * li.unitPrice), sortOrder: idx, })), ); } // Link expenses if (expenseIds.length > 0) { await tx.insert(invoiceExpenses).values( expenseIds.map((expenseId) => ({ invoiceId: newInvoice.id, expenseId, })), ); } return newInvoice; }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'invoice', entityId: invoice.id, newValue: invoice as unknown as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'invoice:created', { invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, total: Number(invoice.total), clientName: invoice.clientName, }); return invoice; } // ─── Update (draft only) ────────────────────────────────────────────────── export async function updateInvoice( id: string, portId: string, data: UpdateInvoiceInput, meta: ServiceAuditMeta, ) { const existing = await getInvoiceById(id, portId); if (existing.status !== 'draft') { throw new ConflictError('Only draft invoices can be updated'); } const updated = await withTransaction(async (tx) => { const updateData: Record = { updatedAt: new Date() }; if (data.clientName !== undefined) updateData.clientName = data.clientName; if (data.billingEmail !== undefined) updateData.billingEmail = data.billingEmail; if (data.billingAddress !== undefined) updateData.billingAddress = data.billingAddress; if (data.dueDate !== undefined) updateData.dueDate = data.dueDate; if (data.paymentTerms !== undefined) updateData.paymentTerms = data.paymentTerms; if (data.currency !== undefined) updateData.currency = data.currency; if (data.notes !== undefined) updateData.notes = data.notes; // Recalculate totals if line items changed if (data.lineItems !== undefined) { const lineItemsData = data.lineItems; const subtotal = lineItemsData.reduce( (sum, li) => sum + li.quantity * li.unitPrice, 0, ); const paymentTerms = data.paymentTerms ?? existing.paymentTerms; let discountPct = 0; if (paymentTerms === 'net10') { const [setting] = await tx .select({ value: systemSettings.value }) .from(systemSettings) .where( and( eq(systemSettings.key, 'invoice_net10_discount'), eq(systemSettings.portId, portId), ), ) .limit(1); discountPct = setting ? Number(setting.value) || 2 : 2; } const discountAmount = (subtotal * discountPct) / 100; const feeAmount = Number(existing.feeAmount) || 0; const feePct = Number(existing.feePct) || 0; const total = subtotal - discountAmount + feeAmount; updateData.subtotal = String(subtotal); updateData.discountPct = String(discountPct); updateData.discountAmount = String(discountAmount); updateData.feePct = String(feePct); updateData.feeAmount = String(feeAmount); updateData.total = String(total); // Replace line items await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)); if (lineItemsData.length > 0) { await tx.insert(invoiceLineItems).values( lineItemsData.map((li, idx) => ({ invoiceId: id, description: li.description, quantity: String(li.quantity), unitPrice: String(li.unitPrice), total: String(li.quantity * li.unitPrice), sortOrder: idx, })), ); } } // Replace expense links if provided if (data.expenseIds !== undefined) { // BR-045 if (data.expenseIds.length > 0) { const alreadyLinked = await tx .select({ expenseId: invoiceExpenses.expenseId }) .from(invoiceExpenses) .innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId)) .where( and( inArray(invoiceExpenses.expenseId, data.expenseIds), sql`${invoices.status} != 'draft'`, ne(invoices.id, id), ), ) .limit(1); if (alreadyLinked.length > 0) { throw new ConflictError( 'One or more expenses are already linked to a non-draft invoice', ); } } await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id)); if (data.expenseIds.length > 0) { await tx.insert(invoiceExpenses).values( data.expenseIds.map((expenseId) => ({ invoiceId: id, expenseId })), ); } } const [result] = await tx .update(invoices) .set(updateData as any) .where(and(eq(invoices.id, id), eq(invoices.portId, portId))) .returning(); if (!result) throw new NotFoundError('Invoice'); return result; }); const { diff } = diffEntity( existing as unknown as Record, updated as unknown as Record, ); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'invoice', entityId: id, oldValue: existing as unknown as Record, newValue: updated as unknown as Record, metadata: { diff }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'invoice:updated', { invoiceId: id, changedFields: Object.keys(diff), }); return updated; } // ─── Delete (draft only) ────────────────────────────────────────────────── export async function deleteInvoice( id: string, portId: string, meta: ServiceAuditMeta, ) { const existing = await getInvoiceById(id, portId); if (existing.status !== 'draft') { throw new ConflictError('Only draft invoices can be deleted'); } await withTransaction(async (tx) => { await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id)); await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)); await tx .delete(invoices) .where(and(eq(invoices.id, id), eq(invoices.portId, portId))); }); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'invoice', entityId: id, oldValue: existing as unknown as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'invoice:updated', { invoiceId: id, changedFields: ['status'], }); } // ─── Generate PDF ───────────────────────────────────────────────────────── export async function generateInvoicePdf( id: string, portId: string, meta: ServiceAuditMeta, ) { const invoice = await getInvoiceById(id, portId); const [port] = await db .select({ id: ports.id, name: ports.name, slug: ports.slug }) .from(ports) .where(eq(ports.id, portId)) .limit(1); const inputs = buildInvoiceInputs(invoice, invoice.lineItems, port); const pdfBytes = await generatePdf(invoiceTemplate, [inputs]); const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(port?.slug ?? portId, 'invoices', id, fileId, 'pdf'); await minioClient.putObject( env.MINIO_BUCKET, storagePath, Buffer.from(pdfBytes), pdfBytes.length, { 'Content-Type': 'application/pdf' }, ); const [fileRecord] = await db .insert(files) .values({ portId, filename: `invoice-${invoice.invoiceNumber}.pdf`, originalName: `invoice-${invoice.invoiceNumber}.pdf`, mimeType: 'application/pdf', sizeBytes: String(pdfBytes.length), storagePath, storageBucket: env.MINIO_BUCKET, category: 'invoice', uploadedBy: meta.userId, }) .returning(); if (!fileRecord) throw new Error('File record insert failed'); await db .update(invoices) .set({ pdfFileId: fileRecord.id, updatedAt: new Date() }) .where(and(eq(invoices.id, id), eq(invoices.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'invoice', entityId: id, metadata: { action: 'pdf_generated', fileId: fileRecord.id }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return fileRecord; } // ─── Send invoice ───────────────────────────────────────────────────────── export async function sendInvoice( id: string, portId: string, meta: ServiceAuditMeta, ) { const invoice = await getInvoiceById(id, portId); // Generate PDF if not exists let pdfFileId = invoice.pdfFileId; if (!pdfFileId) { const fileRecord = await generateInvoicePdf(id, portId, meta); pdfFileId = fileRecord.id; } // Queue email job await getQueue('email').add('send-invoice', { invoiceId: id, portId }); // Update status to 'sent' const [updated] = await db .update(invoices) .set({ status: 'sent', updatedAt: new Date() }) .where(and(eq(invoices.id, id), eq(invoices.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'invoice', entityId: id, oldValue: { status: invoice.status }, newValue: { status: 'sent' }, metadata: { action: 'invoice_sent' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'invoice:sent', { invoiceId: id, invoiceNumber: invoice.invoiceNumber, recipientEmail: invoice.billingEmail ?? '', }); return updated; } // ─── Record payment ─────────────────────────────────────────────────────── export async function recordPayment( id: string, portId: string, data: RecordPaymentInput, meta: ServiceAuditMeta, ) { const existing = await getInvoiceById(id, portId); const [updated] = await db .update(invoices) .set({ paymentStatus: 'paid', paymentDate: data.paymentDate, paymentMethod: data.paymentMethod ?? null, paymentReference: data.paymentReference ?? null, status: 'paid', updatedAt: new Date(), }) .where(and(eq(invoices.id, id), eq(invoices.portId, portId))) .returning(); if (!updated) throw new NotFoundError('Invoice'); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'invoice', entityId: id, oldValue: { status: existing.status, paymentStatus: existing.paymentStatus }, newValue: { status: 'paid', paymentStatus: 'paid', paymentDate: data.paymentDate }, metadata: { action: 'payment_recorded' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'invoice:paid', { invoiceId: id, invoiceNumber: existing.invoiceNumber, amount: Number(existing.total), }); return updated; } // ─── Detect overdue (BR-044) ────────────────────────────────────────────── export async function detectOverdue(portId: string) { const today = new Date().toISOString().split('T')[0]!; const overdueInvoices = await db .select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber, dueDate: invoices.dueDate }) .from(invoices) .where( and( eq(invoices.portId, portId), eq(invoices.status, 'sent'), lt(invoices.dueDate, today), ), ); if (overdueInvoices.length === 0) return; for (const inv of overdueInvoices) { await db .update(invoices) .set({ status: 'overdue', updatedAt: new Date() }) .where(eq(invoices.id, inv.id)); const daysPastDue = Math.max(1, Math.ceil( (Date.now() - new Date(inv.dueDate).getTime()) / (1000 * 60 * 60 * 24), )); emitToRoom(`port:${portId}`, 'invoice:overdue', { invoiceId: inv.id, invoiceNumber: inv.invoiceNumber, daysPastDue, }); await getQueue('notifications').add('invoice-overdue-notify', { invoiceId: inv.id, portId, }); logger.info( { invoiceId: inv.id, invoiceNumber: inv.invoiceNumber, portId }, 'Invoice marked overdue', ); } }