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
+94
View File
@@ -15,6 +15,7 @@
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"jszip": "^3.10.1",
"papaparse": "^5.5.2", "papaparse": "^5.5.2",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pg": "^8.13.1", "pg": "^8.13.1",
@@ -2353,6 +2354,12 @@
"node": ">= 0.6" "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": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -2767,6 +2774,18 @@
"url": "https://github.com/sponsors/typicode" "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": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -2800,6 +2819,12 @@
"@types/estree": "*" "@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": { "node_modules/isexe": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -2820,6 +2845,18 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -2830,6 +2867,15 @@
"node": ">=6" "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": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -3368,6 +3414,27 @@
"node": ">=0.10.0" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3471,6 +3538,12 @@
"node": ">=6" "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": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3491,6 +3564,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
@@ -3559,6 +3638,15 @@
"node": ">= 10.x" "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": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3710,6 +3798,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+1
View File
@@ -23,6 +23,7 @@
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"jszip": "^3.10.1",
"papaparse": "^5.5.2", "papaparse": "^5.5.2",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pg": "^8.13.1", "pg": "^8.13.1",
+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;
}