Files
accounted/lib/reports/full-archive-export.ts
T
Jakob Wennberg f776e375c5 feat(payments): supplier payment batch schema + pain.001 domain lib (betalfil 1/3) (#1500)
* feat(payments): supplier payment batch schema + pain.001 domain lib

Betalfil for leverantorsfakturor, part 1 of 3. New tables
supplier_payment_batches + supplier_payment_batch_items (RLS, immutable
item snapshots, FK RESTRICT on invoices), payee/reference resolution,
eligibility rules shared by preview and create, and a supplier-dialect
pain.001.001.03 generator (SESBA 9900 BGNR / 9960 BBAN / clearing BBAN,
SCOR for Luhn-valid OCR, Ustrd fallback, no SvcLvl/CtgyPurp).
Deterministic regeneration: msg_id derives from the batch id, CreDtTm
from created_at, so re-downloads are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(payments): use lib/money helpers instead of raw ore rounding

The naive-ore-round ratchet flags new Math.round(x*100)/100 sites;
roundOre/sumOre/ORE_TOLERANCE are the sanctioned forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): classify batch tables in full-archive contract + fixture

The no-phantom-columns contract requires every company-scoped table to
be triaged in full-archive-export; the batch rows are underlag for the
payments they initiated, so they dump with the archive. makeSupplier
gains the clearing/account columns the Supplier type now carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): harden batch integrity per review

Composite (id, company_id) FKs so items can never cross-link a batch and
an invoice from different companies; BEFORE UPDATE trigger keeps batches
immutable outside lifecycle + download metadata and one-way on
created -> cancelled; active-batch lookup now fails closed (an error no
longer reads as no active batches, which would have silently disabled
the duplicate-batch guard); today derives from Europe/Stockholm, not
UTC; pain.001 control sums add the amounts as rendered so CtrlSum always
equals sum(InstdAmt); event-bus reset in test hooks; Danske LB date
claim in DECISIONS verified against the primary page (the bot's 12 May
date is the alias-initiation date, not LB retirement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): bind cancellation metadata to the cancel transition

cancelled_at/cancelled_by may only be written by created -> cancelled;
cancelled_by may still become NULL so the FK's ON DELETE SET NULL keeps
working when the cancelling user's account is deleted (proven in pg).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 19:53:39 +02:00

