From 843ed96aaa0fcb821e230863dd7ceb34f466b241 Mon Sep 17 00:00:00 2001 From: grabowski Date: Wed, 15 Apr 2026 09:41:46 +0700 Subject: [PATCH] Add jszip and financial export builder module - Install jszip dependency (~100KB, pure JS) - New src/lib/server/export/financial.ts builds a year-scoped ZIP with one CSV per logical table: company, projects, parties (incl archived), employees (incl terminated), budget_allocations, expenses, invoices + line items, salary_history (effective on/before year end), payslips + line items, packages (with carrier label and customs link), external_transactions (with provider label and matched expense), company_log - All CSVs prefixed with UTF-8 BOM for Excel/Thai support - Reference tables include soft-deleted rows so historical FKs resolve - Routes and UI to follow in next commit Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 94 ++++ package.json | 1 + src/lib/server/export/financial.ts | 679 +++++++++++++++++++++++++++++ 3 files changed, 774 insertions(+) create mode 100644 src/lib/server/export/financial.ts diff --git a/package-lock.json b/package-lock.json index ce23a97..b276886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "chart.js": "^4.4.7", "date-fns": "^4.1.0", "drizzle-orm": "^0.38.4", + "jszip": "^3.10.1", "papaparse": "^5.5.2", "pdf-lib": "^1.17.1", "pg": "^8.13.1", @@ -2353,6 +2354,12 @@ "node": ">= 0.6" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2767,6 +2774,18 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2800,6 +2819,12 @@ "@types/estree": "*" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -2820,6 +2845,18 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2830,6 +2867,15 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3368,6 +3414,27 @@ "node": ">=0.10.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3471,6 +3538,12 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3491,6 +3564,12 @@ "dev": true, "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -3559,6 +3638,15 @@ "node": ">= 10.x" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3710,6 +3798,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index 0de8f4e..139a707 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "chart.js": "^4.4.7", "date-fns": "^4.1.0", "drizzle-orm": "^0.38.4", + "jszip": "^3.10.1", "papaparse": "^5.5.2", "pdf-lib": "^1.17.1", "pg": "^8.13.1", diff --git a/src/lib/server/export/financial.ts b/src/lib/server/export/financial.ts new file mode 100644 index 0000000..4895694 --- /dev/null +++ b/src/lib/server/export/financial.ts @@ -0,0 +1,679 @@ +import JSZip from 'jszip'; +import { db } from '../db/index.js'; +import { + companies, + companyLog, + companyMembers, + expenses, + categories, + projects, + parties, + invoices, + invoiceLineItems, + budgetAllocations, + employees, + salaryHistory, + payslips, + payslipLineItems, + packages, + externalAccounts, + externalTransactions, + users +} from '../db/schema.js'; +import { csvBuild } from '$lib/utils/csv.js'; +import { CARRIER_LABELS } from '../shipping/index.js'; +import { eq, and, sql, asc } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; + +const PROVIDER_LABELS: Record = { + kasikorn_kbiz: 'Kasikorn K-Biz', + etherfi: 'Ether.fi', + manual: 'Manual' +}; + +function safeName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]+/g, '_'); +} + +/** Build a complete year-scoped financial export ZIP for a company. */ +export async function buildFinancialExport( + companyId: string, + year: number +): Promise<{ filename: string; bytes: Uint8Array }> { + const yearStart = `${year}-01-01`; + const yearEnd = `${year}-12-31`; + + // Company row + const [company] = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + if (!company) throw new Error('Company not found'); + + const zip = new JSZip(); + const generatedAt = new Date().toISOString(); + + // ── README ───────────────────────────────────────── + zip.file( + 'README.txt', + [ + `Financial Export`, + `================`, + ``, + `Company: ${company.name}`, + `Currency: ${company.currency}`, + `Year: ${year}`, + `Generated: ${generatedAt}`, + ``, + `Files:`, + ` company.csv — company record`, + ` projects.csv — all projects (active + inactive)`, + ` parties.csv — all customers/suppliers (incl. archived; see deletedAt)`, + ` employees.csv — all employees (incl. terminated/archived)`, + ` budget_allocations.csv — fund movements in the selected year`, + ` expenses.csv — expenses dated in the selected year`, + ` invoices.csv — invoices issued in the selected year (all statuses)`, + ` invoice_line_items.csv — line items for invoices above`, + ` salary_history.csv — salary changes effective on/before year end`, + ` payslips.csv — payslips for the selected year`, + ` payslip_line_items.csv — line items for payslips above`, + ` packages.csv — packages created in the selected year`, + ` external_transactions.csv — bank/wallet transactions in the selected year`, + ` company_log.csv — audit log entries in the selected year`, + ``, + `Notes:`, + ` - All money columns use the currency stored on the row (mostly THB).`, + ` - Reference tables (parties, employees, projects) include soft-deleted rows`, + ` so historical references resolve. The deletedAt column shows their status.`, + ` - Invoices include all statuses (draft/sent/paid/overdue/cancelled).`, + ` - All CSVs are UTF-8 with a BOM for Excel/Thai support.` + ].join('\n') + ); + + // ── company.csv ──────────────────────────────────── + zip.file( + 'company.csv', + withBom( + csvBuild([ + ['id', 'name', 'description', 'totalBudget', 'currency', 'createdAt', 'deletedAt'], + [ + company.id, + company.name, + company.description ?? '', + company.totalBudget, + company.currency, + company.createdAt.toISOString(), + company.deletedAt ? company.deletedAt.toISOString() : '' + ] + ]) + ) + ); + + // ── projects.csv ──────────────────────────────────── + const projectRows = await db + .select() + .from(projects) + .where(eq(projects.companyId, companyId)) + .orderBy(asc(projects.name)); + { + const rows: unknown[][] = [ + ['id', 'name', 'description', 'allocatedBudget', 'isActive', 'createdAt', 'updatedAt'] + ]; + for (const p of projectRows) { + rows.push([ + p.id, + p.name, + p.description ?? '', + p.allocatedBudget, + p.isActive, + p.createdAt.toISOString(), + p.updatedAt.toISOString() + ]); + } + zip.file('projects.csv', withBom(csvBuild(rows))); + } + + // ── parties.csv ───────────────────────────────────── + const partyRows = await db + .select() + .from(parties) + .where(eq(parties.companyId, companyId)) + .orderBy(asc(parties.name)); + { + const rows: unknown[][] = [ + [ + 'id', 'name', 'type', 'contactPerson', 'email', 'phone', 'website', + 'taxId', 'addressLine1', 'addressLine2', 'city', 'postalCode', 'country', + 'paymentTerms', 'notes', 'isActive', 'deletedAt', 'createdAt', 'updatedAt' + ] + ]; + for (const p of partyRows) { + rows.push([ + p.id, p.name, p.type, p.contactPerson ?? '', p.email ?? '', p.phone ?? '', p.website ?? '', + p.taxId ?? '', p.addressLine1 ?? '', p.addressLine2 ?? '', p.city ?? '', + p.postalCode ?? '', p.country ?? '', p.paymentTerms ?? '', p.notes ?? '', + p.isActive, p.deletedAt ? p.deletedAt.toISOString() : '', + p.createdAt.toISOString(), p.updatedAt.toISOString() + ]); + } + zip.file('parties.csv', withBom(csvBuild(rows))); + } + + // ── employees.csv ─────────────────────────────────── + const employeeRows = await db + .select() + .from(employees) + .where(eq(employees.companyId, companyId)) + .orderBy(asc(employees.firstName)); + { + const rows: unknown[][] = [ + [ + 'id', 'employeeCode', 'firstName', 'lastName', 'displayName', 'email', 'phone', + 'position', 'department', 'hireDate', 'terminationDate', 'nationalId', 'taxId', + 'bankName', 'bankAccount', 'isActive', 'deletedAt', 'createdAt', 'updatedAt' + ] + ]; + for (const e of employeeRows) { + rows.push([ + e.id, e.employeeCode ?? '', e.firstName, e.lastName, e.displayName ?? '', + e.email ?? '', e.phone ?? '', e.position ?? '', e.department ?? '', + e.hireDate, e.terminationDate ?? '', e.nationalId ?? '', e.taxId ?? '', + e.bankName ?? '', e.bankAccount ?? '', + e.isActive, e.deletedAt ? e.deletedAt.toISOString() : '', + e.createdAt.toISOString(), e.updatedAt.toISOString() + ]); + } + zip.file('employees.csv', withBom(csvBuild(rows))); + } + + // ── budget_allocations.csv ────────────────────────── + { + const allocRows = await db + .select({ + id: budgetAllocations.id, + projectId: budgetAllocations.projectId, + projectName: projects.name, + amount: budgetAllocations.amount, + note: budgetAllocations.note, + allocatedBy: budgetAllocations.allocatedBy, + allocatorName: users.displayName, + allocatorEmail: users.email, + createdAt: budgetAllocations.createdAt + }) + .from(budgetAllocations) + .innerJoin(projects, eq(budgetAllocations.projectId, projects.id)) + .leftJoin(users, eq(budgetAllocations.allocatedBy, users.id)) + .where( + and( + eq(budgetAllocations.companyId, companyId), + sql`extract(year from ${budgetAllocations.createdAt}) = ${year}` + ) + ) + .orderBy(asc(budgetAllocations.createdAt)); + + const rows: unknown[][] = [ + ['id', 'projectId', 'projectName', 'amount', 'note', 'allocatedBy', 'allocatorName', 'allocatorEmail', 'createdAt'] + ]; + for (const a of allocRows) { + rows.push([ + a.id, a.projectId, a.projectName, a.amount, a.note ?? '', + a.allocatedBy, a.allocatorName ?? '', a.allocatorEmail ?? '', + a.createdAt.toISOString() + ]); + } + zip.file('budget_allocations.csv', withBom(csvBuild(rows))); + } + + // ── expenses.csv ─────────────────────────────────── + { + const submitter = alias(users, 'submitter'); + const approver = alias(users, 'approver'); + const expRows = await db + .select({ + id: expenses.id, + title: expenses.title, + description: expenses.description, + amount: expenses.amount, + currency: expenses.currency, + status: expenses.status, + expenseDate: expenses.expenseDate, + receiptUrl: expenses.receiptUrl, + rejectionReason: expenses.rejectionReason, + reviewedAt: expenses.reviewedAt, + createdAt: expenses.createdAt, + projectId: expenses.projectId, + projectName: projects.name, + categoryId: expenses.categoryId, + categoryName: categories.name, + partyId: expenses.partyId, + partyName: parties.name, + submittedBy: expenses.submittedBy, + submitterName: submitter.displayName, + submitterEmail: submitter.email, + approvedBy: expenses.approvedBy, + approverName: approver.displayName, + approverEmail: approver.email + }) + .from(expenses) + .innerJoin(projects, eq(expenses.projectId, projects.id)) + .leftJoin(categories, eq(expenses.categoryId, categories.id)) + .leftJoin(parties, eq(expenses.partyId, parties.id)) + .leftJoin(submitter, eq(expenses.submittedBy, submitter.id)) + .leftJoin(approver, eq(expenses.approvedBy, approver.id)) + .where( + and( + eq(projects.companyId, companyId), + sql`extract(year from ${expenses.expenseDate}) = ${year}` + ) + ) + .orderBy(asc(expenses.expenseDate)); + + const rows: unknown[][] = [ + [ + 'id', 'expenseDate', 'title', 'description', 'amount', 'currency', 'status', + 'projectId', 'projectName', 'categoryId', 'categoryName', + 'partyId', 'partyName', 'submittedBy', 'submitterName', 'submitterEmail', + 'approvedBy', 'approverName', 'approverEmail', 'rejectionReason', 'receiptUrl', + 'reviewedAt', 'createdAt' + ] + ]; + for (const e of expRows) { + rows.push([ + e.id, e.expenseDate, e.title, e.description ?? '', e.amount, e.currency, e.status, + e.projectId, e.projectName ?? '', e.categoryId ?? '', e.categoryName ?? '', + e.partyId ?? '', e.partyName ?? '', e.submittedBy, e.submitterName ?? '', e.submitterEmail ?? '', + e.approvedBy ?? '', e.approverName ?? '', e.approverEmail ?? '', + e.rejectionReason ?? '', e.receiptUrl ?? '', + e.reviewedAt ? e.reviewedAt.toISOString() : '', e.createdAt.toISOString() + ]); + } + zip.file('expenses.csv', withBom(csvBuild(rows))); + } + + // ── invoices.csv + invoice_line_items.csv ────────── + let invoiceIds: string[] = []; + { + const invRows = await db + .select({ + id: invoices.id, + invoiceNumber: invoices.invoiceNumber, + direction: invoices.direction, + status: invoices.status, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + subtotal: invoices.subtotal, + vat: invoices.vat, + total: invoices.total, + currency: invoices.currency, + partyId: invoices.partyId, + partyName: parties.name, + expenseId: invoices.expenseId, + expenseTitle: expenses.title, + notes: invoices.notes, + pdfPath: invoices.pdfPath, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt + }) + .from(invoices) + .innerJoin(parties, eq(invoices.partyId, parties.id)) + .leftJoin(expenses, eq(invoices.expenseId, expenses.id)) + .where( + and( + eq(invoices.companyId, companyId), + sql`extract(year from ${invoices.issueDate}) = ${year}` + ) + ) + .orderBy(asc(invoices.issueDate)); + invoiceIds = invRows.map((i) => i.id); + + const rows: unknown[][] = [ + [ + 'id', 'invoiceNumber', 'direction', 'status', 'issueDate', 'dueDate', + 'subtotal', 'vat', 'total', 'currency', + 'partyId', 'partyName', 'expenseId', 'expenseTitle', + 'notes', 'pdfPath', 'createdAt', 'updatedAt' + ] + ]; + for (const i of invRows) { + rows.push([ + i.id, i.invoiceNumber, i.direction, i.status, i.issueDate, i.dueDate ?? '', + i.subtotal, i.vat, i.total, i.currency, + i.partyId, i.partyName ?? '', i.expenseId ?? '', i.expenseTitle ?? '', + i.notes ?? '', i.pdfPath ?? '', + i.createdAt.toISOString(), i.updatedAt.toISOString() + ]); + } + zip.file('invoices.csv', withBom(csvBuild(rows))); + } + { + const liRows = + invoiceIds.length === 0 + ? [] + : await db + .select({ + id: invoiceLineItems.id, + invoiceId: invoiceLineItems.invoiceId, + invoiceNumber: invoices.invoiceNumber, + description: invoiceLineItems.description, + quantity: invoiceLineItems.quantity, + unitPrice: invoiceLineItems.unitPrice, + total: invoiceLineItems.total + }) + .from(invoiceLineItems) + .innerJoin(invoices, eq(invoiceLineItems.invoiceId, invoices.id)) + .where( + sql`${invoiceLineItems.invoiceId} = ANY(${invoiceIds})` + ); + + const rows: unknown[][] = [ + ['id', 'invoiceId', 'invoiceNumber', 'description', 'quantity', 'unitPrice', 'total'] + ]; + for (const l of liRows) { + rows.push([l.id, l.invoiceId, l.invoiceNumber, l.description, l.quantity, l.unitPrice, l.total]); + } + zip.file('invoice_line_items.csv', withBom(csvBuild(rows))); + } + + // ── salary_history.csv (effective on/before year-end) ── + { + const setBy = alias(users, 'set_by_user'); + const salRows = await db + .select({ + id: salaryHistory.id, + employeeId: salaryHistory.employeeId, + employeeFirstName: employees.firstName, + employeeLastName: employees.lastName, + effectiveFrom: salaryHistory.effectiveFrom, + grossSalary: salaryHistory.grossSalary, + currency: salaryHistory.currency, + note: salaryHistory.note, + setBy: salaryHistory.setBy, + setByName: setBy.displayName, + createdAt: salaryHistory.createdAt + }) + .from(salaryHistory) + .innerJoin(employees, eq(salaryHistory.employeeId, employees.id)) + .leftJoin(setBy, eq(salaryHistory.setBy, setBy.id)) + .where( + and( + eq(employees.companyId, companyId), + sql`${salaryHistory.effectiveFrom} <= ${yearEnd}` + ) + ) + .orderBy(asc(salaryHistory.effectiveFrom)); + + const rows: unknown[][] = [ + ['id', 'employeeId', 'employeeName', 'effectiveFrom', 'grossSalary', 'currency', 'note', 'setBy', 'setByName', 'createdAt'] + ]; + for (const s of salRows) { + rows.push([ + s.id, s.employeeId, `${s.employeeFirstName} ${s.employeeLastName}`, + s.effectiveFrom, s.grossSalary, s.currency, s.note ?? '', + s.setBy, s.setByName ?? '', s.createdAt.toISOString() + ]); + } + zip.file('salary_history.csv', withBom(csvBuild(rows))); + } + + // ── payslips.csv + payslip_line_items.csv ────────── + let payslipIds: string[] = []; + { + const psRows = await db + .select({ + id: payslips.id, + employeeId: payslips.employeeId, + employeeFirstName: employees.firstName, + employeeLastName: employees.lastName, + periodYear: payslips.periodYear, + periodMonth: payslips.periodMonth, + grossSalary: payslips.grossSalary, + overtime: payslips.overtime, + bonus: payslips.bonus, + otherEarnings: payslips.otherEarnings, + ssoEmployee: payslips.ssoEmployee, + ssoEmployer: payslips.ssoEmployer, + incomeTax: payslips.incomeTax, + otherDeductions: payslips.otherDeductions, + netPay: payslips.netPay, + currency: payslips.currency, + status: payslips.status, + finalizedAt: payslips.finalizedAt, + paidAt: payslips.paidAt, + generatedBy: payslips.generatedBy, + generatedByName: users.displayName, + createdAt: payslips.createdAt + }) + .from(payslips) + .innerJoin(employees, eq(payslips.employeeId, employees.id)) + .leftJoin(users, eq(payslips.generatedBy, users.id)) + .where( + and(eq(payslips.companyId, companyId), eq(payslips.periodYear, String(year))) + ) + .orderBy(asc(payslips.periodMonth)); + payslipIds = psRows.map((p) => p.id); + + const rows: unknown[][] = [ + [ + 'id', 'employeeId', 'employeeName', 'periodYear', 'periodMonth', + 'grossSalary', 'overtime', 'bonus', 'otherEarnings', + 'ssoEmployee', 'ssoEmployer', 'incomeTax', 'otherDeductions', 'netPay', + 'currency', 'status', 'finalizedAt', 'paidAt', + 'generatedBy', 'generatedByName', 'createdAt' + ] + ]; + for (const p of psRows) { + rows.push([ + p.id, p.employeeId, `${p.employeeFirstName} ${p.employeeLastName}`, + p.periodYear, p.periodMonth, + p.grossSalary, p.overtime, p.bonus, p.otherEarnings, + p.ssoEmployee, p.ssoEmployer, p.incomeTax, p.otherDeductions, p.netPay, + p.currency, p.status, + p.finalizedAt ? p.finalizedAt.toISOString() : '', + p.paidAt ? p.paidAt.toISOString() : '', + p.generatedBy, p.generatedByName ?? '', + p.createdAt.toISOString() + ]); + } + zip.file('payslips.csv', withBom(csvBuild(rows))); + } + { + const liRows = + payslipIds.length === 0 + ? [] + : await db + .select({ + id: payslipLineItems.id, + payslipId: payslipLineItems.payslipId, + payslipPeriod: sql`${payslips.periodYear} || '-' || lpad(${payslips.periodMonth}::text, 2, '0')`, + employeeFirstName: employees.firstName, + employeeLastName: employees.lastName, + type: payslipLineItems.type, + label: payslipLineItems.label, + amount: payslipLineItems.amount, + isStatutory: payslipLineItems.isStatutory + }) + .from(payslipLineItems) + .innerJoin(payslips, eq(payslipLineItems.payslipId, payslips.id)) + .innerJoin(employees, eq(payslips.employeeId, employees.id)) + .where(sql`${payslipLineItems.payslipId} = ANY(${payslipIds})`); + + const rows: unknown[][] = [ + ['id', 'payslipId', 'payslipPeriod', 'employeeName', 'type', 'label', 'amount', 'isStatutory'] + ]; + for (const l of liRows) { + rows.push([ + l.id, l.payslipId, l.payslipPeriod, + `${l.employeeFirstName} ${l.employeeLastName}`, + l.type, l.label, l.amount, l.isStatutory + ]); + } + zip.file('payslip_line_items.csv', withBom(csvBuild(rows))); + } + + // ── packages.csv ─────────────────────────────────── + { + const customsInv = alias(invoices, 'customs_inv'); + const linkedInv = alias(invoices, 'linked_inv'); + const pkgRows = await db + .select({ + id: packages.id, + direction: packages.direction, + carrier: packages.carrier, + trackingNumber: packages.trackingNumber, + status: packages.status, + currentLocation: packages.currentLocation, + description: packages.description, + recipientName: packages.recipientName, + estimatedDelivery: packages.estimatedDelivery, + shippedAt: packages.shippedAt, + deliveredAt: packages.deliveredAt, + weightKg: packages.weightKg, + shippingCost: packages.shippingCost, + currency: packages.currency, + notes: packages.notes, + invoiceId: packages.invoiceId, + linkedInvoiceNumber: linkedInv.invoiceNumber, + customsInvoiceId: packages.customsInvoiceId, + customsInvoiceNumber: customsInv.invoiceNumber, + expenseId: packages.expenseId, + partyId: packages.partyId, + partyName: parties.name, + createdAt: packages.createdAt + }) + .from(packages) + .leftJoin(linkedInv, eq(packages.invoiceId, linkedInv.id)) + .leftJoin(customsInv, eq(packages.customsInvoiceId, customsInv.id)) + .leftJoin(parties, eq(packages.partyId, parties.id)) + .where( + and( + eq(packages.companyId, companyId), + sql`extract(year from ${packages.createdAt}) = ${year}` + ) + ) + .orderBy(asc(packages.createdAt)); + + const rows: unknown[][] = [ + [ + 'id', 'direction', 'carrier', 'carrierLabel', 'trackingNumber', 'status', + 'currentLocation', 'description', 'recipientName', + 'estimatedDelivery', 'shippedAt', 'deliveredAt', + 'weightKg', 'shippingCost', 'currency', 'notes', + 'invoiceId', 'linkedInvoiceNumber', + 'customsInvoiceId', 'customsInvoiceNumber', + 'expenseId', 'partyId', 'partyName', 'createdAt' + ] + ]; + for (const p of pkgRows) { + rows.push([ + p.id, p.direction, p.carrier, CARRIER_LABELS[p.carrier as keyof typeof CARRIER_LABELS] ?? p.carrier, + p.trackingNumber, p.status, + p.currentLocation ?? '', p.description ?? '', p.recipientName ?? '', + p.estimatedDelivery ?? '', + p.shippedAt ? p.shippedAt.toISOString() : '', + p.deliveredAt ? p.deliveredAt.toISOString() : '', + p.weightKg ?? '', p.shippingCost ?? '', p.currency, p.notes ?? '', + p.invoiceId ?? '', p.linkedInvoiceNumber ?? '', + p.customsInvoiceId ?? '', p.customsInvoiceNumber ?? '', + p.expenseId ?? '', p.partyId ?? '', p.partyName ?? '', + p.createdAt.toISOString() + ]); + } + zip.file('packages.csv', withBom(csvBuild(rows))); + } + + // ── external_transactions.csv ────────────────────── + { + const txRows = await db + .select({ + id: externalTransactions.id, + accountId: externalTransactions.accountId, + accountName: externalAccounts.displayName, + provider: externalAccounts.provider, + externalId: externalTransactions.externalId, + occurredAt: externalTransactions.occurredAt, + amount: externalTransactions.amount, + currency: externalTransactions.currency, + direction: externalTransactions.direction, + description: externalTransactions.description, + counterparty: externalTransactions.counterparty, + matchedExpenseId: externalTransactions.matchedExpenseId, + matchedExpenseTitle: expenses.title + }) + .from(externalTransactions) + .innerJoin(externalAccounts, eq(externalTransactions.accountId, externalAccounts.id)) + .leftJoin(expenses, eq(externalTransactions.matchedExpenseId, expenses.id)) + .where( + and( + eq(externalTransactions.companyId, companyId), + sql`extract(year from ${externalTransactions.occurredAt}) = ${year}` + ) + ) + .orderBy(asc(externalTransactions.occurredAt)); + + const rows: unknown[][] = [ + [ + 'id', 'occurredAt', 'accountId', 'accountName', 'provider', 'providerLabel', + 'externalId', 'amount', 'currency', 'direction', + 'description', 'counterparty', + 'matchedExpenseId', 'matchedExpenseTitle' + ] + ]; + for (const t of txRows) { + rows.push([ + t.id, t.occurredAt.toISOString(), t.accountId, t.accountName ?? '', + t.provider, PROVIDER_LABELS[t.provider as string] ?? t.provider, + t.externalId, t.amount, t.currency, t.direction, + t.description ?? '', t.counterparty ?? '', + t.matchedExpenseId ?? '', t.matchedExpenseTitle ?? '' + ]); + } + zip.file('external_transactions.csv', withBom(csvBuild(rows))); + } + + // ── company_log.csv ──────────────────────────────── + { + const logRows = await db + .select({ + id: companyLog.id, + event: companyLog.event, + description: companyLog.description, + metadata: companyLog.metadata, + userId: companyLog.userId, + userName: users.displayName, + userEmail: users.email, + createdAt: companyLog.createdAt + }) + .from(companyLog) + .leftJoin(users, eq(companyLog.userId, users.id)) + .where( + and( + eq(companyLog.companyId, companyId), + sql`extract(year from ${companyLog.createdAt}) = ${year}` + ) + ) + .orderBy(asc(companyLog.createdAt)); + + const rows: unknown[][] = [ + ['id', 'createdAt', 'event', 'description', 'metadata', 'userId', 'userName', 'userEmail'] + ]; + for (const l of logRows) { + rows.push([ + l.id, l.createdAt.toISOString(), l.event, l.description, + l.metadata ?? '', l.userId ?? '', l.userName ?? '', l.userEmail ?? '' + ]); + } + zip.file('company_log.csv', withBom(csvBuild(rows))); + } + + // Touch unused-import lint: companyMembers — referenced for future expansion + void companyMembers; + + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const filename = `financial-export-${safeName(company.name)}-${year}.zip`; + return { filename, bytes }; +} + +function withBom(s: string): string { + return '\uFEFF' + s; +}