Files
accounted/lib/reports/full-archive-export.ts
T
Jakob Wennberg 29240738fa feat: add INK2 declaration, full archive export, AI consent gate, fix VAT declaration rutor
- Fix VAT declaration ruta mappings to match SKV 4700 form correctly
  (ruta 05 = total taxable sales, ruta 10/11/12 = output VAT per rate)
- Add INK2 declaration report for aktiebolag with SRU export
- Add full archive ZIP export for 7-year retention compliance
- Add AI consent gate requiring user approval before AI extension API calls
- Add DPA and privacy policy public pages
- Add audit trail API routes
- Update VAT registration threshold from 80k to 120k kr in onboarding
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:20:47 +01:00

192 lines
6.0 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import JSZip from 'jszip'
import { generateSIEExport } from './sie-export'
import { generateTrialBalance } from './trial-balance'
import { generateIncomeStatement } from './income-statement'
import { generateBalanceSheet } from './balance-sheet'
import { generateGeneralLedger } from './general-ledger'
import { generateJournalRegister } from './journal-register'
import { calculateVatDeclaration } from './vat-declaration'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditLogEntry } from '@/types'
export interface FullArchiveOptions {
period_id: string
include_documents?: boolean
}
interface DocumentManifestEntry {
file_name: string
storage_path: string
status: 'downloaded' | 'missing' | 'error'
error?: string
}
/**
* Generate a full archive ZIP for a fiscal period.
*
* Contains SIE4 file, all financial reports, attached documents, and audit trail.
* This fulfills the Swedish accounting law (BFL) requirement for complete archives.
*/
export async function generateFullArchive(
supabase: SupabaseClient,
userId: string,
options: FullArchiveOptions
): Promise<ArrayBuffer> {
const { period_id, include_documents = true } = options
// Fetch fiscal period
const { data: period } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', period_id)
.eq('user_id', userId)
.single()
if (!period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: company } = await supabase
.from('company_settings')
.select('company_name, org_number, moms_period')
.eq('user_id', userId)
.single()
if (!company) {
throw new Error('Company settings not found')
}
const zip = new JSZip()
// 1. SIE4 export
const sieContent = await generateSIEExport(supabase, userId, {
fiscal_period_id: period_id,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
program_name: 'ERPBase',
})
zip.file('bokforing.se', sieContent)
// 2. Reports folder
const rapporter = zip.folder('rapporter')!
const [trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister] =
await Promise.all([
generateTrialBalance(supabase, userId, period_id),
generateIncomeStatement(supabase, userId, period_id),
generateBalanceSheet(supabase, userId, period_id),
generateGeneralLedger(supabase, userId, period_id),
generateJournalRegister(supabase, userId, period_id),
])
rapporter.file('saldobalans.json', JSON.stringify(trialBalance, null, 2))
rapporter.file('resultatrakning.json', JSON.stringify(incomeStatement, null, 2))
rapporter.file('balansrakning.json', JSON.stringify(balanceSheet, null, 2))
rapporter.file('huvudbok.json', JSON.stringify(generalLedger, null, 2))
rapporter.file('grundbok.json', JSON.stringify(journalRegister, null, 2))
// VAT declaration — calculate for the full fiscal period as yearly
try {
const startDate = new Date(period.period_start)
const vatDeclaration = await calculateVatDeclaration(
supabase,
userId,
'yearly',
startDate.getFullYear(),
1
)
rapporter.file('momsdeklaration.json', JSON.stringify(vatDeclaration, null, 2))
} catch {
// VAT declaration may fail if no relevant entries exist — skip gracefully
}
// 3. Documents folder
if (include_documents) {
const dokument = zip.folder('dokument')!
const manifest: DocumentManifestEntry[] = []
// Fetch document attachments linked to journal entries in this period
const { data: documents } = await supabase
.from('document_attachments')
.select('id, file_name, storage_path, journal_entry_id')
.eq('user_id', userId)
.not('journal_entry_id', 'is', null)
if (documents && documents.length > 0) {
// Filter to entries in this period
const { data: periodEntryIds } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.eq('fiscal_period_id', period_id)
.in('status', ['posted', 'reversed'])
const periodEntryIdSet = new Set((periodEntryIds || []).map((e: { id: string }) => e.id))
const periodDocuments = documents.filter(
(d: { journal_entry_id: string | null }) => d.journal_entry_id && periodEntryIdSet.has(d.journal_entry_id)
)
for (const doc of periodDocuments) {
try {
const { data: fileData, error } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (error || !fileData) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'error',
error: error?.message || 'Download returned no data',
})
continue
}
const buffer = await fileData.arrayBuffer()
dokument.file(doc.file_name, buffer)
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'downloaded',
})
} catch (err) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
}
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
// 4. Audit trail
const revision = zip.folder('revision')!
const allAuditEntries: AuditLogEntry[] = []
let page = 1
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, userId, {
from_date: period.period_start,
to_date: period.period_end,
page,
pageSize,
})
allAuditEntries.push(...result.data)
if (allAuditEntries.length >= result.count || result.data.length < pageSize) {
break
}
page++
}
revision.file('behandlingshistorik.json', JSON.stringify(allAuditEntries, null, 2))
return zip.generateAsync({ type: 'arraybuffer' })
}