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, companyAddresses, companyAccounts, companyAccountTransactions, companyDocuments, companyDocumentVersions } from '../db/schema.js'; import { csvBuild } from '$lib/utils/csv.js'; import { CARRIER_LABELS } from '../shipping/index.js'; import { eq, and, sql, asc, inArray } 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`, ` company_accounts.csv — unified ledger accounts (bank, card, cash, etc.)`, ` company_account_transactions.csv — ledger transactions in the selected year`, ` company_addresses.csv — legal/shipping/billing/other addresses`, ` company_documents.csv — uploaded document metadata (files not bundled)`, ` 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() : '' ] ]) ) ); // ── company_accounts.csv ─────────────────────────── { const acctRows = await db .select() .from(companyAccounts) .where(eq(companyAccounts.companyId, companyId)) .orderBy(asc(companyAccounts.accountType), asc(companyAccounts.name)); const rows: unknown[][] = [ [ 'id', 'accountType', 'name', 'currency', 'isActive', 'isArchived', 'bankName', 'accountNumber', 'branch', 'swiftBic', 'iban', 'accountHolderName', 'cardBrand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear', 'creditLimit', 'statementCloseDay', 'paymentDueDay', 'externalAccountId', 'notes', 'deletedAt', 'createdAt', 'updatedAt' ] ]; for (const a of acctRows) { rows.push([ a.id, a.accountType, a.name, a.currency, a.isActive, a.isArchived, a.bankName ?? '', a.accountNumber ?? '', a.branch ?? '', a.swiftBic ?? '', a.iban ?? '', a.accountHolderName ?? '', a.cardBrand ?? '', a.last4 ?? '', a.cardholderName ?? '', a.expiryMonth ?? '', a.expiryYear ?? '', a.creditLimit ?? '', a.statementCloseDay ?? '', a.paymentDueDay ?? '', a.externalAccountId ?? '', a.notes ?? '', a.deletedAt ? a.deletedAt.toISOString() : '', a.createdAt.toISOString(), a.updatedAt.toISOString() ]); } zip.file('company_accounts.csv', withBom(csvBuild(rows))); } // ── company_account_transactions.csv ─────────────── { const yearStartDate = new Date(`${year}-01-01T00:00:00Z`); const yearEndDate = new Date(`${year}-12-31T23:59:59.999Z`); const txRows = await db .select({ id: companyAccountTransactions.id, accountId: companyAccountTransactions.accountId, accountName: companyAccounts.name, type: companyAccountTransactions.type, amount: companyAccountTransactions.amount, currency: companyAccountTransactions.currency, occurredAt: companyAccountTransactions.occurredAt, description: companyAccountTransactions.description, reference: companyAccountTransactions.reference, counterpartyAccountId: companyAccountTransactions.counterpartyAccountId, sourceExpenseId: companyAccountTransactions.sourceExpenseId, sourceInvoiceId: companyAccountTransactions.sourceInvoiceId, sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId, fxRate: companyAccountTransactions.fxRate, fxAmount: companyAccountTransactions.fxAmount, createdAt: companyAccountTransactions.createdAt }) .from(companyAccountTransactions) .innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id)) .where( and( eq(companyAccountTransactions.companyId, companyId), sql`${companyAccountTransactions.occurredAt} >= ${yearStartDate}`, sql`${companyAccountTransactions.occurredAt} <= ${yearEndDate}` ) ) .orderBy( asc(companyAccountTransactions.occurredAt), asc(companyAccountTransactions.createdAt) ); const rows: unknown[][] = [ [ 'id', 'accountId', 'accountName', 'type', 'amount', 'currency', 'occurredAt', 'description', 'reference', 'counterpartyAccountId', 'sourceExpenseId', 'sourceInvoiceId', 'sourceExternalTransactionId', 'fxRate', 'fxAmount', 'createdAt' ] ]; for (const t of txRows) { rows.push([ t.id, t.accountId, t.accountName, t.type, t.amount, t.currency, t.occurredAt.toISOString(), t.description ?? '', t.reference ?? '', t.counterpartyAccountId ?? '', t.sourceExpenseId ?? '', t.sourceInvoiceId ?? '', t.sourceExternalTransactionId ?? '', t.fxRate ?? '', t.fxAmount ?? '', t.createdAt.toISOString() ]); } zip.file('company_account_transactions.csv', withBom(csvBuild(rows))); } // ── company_addresses.csv ────────────────────────── { const addrRows = await db .select() .from(companyAddresses) .where(eq(companyAddresses.companyId, companyId)) .orderBy(asc(companyAddresses.type)); const rows: unknown[][] = [ [ 'id', 'type', 'label', 'recipient', 'addressLine1', 'addressLine2', 'subdistrict', 'district', 'province', 'postalCode', 'country', 'contactPerson', 'contactPhone', 'isDefault', 'notes', 'createdAt', 'updatedAt' ] ]; for (const a of addrRows) { rows.push([ a.id, a.type, a.label ?? '', a.recipient ?? '', a.addressLine1 ?? '', a.addressLine2 ?? '', a.subdistrict ?? '', a.district ?? '', a.province ?? '', a.postalCode ?? '', a.country, a.contactPerson ?? '', a.contactPhone ?? '', a.isDefault, a.notes ?? '', a.createdAt.toISOString(), a.updatedAt.toISOString() ]); } zip.file('company_addresses.csv', withBom(csvBuild(rows))); } // ── company_documents.csv ────────────────────────── { const docRows = await db .select() .from(companyDocuments) .where(eq(companyDocuments.companyId, companyId)) .orderBy(asc(companyDocuments.category), asc(companyDocuments.title)); // Latest version per document (joined) const latestByDoc = new Map< string, { versionNumber: number; fileName: string; mimeType: string; sizeBytes: number; uploadedBy: string | null; uploadedAt: Date; } >(); if (docRows.length > 0) { const versionRows = await db .select({ documentId: companyDocumentVersions.documentId, versionNumber: companyDocumentVersions.versionNumber, fileName: companyDocumentVersions.fileName, mimeType: companyDocumentVersions.mimeType, sizeBytes: companyDocumentVersions.sizeBytes, uploadedBy: companyDocumentVersions.uploadedBy, uploadedAt: companyDocumentVersions.uploadedAt }) .from(companyDocumentVersions) .where( inArray( companyDocumentVersions.documentId, docRows.map((d) => d.id) ) ); for (const v of versionRows) { const existing = latestByDoc.get(v.documentId); if (!existing || v.versionNumber > existing.versionNumber) { latestByDoc.set(v.documentId, v); } } } const rows: unknown[][] = [ [ 'id', 'category', 'customLabel', 'title', 'description', 'expiresAt', 'currentVersion', 'currentFilename', 'currentSizeBytes', 'currentMimeType', 'uploadedBy', 'uploadedAt', 'deletedAt', 'createdAt', 'updatedAt' ] ]; for (const d of docRows) { const latest = latestByDoc.get(d.id); rows.push([ d.id, d.category, d.customLabel ?? '', d.title, d.description ?? '', d.expiresAt ?? '', latest?.versionNumber ?? '', latest?.fileName ?? '', latest?.sizeBytes ?? '', latest?.mimeType ?? '', latest?.uploadedBy ?? '', latest?.uploadedAt.toISOString() ?? '', d.deletedAt ? d.deletedAt.toISOString() : '', d.createdAt.toISOString(), d.updatedAt.toISOString() ]); } zip.file('company_documents.csv', withBom(csvBuild(rows))); } // ── 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', // Personal 'dateOfBirth', 'gender', 'nationality', 'maritalStatus', // Address 'addressLine1', 'addressLine2', 'subdistrict', 'district', 'province', 'postalCode', 'country', // Emergency contact 'emergencyContactName', 'emergencyContactPhone', 'emergencyContactRelationship', '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.dateOfBirth ?? '', e.gender ?? '', e.nationality ?? '', e.maritalStatus ?? '', e.addressLine1 ?? '', e.addressLine2 ?? '', e.subdistrict ?? '', e.district ?? '', e.province ?? '', e.postalCode ?? '', e.country ?? '', e.emergencyContactName ?? '', e.emergencyContactPhone ?? '', e.emergencyContactRelationship ?? '', 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(inArray(invoiceLineItems.invoiceId, 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(inArray(payslipLineItems.payslipId, 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; }