29240738fa
- 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>
192 lines
6.0 KiB
TypeScript
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' })
|
|
}
|