diff --git a/packages/backend/api-tests/suites/accounting.ts b/packages/backend/api-tests/suites/accounting.ts new file mode 100644 index 0000000..2f05f2d --- /dev/null +++ b/packages/backend/api-tests/suites/accounting.ts @@ -0,0 +1,202 @@ +import { suite } from '../lib/context.js' + +suite('Accounting', { tags: ['accounting'] }, (t) => { + // ─── Invoices: CRUD ─────────────────────────────────────────────────────── + + t.test('creates a manual invoice', { tags: ['invoices', 'create'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Invoice Test Acct', billingMode: 'consolidated' }) + const res = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [ + { description: 'Service A', qty: 2, unitPrice: 50, taxRate: 0.0825 }, + { description: 'Service B', qty: 1, unitPrice: 100 }, + ], + }) + t.assert.status(res, 201) + t.assert.ok(res.data.invoiceNumber) + t.assert.equal(res.data.status, 'draft') + t.assert.ok(parseFloat(res.data.total) > 0) + t.assert.equal(res.data.balance, res.data.total) + }) + + t.test('rejects invoice without line items', { tags: ['invoices', 'validation'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Empty Invoice Acct', billingMode: 'consolidated' }) + const res = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [], + }) + t.assert.status(res, 400) + }) + + t.test('lists invoices with pagination', { tags: ['invoices', 'list'] }, async () => { + const res = await t.api.get('/v1/invoices', { page: 1, limit: 25 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data) + t.assert.ok(res.data.pagination) + }) + + t.test('gets invoice detail with line items', { tags: ['invoices', 'detail'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Detail Invoice Acct', billingMode: 'consolidated' }) + const inv = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [{ description: 'Widget', qty: 3, unitPrice: 25 }], + }) + + const res = await t.api.get(`/v1/invoices/${inv.data.id}`) + t.assert.status(res, 200) + t.assert.ok(res.data.lineItems) + t.assert.equal(res.data.lineItems.length, 1) + t.assert.ok(res.data.payments) + }) + + t.test('returns 404 for missing invoice', { tags: ['invoices', 'detail'] }, async () => { + const res = await t.api.get('/v1/invoices/00000000-0000-0000-0000-000000000000') + t.assert.status(res, 404) + }) + + // ─── Invoice Workflow ───────────────────────────────────────────────────── + + t.test('sends a draft invoice', { tags: ['invoices', 'workflow'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Send Invoice Acct', billingMode: 'consolidated' }) + const inv = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [{ description: 'Send Test', qty: 1, unitPrice: 100 }], + }) + + const res = await t.api.post(`/v1/invoices/${inv.data.id}/send`) + t.assert.status(res, 200) + t.assert.equal(res.data.status, 'sent') + }) + + t.test('applies payment to invoice', { tags: ['invoices', 'payment'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Payment Invoice Acct', billingMode: 'consolidated' }) + const inv = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [{ description: 'Pay Test', qty: 1, unitPrice: 200 }], + }) + await t.api.post(`/v1/invoices/${inv.data.id}/send`) + + // Partial payment + const res1 = await t.api.post(`/v1/invoices/${inv.data.id}/apply-payment`, { amount: 100 }) + t.assert.status(res1, 200) + t.assert.ok(res1.data.id) + + // Check invoice updated + const detail = await t.api.get(`/v1/invoices/${inv.data.id}`) + t.assert.equal(detail.data.status, 'partial') + t.assert.equal(detail.data.amountPaid, '100.00') + t.assert.equal(detail.data.balance, '100.00') + + // Full payment + const res2 = await t.api.post(`/v1/invoices/${inv.data.id}/apply-payment`, { amount: 100 }) + t.assert.status(res2, 200) + + const final = await t.api.get(`/v1/invoices/${inv.data.id}`) + t.assert.equal(final.data.status, 'paid') + t.assert.equal(final.data.balance, '0.00') + }) + + t.test('rejects overpayment', { tags: ['invoices', 'payment', 'validation'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Overpay Acct', billingMode: 'consolidated' }) + const inv = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [{ description: 'Small Item', qty: 1, unitPrice: 50 }], + }) + await t.api.post(`/v1/invoices/${inv.data.id}/send`) + + const res = await t.api.post(`/v1/invoices/${inv.data.id}/apply-payment`, { amount: 100 }) + t.assert.status(res, 400) + }) + + t.test('voids an invoice', { tags: ['invoices', 'void'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Void Invoice Acct', billingMode: 'consolidated' }) + const inv = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [{ description: 'Void Test', qty: 1, unitPrice: 75 }], + }) + await t.api.post(`/v1/invoices/${inv.data.id}/send`) + + const res = await t.api.post(`/v1/invoices/${inv.data.id}/void`, { reason: 'Customer changed mind' }) + t.assert.status(res, 200) + t.assert.equal(res.data.status, 'void') + t.assert.equal(res.data.balance, '0') + }) + + t.test('writes off a bad debt', { tags: ['invoices', 'write-off'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'WriteOff Acct', billingMode: 'consolidated' }) + const inv = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [{ description: 'Bad Debt Item', qty: 1, unitPrice: 300 }], + }) + await t.api.post(`/v1/invoices/${inv.data.id}/send`) + + const res = await t.api.post(`/v1/invoices/${inv.data.id}/write-off`, { reason: 'Uncollectable' }) + t.assert.status(res, 200) + t.assert.equal(res.data.status, 'written_off') + }) + + // ─── Account Balance ────────────────────────────────────────────────────── + + t.test('tracks account balance on invoice send', { tags: ['balance'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Balance Track Acct', billingMode: 'consolidated' }) + + // Create and send invoice (which triggers AR balance update via service) + const inv = await t.api.post('/v1/invoices', { + accountId: acct.data.id, + issueDate: '2026-04-06', + dueDate: '2026-05-06', + lineItems: [{ description: 'Balance Test', qty: 1, unitPrice: 150 }], + }) + + const balRes = await t.api.get(`/v1/accounts/${acct.data.id}/balance`) + t.assert.status(balRes, 200) + t.assert.ok(balRes.data.accountId) + }) + + // ─── CSV Export ─────────────────────────────────────────────────────────── + + t.test('exports invoices as CSV', { tags: ['export'] }, async () => { + const res = await t.api.get('/v1/invoices/export') + t.assert.status(res, 200) + // CSV response should have headers + const text = typeof res.data === 'string' ? res.data : JSON.stringify(res.data) + t.assert.contains(text, 'Invoice Number') + }) + + // ─── AR Aging ───────────────────────────────────────────────────────────── + + t.test('returns AR aging report', { tags: ['reports'] }, async () => { + const res = await t.api.get('/v1/invoices/aging') + t.assert.status(res, 200) + t.assert.ok(res.data.current !== undefined) + t.assert.ok(res.data.days30 !== undefined) + t.assert.ok(res.data.days60 !== undefined) + t.assert.ok(res.data.days90plus !== undefined) + t.assert.ok(res.data.total !== undefined) + }) + + // ─── Outstanding Accounts ───────────────────────────────────────────────── + + t.test('lists outstanding accounts', { tags: ['balance'] }, async () => { + const res = await t.api.get('/v1/accounts/outstanding', { page: 1, limit: 25 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data) + t.assert.ok(res.data.pagination) + }) +}) diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index cfd6e60..d5e7faf 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -33,6 +33,7 @@ import { vaultRoutes } from './routes/v1/vault.js' import { webdavRoutes } from './routes/webdav/index.js' import { moduleRoutes } from './routes/v1/modules.js' import { configRoutes } from './routes/v1/config.js' +import { invoiceRoutes } from './routes/v1/invoices.js' import { RbacService } from './services/rbac.service.js' import { ModuleService } from './services/module.service.js' import { AppConfigService } from './services/config.service.js' @@ -202,6 +203,7 @@ export async function buildApp() { await app.register(storeRoutes, { prefix: '/v1' }) await app.register(moduleRoutes, { prefix: '/v1' }) await app.register(configRoutes, { prefix: '/v1' }) + await app.register(invoiceRoutes, { prefix: '/v1' }) await app.register(lookupRoutes, { prefix: '/v1' }) // Module-gated routes diff --git a/packages/backend/src/routes/v1/invoices.ts b/packages/backend/src/routes/v1/invoices.ts new file mode 100644 index 0000000..989e2f2 --- /dev/null +++ b/packages/backend/src/routes/v1/invoices.ts @@ -0,0 +1,130 @@ +import type { FastifyPluginAsync } from 'fastify' +import { + PaginationSchema, + InvoiceCreateSchema, + PaymentApplicationSchema, + InvoiceVoidSchema, + InvoiceWriteOffSchema, +} from '@lunarfront/shared/schemas' +import { InvoiceService } from '../../services/invoice.service.js' +import { AccountBalanceService } from '../../services/account-balance.service.js' + +export const invoiceRoutes: FastifyPluginAsync = async (app) => { + // --- Invoices --- + + app.get('/invoices', { preHandler: [app.authenticate, app.requirePermission('accounting.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const filters = { + accountId: query.accountId, + status: query.status, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + } + const result = await InvoiceService.list(app.db, params, filters) + return reply.send(result) + }) + + app.get('/invoices/:id', { preHandler: [app.authenticate, app.requirePermission('accounting.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const invoice = await InvoiceService.getById(app.db, id) + if (!invoice) return reply.status(404).send({ error: { message: 'Invoice not found', statusCode: 404 } }) + return reply.send(invoice) + }) + + app.post('/invoices', { preHandler: [app.authenticate, app.requirePermission('accounting.edit')] }, async (request, reply) => { + const parsed = InvoiceCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const invoice = await InvoiceService.create(app.db, parsed.data, request.user.id) + request.log.info({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber }, 'Invoice created') + return reply.status(201).send(invoice) + }) + + app.post('/invoices/:id/send', { preHandler: [app.authenticate, app.requirePermission('accounting.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const invoice = await InvoiceService.send(app.db, id) + request.log.info({ invoiceId: id }, 'Invoice sent') + return reply.send(invoice) + }) + + app.post('/invoices/:id/apply-payment', { preHandler: [app.authenticate, app.requirePermission('accounting.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = PaymentApplicationSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const application = await InvoiceService.applyPayment(app.db, id, parsed.data, request.user.id) + request.log.info({ invoiceId: id, amount: parsed.data.amount }, 'Payment applied') + return reply.send(application) + }) + + app.post('/invoices/:id/void', { preHandler: [app.authenticate, app.requirePermission('accounting.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = InvoiceVoidSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const invoice = await InvoiceService.void(app.db, id, parsed.data.reason, request.user.id) + request.log.info({ invoiceId: id, reason: parsed.data.reason }, 'Invoice voided') + return reply.send(invoice) + }) + + app.post('/invoices/:id/write-off', { preHandler: [app.authenticate, app.requirePermission('accounting.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = InvoiceWriteOffSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const invoice = await InvoiceService.writeOff(app.db, id, parsed.data.reason, request.user.id) + request.log.info({ invoiceId: id, reason: parsed.data.reason }, 'Invoice written off') + return reply.send(invoice) + }) + + // --- Account Invoices & Balance --- + + app.get('/accounts/:id/invoices', { preHandler: [app.authenticate, app.requirePermission('accounting.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const query = request.query as Record + const params = PaginationSchema.parse(query) + const result = await InvoiceService.listByAccount(app.db, id, params) + return reply.send(result) + }) + + app.get('/accounts/:id/balance', { preHandler: [app.authenticate, app.requirePermission('accounting.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const balance = await AccountBalanceService.getBalance(app.db, id) + return reply.send(balance) + }) + + // --- Export --- + + app.get('/invoices/export', { preHandler: [app.authenticate, app.requirePermission('accounting.view')] }, async (request, reply) => { + const query = request.query as Record + const csv = await InvoiceService.exportCSV(app.db, { + dateFrom: query.dateFrom, + dateTo: query.dateTo, + }) + return reply + .header('Content-Type', 'text/csv') + .header('Content-Disposition', `attachment; filename="invoices-${new Date().toISOString().slice(0, 10)}.csv"`) + .send(csv) + }) + + // --- AR Aging --- + + app.get('/invoices/aging', { preHandler: [app.authenticate, app.requirePermission('accounting.view')] }, async (request, reply) => { + const report = await InvoiceService.getAgingReport(app.db) + return reply.send(report) + }) + + // --- Outstanding Accounts --- + + app.get('/accounts/outstanding', { preHandler: [app.authenticate, app.requirePermission('accounting.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const result = await AccountBalanceService.getOutstandingAccounts(app.db, params) + return reply.send(result) + }) +} diff --git a/packages/backend/src/services/invoice.service.ts b/packages/backend/src/services/invoice.service.ts new file mode 100644 index 0000000..90fbb03 --- /dev/null +++ b/packages/backend/src/services/invoice.service.ts @@ -0,0 +1,499 @@ +import { eq, and, count, sql, type Column, lte, inArray } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { invoices, invoiceLineItems, paymentApplications } from '../db/schema/accounting.js' +import { transactions, transactionLineItems } from '../db/schema/pos.js' +import { repairTickets, repairLineItems } from '../db/schema/repairs.js' +import { accounts } from '../db/schema/accounts.js' +import { NotFoundError, ValidationError } from '../lib/errors.js' +import { AccountBalanceService } from './account-balance.service.js' +import type { InvoiceCreateInput, PaginationInput } from '@lunarfront/shared/schemas' +import { buildSearchCondition } from '../utils/pagination.js' + +function generateInvoiceNumber(): string { + const date = new Date() + const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '') + const seq = String(Math.floor(Math.random() * 9999) + 1).padStart(4, '0') + return `INV-${dateStr}-${seq}` +} + +export const InvoiceService = { + async create(db: PostgresJsDatabase, input: InvoiceCreateInput, createdBy: string) { + const invoiceNumber = generateInvoiceNumber() + + // Calculate totals from line items + let subtotal = 0 + let discountTotal = 0 + let taxTotal = 0 + + const lineItemValues = input.lineItems.map((item) => { + const lineSubtotal = item.qty * item.unitPrice + const lineDiscount = item.discountAmount ?? 0 + const lineTaxable = lineSubtotal - lineDiscount + const lineTax = parseFloat((lineTaxable * (item.taxRate ?? 0)).toFixed(2)) + const lineTotal = parseFloat((lineTaxable + lineTax).toFixed(2)) + + subtotal += lineSubtotal + discountTotal += lineDiscount + taxTotal += lineTax + + return { + description: item.description, + qty: item.qty, + unitPrice: item.unitPrice.toFixed(2), + discountAmount: lineDiscount.toFixed(2), + taxRate: (item.taxRate ?? 0).toFixed(4), + taxAmount: lineTax.toFixed(2), + lineTotal: lineTotal.toFixed(2), + accountCodeId: item.accountCodeId ?? null, + } + }) + + const total = parseFloat((subtotal - discountTotal + taxTotal).toFixed(2)) + + const [invoice] = await db.insert(invoices).values({ + invoiceNumber, + accountId: input.accountId, + locationId: input.locationId ?? null, + status: 'draft', + issueDate: input.issueDate, + dueDate: input.dueDate, + subtotal: subtotal.toFixed(2), + discountTotal: discountTotal.toFixed(2), + taxTotal: taxTotal.toFixed(2), + total: total.toFixed(2), + balance: total.toFixed(2), + notes: input.notes ?? null, + createdBy, + }).returning() + + // Insert line items + for (const item of lineItemValues) { + await db.insert(invoiceLineItems).values({ + invoiceId: invoice.id, + ...item, + }) + } + + return invoice + }, + + async createFromTransaction(db: PostgresJsDatabase, transactionId: string, createdBy: string) { + const [txn] = await db.select().from(transactions).where(eq(transactions.id, transactionId)).limit(1) + if (!txn) throw new NotFoundError('Transaction') + if (!txn.accountId) throw new ValidationError('Transaction must be linked to an account for invoicing') + + const lineItems = await db.select().from(transactionLineItems).where(eq(transactionLineItems.transactionId, transactionId)) + + const invoiceNumber = generateInvoiceNumber() + const isPaid = txn.paymentMethod !== 'account_charge' + const today = new Date().toISOString().slice(0, 10) + + const [invoice] = await db.insert(invoices).values({ + invoiceNumber, + accountId: txn.accountId, + locationId: txn.locationId ?? null, + status: isPaid ? 'paid' : 'sent', + issueDate: today, + dueDate: today, // Immediate for POS, could add net-30 logic later + sourceType: 'transaction', + sourceId: transactionId, + subtotal: txn.subtotal, + discountTotal: txn.discountTotal, + taxTotal: txn.taxTotal, + total: txn.total, + amountPaid: isPaid ? txn.total : '0', + balance: isPaid ? '0' : txn.total, + createdBy, + }).returning() + + // Copy line items + for (const item of lineItems) { + await db.insert(invoiceLineItems).values({ + invoiceId: invoice.id, + description: item.description, + qty: item.qty, + unitPrice: item.unitPrice, + discountAmount: item.discountAmount ?? '0', + taxRate: item.taxRate ?? '0', + taxAmount: item.taxAmount ?? '0', + lineTotal: item.lineTotal ?? '0', + sourceType: 'transaction_line_item', + sourceId: item.id, + }) + } + + // If paid, record the payment application + if (isPaid) { + await db.insert(paymentApplications).values({ + invoiceId: invoice.id, + transactionId, + amount: txn.total, + appliedBy: createdBy, + }) + } + + // If account_charge, update AR balance + if (!isPaid) { + await AccountBalanceService.adjustBalance(db, txn.accountId, parseFloat(txn.total), 'invoice') + } + + return invoice + }, + + async createFromRepairTicket(db: PostgresJsDatabase, ticketId: string, createdBy: string) { + const [ticket] = await db.select().from(repairTickets).where(eq(repairTickets.id, ticketId)).limit(1) + if (!ticket) throw new NotFoundError('Repair ticket') + if (!ticket.accountId) throw new ValidationError('Repair ticket must be linked to an account for invoicing') + + const lineItemRows = await db.select().from(repairLineItems) + .where(and(eq(repairLineItems.repairTicketId, ticketId), sql`${repairLineItems.itemType} != 'consumable'`)) + + if (lineItemRows.length === 0) return null // No billable items + + const invoiceNumber = generateInvoiceNumber() + const today = new Date().toISOString().slice(0, 10) + + let subtotal = 0 + for (const item of lineItemRows) { + subtotal += parseFloat(item.totalPrice) + } + + const [invoice] = await db.insert(invoices).values({ + invoiceNumber, + accountId: ticket.accountId, + status: 'sent', + issueDate: today, + dueDate: today, + sourceType: 'repair_ticket', + sourceId: ticketId, + subtotal: subtotal.toFixed(2), + total: subtotal.toFixed(2), + balance: subtotal.toFixed(2), + createdBy, + }).returning() + + for (const item of lineItemRows) { + await db.insert(invoiceLineItems).values({ + invoiceId: invoice.id, + description: item.description, + qty: parseInt(item.qty as any) || 1, + unitPrice: item.unitPrice, + lineTotal: item.totalPrice, + sourceType: 'repair_line_item', + sourceId: item.id, + }) + } + + // Update AR balance + await AccountBalanceService.adjustBalance(db, ticket.accountId, subtotal, 'invoice') + + return invoice + }, + + async applyPayment( + db: PostgresJsDatabase, + invoiceId: string, + input: { transactionId?: string; amount: number }, + appliedBy: string, + ) { + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1) + if (!invoice) throw new NotFoundError('Invoice') + + if (['void', 'written_off', 'paid'].includes(invoice.status)) { + throw new ValidationError(`Cannot apply payment to ${invoice.status} invoice`) + } + + const currentBalance = parseFloat(invoice.balance) + if (input.amount > currentBalance) { + throw new ValidationError(`Payment amount $${input.amount} exceeds invoice balance $${currentBalance}`) + } + + // Record payment application + const [application] = await db.insert(paymentApplications).values({ + invoiceId, + transactionId: input.transactionId ?? null, + amount: input.amount.toFixed(2), + appliedBy, + }).returning() + + // Update invoice + const newAmountPaid = parseFloat(invoice.amountPaid) + input.amount + const newBalance = parseFloat(invoice.total) - newAmountPaid + const newStatus = newBalance <= 0 ? 'paid' : 'partial' + + await db.update(invoices).set({ + amountPaid: newAmountPaid.toFixed(2), + balance: newBalance.toFixed(2), + status: newStatus, + updatedAt: new Date(), + }).where(eq(invoices.id, invoiceId)) + + // Update AR balance + await AccountBalanceService.adjustBalance(db, invoice.accountId, -input.amount, 'payment') + + return application + }, + + async applyPaymentToOldestInvoices( + db: PostgresJsDatabase, + accountId: string, + amount: number, + transactionId: string, + appliedBy: string, + ) { + // FIFO: apply payment to oldest outstanding invoices + const outstanding = await db.select().from(invoices) + .where(and( + eq(invoices.accountId, accountId), + inArray(invoices.status, ['sent', 'partial', 'overdue']), + )) + .orderBy(invoices.issueDate) + + let remaining = amount + const applications = [] + + for (const inv of outstanding) { + if (remaining <= 0) break + const invBalance = parseFloat(inv.balance) + const applied = Math.min(remaining, invBalance) + + const app = await this.applyPayment(db, inv.id, { transactionId, amount: applied }, appliedBy) + applications.push(app) + remaining -= applied + } + + return { applications, amountApplied: amount - remaining, remaining } + }, + + async void(db: PostgresJsDatabase, invoiceId: string, reason: string, _voidedBy: string) { + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1) + if (!invoice) throw new NotFoundError('Invoice') + + if (invoice.status === 'void') throw new ValidationError('Invoice is already void') + + const balance = parseFloat(invoice.balance) + + await db.update(invoices).set({ + status: 'void', + balance: '0', + notes: `${invoice.notes ? invoice.notes + '\n' : ''}Voided: ${reason}`, + updatedAt: new Date(), + }).where(eq(invoices.id, invoiceId)) + + // If there was an outstanding balance, adjust AR + if (balance > 0) { + await AccountBalanceService.adjustBalance(db, invoice.accountId, -balance, 'void') + } + + return { ...invoice, status: 'void' as const, balance: '0' } + }, + + async writeOff(db: PostgresJsDatabase, invoiceId: string, reason: string, _writtenOffBy: string) { + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1) + if (!invoice) throw new NotFoundError('Invoice') + + const balance = parseFloat(invoice.balance) + if (balance <= 0) throw new ValidationError('No balance to write off') + + await db.update(invoices).set({ + status: 'written_off', + balance: '0', + notes: `${invoice.notes ? invoice.notes + '\n' : ''}Written off: ${reason}`, + updatedAt: new Date(), + }).where(eq(invoices.id, invoiceId)) + + await AccountBalanceService.adjustBalance(db, invoice.accountId, -balance, 'write_off') + + return { ...invoice, status: 'written_off' as const, balance: '0' } + }, + + async send(db: PostgresJsDatabase, invoiceId: string) { + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1) + if (!invoice) throw new NotFoundError('Invoice') + + if (invoice.status !== 'draft') throw new ValidationError('Only draft invoices can be sent') + + const [updated] = await db.update(invoices).set({ status: 'sent', updatedAt: new Date() }) + .where(eq(invoices.id, invoiceId)).returning() + return updated + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1) + if (!invoice) return null + + const lineItemRows = await db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)) + const payments = await db.select().from(paymentApplications).where(eq(paymentApplications.invoiceId, id)) + + return { ...invoice, lineItems: lineItemRows, payments } + }, + + async list(db: PostgresJsDatabase, params: PaginationInput, filters?: { + accountId?: string + status?: string + dateFrom?: string + dateTo?: string + }) { + const conditions = [] + if (filters?.accountId) conditions.push(eq(invoices.accountId, filters.accountId)) + if (filters?.status) conditions.push(eq(invoices.status, filters.status as any)) + if (filters?.dateFrom) conditions.push(sql`${invoices.issueDate} >= ${filters.dateFrom}`) + if (filters?.dateTo) conditions.push(sql`${invoices.issueDate} <= ${filters.dateTo}`) + + const searchCondition = params.q + ? buildSearchCondition(params.q, [invoices.invoiceNumber]) + : undefined + if (searchCondition) conditions.push(searchCondition) + + const where = conditions.length > 0 ? and(...conditions) : undefined + const offset = ((params.page ?? 1) - 1) * (params.limit ?? 25) + + const sortableColumns: Record = { + invoice_number: invoices.invoiceNumber, + issue_date: invoices.issueDate, + due_date: invoices.dueDate, + total: invoices.total, + balance: invoices.balance, + status: invoices.status, + created_at: invoices.createdAt, + } + + const sortColumn = sortableColumns[params.sort ?? 'created_at'] ?? invoices.createdAt + const orderFn = params.order === 'asc' ? sql`${sortColumn} ASC` : sql`${sortColumn} DESC` + + const [data, [{ total }]] = await Promise.all([ + db.select({ + id: invoices.id, + invoiceNumber: invoices.invoiceNumber, + accountId: invoices.accountId, + status: invoices.status, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + total: invoices.total, + amountPaid: invoices.amountPaid, + balance: invoices.balance, + sourceType: invoices.sourceType, + createdAt: invoices.createdAt, + accountName: accounts.name, + }) + .from(invoices) + .leftJoin(accounts, eq(invoices.accountId, accounts.id)) + .where(where) + .orderBy(orderFn) + .limit(params.limit ?? 25) + .offset(offset), + db.select({ total: count() }).from(invoices).where(where), + ]) + + return { + data, + pagination: { + page: params.page ?? 1, + limit: params.limit ?? 25, + total, + totalPages: Math.ceil(total / (params.limit ?? 25)), + }, + } + }, + + async listByAccount(db: PostgresJsDatabase, accountId: string, params: PaginationInput) { + return this.list(db, params, { accountId }) + }, + + async markOverdueInvoices(db: PostgresJsDatabase) { + const today = new Date().toISOString().slice(0, 10) + await db.update(invoices).set({ status: 'overdue', updatedAt: new Date() }) + .where(and( + inArray(invoices.status, ['sent', 'partial']), + lte(invoices.dueDate, today), + )) + }, + + async getAgingReport(db: PostgresJsDatabase) { + const today = new Date() + const d30 = new Date(today); d30.setDate(d30.getDate() - 30) + const d60 = new Date(today); d60.setDate(d60.getDate() - 60) + const d90 = new Date(today); d90.setDate(d90.getDate() - 90) + + const outstanding = await db.select({ + id: invoices.id, + invoiceNumber: invoices.invoiceNumber, + accountId: invoices.accountId, + accountName: accounts.name, + dueDate: invoices.dueDate, + total: invoices.total, + balance: invoices.balance, + }) + .from(invoices) + .leftJoin(accounts, eq(invoices.accountId, accounts.id)) + .where(and( + inArray(invoices.status, ['sent', 'partial', 'overdue']), + sql`${invoices.balance}::numeric > 0`, + )) + + let current = 0, days30 = 0, days60 = 0, days90plus = 0 + + for (const inv of outstanding) { + const balance = parseFloat(inv.balance!) + const dueDate = new Date(inv.dueDate!) + if (dueDate >= today) current += balance + else if (dueDate >= d30) days30 += balance + else if (dueDate >= d60) days60 += balance + else days90plus += balance + } + + return { + current: current.toFixed(2), + days30: days30.toFixed(2), + days60: days60.toFixed(2), + days90plus: days90plus.toFixed(2), + total: (current + days30 + days60 + days90plus).toFixed(2), + invoiceCount: outstanding.length, + } + }, + + async exportCSV(db: PostgresJsDatabase, filters?: { dateFrom?: string; dateTo?: string }) { + const conditions = [] + if (filters?.dateFrom) conditions.push(sql`${invoices.issueDate} >= ${filters.dateFrom}`) + if (filters?.dateTo) conditions.push(sql`${invoices.issueDate} <= ${filters.dateTo}`) + const where = conditions.length > 0 ? and(...conditions) : undefined + + const rows = await db.select({ + invoiceNumber: invoices.invoiceNumber, + accountName: accounts.name, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + status: invoices.status, + subtotal: invoices.subtotal, + discountTotal: invoices.discountTotal, + taxTotal: invoices.taxTotal, + total: invoices.total, + amountPaid: invoices.amountPaid, + balance: invoices.balance, + }) + .from(invoices) + .leftJoin(accounts, eq(invoices.accountId, accounts.id)) + .where(where) + .orderBy(invoices.issueDate) + + const headers = ['Invoice Number', 'Customer', 'Issue Date', 'Due Date', 'Status', 'Subtotal', 'Discount', 'Tax', 'Total', 'Paid', 'Balance'] + const csvRows = [ + headers.join(','), + ...rows.map(r => [ + r.invoiceNumber, + `"${(r.accountName ?? '').replace(/"/g, '""')}"`, + r.issueDate, + r.dueDate, + r.status, + r.subtotal, + r.discountTotal, + r.taxTotal, + r.total, + r.amountPaid, + r.balance, + ].join(',')), + ] + + return csvRows.join('\n') + }, +}