Files
accounted/lib/reports/full-archive-export.ts
T
MattssonandClaude Fable 5 b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

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

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

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

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

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

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

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

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

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

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

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

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

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

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

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

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00

1137 lines
42 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 { 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 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))
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),
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,
'accrual',
{ 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 {
const { data: fileData, error } = await supabase.storage
.from('documents')
.download(doc.storage_path)
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 }
}
/**
* 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' } },
{ name: 'invoice_payments', file: 'invoice_payments.json', orderBy: 'payment_date' },
{ name: 'invoice_reminders', file: 'invoice_reminders.json' },
{ 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' } },
{ name: 'supplier_invoice_payments', file: 'supplier_invoice_payments.json' },
// Receipts
{ name: 'receipts', file: 'receipts.json', orderBy: 'receipt_date' },
{ name: 'receipt_line_items', file: 'receipt_line_items.json', via: { parent: 'receipts', fk: 'receipt_id' } },
// 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' },
// 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' },
{ 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' },
{ name: 'rot_rut_payout_request_items', file: 'rot_rut_payout_request_items.json', via: { parent: 'rot_rut_payout_requests', fk: 'request_id' } },
{ name: 'arsredovisning_narratives', file: 'arsredovisning_narratives.json' },
{ 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',
arsredovisning_signature_requests: 'signing workflow state',
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',
invoice_inbox_items: 'inbox workflow state; the files live in document_attachments',
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',
webhooks: 'automation config with signing 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 parents = await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from(via.parent)
.select('id')
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to)
)
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) => 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]) }
)
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.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select('*').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.
return q.order(pageKey, { ascending: true }).range(from, to)
}, { dedupeBy: (r) => String(r[pageKey]) })
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (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
}
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 })
all.push(...result.data)
if (all.length >= result.count || 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: {
lagringstid_ar: 7,
format: 'WORM (Write Once, Read Many)',
integritetskontroll: 'SHA-256 hashning vid uppladdning, regelbunden verifiering',
lagringsplats: 'Supabase Storage (krypterad)',
},
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}`
}