Files
buildfor_life_budget/src/lib/server/export/financial.ts
T
grabowski 77c5d72e43
Validate / validate (push) Successful in 31s
Reconciliation link, account CSVs in export, drop legacy bank/card tables
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:06:53 +07:00

886 lines
31 KiB
TypeScript

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<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`,
` 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<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(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;
}