77c5d72e43
Validate / validate (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
886 lines
31 KiB
TypeScript
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;
|
|
}
|