Add financial export ZIP download for admin and accountant
Validate / validate (push) Successful in 27s

- New /companies/[id]/export page with year selector and big download button
- GET /export/zip endpoint generates the financial-export-{name}-{year}.zip
  by calling buildFinancialExport, then logs financial_exported in
  the company audit trail
- New "Export" tab in company nav, visible to admin or accountant
- Page lists all included files and warns about sensitive PII

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:43:26 +07:00
parent 843ed96aaa
commit 39ac9d3928
4 changed files with 133 additions and 0 deletions
@@ -32,6 +32,9 @@
{ href: `/companies/${data.company.id}/integrations`, label: 'Integrations' } { href: `/companies/${data.company.id}/integrations`, label: 'Integrations' }
] ]
: []), : []),
...(data.companyRoles.includes('admin') || data.companyRoles.includes('accountant')
? [{ href: `/companies/${data.company.id}/export`, label: 'Export' }]
: []),
...(data.companyRoles.includes('admin') || data.companyRoles.includes('manager') ...(data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
? [ ? [
{ href: `/companies/${data.company.id}/import`, label: 'Import' }, { href: `/companies/${data.company.id}/import`, label: 'Import' },
@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'accountant']);
await parent();
return {};
};
@@ -0,0 +1,90 @@
<script lang="ts">
import type { PageData } from './$types';
let { data } = $props<{ data: PageData }>();
let year = $state(new Date().getFullYear());
const downloadHref = $derived(
`/companies/${data.company.id}/export/zip?year=${year}`
);
const FILES = [
{ name: 'company.csv', desc: 'Company record (name, currency, total budget)' },
{ name: 'projects.csv', desc: 'All projects (active + inactive)' },
{ name: 'parties.csv', desc: 'Customers + suppliers (incl. archived)' },
{ name: 'employees.csv', desc: 'Employees (incl. terminated/archived)' },
{ name: 'budget_allocations.csv', desc: 'Fund movements during the year' },
{ name: 'expenses.csv', desc: 'All expenses dated in the year (any status)' },
{ name: 'invoices.csv', desc: 'Invoices issued in the year (all statuses)' },
{ name: 'invoice_line_items.csv', desc: 'Line items for those invoices' },
{ name: 'salary_history.csv', desc: 'Salary changes effective on/before year end' },
{ name: 'payslips.csv', desc: 'Payslips for the year (Thai SSO + WHT shown)' },
{ name: 'payslip_line_items.csv', desc: 'Line items for those payslips' },
{ name: 'packages.csv', desc: 'Packages created in the year (with customs links)' },
{ name: 'external_transactions.csv', desc: 'Bank/wallet transactions in the year' },
{ name: 'company_log.csv', desc: 'Full audit trail for the year' }
];
</script>
<svelte:head>
<title>Financial Export - {data.company.name}</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Financial Export</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Download a complete year-scoped snapshot for internal review or tax filing.
</p>
<!-- Year + download -->
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<label for="year" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Tax year
</label>
<div class="flex items-center gap-3">
<input
type="number"
id="year"
min="2000"
max={new Date().getFullYear() + 1}
bind:value={year}
class="w-32 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<a
href={downloadHref}
class="rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Download export ZIP
</a>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
ZIP filename: <code class="rounded bg-gray-100 px-1 dark:bg-gray-700">financial-export-{data.company.name.replace(/[^a-zA-Z0-9_-]+/g, '_')}-{year}.zip</code>
</p>
</div>
<!-- Contents -->
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">Contents</h2>
<ul class="mt-3 divide-y divide-gray-100 text-sm dark:divide-gray-700">
{#each FILES as f}
<li class="flex items-center justify-between gap-4 py-2">
<code class="text-gray-700 dark:text-gray-300">{f.name}</code>
<span class="text-xs text-gray-500 dark:text-gray-400">{f.desc}</span>
</li>
{/each}
</ul>
</div>
<!-- Notes -->
<div class="mt-4 rounded-md bg-amber-50 p-4 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<p class="font-medium">Sensitive data — handle with care</p>
<p class="mt-1">
This export contains <strong>full salaries, bank account numbers, national IDs, and tax IDs</strong>.
Store the ZIP securely and only share with parties (auditors, accountants) who require the data.
</p>
<p class="mt-1">
All generations are logged in the company audit trail.
</p>
</div>
</div>
@@ -0,0 +1,32 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { buildFinancialExport } from '$lib/server/export/financial.js';
export const GET: RequestHandler = async ({ locals, params, url }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'accountant']);
const yearParam = url.searchParams.get('year');
const year = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear();
if (isNaN(year) || year < 2000 || year > 2100) {
error(400, 'Invalid year');
}
const { filename, bytes } = await buildFinancialExport(params.companyId, year);
await logCompanyEvent(
params.companyId,
user.id,
'financial_exported',
`Financial export generated for ${year}`,
{ year, sizeBytes: bytes.length }
);
return new Response(new Blob([bytes as BlobPart], { type: 'application/zip' }), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`
}
});
};