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) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:41:46 +07:00
parent 0bfbcef043
commit 843ed96aaa
3 changed files with 774 additions and 0 deletions
+679
View File
@@ -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<string, string> = {
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<string>`${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;
}