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:
Generated
+94
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user