Files
accounted/lib/reports/__tests__/full-archive-export.test.ts
T
Jakob Wennberg 9ce1ebc65f feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) (#1874)
* feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4)

One bilaga per balance account as of the balansdag: IB, movement and UB
from the trial balance, what it was reconciled against, the difference,
the sign-off with who, when and note, and every attached file with its
SHA-256; the closing checklist as the first page. JSON and PDF through
/api/reports/bokslutsbilagor, in the reports library and on the
Avstämning page, and written into every period folder of the full
archive. Built from the attested rows, never by recomputing live status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reports): load the pärm renderer on demand in the full archive so PDF stubs elsewhere keep working

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): neutral rail dot for a manual account that is merely not attested yet

An unsigned manual account without a system specification has nothing to
compare against, so an amber dot read as a problem on every balance account
of a freshly migrated company. Neutral until it is signed or a
specification differs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:35:02 +02:00

1081 lines
39 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import JSZip from 'jszip'
import {
generateFullArchive,
generateBaseDataArchive,
estimateArchiveSize,
MASTER_DATA_DUMP_TABLES,
} from '../full-archive-export'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditLogEntry } from '@/types'
vi.mock('../sie-export', () => ({
generateSIEExport: vi.fn().mockResolvedValue('#FLAGGA 0\n#PROGRAM "ERPBase"'),
}))
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn().mockResolvedValue({
rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true,
}),
}))
vi.mock('../income-statement', () => ({
generateIncomeStatement: vi.fn().mockResolvedValue({
revenue_sections: [], total_revenue: 0,
expense_sections: [], total_expenses: 0,
financial_sections: [], total_financial: 0,
net_result: 0, period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../balance-sheet', () => ({
generateBalanceSheet: vi.fn().mockResolvedValue({
asset_sections: [], equity_liability_sections: [],
total_assets: 0, total_equity_liabilities: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../general-ledger', () => ({
generateGeneralLedger: vi.fn().mockResolvedValue({
accounts: [], period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../journal-register', () => ({
generateJournalRegister: vi.fn().mockResolvedValue({
entries: [], total_entries: 0, total_debit: 0, total_credit: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
// The bilagor step reads account_reconciliation_attachments through the
// store; an empty list keeps the queued-mock order of these tests intact.
// The pärm step runs the reconciliation and checklist readers; a null report
// keeps the queued-mock order of these tests intact.
vi.mock('../bokslutsbilagor', () => ({
generateBokslutsbilagor: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/reconciliation/attachments-store', () => ({
listAttachmentRowsInRange: vi.fn().mockResolvedValue([]),
}))
vi.mock('../vat-declaration', () => ({
calculateVatDeclaration: vi.fn().mockResolvedValue({
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
rutor: {
ruta05: 0, ruta06: 0, ruta07: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta39: 0, ruta40: 0, ruta48: 0, ruta49: 0,
},
invoiceCount: 0, transactionCount: 0,
breakdown: {
invoices: { ruta05: 0, ruta06: 0, ruta07: 0, ruta10: 0, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 0, base12: 0, base6: 0 },
transactions: { ruta48: 0 },
receipts: { ruta48: 0 },
},
}),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn().mockResolvedValue({ data: [], count: 0 }),
}))
const mockGetAuditLog = vi.mocked(getAuditLog)
const COMPANY_ROW = {
company_name: 'Test AB',
org_number: '5566778899',
moms_period: 'quarterly',
}
const PERIOD_2024 = {
id: 'period-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
opening_balance_entry_id: null,
}
const PERIOD_2023 = {
id: 'period-2023',
period_start: '2023-01-01',
period_end: '2023-12-31',
opening_balance_entry_id: null,
}
/**
* Queue one mock response per query writeMasterData issues, in order.
*
* Direct tables issue a single query; via-tables issue a parent-id query and,
* only when parents exist, one chunked child query. Tables not named in
* `opts` come back empty.
*/
function buildMasterDataQueue(opts: {
direct?: Record<string, unknown[]>
via?: Record<string, { parents: unknown[]; children: unknown[] }>
}): { data: unknown }[] {
return MASTER_DATA_DUMP_TABLES.flatMap((t): { data: unknown }[] => {
if (t.via) {
const spec = opts.via?.[t.name]
if (!spec || spec.parents.length === 0) return [{ data: [] }]
return [{ data: spec.parents }, { data: spec.children }]
}
return [{ data: opts.direct?.[t.name] ?? [] }]
})
}
describe('generateFullArchive', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
let findCall: ReturnType<typeof createQueuedMockSupabase>['findCall']
beforeEach(() => {
vi.clearAllMocks()
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
findCall = mock.findCall
})
describe('scope: period', () => {
it('generates a ZIP with expected file structure', async () => {
enqueueMany([
{ data: COMPANY_ROW }, // company_settings
{ data: PERIOD_2024 }, // fiscal_periods (single)
{ data: [] }, // document_attachments
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('rapporter/saldobalans.json')).not.toBeNull()
expect(zip.file('rapporter/resultatrakning.json')).not.toBeNull()
expect(zip.file('rapporter/balansrakning.json')).not.toBeNull()
expect(zip.file('rapporter/huvudbok.json')).not.toBeNull()
expect(zip.file('rapporter/grundbok.json')).not.toBeNull()
expect(zip.file('rapporter/momsdeklaration.json')).not.toBeNull()
expect(zip.file('dokument/manifest.json')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
// Human-readable layer: CSV twins + the Swedish README.
expect(zip.file('rapporter/saldobalans.csv')).not.toBeNull()
expect(zip.file('rapporter/resultatrakning.csv')).not.toBeNull()
expect(zip.file('rapporter/balansrakning.csv')).not.toBeNull()
expect(zip.file('rapporter/huvudbok.csv')).not.toBeNull()
const readme = zip.file('LÄSMIG.txt')
expect(readme).not.toBeNull()
const readmeText = await readme!.async('text')
expect(readmeText).toContain('Test AB')
expect(readmeText).toContain('Räkenskapsår')
})
it('handles missing documents gracefully', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: PERIOD_2024 },
{
data: [
{
id: 'doc-1',
file_name: 'receipt.pdf',
storage_path: 'documents/user-1/receipt.pdf',
journal_entry_id: 'entry-1',
journal_entries: {
voucher_number: 17,
voucher_series: 'A',
entry_date: '2024-03-15',
},
},
],
},
{ data: [{ id: 'entry-1', fiscal_period_id: PERIOD_2024.id }] },
])
supabase.storage.from = vi.fn().mockReturnValue({
download: vi.fn().mockResolvedValue({
data: null,
error: { message: 'File not found' },
}),
})
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
const zip = await JSZip.loadAsync(buffer)
const manifestFile = zip.file('dokument/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(1)
expect(manifest[0].status).toBe('error')
expect(manifest[0].error).toBe('File not found')
expect(manifest[0].fiscal_period_id).toBe(PERIOD_2024.id)
// New manifest fields populated even on error (path is computed before download)
expect(manifest[0].voucher_number).toBe('A17')
expect(manifest[0].entry_date).toBe('2024-03-15')
expect(manifest[0].zip_path).toBe('dokument/2024/A17_receipt.pdf')
})
it('falls back to the company-scoped key when the stored pointer is stale', async () => {
// A concurrent Phase B backfill re-homed the object mid-run: the
// legacy pointer 404s but the company-scoped copy exists. The archive
// must contain the bytes, not a manifest error.
enqueueMany([
{ data: COMPANY_ROW },
{ data: PERIOD_2024 },
{
data: [
{
id: 'doc-1',
file_name: 'receipt.pdf',
storage_path: 'documents/user-1/receipt.pdf',
journal_entry_id: 'entry-1',
journal_entries: {
voucher_number: 17,
voucher_series: 'A',
entry_date: '2024-03-15',
},
},
],
},
{ data: [{ id: 'entry-1', fiscal_period_id: PERIOD_2024.id }] },
])
const download = vi.fn(async (path: string) =>
path === 'documents/company-1/user-1/receipt.pdf'
? { data: new Blob(['receipt bytes']), error: null }
: { data: null, error: { message: 'Object not found' } },
)
supabase.storage.from = vi.fn().mockReturnValue({ download })
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
const zip = await JSZip.loadAsync(buffer)
const manifest = JSON.parse(await zip.file('dokument/manifest.json')!.async('text'))
expect(manifest).toHaveLength(1)
expect(manifest[0].status).toBe('downloaded')
expect(zip.file('dokument/2024/A17_receipt.pdf')).not.toBeNull()
// Stored pointer first, alternate layout second.
expect(download).toHaveBeenNthCalledWith(1, 'documents/user-1/receipt.pdf')
expect(download).toHaveBeenNthCalledWith(2, 'documents/company-1/user-1/receipt.pdf')
})
it('skips documents when include_documents is false', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: PERIOD_2024 },
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
include_documents: false,
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('dokument/manifest.json')).toBeNull()
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
})
it('throws when fiscal period not found', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: null },
])
await expect(
generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: 'nonexistent',
})
).rejects.toThrow('Fiscal period not found')
})
it('filters audit trail by period dates', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: PERIOD_2024 },
{ data: [] },
])
await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
expect(mockGetAuditLog).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({
from_date: PERIOD_2024.period_start,
to_date: `${PERIOD_2024.period_end}T23:59:59.999Z`,
})
)
})
it("includes audit rows for the period's entries booked outside the window", async () => {
// Bokslut reality: the FY2024 year-end entry is committed in March 2025,
// so its audit rows fall outside the period's date window. The year's
// archive must still carry them (BFNAR 2013:2 kap 8).
const inWindow: AuditLogEntry = {
id: 'audit-1',
user_id: 'user-1',
company_id: 'company-1',
action: 'INSERT',
table_name: 'journal_entries',
record_id: 'e-1',
actor_id: 'user-1',
actor_type: null,
actor_label: null,
old_state: null,
new_state: null,
description: 'Created journal_entries record',
created_at: '2024-06-01T10:00:00Z',
}
const outOfWindowCommit: AuditLogEntry = {
...inWindow,
id: 'audit-2',
action: 'COMMIT',
description: 'Committed journal entry A55',
created_at: '2025-03-15T09:00:00Z',
}
// Line audit rows carry company_id NULL (write_audit_log finds no
// company_id column on journal_entry_lines).
const lineRow: AuditLogEntry = {
...inWindow,
id: 'audit-3',
company_id: null,
table_name: 'journal_entry_lines',
record_id: 'l-1',
created_at: '2025-03-15T09:00:01Z',
}
mockGetAuditLog.mockResolvedValue({ data: [inWindow], count: 1 })
enqueueMany([
{ data: COMPANY_ROW }, // company_settings
{ data: PERIOD_2024 }, // fiscal_periods
{ data: [] }, // document_attachments
{ data: [{ id: 'e-1' }] }, // journal_entries ids for the period
{ data: [{ id: 'l-1' }] }, // journal_entry_lines ids
// audit_log by record id: returns the in-window row again (dedupe)
// plus the two out-of-window rows
{ data: [inWindow, outOfWindowCommit, lineRow] },
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'period',
period_id: PERIOD_2024.id,
})
const zip = await JSZip.loadAsync(buffer)
const history = JSON.parse(
await zip.file('revision/behandlingshistorik.json')!.async('text')
) as Array<{ id: string }>
// Deduped: audit-1 appears once despite arriving via both fetches.
expect(history).toHaveLength(3)
const ids = history.map((h) => h.id)
expect(ids).toContain('audit-2')
expect(ids).toContain('audit-3')
// Newest first, matching getAuditLog's output order.
expect(ids[0]).toBe('audit-3')
expect(ids[2]).toBe('audit-1')
})
})
describe('scope: all', () => {
it('generates per-period SIE files and report subfolders', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2023, PERIOD_2024] }, // fiscal_periods (list for fetchAllPeriods)
{ data: [] }, // document_attachments
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('sie/2023-01-01_2023-12-31.se')).not.toBeNull()
expect(zip.file('sie/2024-01-01_2024-12-31.se')).not.toBeNull()
expect(zip.file('rapporter/2023-01-01_2023-12-31/saldobalans.json')).not.toBeNull()
expect(zip.file('rapporter/2024-01-01_2024-12-31/saldobalans.json')).not.toBeNull()
expect(zip.file('rapporter/2024-01-01_2024-12-31/saldobalans.csv')).not.toBeNull()
expect(zip.file('rapporter/2024-01-01_2024-12-31/huvudbok.csv')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
const readmeText = await zip.file('LÄSMIG.txt')!.async('text')
expect(readmeText).toContain('Hela bokföringen')
// No root bokforing.se in all-mode
expect(zip.file('bokforing.se')).toBeNull()
})
it('does not filter audit trail by date in all-mode', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{ data: [] },
])
await generateFullArchive(supabase as any, 'company-1', { scope: 'all' })
const call = mockGetAuditLog.mock.calls[0]
expect(call[2]).not.toHaveProperty('from_date')
expect(call[2]).not.toHaveProperty('to_date')
})
it('tags each document with its fiscal_period_id across periods', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2023, PERIOD_2024] },
{
data: [
{
id: 'doc-2023',
file_name: 'r23.pdf',
storage_path: 'p/r23.pdf',
journal_entry_id: 'e-2023',
journal_entries: { voucher_number: 7, voucher_series: 'A', entry_date: '2023-06-01' },
},
{
id: 'doc-2024',
file_name: 'r24.pdf',
storage_path: 'p/r24.pdf',
journal_entry_id: 'e-2024',
journal_entries: { voucher_number: 12, voucher_series: 'B', entry_date: '2024-08-20' },
},
],
},
{
data: [
{ id: 'e-2023', fiscal_period_id: PERIOD_2023.id },
{ id: 'e-2024', fiscal_period_id: PERIOD_2024.id },
],
},
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const manifestFile = zip.file('dokument/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(2)
const byId = Object.fromEntries(
(
manifest as Array<{
document_id: string
fiscal_period_id: string | null
voucher_number: string | null
zip_path: string | null
}>
).map((m) => [m.document_id, m])
)
expect(byId['doc-2023'].fiscal_period_id).toBe(PERIOD_2023.id)
expect(byId['doc-2024'].fiscal_period_id).toBe(PERIOD_2024.id)
expect(byId['doc-2023'].voucher_number).toBe('A7')
expect(byId['doc-2024'].voucher_number).toBe('B12')
expect(byId['doc-2023'].zip_path).toBe('dokument/2023/A7_r23.pdf')
expect(byId['doc-2024'].zip_path).toBe('dokument/2024/B12_r24.pdf')
// Files actually written under the new path
expect(zip.file('dokument/2023/A7_r23.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/B12_r24.pdf')).not.toBeNull()
})
it('routes draft entries and orphans to dokument/_okopplade and disambiguates collisions', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{
data: [
// True orphan: uploaded but never linked to any entry. Backups
// must still carry it (inbox items are räkenskapsinformation).
{
id: 'doc-orphan',
file_name: 'inbox.pdf',
storage_path: 'p/inbox.pdf',
journal_entry_id: null,
journal_entries: null,
},
// Draft entry: journal_entry_id present but voucher_number is null
{
id: 'doc-draft',
file_name: 'invoice.pdf',
storage_path: 'p/invoice.pdf',
journal_entry_id: 'e-draft',
journal_entries: { voucher_number: null, voucher_series: 'A', entry_date: null },
},
// Two posted docs that collide on the same voucher+filename
{
id: 'doc-collide-1234abcd-ee',
file_name: 'kvitto.pdf',
storage_path: 'p/kvitto-1.pdf',
journal_entry_id: 'e-posted',
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
},
{
id: 'doc-collide-5678efgh-ff',
file_name: 'kvitto.pdf',
storage_path: 'p/kvitto-2.pdf',
journal_entry_id: 'e-posted',
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
},
],
},
// entryIdToPeriodId map: draft and posted both resolve to PERIOD_2024
{
data: [
{ id: 'e-draft', fiscal_period_id: PERIOD_2024.id },
{ id: 'e-posted', fiscal_period_id: PERIOD_2024.id },
],
},
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const manifest = JSON.parse(await zip.file('dokument/manifest.json')!.async('text')) as Array<{
document_id: string
voucher_number: string | null
zip_path: string | null
}>
const byId = Object.fromEntries(manifest.map((m) => [m.document_id, m]))
// Unlinked orphan -> _okopplade, included in the backup
expect(byId['doc-orphan'].voucher_number).toBeNull()
expect(byId['doc-orphan'].zip_path).toBe('dokument/_okopplade/inbox.pdf')
// Draft -> _okopplade, no voucher prefix
expect(byId['doc-draft'].voucher_number).toBeNull()
expect(byId['doc-draft'].zip_path).toBe('dokument/_okopplade/invoice.pdf')
// First posted doc gets the canonical path
expect(byId['doc-collide-1234abcd-ee'].zip_path).toBe('dokument/2024/A5_kvitto.pdf')
// Second posted doc gets the id-suffix disambiguation before the extension
expect(byId['doc-collide-5678efgh-ff'].zip_path).toBe(
'dokument/2024/A5_kvitto_doc-coll.pdf'
)
// All files exist in the ZIP
expect(zip.file('dokument/_okopplade/inbox.pdf')).not.toBeNull()
expect(zip.file('dokument/_okopplade/invoice.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/A5_kvitto.pdf')).not.toBeNull()
expect(zip.file('dokument/2024/A5_kvitto_doc-coll.pdf')).not.toBeNull()
})
it('throws when no fiscal periods exist', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [] },
])
await expect(
generateFullArchive(supabase as any, 'company-1', { scope: 'all' })
).rejects.toThrow('No fiscal periods found')
})
it('includes imported SIE source files and master-data dumps in all-mode', async () => {
const importRow = {
id: 'import-1',
filename: 'original.se',
file_hash: 'abc123',
file_storage_path: 'company-1/import-1.se',
org_number: '5560000000',
company_name: 'Test AB',
sie_type: 4,
fiscal_year_start: '2024-01-01',
fiscal_year_end: '2024-12-31',
accounts_count: 42,
transactions_count: 120,
status: 'completed',
fiscal_period_id: PERIOD_2024.id,
imported_at: '2024-11-01T10:00:00Z',
created_at: '2024-11-01T09:55:00Z',
}
// Master-data dump runs sequentially over MASTER_DATA_DUMP_TABLES.
// Direct tables issue one query; via-tables issue a parent-id query and,
// when parents exist, one chunked child query.
const masterDataQueue = buildMasterDataQueue({
direct: {
customers: [{ id: 'cust-1', name: 'Acme AB' }],
company_settings: [COMPANY_ROW],
},
via: {
invoice_items: {
parents: [{ id: 'inv-1', currency: 'SEK', exchange_rate: null }],
children: [{ id: 'item-1', invoice_id: 'inv-1', description: 'Konsulttid' }],
},
},
})
enqueueMany([
{ data: COMPANY_ROW }, // fetchCompany
{ data: [PERIOD_2024] }, // fetchAllPeriods
{ data: [] }, // document_attachments
{ data: [importRow] }, // sie_imports
{ data: [{ source_account: '9999', target_account: '1510' }] }, // sie_account_mappings
...masterDataQueue,
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const originalFile = zip.file('sie/original/import-1_original.se')
expect(originalFile).not.toBeNull()
const manifestFile = zip.file('sie/original/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest[0].import_id).toBe('import-1')
expect(manifest[0].status).toBe('downloaded')
const imports = JSON.parse(await zip.file('sie/imports.json')!.async('text'))
expect(imports[0].filename).toBe('original.se')
expect(zip.file('sie/account_mappings.json')).not.toBeNull()
// Every table in the dump contract gets a file, even when empty.
for (const t of MASTER_DATA_DUMP_TABLES) {
expect(zip.file(`data/${t.file}`), `data/${t.file}`).not.toBeNull()
}
const customers = JSON.parse(await zip.file('data/customers.json')!.async('text'))
expect(customers).toEqual([{ id: 'cust-1', name: 'Acme AB' }])
// Child table fetched via parent ids (invoice_items has no company_id).
// A SEK company's rows are unchanged apart from the appended unit.
const items = JSON.parse(await zip.file('data/invoice_items.json')!.async('text'))
expect(items).toEqual([
{
id: 'item-1',
invoice_id: 'inv-1',
description: 'Konsulttid',
invoice_currency: 'SEK',
invoice_exchange_rate: null,
},
])
expect(supabase.rpc).toHaveBeenCalledWith(
'export_invoice_delivery_evidence',
{ p_company_id: 'company-1' },
)
})
it('makes a foreign-currency invoice line readable without joining the parent', async () => {
// A revisor opening data/invoice_items.json must be able to tell that
// line_total 1000 is EUR, not SEK. The row's own `unit` column says
// "st" (the quantity unit), so nothing in the file used to state the
// money unit: it was only recoverable by joining data/invoices.json.
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{ data: [] }, // document_attachments
{ data: [] }, // sie_imports
{ data: [] }, // sie_account_mappings
...buildMasterDataQueue({
via: {
invoice_items: {
parents: [
{ id: 'inv-eur', currency: 'EUR', exchange_rate: 11.23 },
{ id: 'inv-sek', currency: 'SEK', exchange_rate: null },
],
children: [
{
id: 'item-eur',
invoice_id: 'inv-eur',
description: 'Konsulttid',
quantity: 10,
unit: 'st',
unit_price: 100,
line_total: 1000,
vat_amount: 250,
},
{
id: 'item-sek',
invoice_id: 'inv-sek',
description: 'Support',
line_total: 500,
vat_amount: 125,
},
],
},
},
}),
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const items = JSON.parse(
await zip.file('data/invoice_items.json')!.async('text')
) as Array<Record<string, unknown>>
const byId = Object.fromEntries(items.map((i) => [i.id as string, i]))
expect(byId['item-eur'].invoice_currency).toBe('EUR')
expect(byId['item-eur'].invoice_exchange_rate).toBe(11.23)
// The SEK line says SEK explicitly rather than leaving it implied.
expect(byId['item-sek'].invoice_currency).toBe('SEK')
expect(byId['item-sek'].invoice_exchange_rate).toBeNull()
// Every money-bearing row states its unit: no row is ambiguous.
for (const item of items) {
expect(item, `row ${item.id} has no unit`).toHaveProperty('invoice_currency')
expect(item.invoice_currency).not.toBeNull()
}
// Additive: the row keeps every column it shipped with before, and the
// unit is appended rather than replacing anything.
expect(byId['item-eur']).toMatchObject({
id: 'item-eur',
invoice_id: 'inv-eur',
description: 'Konsulttid',
quantity: 10,
unit: 'st',
unit_price: 100,
line_total: 1000,
vat_amount: 250,
})
})
it('carries the unit on supplier invoice lines and receipt lines too', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{ data: [] }, // document_attachments
{ data: [] }, // sie_imports
{ data: [] }, // sie_account_mappings
...buildMasterDataQueue({
via: {
supplier_invoice_items: {
parents: [{ id: 'sinv-1', currency: 'USD', exchange_rate: 9.87 }],
children: [
{ id: 'sitem-1', supplier_invoice_id: 'sinv-1', line_total: 200 },
],
},
// receipts has no exchange_rate column: currency alone.
receipt_line_items: {
parents: [{ id: 'rec-1', currency: 'NOK' }],
children: [{ id: 'rline-1', receipt_id: 'rec-1', line_total: 49 }],
},
},
}),
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const supplierItems = JSON.parse(
await zip.file('data/supplier_invoice_items.json')!.async('text')
)
expect(supplierItems[0].supplier_invoice_currency).toBe('USD')
expect(supplierItems[0].supplier_invoice_exchange_rate).toBe(9.87)
const receiptLines = JSON.parse(
await zip.file('data/receipt_line_items.json')!.async('text')
)
expect(receiptLines[0].receipt_currency).toBe('NOK')
expect(receiptLines[0]).not.toHaveProperty('receipt_exchange_rate')
})
it('does not invent a unit for rot/rut payout items (parent has no currency)', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{ data: [] }, // document_attachments
{ data: [] }, // sie_imports
{ data: [] }, // sie_account_mappings
...buildMasterDataQueue({
via: {
rot_rut_payout_request_items: {
parents: [{ id: 'req-1' }],
children: [
{ id: 'ritem-1', request_id: 'req-1', requested_amount: 5000 },
],
},
},
}),
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const rows = JSON.parse(
await zip.file('data/rot_rut_payout_request_items.json')!.async('text')
)
// HUS-avdrag is SEK by statute and the parent carries no currency
// column, so there is nothing to copy: the row stays as the DB has it.
expect(rows).toEqual([
{ id: 'ritem-1', request_id: 'req-1', requested_amount: 5000 },
])
})
// The archive is what a company leaving Accounted keeps as its BFL
// 7-year record. A representation answer given in chat is documented in
// full only in invoice_inbox_items.channel_context: the verifikat line
// caps at 220 chars and drops whole names ("… och N till"), while
// Skatteverket wants every deltagare. So the answers must be exported.
it('exports the chat answers behind a verifikat, in full', async () => {
const participants = Array.from({ length: 10 }, (_, i) => ({
name: `Deltagare Efternamnsson ${i + 1}`,
company: 'Företagsnamnet Aktiebolag',
}))
const inboxRow = {
id: 'inbox-1',
created_at: '2024-06-01T10:00:00Z',
source: 'whatsapp',
status: 'confirmed',
document_id: 'doc-1',
matched_transaction_id: 'tx-1',
created_journal_entry_id: 'je-1',
created_supplier_invoice_id: null,
channel_context: {
channel: 'whatsapp',
representation: {
participants,
purpose: 'avtalsförhandling',
event_date: '2024-06-01',
raw_answer: 'tio personer, avtalsförhandling',
answered_at: '2024-06-01T18:00:00Z',
},
},
}
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{ data: [] }, // document_attachments
{ data: [] }, // sie_imports
{ data: [] }, // sie_account_mappings
...buildMasterDataQueue({ direct: { invoice_inbox_items: [inboxRow] } }),
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
})
const zip = await JSZip.loadAsync(buffer)
const rows = JSON.parse(
await zip.file('data/invoice_inbox_items.json')!.async('text')
) as Array<Record<string, any>>
// Every deltagare survives, not the three that fit on the verifikat line.
expect(rows[0].channel_context.representation.participants).toHaveLength(10)
expect(rows[0].channel_context.representation.raw_answer).toBe(
'tio personer, avtalsförhandling'
)
// Tied to what was booked from it, so a revisor can find the verifikat.
expect(rows[0].created_journal_entry_id).toBe('je-1')
// A projection, not the whole row: inbox workflow state (email bodies,
// OCR output, error messages) stays out of the archive.
const select = findCall('invoice_inbox_items', 'select')?.[0] as string
expect(select).toContain('channel_context')
expect(select).toContain('created_journal_entry_id')
expect(select).not.toContain('email_body_text')
expect(select).not.toContain('extracted_data')
expect(select).not.toBe('*')
})
it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => {
enqueueMany([
{ data: COMPANY_ROW },
{ data: [PERIOD_2024] },
{
data: [
{
id: 'import-1',
filename: 'x.se',
file_hash: 'h',
file_storage_path: 'company-1/import-1.se',
status: 'completed',
imported_at: '2024-11-01T10:00:00Z',
created_at: '2024-11-01T09:55:00Z',
},
],
}, // sie_imports
{ data: [] }, // sie_account_mappings
])
const buffer = await generateFullArchive(supabase as any, 'company-1', {
scope: 'all',
include_documents: false,
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('sie/imports.json')).not.toBeNull()
expect(zip.file('sie/account_mappings.json')).not.toBeNull()
expect(zip.file('sie/original/import-1_x.se')).toBeNull()
expect(zip.file('sie/original/manifest.json')).toBeNull()
expect(zip.file('data/customers.json')).not.toBeNull()
})
})
})
describe('generateBaseDataArchive', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
beforeEach(() => {
vi.clearAllMocks()
mockGetAuditLog.mockResolvedValue({ data: [], count: 0 })
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
})
it('bundles unlinked documents, master data, SIE sources and the audit trail', async () => {
const masterDataQueue = buildMasterDataQueue({
direct: { customers: [{ id: 'cust-1', name: 'Acme AB' }] },
})
enqueueMany([
{ data: COMPANY_ROW }, // fetchCompany
{ data: [PERIOD_2024] }, // fetchAllPeriods
{
data: [
// Orphan: goes into Grunddata.
{
id: 'doc-orphan',
file_name: 'inbox.pdf',
storage_path: 'p/inbox.pdf',
journal_entry_id: null,
journal_entries: null,
},
// Linked to a posted entry: belongs to the period archive, not here.
{
id: 'doc-linked',
file_name: 'kvitto.pdf',
storage_path: 'p/kvitto.pdf',
journal_entry_id: 'e-1',
journal_entries: { voucher_number: 5, voucher_series: 'A', entry_date: '2024-05-01' },
},
],
}, // document_attachments
{ data: [{ id: 'e-1', fiscal_period_id: PERIOD_2024.id }] }, // entry->period map
{ data: [] }, // sie_imports
{ data: [] }, // sie_account_mappings
...masterDataQueue,
])
const buffer = await generateBaseDataArchive(supabase as any, 'company-1')
const zip = await JSZip.loadAsync(buffer)
const manifest = JSON.parse(await zip.file('dokument/manifest.json')!.async('text'))
expect(manifest).toHaveLength(1)
expect(manifest[0].document_id).toBe('doc-orphan')
expect(zip.file('dokument/_okopplade/inbox.pdf')).not.toBeNull()
expect(zip.file('data/customers.json')).not.toBeNull()
expect(zip.file('sie/imports.json')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
const readme = await zip.file('LÄSMIG.txt')!.async('text')
expect(readme).toContain('Grunddata')
// Period-scoped content stays out of Grunddata.
expect(zip.file('bokforing.se')).toBeNull()
expect(zip.file('rapporter/saldobalans.json')).toBeNull()
})
})
describe('estimateArchiveSize', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
beforeEach(() => {
vi.clearAllMocks()
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
})
it('sums document file_size_bytes in all-mode plus overhead', async () => {
enqueueMany([
{
data: [
{ file_size_bytes: 1_000_000, journal_entry_id: 'e1' },
{ file_size_bytes: 2_500_000, journal_entry_id: 'e2' },
],
count: 2,
},
])
const result = await estimateArchiveSize(supabase as any, 'company-1', 'all')
expect(result.document_bytes).toBe(3_500_000)
expect(result.document_count).toBe(2)
// overhead is +8 MB
expect(result.total_bytes).toBe(3_500_000 + 8 * 1024 * 1024)
})
it('returns overhead only when no documents in scope', async () => {
enqueueMany([
{ data: [], count: 0 }, // journal_entries for periodEntryIds
{ data: [], count: 0 }, // document_attachments
])
const result = await estimateArchiveSize(supabase as any, 'company-1', 'period', 'p-1')
expect(result.document_bytes).toBe(0)
expect(result.document_count).toBe(0)
expect(result.total_bytes).toBe(8 * 1024 * 1024)
})
it('paginates the all-mode document read past the page cap', async () => {
const firstPage = Array.from({ length: 1000 }, () => ({ file_size_bytes: 1_000 }))
enqueueMany([
{ data: firstPage }, // full first page forces a second fetch
{ data: [{ file_size_bytes: 1_000 }] },
])
const result = await estimateArchiveSize(supabase as any, 'company-1', 'all')
expect(result.document_count).toBe(1001)
expect(result.document_bytes).toBe(1_001_000)
})
it('chunks the period-mode entry-id filter and sums across chunks', async () => {
// 250 posted entries -> three IN() chunks of max 100 ids. An unchunked
// implementation consumes a single document response and undercounts.
const entryIds = Array.from({ length: 250 }, (_, i) => ({ id: `e${i}` }))
enqueueMany([
{ data: entryIds }, // journal_entries for periodEntryIds
{ data: [{ file_size_bytes: 100 }] }, // chunk 1
{ data: [{ file_size_bytes: 200 }] }, // chunk 2
{ data: [{ file_size_bytes: 300 }] }, // chunk 3
])
const result = await estimateArchiveSize(supabase as any, 'company-1', 'period', 'p-1')
expect(result.document_count).toBe(3)
expect(result.document_bytes).toBe(600)
})
})