Feat/cloud backup (#277)
* feat: cloud backup to Google Drive + full-archive all-scope Adds a cloud-backup extension that uploads a full-company backup ZIP to the user's own Google Drive via OAuth (drive.file scope only). Refresh tokens are AES-256-GCM encrypted before being stored in extension_data. The full-archive export gains a scope=all mode for whole-company backups (per-period SIE under sie/, per-period rapporter/ subfolders, flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size guard short-circuits generation before the platform response limit. Also fixes a latent bug in lib/core/audit/audit-service.ts where the parameter was named userId while the query filtered by company_id; the audit-trail API route was passing user.id so audit queries returned empty unless user and company shared a UUID. Drive-by: scope the dashboard "fresh start" localStorage key per companyId so dismissing the setup checklist in one company no longer carries over to others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address review comments on cloud backup + archive export - Extend audit trail to_date to end-of-day so last-day entries aren't silently excluded from period-scoped archives. - Apply 413 size-limit guard regardless of include_documents, using the overhead-only figure when documents are excluded. - Use crypto.randomUUID() for Drive multipart boundary to eliminate any collision risk with ZIP payload bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: migrate legacy setup-gate localStorage keys on dashboard Users who previously dismissed the setup checklist via the old global erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after the switch to a company-scoped key. Fall back to the legacy keys on read and migrate them to the scoped key on first hit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: update customer email handling and anonymization rules in supportmail-to-ticket skill * test: update audit trail to_date expectation for end-of-day timestamp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
2ea5a72b3d
commit
d708a85d4c
@@ -19,11 +19,11 @@ export interface AuditLogFilters {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated audit log entries for a user
|
||||
* Get paginated audit log entries for a company
|
||||
*/
|
||||
export async function getAuditLog(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
filters: AuditLogFilters = {}
|
||||
): Promise<{ data: AuditLogEntry[]; count: number }> {
|
||||
const page = filters.page ?? 1
|
||||
@@ -33,7 +33,7 @@ export async function getAuditLog(
|
||||
let query = supabase
|
||||
.from('audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1)
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function getAuditLog(
|
||||
*/
|
||||
export async function getEntityHistory(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
tableName: string,
|
||||
recordId: string
|
||||
): Promise<AuditLogEntry[]> {
|
||||
@@ -78,7 +78,7 @@ export async function getEntityHistory(
|
||||
const { data, error } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('table_name', tableName)
|
||||
.eq('record_id', recordId)
|
||||
.order('created_at', { ascending: true })
|
||||
@@ -96,7 +96,7 @@ export async function getEntityHistory(
|
||||
*/
|
||||
export async function getCorrectionChain(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
journalEntryId: string
|
||||
): Promise<AuditLogEntry[]> {
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function getCorrectionChain(
|
||||
.from('journal_entries')
|
||||
.select('id, reverses_id, reversed_by_id, correction_of_id')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
@@ -122,7 +122,7 @@ export async function getCorrectionChain(
|
||||
const { data: referencing } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.or(`reverses_id.eq.${journalEntryId},reversed_by_id.eq.${journalEntryId},correction_of_id.eq.${journalEntryId}`)
|
||||
|
||||
for (const ref of referencing || []) {
|
||||
@@ -133,7 +133,7 @@ export async function getCorrectionChain(
|
||||
const { data, error } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('company_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('table_name', 'journal_entries')
|
||||
.in('record_id', Array.from(relatedIds))
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should have 9 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(9)
|
||||
it('should have 10 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(10)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(9)
|
||||
expect(extensions.length).toBe(10)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
@@ -6,4 +6,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'arcim-migration',
|
||||
'tic',
|
||||
'mcp-server',
|
||||
'cloud-backup',
|
||||
])
|
||||
|
||||
@@ -5,6 +5,7 @@ import { emailExtension } from '@/extensions/general/email'
|
||||
import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
|
||||
import { ticExtension } from '@/extensions/general/tic'
|
||||
import { mcpServerExtension } from '@/extensions/general/mcp-server'
|
||||
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
enableBankingExtension,
|
||||
@@ -12,4 +13,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
arcimMigrationExtension,
|
||||
ticExtension,
|
||||
mcpServerExtension,
|
||||
cloudBackupExtension,
|
||||
]
|
||||
|
||||
@@ -68,5 +68,17 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"description": "Gör bokföring via Claude, Cursor eller annan MCP-klient",
|
||||
"longDescription": "Exponerar gnuboks bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor."
|
||||
},
|
||||
{
|
||||
"slug": "cloud-backup",
|
||||
"name": "Molnsynkronisering",
|
||||
"sector": "general",
|
||||
"category": "operations",
|
||||
"icon": "Cloud",
|
||||
"dataPattern": "manual",
|
||||
"description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||
"longDescription": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Gnubok skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"hasOwnData": true,
|
||||
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google."
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
|
||||
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
|
||||
'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ const SETTINGS_PANELS: Record<string, ComponentType> = {
|
||||
'enable-banking': dynamic(
|
||||
() => import('@/extensions/general/enable-banking/components/BankingSettingsPanel')
|
||||
),
|
||||
'cloud-backup': dynamic(
|
||||
() => import('@/extensions/general/cloud-backup/components/CloudBackupCard')
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import JSZip from 'jszip'
|
||||
import { generateFullArchive } from '../full-archive-export'
|
||||
import { generateFullArchive, estimateArchiveSize } from '../full-archive-export'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
|
||||
vi.mock('../sie-export', () => ({
|
||||
generateSIEExport: vi.fn().mockResolvedValue('#FLAGGA 0\n#PROGRAM "ERPBase"'),
|
||||
@@ -62,10 +63,257 @@ 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',
|
||||
trade_name: null,
|
||||
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,
|
||||
}
|
||||
|
||||
describe('generateFullArchive', () => {
|
||||
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
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ 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)
|
||||
})
|
||||
|
||||
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`,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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('revision/behandlingshistorik.json')).not.toBeNull()
|
||||
expect(zip.file('revision/systemdokumentation.json')).not.toBeNull()
|
||||
// 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' },
|
||||
{ id: 'doc-2024', file_name: 'r24.pdf', storage_path: 'p/r24.pdf', journal_entry_id: 'e-2024' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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 }>).map((m) => [
|
||||
m.document_id,
|
||||
m.fiscal_period_id,
|
||||
])
|
||||
)
|
||||
expect(byId['doc-2023']).toBe(PERIOD_2023.id)
|
||||
expect(byId['doc-2024']).toBe(PERIOD_2024.id)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateArchiveSize', () => {
|
||||
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
|
||||
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const mock = createQueuedMockSupabase()
|
||||
@@ -73,165 +321,35 @@ describe('generateFullArchive', () => {
|
||||
enqueueMany = mock.enqueueMany
|
||||
})
|
||||
|
||||
function enqueueStandardResponses(opts?: { includeDocuments?: boolean }) {
|
||||
// 1. fiscal_periods query
|
||||
it('sums document file_size_bytes in all-mode plus overhead', async () => {
|
||||
enqueueMany([
|
||||
{
|
||||
data: {
|
||||
id: 'period-1',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
user_id: 'user-1',
|
||||
},
|
||||
},
|
||||
// 2. company_settings query
|
||||
{
|
||||
data: {
|
||||
company_name: 'Test AB',
|
||||
org_number: '5566778899',
|
||||
moms_period: 'quarterly',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
if (opts?.includeDocuments !== false) {
|
||||
enqueueMany([
|
||||
// 3. document_attachments query
|
||||
{ data: [] },
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
it('generates a ZIP with expected file structure', async () => {
|
||||
enqueueStandardResponses()
|
||||
|
||||
const buffer = await generateFullArchive(supabase as any, 'company-1', {
|
||||
period_id: 'period-1',
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('handles missing documents gracefully', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
supabase = mock.supabase
|
||||
enqueueMany = mock.enqueueMany
|
||||
|
||||
enqueueMany([
|
||||
// fiscal_periods
|
||||
{
|
||||
data: {
|
||||
id: 'period-1',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
user_id: 'user-1',
|
||||
},
|
||||
},
|
||||
// company_settings
|
||||
{
|
||||
data: {
|
||||
company_name: 'Test AB',
|
||||
org_number: '5566778899',
|
||||
moms_period: 'quarterly',
|
||||
},
|
||||
},
|
||||
// document_attachments — one document
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
file_name: 'receipt.pdf',
|
||||
storage_path: 'documents/user-1/receipt.pdf',
|
||||
journal_entry_id: 'entry-1',
|
||||
},
|
||||
{ file_size_bytes: 1_000_000, journal_entry_id: 'e1' },
|
||||
{ file_size_bytes: 2_500_000, journal_entry_id: 'e2' },
|
||||
],
|
||||
},
|
||||
// journal_entries in period
|
||||
{
|
||||
data: [{ id: 'entry-1' }],
|
||||
count: 2,
|
||||
},
|
||||
])
|
||||
|
||||
// Mock storage download to fail
|
||||
supabase.storage.from = vi.fn().mockReturnValue({
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'File not found' },
|
||||
}),
|
||||
})
|
||||
const result = await estimateArchiveSize(supabase as any, 'company-1', 'all')
|
||||
|
||||
const buffer = await generateFullArchive(supabase as any, 'company-1', {
|
||||
period_id: 'period-1',
|
||||
})
|
||||
|
||||
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(result.document_bytes).toBe(3_500_000)
|
||||
expect(result.document_count).toBe(2)
|
||||
// overhead is +5 MB
|
||||
expect(result.total_bytes).toBe(3_500_000 + 5 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('skips documents when include_documents is false', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
supabase = mock.supabase
|
||||
enqueueMany = mock.enqueueMany
|
||||
|
||||
it('returns overhead only when no documents in scope', async () => {
|
||||
enqueueMany([
|
||||
// fiscal_periods
|
||||
{
|
||||
data: {
|
||||
id: 'period-1',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
user_id: 'user-1',
|
||||
},
|
||||
},
|
||||
// company_settings
|
||||
{
|
||||
data: {
|
||||
company_name: 'Test AB',
|
||||
org_number: '5566778899',
|
||||
moms_period: 'quarterly',
|
||||
},
|
||||
},
|
||||
{ data: [], count: 0 }, // journal_entries for periodEntryIds
|
||||
{ data: [], count: 0 }, // document_attachments
|
||||
])
|
||||
|
||||
const buffer = await generateFullArchive(supabase as any, 'company-1', {
|
||||
period_id: 'period-1',
|
||||
include_documents: false,
|
||||
})
|
||||
const result = await estimateArchiveSize(supabase as any, 'company-1', 'period', 'p-1')
|
||||
|
||||
const zip = await JSZip.loadAsync(buffer)
|
||||
|
||||
// Should not have dokument folder
|
||||
expect(zip.file('dokument/manifest.json')).toBeNull()
|
||||
// Should still have other files
|
||||
expect(zip.file('bokforing.se')).not.toBeNull()
|
||||
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('throws when fiscal period not found', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
supabase = mock.supabase
|
||||
enqueueMany = mock.enqueueMany
|
||||
|
||||
enqueueMany([{ data: null }])
|
||||
|
||||
await expect(
|
||||
generateFullArchive(supabase as any, 'company-1', { period_id: 'nonexistent' })
|
||||
).rejects.toThrow('Fiscal period not found')
|
||||
expect(result.document_bytes).toBe(0)
|
||||
expect(result.document_count).toBe(0)
|
||||
expect(result.total_bytes).toBe(5 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
|
||||
+345
-117
@@ -11,10 +11,11 @@ import { getAuditLog } from '@/lib/core/audit/audit-service'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { AuditLogEntry } from '@/types'
|
||||
|
||||
export interface FullArchiveOptions {
|
||||
period_id: string
|
||||
include_documents?: boolean
|
||||
}
|
||||
export type FullArchiveOptions =
|
||||
| { scope: 'period'; period_id: string; include_documents?: boolean }
|
||||
| { scope: 'all'; include_documents?: boolean }
|
||||
|
||||
export type ArchiveScope = FullArchiveOptions['scope']
|
||||
|
||||
interface DocumentManifestEntry {
|
||||
document_id: string
|
||||
@@ -22,6 +23,7 @@ interface DocumentManifestEntry {
|
||||
storage_path: string
|
||||
sha256_hash: string
|
||||
journal_entry_id: string | null
|
||||
fiscal_period_id: string | null
|
||||
version: number
|
||||
digitization_date: string | null
|
||||
upload_source: string | null
|
||||
@@ -31,133 +33,320 @@ interface DocumentManifestEntry {
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface FiscalPeriodRow {
|
||||
id: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
opening_balance_entry_id: string | null
|
||||
}
|
||||
|
||||
interface CompanyInfo {
|
||||
company_name: string | null
|
||||
trade_name: string | null
|
||||
org_number: string | null
|
||||
moms_period: string | null
|
||||
}
|
||||
|
||||
interface DocumentRow {
|
||||
id: string
|
||||
file_name: string
|
||||
storage_path: string
|
||||
journal_entry_id: string | null
|
||||
sha256_hash: string
|
||||
version: number
|
||||
digitization_date: string | null
|
||||
upload_source: string | null
|
||||
mime_type: string | null
|
||||
file_size_bytes: number | null
|
||||
}
|
||||
|
||||
interface PeriodReports {
|
||||
trialBalance: unknown
|
||||
incomeStatement: unknown
|
||||
balanceSheet: unknown
|
||||
generalLedger: unknown
|
||||
journalRegister: unknown
|
||||
vatDeclaration: unknown | null
|
||||
}
|
||||
|
||||
const REPORT_CONCURRENCY = 3
|
||||
const ARCHIVE_OVERHEAD_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Generate a full archive ZIP for a fiscal period.
|
||||
* Generate a full archive ZIP for a company.
|
||||
*
|
||||
* Contains SIE4 file, all financial reports, attached documents, and audit trail.
|
||||
* This fulfills the Swedish accounting law (BFL) requirement for complete archives.
|
||||
* `scope: 'period'` produces the single-period archive used by account/company
|
||||
* deletion flows: `bokforing.se`, flat `rapporter/*.json`, `dokument/*`, and
|
||||
* `revision/*`.
|
||||
*
|
||||
* `scope: 'all'` produces the "säkerhetsbackup" covering the entire company
|
||||
* history: one SIE4 file per period under `sie/`, per-period `rapporter/`
|
||||
* subfolders, a flat `dokument/` with manifest tagged by fiscal_period_id,
|
||||
* and an unfiltered `revision/behandlingshistorik.json`.
|
||||
*/
|
||||
export async function generateFullArchive(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: FullArchiveOptions
|
||||
): Promise<ArrayBuffer> {
|
||||
const { period_id, include_documents = true } = options
|
||||
const company = await fetchCompany(supabase, companyId)
|
||||
const periods =
|
||||
options.scope === 'all'
|
||||
? await fetchAllPeriods(supabase, companyId)
|
||||
: [await fetchSinglePeriod(supabase, companyId, options.period_id)]
|
||||
|
||||
// Fetch fiscal period
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', period_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
if (periods.length === 0) {
|
||||
throw new Error('No fiscal periods found')
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company } = await supabase
|
||||
const zip = new JSZip()
|
||||
|
||||
if (options.scope === 'all') {
|
||||
const sieFolder = zip.folder('sie')!
|
||||
const rapporterFolder = zip.folder('rapporter')!
|
||||
|
||||
for (let i = 0; i < periods.length; i += REPORT_CONCURRENCY) {
|
||||
const batch = periods.slice(i, i + REPORT_CONCURRENCY)
|
||||
await Promise.all(
|
||||
batch.map(async (period) => {
|
||||
const sie = await generateSIEExport(supabase, companyId, {
|
||||
fiscal_period_id: period.id,
|
||||
company_name: company.company_name || 'Unknown',
|
||||
trade_name: company.trade_name,
|
||||
org_number: company.org_number,
|
||||
program_name: 'ERPBase',
|
||||
})
|
||||
sieFolder.file(`${periodLabel(period)}.se`, sie)
|
||||
|
||||
const reports = await generatePeriodReports(supabase, companyId, period)
|
||||
const periodFolder = rapporterFolder.folder(periodLabel(period))!
|
||||
writeReports(periodFolder, reports)
|
||||
})
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const period = periods[0]
|
||||
const sie = await generateSIEExport(supabase, companyId, {
|
||||
fiscal_period_id: period.id,
|
||||
company_name: company.company_name || 'Unknown',
|
||||
trade_name: company.trade_name,
|
||||
org_number: company.org_number,
|
||||
program_name: 'ERPBase',
|
||||
})
|
||||
zip.file('bokforing.se', sie)
|
||||
|
||||
const reports = await generatePeriodReports(supabase, companyId, period)
|
||||
const rapporter = zip.folder('rapporter')!
|
||||
writeReports(rapporter, reports)
|
||||
}
|
||||
|
||||
if (options.include_documents !== false) {
|
||||
await writeDocuments(zip, supabase, companyId, periods, options.scope)
|
||||
}
|
||||
|
||||
const revision = zip.folder('revision')!
|
||||
|
||||
const auditFilters =
|
||||
options.scope === 'period'
|
||||
? {
|
||||
from_date: periods[0].period_start,
|
||||
to_date: `${periods[0].period_end}T23:59:59.999Z`,
|
||||
}
|
||||
: {}
|
||||
const auditEntries = await fetchAllAuditEntries(supabase, companyId, auditFilters)
|
||||
revision.file('behandlingshistorik.json', JSON.stringify(auditEntries, null, 2))
|
||||
|
||||
const systemDoc = await buildSystemDoc(supabase, companyId, periods, options.scope)
|
||||
revision.file('systemdokumentation.json', JSON.stringify(systemDoc, null, 2))
|
||||
|
||||
return zip.generateAsync({ type: 'arraybuffer' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the uncompressed size of the archive in bytes.
|
||||
*
|
||||
* Sums `file_size_bytes` across all documents in scope plus a fixed overhead
|
||||
* for SIE, reports, audit trail, and system documentation. Used by the API
|
||||
* route to short-circuit generation when the payload would exceed the
|
||||
* platform's response-size ceiling.
|
||||
*/
|
||||
export async function estimateArchiveSize(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
scope: ArchiveScope,
|
||||
periodId?: string
|
||||
): Promise<{ total_bytes: number; document_bytes: number; document_count: number }> {
|
||||
let query = supabase
|
||||
.from('document_attachments')
|
||||
.select('file_size_bytes, journal_entry_id', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
|
||||
if (scope === 'period') {
|
||||
if (!periodId) {
|
||||
throw new Error('period_id is required for scope=period')
|
||||
}
|
||||
const periodEntryIds = await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', periodId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.range(from, to)
|
||||
)
|
||||
const ids = periodEntryIds.map((e) => e.id)
|
||||
if (ids.length === 0) {
|
||||
return { total_bytes: ARCHIVE_OVERHEAD_BYTES, document_bytes: 0, document_count: 0 }
|
||||
}
|
||||
query = query.in('journal_entry_id', ids)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
throw new Error(`Failed to estimate archive size: ${error.message}`)
|
||||
}
|
||||
|
||||
const rows = (data as { file_size_bytes: number | null }[]) || []
|
||||
const documentBytes = rows.reduce((sum, r) => sum + (Number(r.file_size_bytes) || 0), 0)
|
||||
|
||||
return {
|
||||
total_bytes: documentBytes + ARCHIVE_OVERHEAD_BYTES,
|
||||
document_bytes: documentBytes,
|
||||
document_count: rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCompany(supabase: SupabaseClient, companyId: string): Promise<CompanyInfo> {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, trade_name, org_number, moms_period')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
if (!data) {
|
||||
throw new Error('Company settings not found')
|
||||
}
|
||||
return data as CompanyInfo
|
||||
}
|
||||
|
||||
const zip = new JSZip()
|
||||
async function fetchSinglePeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periodId: string
|
||||
): Promise<FiscalPeriodRow> {
|
||||
const { data } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, opening_balance_entry_id')
|
||||
.eq('id', periodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
// 1. SIE4 export
|
||||
const sieContent = await generateSIEExport(supabase, companyId, {
|
||||
fiscal_period_id: period_id,
|
||||
company_name: company.company_name || 'Unknown',
|
||||
trade_name: company.trade_name,
|
||||
org_number: company.org_number,
|
||||
program_name: 'ERPBase',
|
||||
})
|
||||
zip.file('bokforing.se', sieContent)
|
||||
if (!data) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
return data as FiscalPeriodRow
|
||||
}
|
||||
|
||||
// 2. Reports folder
|
||||
const rapporter = zip.folder('rapporter')!
|
||||
async function fetchAllPeriods(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<FiscalPeriodRow[]> {
|
||||
const rows = await fetchAllRows<FiscalPeriodRow>(({ from, to }) =>
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, opening_balance_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
return rows
|
||||
}
|
||||
|
||||
async function generatePeriodReports(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
period: FiscalPeriodRow
|
||||
): Promise<PeriodReports> {
|
||||
const [trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister] =
|
||||
await Promise.all([
|
||||
generateTrialBalance(supabase, companyId, period_id),
|
||||
generateIncomeStatement(supabase, companyId, period_id),
|
||||
generateBalanceSheet(supabase, companyId, period_id),
|
||||
generateGeneralLedger(supabase, companyId, period_id),
|
||||
generateJournalRegister(supabase, companyId, period_id),
|
||||
generateTrialBalance(supabase, companyId, period.id),
|
||||
generateIncomeStatement(supabase, companyId, period.id),
|
||||
generateBalanceSheet(supabase, companyId, period.id),
|
||||
generateGeneralLedger(supabase, companyId, period.id),
|
||||
generateJournalRegister(supabase, companyId, 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
|
||||
let vatDeclaration: unknown = null
|
||||
try {
|
||||
const startDate = new Date(period.period_start)
|
||||
const vatDeclaration = await calculateVatDeclaration(
|
||||
vatDeclaration = await calculateVatDeclaration(
|
||||
supabase,
|
||||
companyId,
|
||||
'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[] = []
|
||||
return { trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister, vatDeclaration }
|
||||
}
|
||||
|
||||
// Fetch document attachments linked to journal entries in this period
|
||||
// Wrapped in try/catch to match VAT section — a failed document fetch
|
||||
// should not prevent the rest of the archive from being generated.
|
||||
try {
|
||||
const documents = await fetchAllRows<{
|
||||
id: string; file_name: string; storage_path: string; journal_entry_id: string | null
|
||||
sha256_hash: string; version: number; digitization_date: string | null
|
||||
upload_source: string | null; mime_type: string | null; file_size_bytes: number | null
|
||||
}>(({ from, to }) =>
|
||||
function writeReports(folder: JSZip, reports: PeriodReports): void {
|
||||
folder.file('saldobalans.json', JSON.stringify(reports.trialBalance, null, 2))
|
||||
folder.file('resultatrakning.json', JSON.stringify(reports.incomeStatement, null, 2))
|
||||
folder.file('balansrakning.json', JSON.stringify(reports.balanceSheet, null, 2))
|
||||
folder.file('huvudbok.json', JSON.stringify(reports.generalLedger, null, 2))
|
||||
folder.file('grundbok.json', JSON.stringify(reports.journalRegister, null, 2))
|
||||
if (reports.vatDeclaration) {
|
||||
folder.file('momsdeklaration.json', JSON.stringify(reports.vatDeclaration, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDocuments(
|
||||
zip: JSZip,
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periods: FiscalPeriodRow[],
|
||||
scope: ArchiveScope
|
||||
): Promise<void> {
|
||||
const dokument = zip.folder('dokument')!
|
||||
const manifest: DocumentManifestEntry[] = []
|
||||
|
||||
try {
|
||||
const documents = await fetchAllRows<DocumentRow>(({ from, to }) =>
|
||||
supabase
|
||||
.from('document_attachments')
|
||||
.select('id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes')
|
||||
.select(
|
||||
'id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes'
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (documents.length > 0) {
|
||||
// Filter to entries in this period
|
||||
const periodEntryIds = await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', period_id)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.range(from, to)
|
||||
)
|
||||
const entryIdToPeriodId = await buildEntryToPeriodMap(supabase, companyId, periods, scope)
|
||||
|
||||
const periodEntryIdSet = new Set(periodEntryIds.map((e) => e.id))
|
||||
const periodDocuments = documents.filter(
|
||||
(d: { journal_entry_id: string | null }) => d.journal_entry_id && periodEntryIdSet.has(d.journal_entry_id)
|
||||
)
|
||||
const inScopeDocuments =
|
||||
scope === 'period'
|
||||
? documents.filter((d) => d.journal_entry_id && entryIdToPeriodId.has(d.journal_entry_id))
|
||||
: documents.filter((d) => d.journal_entry_id) // all-mode: keep every linked doc
|
||||
|
||||
for (const doc of inScopeDocuments) {
|
||||
const fiscalPeriodId = doc.journal_entry_id
|
||||
? entryIdToPeriodId.get(doc.journal_entry_id) ?? null
|
||||
: null
|
||||
|
||||
for (const doc of periodDocuments) {
|
||||
const baseManifest = {
|
||||
document_id: doc.id,
|
||||
file_name: doc.file_name,
|
||||
storage_path: doc.storage_path,
|
||||
sha256_hash: doc.sha256_hash,
|
||||
journal_entry_id: doc.journal_entry_id,
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
version: doc.version,
|
||||
digitization_date: doc.digitization_date,
|
||||
upload_source: doc.upload_source,
|
||||
@@ -180,13 +369,9 @@ export async function generateFullArchive(
|
||||
}
|
||||
|
||||
const buffer = await fileData.arrayBuffer()
|
||||
// Prefix with document ID to prevent duplicate filename collisions
|
||||
const zipFileName = `${doc.id}_${doc.file_name}`
|
||||
dokument.file(zipFileName, buffer)
|
||||
manifest.push({
|
||||
...baseManifest,
|
||||
status: 'downloaded',
|
||||
})
|
||||
manifest.push({ ...baseManifest, status: 'downloaded' })
|
||||
} catch (err) {
|
||||
manifest.push({
|
||||
...baseManifest,
|
||||
@@ -196,50 +381,90 @@ export async function generateFullArchive(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Document fetch failed — archive will still contain reports and audit trail
|
||||
}
|
||||
|
||||
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
} catch {
|
||||
// Document fetch failed — archive will still contain reports and audit trail
|
||||
}
|
||||
|
||||
// 4. Audit trail
|
||||
const revision = zip.folder('revision')!
|
||||
const allAuditEntries: AuditLogEntry[] = []
|
||||
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
async function buildEntryToPeriodMap(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periods: FiscalPeriodRow[],
|
||||
scope: ArchiveScope
|
||||
): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
const periodIds = periods.map((p) => p.id)
|
||||
if (periodIds.length === 0) return map
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
.select('id, fiscal_period_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
|
||||
if (scope === 'period') {
|
||||
query = query.eq('fiscal_period_id', periodIds[0])
|
||||
} else {
|
||||
query = query.in('fiscal_period_id', periodIds)
|
||||
}
|
||||
|
||||
const entries = await fetchAllRows<{ id: string; fiscal_period_id: string }>(({ from, to }) =>
|
||||
query.range(from, to)
|
||||
)
|
||||
|
||||
for (const entry of entries) {
|
||||
map.set(entry.id, entry.fiscal_period_id)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function fetchAllAuditEntries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
filters: { from_date?: string; to_date?: string }
|
||||
): Promise<AuditLogEntry[]> {
|
||||
const all: AuditLogEntry[] = []
|
||||
let page = 1
|
||||
const pageSize = 500
|
||||
|
||||
while (true) {
|
||||
const result = await getAuditLog(supabase, companyId, {
|
||||
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) {
|
||||
const result = await getAuditLog(supabase, companyId, { ...filters, page, pageSize })
|
||||
all.push(...result.data)
|
||||
if (all.length >= result.count || result.data.length < pageSize) {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
revision.file('behandlingshistorik.json', JSON.stringify(allAuditEntries, null, 2))
|
||||
async function buildSystemDoc(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periods: FiscalPeriodRow[],
|
||||
scope: ArchiveScope
|
||||
): Promise<Record<string, unknown>> {
|
||||
let voucherSeriesQuery = supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series, last_number, fiscal_period_id')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (scope === 'period') {
|
||||
voucherSeriesQuery = voucherSeriesQuery.eq('fiscal_period_id', periods[0].id)
|
||||
}
|
||||
|
||||
// 5. Systemdokumentation (BFNAR 2013:2 kap 8)
|
||||
const [accountsResult, voucherSeriesResult] = await Promise.all([
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_type, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number'),
|
||||
supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series, last_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', period_id),
|
||||
voucherSeriesQuery,
|
||||
])
|
||||
|
||||
const systemdokumentation = {
|
||||
return {
|
||||
system: {
|
||||
name: 'gnubok',
|
||||
description: 'Bokforingssystem for enskild firma och aktiebolag',
|
||||
@@ -249,10 +474,13 @@ export async function generateFullArchive(
|
||||
standard: 'BAS 2026',
|
||||
accounts: accountsResult.data || [],
|
||||
},
|
||||
verifikationsserier: (voucherSeriesResult.data || []).map((vs: { voucher_series: string; last_number: number }) => ({
|
||||
serie: vs.voucher_series,
|
||||
senaste_nummer: vs.last_number,
|
||||
})),
|
||||
verifikationsserier: (voucherSeriesResult.data || []).map(
|
||||
(vs: { voucher_series: string; last_number: number; fiscal_period_id?: string }) => ({
|
||||
serie: vs.voucher_series,
|
||||
senaste_nummer: vs.last_number,
|
||||
fiscal_period_id: vs.fiscal_period_id ?? null,
|
||||
})
|
||||
),
|
||||
behorighetskontroll: {
|
||||
description: 'Rollbaserad atkomstkontroll med owner/admin/member/viewer',
|
||||
mfa_stod: true,
|
||||
@@ -270,14 +498,14 @@ export async function generateFullArchive(
|
||||
export_format: 'SIE4',
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
fiscal_period: {
|
||||
id: period.id,
|
||||
start: period.period_start,
|
||||
end: period.period_end,
|
||||
},
|
||||
fiscal_periods: periods.map((p) => ({
|
||||
id: p.id,
|
||||
start: p.period_start,
|
||||
end: p.period_end,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
revision.file('systemdokumentation.json', JSON.stringify(systemdokumentation, null, 2))
|
||||
|
||||
return zip.generateAsync({ type: 'arraybuffer' })
|
||||
function periodLabel(period: FiscalPeriodRow): string {
|
||||
return `${period.period_start}_${period.period_end}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user