1389 lines
54 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 { downloadDocumentObject } from '@/lib/core/documents/document-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getBranding } from '@/lib/branding/service'
import {
trialBalanceToCsv,
incomeStatementToCsv,
balanceSheetToCsv,
generalLedgerToCsv,
type TrialBalanceLike,
} from './archive-csv'
import { buildArchiveReadme, buildDriveFolderReadme } from './archive-readme'
import type { GeneralLedgerReport } from './general-ledger'
import type {
AuditLogEntry,
BalanceSheetReport,
IncomeStatementReport,
} from '@/types'
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
file_name: string
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
mime_type: string | null
file_size_bytes: number | null
// New fields (added to make ZIP entries sortable by verifikatnummer)
voucher_number: string | null
entry_date: string | null
zip_path: string | null
status: 'downloaded' | 'missing' | 'error'
error?: string
}
interface FiscalPeriodRow {
id: string
period_start: string
period_end: string
opening_balance_entry_id: string | null
}
interface CompanyInfo {
company_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
// Joined from journal_entries via journal_entry_id. May be null when the
// entry is a draft (no voucher_number yet) or when the doc is orphaned.
// PostgREST returns a single row as an object, not an array, when the FK
// is many-to-one, but we tolerate both shapes defensively.
journal_entries?:
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }
| { voucher_number: number | null; voucher_series: string | null; entry_date: string | null }[]
| null
}
interface PeriodReports {
trialBalance: unknown
incomeStatement: unknown
balanceSheet: unknown
generalLedger: unknown
journalRegister: unknown
vatDeclaration: unknown | null
}
const REPORT_CONCURRENCY = 3
// 5 MB for SIE + reports + audit + system doc, +3 MB headroom for master-data
// JSON dumps and raw imported SIE files (the bucket caps each file at 50 MB,
// but typical SIE4 files are tens of KB so a few MB covers most companies).
export const ARCHIVE_OVERHEAD_BYTES = 8 * 1024 * 1024
/** Documents included in an archive: per-period, everything, or only the rest. */
type DocumentMode = ArchiveScope | 'unlinked'
/**
* Generate a full archive ZIP for a company.
*
* `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 company = await fetchCompany(supabase, companyId)
const periods =
options.scope === 'all'
? await fetchAllPeriods(supabase, companyId)
: [await fetchSinglePeriod(supabase, companyId, options.period_id)]
if (periods.length === 0) {
throw new Error('No fiscal periods found')
}
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',
org_number: company.org_number,
})
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',
org_number: company.org_number,
})
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)
}
if (options.scope === 'all') {
await writeSieSourceFiles(zip, supabase, companyId, options.include_documents !== false)
await writeMasterData(zip, supabase, companyId)
}
const revision = zip.folder('revision')!
const auditEntries =
options.scope === 'period'
? await fetchPeriodAuditEntries(supabase, companyId, periods[0])
: await fetchAllAuditEntries(supabase, companyId, {})
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))
zip.file(
'LÄSMIG.txt',
buildArchiveReadme({
companyName: company.company_name || 'Okänt företag',
orgNumber: company.org_number,
generatedAt: new Date().toISOString(),
scope: options.scope,
periodLabel: options.scope === 'period' ? periodLabel(periods[0]) : undefined,
appName: getBranding().appName,
})
)
return zip.generateAsync({ type: 'arraybuffer' })
}
/**
* Generate the "Grunddata" archive for the per-fiscal-year Drive backup:
* everything that is not tied to a single fiscal year. Master-data JSON
* dumps, original imported SIE files, documents no period archive carries
* (unlinked/draft), the full behandlingshistorik and the system
* documentation. Complements one `generateFullArchive(scope='period')` ZIP
* per räkenskapsår.
*/
export async function generateBaseDataArchive(
supabase: SupabaseClient,
companyId: string,
options: { include_documents?: boolean } = {}
): Promise<ArrayBuffer> {
const company = await fetchCompany(supabase, companyId)
const periods = await fetchAllPeriods(supabase, companyId)
const includeDocuments = options.include_documents !== false
const zip = new JSZip()
if (includeDocuments) {
await writeDocuments(zip, supabase, companyId, periods, 'unlinked')
}
await writeSieSourceFiles(zip, supabase, companyId, includeDocuments)
await writeMasterData(zip, supabase, companyId)
const revision = zip.folder('revision')!
const auditEntries = await fetchAllAuditEntries(supabase, companyId, {})
revision.file('behandlingshistorik.json', JSON.stringify(auditEntries, null, 2))
const systemDoc = await buildSystemDoc(supabase, companyId, periods, 'all')
revision.file('systemdokumentation.json', JSON.stringify(systemDoc, null, 2))
zip.file(
'LÄSMIG.txt',
buildDriveFolderReadme({
companyName: company.company_name || 'Okänt företag',
orgNumber: company.org_number,
generatedAt: new Date().toISOString(),
appName: getBranding().appName,
})
)
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 }> {
// Scope=all counts every document (linked or not), mirroring writeDocuments.
let query = supabase
.from('document_attachments')
.select('file_size_bytes, journal_entry_id', { count: 'exact' })
.eq('company_id', companyId)
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'])
// Stable total order for correct paging (see fetch-all.ts).
.order('id', { ascending: true })
.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, org_number, moms_period')
.eq('company_id', companyId)
.single()
if (!data) {
throw new Error('Company settings not found')
}
return data as CompanyInfo
}
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()
if (!data) {
throw new Error('Fiscal period not found')
}
return data as FiscalPeriodRow
}
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, { closingEntry: 'include' }),
generateIncomeStatement(supabase, companyId, period.id),
generateBalanceSheet(supabase, companyId, period.id),
generateGeneralLedger(supabase, companyId, period.id),
generateJournalRegister(supabase, companyId, period.id),
])
let vatDeclaration: unknown = null
try {
const startDate = new Date(period.period_start)
// Annual VAT for an archive must cover the whole räkenskapsår, which may be
// extended/shortened: pass the fiscal period so the span isn't truncated to
// the calendar year that period_start happens to fall in.
vatDeclaration = await calculateVatDeclaration(
supabase,
companyId,
'yearly',
startDate.getFullYear(),
1,
{ fiscalPeriodId: period.id }
)
} catch {
// VAT declaration may fail if no relevant entries exist, skip gracefully
}
return { trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister, vatDeclaration }
}
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))
}
// CSV twins for humans: the JSON is complete but unreadable in Excel.
// Never let a formatting bug take down the archive (the JSON stays
// canonical), and never let one broken report take down the other CSVs.
const tryCsv = (file: string, make: () => string) => {
try {
folder.file(file, make())
} catch {
// Skip this CSV on shape mismatch.
}
}
tryCsv('saldobalans.csv', () => trialBalanceToCsv(reports.trialBalance as TrialBalanceLike))
tryCsv('resultatrakning.csv', () =>
incomeStatementToCsv(reports.incomeStatement as IncomeStatementReport)
)
tryCsv('balansrakning.csv', () =>
balanceSheetToCsv(reports.balanceSheet as BalanceSheetReport)
)
tryCsv('huvudbok.csv', () => generalLedgerToCsv(reports.generalLedger as GeneralLedgerReport))
}
async function writeDocuments(
zip: JSZip,
supabase: SupabaseClient,
companyId: string,
periods: FiscalPeriodRow[],
scope: DocumentMode
): Promise<void> {
const dokument = zip.folder('dokument')!
const manifest: DocumentManifestEntry[] = []
try {
const documents = await fetchAllRows<DocumentRow>(({ from, to }) => {
let q = 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, journal_entries:journal_entry_id(voucher_number, voucher_series, entry_date)'
)
.eq('company_id', companyId)
// Backups (scope=all/unlinked) include every document, even those not
// yet linked to an entry: inbox items and unbooked receipts are
// räkenskapsinformation too. The per-period archive keeps the
// linked-only filter.
if (scope === 'period') {
q = q.not('journal_entry_id', 'is', null)
}
// Stable total order for correct paging (see fetch-all.ts).
return q.order('id', { ascending: true }).range(from, to)
})
if (documents.length > 0) {
const entryIdToPeriodId = await buildEntryToPeriodMap(
supabase,
companyId,
periods,
scope === 'period' ? 'period' : 'all'
)
const inScopeDocuments =
scope === 'period'
? documents.filter((d) => d.journal_entry_id && entryIdToPeriodId.has(d.journal_entry_id))
: scope === 'unlinked'
? // Grunddata mode: only what no period archive carries (orphans
// and docs linked to draft/unposted entries).
documents.filter(
(d) => !d.journal_entry_id || !entryIdToPeriodId.has(d.journal_entry_id)
)
: documents // all-mode: keep every doc, linked or not
// Track used paths so we can disambiguate collisions (two documents with
// identical voucher prefix + filename) by appending a short id suffix.
const usedPaths = new Set<string>()
for (const doc of inScopeDocuments) {
const fiscalPeriodId = doc.journal_entry_id
? entryIdToPeriodId.get(doc.journal_entry_id) ?? null
: null
const entryInfo = extractJoinedEntry(doc.journal_entries)
const voucherLabel = formatVoucherLabel(entryInfo)
const zipPath = buildDocumentZipPath(doc, voucherLabel, entryInfo?.entry_date ?? null, usedPaths)
const baseManifest: Omit<DocumentManifestEntry, 'status'> = {
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,
mime_type: doc.mime_type,
file_size_bytes: doc.file_size_bytes,
voucher_number: voucherLabel,
entry_date: entryInfo?.entry_date ?? null,
zip_path: zipPath,
}
try {
// Dual-layout download: the document batch is snapshotted up
// front, and a concurrent Phase B backfill can re-home an object
// (legacy uploader-scoped -> company-scoped) and later remove the
// source mid-run, leaving the stored pointer stale. The helper
// tries the stored pointer first, then the alternate layout, so a
// healthy document never lands in the manifest as an error.
const { blob: fileData, error } = await downloadDocumentObject(
supabase,
doc.storage_path,
companyId
)
if (error || !fileData) {
manifest.push({
...baseManifest,
status: 'error',
error: error?.message || 'Download returned no data',
})
continue
}
const buffer = await fileData.arrayBuffer()
// zipPath is fully qualified (`dokument/<year>/<voucher>_<file>` etc.),
// so write at the archive root: calling `dokument.file(zipPath)`
// would double-prefix to `dokument/dokument/...`.
zip.file(zipPath, buffer)
manifest.push({ ...baseManifest, status: 'downloaded' })
} catch (err) {
manifest.push({
...baseManifest,
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
}
} catch {
// Document fetch failed: archive will still contain reports and audit trail
}
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
/**
* PostgREST returns a many-to-one embedded resource as either an object or an
* array depending on schema introspection (FK is unique vs not). Normalize.
*/
function extractJoinedEntry(
raw: DocumentRow['journal_entries']
): { voucher_number: number | null; voucher_series: string | null; entry_date: string | null } | null {
if (!raw) return null
if (Array.isArray(raw)) return raw[0] ?? null
return raw
}
/**
* Format the voucher label as `<series><number>` (e.g. `A23`, `B12`). Returns
* null if the entry is a draft (no voucher_number assigned yet), in which case
* the doc is treated as orphaned in the ZIP layout.
*/
function formatVoucherLabel(
entry: { voucher_number: number | null; voucher_series: string | null } | null
): string | null {
if (!entry || entry.voucher_number == null) return null
const series = entry.voucher_series ?? ''
return `${series}${entry.voucher_number}`
}
/**
* Build the in-ZIP path for a document.
*
* - Linked to a posted entry with a date: `dokument/<year>/<voucher>_<file>`
* - Linked to a posted entry without a date (defensive): `dokument/_okant-ar/<voucher>_<file>`
* - Orphan (no entry) or draft (no voucher_number): `dokument/_okopplade/<file>`
*
* Collisions are resolved by appending `_<short-id>` before the file extension.
*/
function buildDocumentZipPath(
doc: { id: string; file_name: string },
voucherLabel: string | null,
entryDate: string | null,
usedPaths: Set<string>
): string {
const safeName = sanitizeFileName(doc.file_name || `${doc.id}.bin`)
let folder: string
let prefix: string
if (voucherLabel) {
const year = entryDate ? new Date(entryDate).getUTCFullYear() : NaN
folder = Number.isFinite(year) ? `dokument/${year}` : 'dokument/_okant-ar'
prefix = `${voucherLabel}_`
} else {
folder = 'dokument/_okopplade'
prefix = ''
}
const candidate = `${folder}/${prefix}${safeName}`
if (!usedPaths.has(candidate)) {
usedPaths.add(candidate)
return candidate
}
// Collision: disambiguate with a short id suffix before the extension.
const dotIdx = safeName.lastIndexOf('.')
const stem = dotIdx > 0 ? safeName.slice(0, dotIdx) : safeName
const ext = dotIdx > 0 ? safeName.slice(dotIdx) : ''
const suffix = doc.id.slice(0, 8)
const disambiguated = `${folder}/${prefix}${stem}_${suffix}${ext}`
usedPaths.add(disambiguated)
return disambiguated
}
interface SieImportRow {
id: string
filename: string | null
file_hash: string | null
file_storage_path: string | null
org_number: string | null
company_name: string | null
sie_type: number | null
fiscal_year_start: string | null
fiscal_year_end: string | null
accounts_count: number | null
transactions_count: number | null
status: string | null
fiscal_period_id: string | null
imported_at: string | null
created_at: string | null
}
interface SieSourceManifestEntry {
import_id: string
filename: string | null
storage_path: string | null
sha256_hash: string | null
sie_type: number | null
fiscal_year_start: string | null
fiscal_year_end: string | null
imported_at: string | null
status: 'downloaded' | 'missing' | 'skipped'
zip_file_name: string | null
error?: string
}
/**
* Copy raw imported SIE files from the `sie-files` storage bucket into the
* archive under `sie/original/`. Preserves the byte-identical source that the
* user uploaded (vs the `sie/<period>.se` files which Accounted re-generates from
* the current journal entries).
*
* `sie/imports.json` and `sie/account_mappings.json` are written regardless of
* `includeFiles`: they're small and critical for reconstructing the import
* history. Blob download is gated behind `includeFiles` since the files can be
* large and share the documents opt-out.
*/
async function writeSieSourceFiles(
zip: JSZip,
supabase: SupabaseClient,
companyId: string,
includeFiles: boolean
): Promise<void> {
const sieFolder = zip.folder('sie')!
try {
const imports = await fetchAllRows<SieImportRow>(({ from, to }) =>
supabase
.from('sie_imports')
.select(
'id, filename, file_hash, file_storage_path, org_number, company_name, sie_type, fiscal_year_start, fiscal_year_end, accounts_count, transactions_count, status, fiscal_period_id, imported_at, created_at'
)
.eq('company_id', companyId)
.order('created_at', { ascending: true })
.range(from, to)
)
sieFolder.file('imports.json', JSON.stringify(imports, null, 2))
const manifest: SieSourceManifestEntry[] = []
if (includeFiles && imports.length > 0) {
const originalFolder = sieFolder.folder('original')!
for (const imp of imports) {
if (!imp.file_storage_path) {
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: null,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'skipped',
zip_file_name: null,
error: 'No storage path on record',
})
continue
}
const zipFileName = `${imp.id}_${sanitizeFileName(imp.filename || `${imp.id}.se`)}`
try {
const { data: fileData, error } = await supabase.storage
.from('sie-files')
.download(imp.file_storage_path)
if (error || !fileData) {
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: imp.file_storage_path,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'missing',
zip_file_name: null,
error: error?.message || 'Download returned no data',
})
continue
}
const buffer = await fileData.arrayBuffer()
originalFolder.file(zipFileName, buffer)
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: imp.file_storage_path,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'downloaded',
zip_file_name: zipFileName,
})
} catch (err) {
manifest.push({
import_id: imp.id,
filename: imp.filename,
storage_path: imp.file_storage_path,
sha256_hash: imp.file_hash,
sie_type: imp.sie_type,
fiscal_year_start: imp.fiscal_year_start,
fiscal_year_end: imp.fiscal_year_end,
imported_at: imp.imported_at,
status: 'missing',
zip_file_name: null,
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
originalFolder.file('manifest.json', JSON.stringify(manifest, null, 2))
}
const mappings = await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
supabase
.from('sie_account_mappings')
.select('*')
.eq('company_id', companyId)
.range(from, to)
)
sieFolder.file('account_mappings.json', JSON.stringify(mappings, null, 2))
} catch {
// SIE metadata fetch failed: archive will still contain the re-generated SIE files
}
}
export interface MasterDataTableSpec {
name: string
file: string
orderBy?: string
/**
* Unique column used as the paging/dedupe key. Defaults to 'id'; override
* for tables whose PK has another name (e.g. journal_entry_no_doc_required).
*/
pageKey?: string
/**
* Child tables without a company_id column: rows are fetched by first
* collecting the parent table's ids for the company, then paging the child
* table through `fk IN (...)` chunks.
*/
via?: { parent: string; fk: string }
/**
* PostgREST select list for a narrow projection. Defaults to `*`.
*
* Only for tables where part of the row is räkenskapsinformation and the
* rest is workflow state that has no place in a portable archive (see
* invoice_inbox_items). Must include the page key.
*
* Additive only, like `denormalize`: an archive already handed to a revisor
* must keep every key it shipped with, so append columns and never drop
* one.
*/
columns?: string
/**
* Parent columns copied onto every child row as `<prefix><column>`.
*
* A child line row carries money but no unit: `invoice_items.line_total` is
* denominated in the parent invoice's currency, and the row's own `unit`
* column means "st"/"timmar", not the money unit. Parent dumps are fine
* (`select('*')` carries `currency`, `exchange_rate` and the `*_sek` twins
* side by side), so this is the only place where a reader of a single file
* cannot tell SEK from EUR. Denormalising the parent's currency makes each
* line self-describing instead of requiring a join back to the parent file.
*
* Copy the conversion basis (currency + exchange_rate), never the parent's
* totals: a line's SEK value is not the invoice's `total_sek`. Leave unset
* when the parent has no currency column (nothing to copy, and inventing
* one would put a fabricated unit into a statutory archive).
*
* Additive only. Archives already handed to a revisor must keep every key
* they shipped with, so append new keys and never rename or drop one.
*/
denormalize?: { prefix: string; columns: string[] }
}
/**
* Tables dumped as JSON under `data/` in the scope='all' backup.
*
* This list is a contract enforced by tests/pg/full-archive-coverage.pg.test.ts:
* every public table with a company_id column must appear here, in
* ARCHIVE_COVERED_ELSEWHERE_TABLES, or in ARCHIVE_EXCLUDED_TABLES. A migration
* that adds a company-scoped table fails that test until the table is
* classified, so the backup can never silently fall behind the schema again.
*/
export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
// Counterparties and articles
{ name: 'customers', file: 'customers.json', orderBy: 'created_at' },
{ name: 'suppliers', file: 'suppliers.json', orderBy: 'created_at' },
{ name: 'articles', file: 'articles.json', orderBy: 'created_at' },
// Customer invoicing
{ name: 'invoices', file: 'invoices.json', orderBy: 'invoice_date' },
{
name: 'invoice_items',
file: 'invoice_items.json',
via: { parent: 'invoices', fk: 'invoice_id' },
denormalize: { prefix: 'invoice_', columns: ['currency', 'exchange_rate'] },
},
{ name: 'invoice_payments', file: 'invoice_payments.json', orderBy: 'payment_date' },
{ name: 'invoice_reminders', file: 'invoice_reminders.json' },
// Delivery metadata proves which recipient received the archived PDF and
// when, so it is räkenskapsinformation alongside the invoice itself.
{ name: 'invoice_deliveries', file: 'invoice_deliveries.json', orderBy: 'created_at' },
{ name: 'recurring_invoice_schedules', file: 'recurring_invoice_schedules.json' },
// Supplier invoicing
{ name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' },
{
name: 'supplier_invoice_items',
file: 'supplier_invoice_items.json',
via: { parent: 'supplier_invoices', fk: 'supplier_invoice_id' },
denormalize: { prefix: 'supplier_invoice_', columns: ['currency', 'exchange_rate'] },
},
{ name: 'supplier_invoice_payments', file: 'supplier_invoice_payments.json' },
// Payment batches (betalfil): the immutable instruction snapshots a
// generated bank payment file derives from; underlag for the payments it
// initiated, so they leave with the archive.
{
name: 'supplier_payment_batches',
file: 'supplier_payment_batches.json',
orderBy: 'created_at',
},
{
name: 'supplier_payment_batch_items',
file: 'supplier_payment_batch_items.json',
orderBy: 'created_at',
},
// Underlag intake: the chat answers behind a verifikat.
//
// A projection, not the whole table. `channel_context` holds the human
// answers the WhatsApp bot collected (representation deltagare + syfte +
// raw_answer), and it is the ONLY complete copy: the verifikat line carries
// a 220-char render that drops whole names ("… och N till"), and Skatte-
// verket's dokumentationskrav wants every deltagare. Without this file a
// company that leaves with its archive keeps an incomplete representation
// trail. The booking columns come along so each answer can be tied to the
// verifikat it belongs to.
//
// Everything else on the row (email bodies, OCR output, error messages,
// retry state) is inbox workflow state and stays out; the documents
// themselves are in dokument/.
{
name: 'invoice_inbox_items',
file: 'invoice_inbox_items.json',
orderBy: 'created_at',
columns:
'id, created_at, source, status, document_id, matched_transaction_id, ' +
'created_journal_entry_id, created_supplier_invoice_id, channel_context',
},
// Receipts
{ name: 'receipts', file: 'receipts.json', orderBy: 'receipt_date' },
// `receipts` has no exchange_rate column, so only the currency is copied:
// enough to read the unit, which is what the line was missing.
{
name: 'receipt_line_items',
file: 'receipt_line_items.json',
via: { parent: 'receipts', fk: 'receipt_id' },
denormalize: { prefix: 'receipt_', columns: ['currency'] },
},
// Bank and categorization
// NOTE: the date column on transactions is `date` (a previous spec said
// booking_date, which does not exist: every backup got an error stub).
{ name: 'transactions', file: 'transactions.json', orderBy: 'date' },
{ name: 'transaction_voucher_links', file: 'transaction_voucher_links.json' },
{ name: 'bank_file_imports', file: 'bank_file_imports.json', orderBy: 'created_at' },
{ name: 'cash_accounts', file: 'cash_accounts.json' },
{ name: 'mapping_rules', file: 'mapping_rules.json' },
{ name: 'categorization_templates', file: 'categorization_templates.json' },
{ name: 'booking_template_library', file: 'booking_template_library.json' },
{ name: 'skattekonto_rules', file: 'skattekonto_rules.json' },
// Salary (räkenskapsinformation with 7-year retention)
{ name: 'employees', file: 'employees.json', orderBy: 'created_at' },
{ name: 'employee_benefits', file: 'employee_benefits.json', orderBy: 'created_at' },
{ name: 'salary_runs', file: 'salary_runs.json', orderBy: 'created_at' },
{ name: 'salary_run_employees', file: 'salary_run_employees.json', orderBy: 'created_at' },
{ name: 'salary_line_items', file: 'salary_line_items.json', orderBy: 'created_at' },
{ name: 'salary_absence_days', file: 'salary_absence_days.json' },
// Cutover state (payroll gap-closure 2.1): part of the payroll underlag a
// switching company brings; belongs in the archive like the run data it
// seeds.
{ name: 'employee_opening_balances', file: 'employee_opening_balances.json' },
// Vacation ledger + year closures (payroll gap-closure 3.1). The closure
// report is the underlag for the drift-adjustment verifikation (BFL 7 kap).
{ name: 'employee_vacation_balances', file: 'employee_vacation_balances.json' },
{ name: 'vacation_year_closures', file: 'vacation_year_closures.json' },
{ name: 'salary_worked_days', file: 'salary_worked_days.json' },
{ name: 'salary_payslip_links', file: 'salary_payslip_links.json' },
{ name: 'shift_premium_rules', file: 'shift_premium_rules.json' },
{ name: 'agi_declarations', file: 'agi_declarations.json', orderBy: 'created_at' },
// Körjournal: trip log underlag for milersättning verifikat (BFL 7-year
// retention per Skatteverket's körjournal documentation requirement).
{ name: 'mileage_trips', file: 'mileage_trips.json', orderBy: 'trip_date' },
// Assets and accruals
{ name: 'assets', file: 'assets.json', orderBy: 'created_at' },
{ name: 'depreciation_schedules', file: 'depreciation_schedules.json', orderBy: 'created_at' },
{ name: 'accrual_schedules', file: 'accrual_schedules.json', orderBy: 'created_at' },
{ name: 'accrual_schedule_installments', file: 'accrual_schedule_installments.json', orderBy: 'created_at' },
// Dimensions
{ name: 'dimensions', file: 'dimensions.json', orderBy: 'created_at' },
{ name: 'dimension_values', file: 'dimension_values.json', orderBy: 'created_at' },
{ name: 'cost_centers', file: 'cost_centers.json', orderBy: 'created_at' },
{ name: 'projects', file: 'projects.json', orderBy: 'created_at' },
{ name: 'account_dimension_rules', file: 'account_dimension_rules.json' },
// Compliance records
{ name: 'voucher_gap_explanations', file: 'voucher_gap_explanations.json', orderBy: 'created_at' },
// Inline rättelse trail (BFL 5 kap 5 § / 9 §): holds the struck original
// lines and the old description/date, i.e. the preserved side of every
// in-verifikat rättelse — räkenskapsinformation, not an operation log.
{ name: 'journal_entry_rattelse_log', file: 'journal_entry_rattelse_log.json', orderBy: 'created_at' },
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
// No `denormalize`: rot_rut_payout_requests has no currency column either.
// A HUS-avdrag claim to Skatteverket is SEK by statute, so there is no unit
// to copy down and asserting one here would fabricate it.
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
{ name: 'fiscal_period_tax_adjustments', file: 'fiscal_period_tax_adjustments.json', orderBy: 'created_at' },
{ name: 'tax_assessment_notices', file: 'tax_assessment_notices.json', orderBy: 'created_at' },
{ name: 'arsredovisning_narratives', file: 'arsredovisning_narratives.json' },
{ name: 'annual_report_profiles', file: 'annual_report_profiles.json', orderBy: 'created_at' },
{ name: 'annual_report_versions', file: 'annual_report_versions.json', orderBy: 'created_at' },
{ name: 'annual_report_validation_runs', file: 'annual_report_validation_runs.json', orderBy: 'created_at' },
{ name: 'arsredovisning_signature_requests', file: 'arsredovisning_signature_requests.json', orderBy: 'created_at' },
{ name: 'arsredovisning_submissions', file: 'arsredovisning_submissions.json' },
// Settings
{ name: 'company_settings', file: 'company_settings.json' },
]
/**
* Company-scoped tables whose content reaches the archive through another
* section, so they are deliberately not part of the `data/` dump.
*/
export const ARCHIVE_COVERED_ELSEWHERE_TABLES: Record<string, string> = {
journal_entries: 'sie/<period>.se + rapporter/<period>/grundbok.json',
fiscal_periods: 'revision/systemdokumentation.json + SIE #RAR',
chart_of_accounts: 'revision/systemdokumentation.json (kontoplan)',
voucher_sequences: 'revision/systemdokumentation.json (verifikationsserier)',
audit_log: 'revision/behandlingshistorik.json',
document_attachments: 'dokument/ + dokument/manifest.json',
sie_imports: 'sie/imports.json + sie/original/',
sie_account_mappings: 'sie/account_mappings.json',
}
/**
* Company-scoped tables deliberately kept out of the archive, with the reason.
* Platform state, secrets, telemetry and re-fetchable mirrors do not belong in
* a portable räkenskapsinformation backup.
*/
export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
agent_conversations: 'AI assistant state, not räkenskapsinformation',
agent_memory: 'AI assistant state, not räkenskapsinformation',
agent_profiles: 'AI assistant state, not räkenskapsinformation',
api_keys: 'secrets',
bank_connections: 'PSD2 connection state and tokens, not portable',
bolagsverket_avtal_acceptances: 'service agreement acceptance state',
bolagsverket_subscriptions: 'integration subscription state',
booking_template_usage: 'usage telemetry',
calendar_feeds: 'feed tokens (secrets)',
capability_grants: 'entitlement state',
chat_messages: 'AI assistant state, not räkenskapsinformation',
chat_sessions: 'AI assistant state, not räkenskapsinformation',
company_capability_config: 'entitlement state',
company_inbound_domains: 'inbound-mail infrastructure',
company_inboxes: 'inbound-mail infrastructure',
company_invitations: 'membership state, meaningless outside the platform',
company_members: 'membership state, meaningless outside the platform',
company_subscriptions: 'billing state',
deadlines: 'regenerable operational calendar state',
dimension_retag_log: 'operation log',
event_log: '30-day TTL event bus log',
extension_data: 'extension runtime state (includes this backup\'s own state)',
graph_counterparties: 'derived AI context graph, regenerable',
graph_transaction_counterparties: 'derived AI context graph, regenerable',
idempotency_keys: 'infrastructure',
inbox_rate_counters: 'infrastructure',
mail_connections:
'mailbox OAuth grants (live refresh tokens), not portable. The receipts they find are archived as documents.',
mcp_tasks: 'MCP task handles: transient tool-call state with a 1-hour TTL',
metered_events: 'billing telemetry',
notification_log: 'notification dedup log',
operations: 'staged-operation workflow state',
payment_match_log: 'derived matching log',
pending_operations: 'staged-operation workflow state',
processing_history: 'internal processing log; behandlingshistorik exports from audit_log',
provider_consents: 'consent tokens, not portable',
salary_payslip_deliveries: 'delivery log',
skattekonto_transactions: 'mirror of Skatteverket skattekonto, re-fetchable at source',
skatteverket_api_audit_log: 'integration audit log',
skatteverket_company_connections: 'integration connection state',
skatteverket_tokens: 'secrets',
stripe_connections: 'Stripe OAuth state (secrets)',
stripe_payment_events: 'mirror of Stripe data, re-fetchable at source',
stripe_payouts: 'mirror of Stripe data, re-fetchable at source',
webhook_deliveries: 'automation delivery log',
whatsapp_conversations:
'WhatsApp bot conversation state (company_id is only a which-company pin); receipts live in document_attachments',
webhooks: 'automation config with signing secrets',
woocommerce_connections: 'WooCommerce connection state (encrypted API secrets)',
shopify_connections: 'Shopify connection state (encrypted API secrets)',
}
/** Max parent ids per `IN (...)` chunk: keeps the PostgREST URL well under limits. */
const CHILD_FK_CHUNK = 100
async function fetchChildTableRows(
supabase: SupabaseClient,
companyId: string,
spec: MasterDataTableSpec
): Promise<Record<string, unknown>[]> {
const via = spec.via!
const denormalize = spec.denormalize
// Narrow select (id + only the denormalized columns): the parent table can
// be large and `*` would pull every invoice column just to read a currency.
const parentSelect = ['id', ...(denormalize?.columns ?? [])].join(', ')
const parents = await fetchAllRows<Record<string, unknown>>(
({ from, to }) =>
supabase
.from(via.parent)
.select(parentSelect)
.eq('company_id', companyId)
.order('id', { ascending: true })
// The select list is built at runtime, so PostgREST's literal-string
// type inference cannot resolve it and falls back to an error type.
// The runtime shape is id + the declared columns, by construction.
.range(from, to) as unknown as PromiseLike<{
data: Record<string, unknown>[] | null
error: { message: string } | null
}>
)
const parentById = new Map<string, Record<string, unknown>>()
if (denormalize) {
for (const parent of parents) parentById.set(String(parent.id), parent)
}
const pageKey = spec.pageKey ?? 'id'
const rows: Record<string, unknown>[] = []
for (let i = 0; i < parents.length; i += CHILD_FK_CHUNK) {
const chunk = parents.slice(i, i + CHILD_FK_CHUNK).map((p) => String(p.id))
const chunkRows = await fetchAllRows<Record<string, unknown>>(
({ from, to }) => {
let q = supabase.from(spec.name).select('*').in(via.fk, chunk)
if (spec.orderBy) q = q.order(spec.orderBy, { ascending: true })
return q.order(pageKey, { ascending: true }).range(from, to)
},
{ dedupeBy: (r) => String(r[pageKey]) }
)
if (denormalize) {
for (const row of chunkRows) {
const parent = parentById.get(String(row[via.fk]))
for (const column of denormalize.columns) {
const key = `${denormalize.prefix}${column}`
// Never clobber a real child column that happens to share the name:
// the table's own data always wins over the copied parent value.
if (key in row) continue
// `?? null` is load-bearing: JSON.stringify drops undefined keys, so
// a missing parent would silently produce a row with no unit again.
row[key] = parent?.[column] ?? null
}
}
}
rows.push(...chunkRows)
}
return rows
}
/**
* Dump structured master data as JSON under `data/`. These records are implicit
* in the SIE export (as journal entries) but not recoverable as domain objects
* without this dump, critical for disaster recovery of a company's state.
*/
async function writeMasterData(
zip: JSZip,
supabase: SupabaseClient,
companyId: string
): Promise<void> {
const data = zip.folder('data')!
// Sequential on purpose: ~50 fast queries in series are gentler on
// PostgREST than 50 concurrent ones, and the deterministic order keeps the
// queued-mock tests stable.
for (const t of MASTER_DATA_DUMP_TABLES) {
const pageKey = t.pageKey ?? 'id'
try {
const rows = t.name === 'invoice_deliveries'
? await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
supabase
.rpc('export_invoice_delivery_evidence', { p_company_id: companyId })
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (row) => String(row.id) })
: t.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select(t.columns ?? '*').eq('company_id', companyId)
if (t.orderBy) {
q = q.order(t.orderBy, { ascending: true })
}
// Always end on the unique PK so paging has a stable TOTAL order. A
// non-unique display order (e.g. created_at) or no order at all
// silently SKIPS/DUPLICATES rows across page boundaries: data loss in
// a statutory 7-year retention archive. dedupeBy is defense-in-depth
// against the duplicate case.
//
// The select list is built at runtime (spec.columns), so
// PostgREST's literal-string type inference cannot resolve it and
// falls back to an error type; the runtime shape is the declared
// columns, by construction. Same cast as fetchChildTableRows.
return q.order(pageKey, { ascending: true }).range(from, to) as unknown as PromiseLike<{
data: Record<string, unknown>[] | null
error: { message: string } | null
}>
}, { dedupeBy: (r) => String(r[pageKey]) })
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (err) {
if (t.name === 'invoice_deliveries') throw err
data.file(
t.file,
JSON.stringify(
{ error: err instanceof Error ? err.message : 'Fetch failed', rows: [] },
null,
2
)
)
}
}
}
function sanitizeFileName(name: string): string {
return name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 120)
}
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)
}
// Stable total order for correct paging (see fetch-all.ts).
query = query.order('id', { ascending: true })
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
}
/**
* Behandlingshistorik for a single räkenskapsår.
*
* A date window alone is not enough: bokslut entries and stornos for the year
* are routinely committed months after period_end, so their audit rows fall
* outside [period_start, period_end]. BFNAR 2013:2 kap 8 expects the year's
* archive to carry the treatment history of the year's bokföringsposter, so
* the window is complemented with every audit row touching the period's
* journal entries and lines, regardless of when it was logged.
*/
async function fetchPeriodAuditEntries(
supabase: SupabaseClient,
companyId: string,
period: FiscalPeriodRow
): Promise<AuditLogEntry[]> {
const windowed = await fetchAllAuditEntries(supabase, companyId, {
from_date: period.period_start,
to_date: `${period.period_end}T23:59:59.999Z`,
})
const entryIds = (
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'])
// Stable total order for correct paging (see fetch-all.ts).
.order('id', { ascending: true })
.range(from, to)
)
).map((r) => r.id)
if (entryIds.length === 0) return windowed
// journal_entry_lines has no company_id column; tenant scoping comes from
// the entry ids fetched above.
const lineIds: string[] = []
for (let i = 0; i < entryIds.length; i += CHILD_FK_CHUNK) {
const chunk = entryIds.slice(i, i + CHILD_FK_CHUNK)
const lines = await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('id')
.in('journal_entry_id', chunk)
.order('id', { ascending: true })
.range(from, to)
)
lineIds.push(...lines.map((r) => r.id))
}
const byId = new Map<string, AuditLogEntry>()
for (const row of windowed) byId.set(row.id, row)
// write_audit_log derives company_id from the audited row, and
// journal_entry_lines has no such column, so line audit rows carry
// company_id NULL: a plain company filter would drop them. The record-id
// set above is already tenant-scoped; the OR admits NULL-company rows only
// for journal_entry_lines. Under RLS (manual download) those rows stay
// invisible; the service-role backup path (Drive cron) sees them.
const recordIds = [...entryIds, ...lineIds]
for (let i = 0; i < recordIds.length; i += CHILD_FK_CHUNK) {
const chunk = recordIds.slice(i, i + CHILD_FK_CHUNK)
const rows = await fetchAllRows<AuditLogEntry>(({ from, to }) =>
supabase
.from('audit_log')
.select('*')
.in('record_id', chunk)
.or(
`company_id.eq.${companyId},and(company_id.is.null,table_name.eq.journal_entry_lines)`
)
.order('id', { ascending: true })
.range(from, to)
)
for (const row of rows) byId.set(row.id, row)
}
// Newest first, matching getAuditLog's output order.
return [...byId.values()].sort((a, b) => b.created_at.localeCompare(a.created_at))
}
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, {
...filters,
page,
pageSize,
includeCount: false,
})
all.push(...result.data)
if (result.data.length < pageSize) {
break
}
page++
}
return all
}
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)
}
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'),
voucherSeriesQuery,
])
const branding = getBranding()
return {
system: {
name: branding.appName.toLowerCase(),
description: 'Bokforingssystem for enskild firma och aktiebolag',
url: branding.appUrl,
},
kontoplan: {
standard: 'BAS 2026',
accounts: accountsResult.data || [],
},
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,
rls_aktiv: true,
},
arkivering: {
lagringsregel: 'Till och med utgången av det sjunde kalenderåret efter det kalenderår då räkenskapsåret avslutades',
gallring_tidigare_an: '1 januari det åttonde efterföljande kalenderåret',
format: 'WORM (Write Once, Read Many)',
integritetskontroll: 'SHA-256 hashning vid uppladdning, regelbunden verifiering',
lagringsplats: 'Supabase Storage (krypterad)',
},
arsredovisning: {
versionering: 'Låsta versioner är oföränderliga och SHA-256-hashade',
kontrollunderlag: 'Regelverksprofil, upplysningsbekräftelser och valideringsresultat sparas med versionen',
underskrifter: 'Undertecknarlista, metod, datum och bevisreferens binds till exakt version',
inlamning: 'Exakt skickad iXBRL-fil och Bolagsverkets kvittens arkiveras före och efter överföring',
},
integrationer: {
bank: 'Enable Banking (PSD2)',
email: 'Resend',
export_format: 'SIE4',
},
generated_at: new Date().toISOString(),
fiscal_periods: periods.map((p) => ({
id: p.id,
start: p.period_start,
end: p.period_end,
})),
}
}
function periodLabel(period: FiscalPeriodRow): string {
return `${period.period_start}_${period.period_end}`
}