feat: add INK2 declaration, full archive export, AI consent gate, fix VAT declaration rutor

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-05 14:20:47 +01:00
co-authored by Claude Opus 4.6
parent 50bf7b3b0f
commit 29240738fa
32 changed files with 3496 additions and 97 deletions
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockSupabase } from '@/tests/helpers'
import {
hasAiConsent,
grantAiConsent,
revokeAiConsent,
isAiExtension,
CURRENT_CONSENT_VERSION,
} from '../ai-consent'
describe('ai-consent', () => {
let supabase: ReturnType<typeof createMockSupabase>['supabase']
let mockResult: ReturnType<typeof createMockSupabase>['mockResult']
beforeEach(() => {
vi.clearAllMocks()
const mock = createMockSupabase()
supabase = mock.supabase
mockResult = mock.mockResult
})
describe('isAiExtension', () => {
it('returns true for AI extensions', () => {
expect(isAiExtension('receipt-ocr')).toBe(true)
expect(isAiExtension('ai-categorization')).toBe(true)
expect(isAiExtension('ai-chat')).toBe(true)
})
it('returns false for non-AI extensions', () => {
expect(isAiExtension('enable-banking')).toBe(false)
expect(isAiExtension('email')).toBe(false)
expect(isAiExtension('calendar')).toBe(false)
})
})
describe('hasAiConsent', () => {
it('returns true for non-AI extensions without checking DB', async () => {
const result = await hasAiConsent(supabase as any, 'user-1', 'enable-banking')
expect(result).toBe(true)
expect(supabase.from).not.toHaveBeenCalled()
})
it('returns false when no consent record exists', async () => {
mockResult({ data: null })
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(false)
})
it('returns true after consent is granted with current version', async () => {
mockResult({
data: {
value: {
consented: true,
version: CURRENT_CONSENT_VERSION,
granted_at: '2024-01-01T00:00:00Z',
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(true)
})
it('returns false after consent is revoked', async () => {
mockResult({
data: {
value: {
consented: false,
version: CURRENT_CONSENT_VERSION,
revoked_at: '2024-01-02T00:00:00Z',
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(false)
})
it('returns false for outdated consent version', async () => {
mockResult({
data: {
value: {
consented: true,
version: CURRENT_CONSENT_VERSION - 1,
granted_at: '2024-01-01T00:00:00Z',
},
},
})
const result = await hasAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(result).toBe(false)
})
})
describe('grantAiConsent', () => {
it('upserts consent record to extension_data', async () => {
mockResult({ data: null, error: null })
await grantAiConsent(supabase as any, 'user-1', 'receipt-ocr')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('does nothing for non-AI extensions', async () => {
await grantAiConsent(supabase as any, 'user-1', 'enable-banking')
expect(supabase.from).not.toHaveBeenCalled()
})
})
describe('revokeAiConsent', () => {
it('upserts revoked consent to extension_data', async () => {
mockResult({ data: null, error: null })
await revokeAiConsent(supabase as any, 'user-1', 'ai-chat')
expect(supabase.from).toHaveBeenCalledWith('extension_data')
})
it('does nothing for non-AI extensions', async () => {
await revokeAiConsent(supabase as any, 'user-1', 'email')
expect(supabase.from).not.toHaveBeenCalled()
})
})
})
+130
View File
@@ -0,0 +1,130 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* AI Consent Service
*
* Manages per-extension consent for AI features that send user data to
* third-party AI providers. Required before any AI extension API call.
*
* Uses the existing `extension_data` table (migration 020) with key='ai_consent'.
*
* Version bump policy:
* - BUMP version when: New sub-processor added, new data type sent to existing
* provider, changed processing purpose.
* - DO NOT bump when: Bug fix, model upgrade within same provider
* (e.g. Haiku 4.5 -> Haiku 5), performance improvements.
* - When version bumps, existing consents become invalid and users must re-consent.
*/
export const CURRENT_CONSENT_VERSION = 1
export const AI_EXTENSIONS = ['receipt-ocr', 'ai-categorization', 'ai-chat'] as const
export type AiExtensionId = (typeof AI_EXTENSIONS)[number]
export function isAiExtension(extensionId: string): extensionId is AiExtensionId {
return (AI_EXTENSIONS as readonly string[]).includes(extensionId)
}
export const AI_DATA_DISCLOSURES: Record<AiExtensionId, {
provider: string
dataTypes: string[]
purpose: string
}> = {
'receipt-ocr': {
provider: 'Anthropic',
dataTypes: ['Kvittobilder', 'Extraherad text fran kvitton'],
purpose: 'Automatisk avlasning och kategorisering av kvitton',
},
'ai-categorization': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokformallar'],
purpose: 'Automatisk kategorisering av banktransaktioner',
},
'ai-chat': {
provider: 'Anthropic, OpenAI',
dataTypes: ['Chattmeddelanden', 'Bokforingsdata som refereras i chatten'],
purpose: 'AI-assistent for bokforingsfragor',
},
}
/**
* Check if user has valid AI consent for the given extension.
* Returns true for non-AI extensions (no consent needed).
*/
export async function hasAiConsent(
supabase: SupabaseClient,
userId: string,
extensionId: string
): Promise<boolean> {
if (!isAiExtension(extensionId)) {
return true
}
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('extension_id', extensionId)
.eq('key', 'ai_consent')
.single()
if (!data?.value) return false
const consent = data.value as { consented: boolean; version: number }
return consent.consented === true && consent.version >= CURRENT_CONSENT_VERSION
}
/**
* Grant AI consent for an extension.
*/
export async function grantAiConsent(
supabase: SupabaseClient,
userId: string,
extensionId: string
): Promise<void> {
if (!isAiExtension(extensionId)) return
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: extensionId,
key: 'ai_consent',
value: {
consented: true,
version: CURRENT_CONSENT_VERSION,
granted_at: new Date().toISOString(),
},
},
{ onConflict: 'user_id,extension_id,key' }
)
}
/**
* Revoke AI consent for an extension.
*/
export async function revokeAiConsent(
supabase: SupabaseClient,
userId: string,
extensionId: string
): Promise<void> {
if (!isAiExtension(extensionId)) return
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: extensionId,
key: 'ai_consent',
value: {
consented: false,
version: CURRENT_CONSENT_VERSION,
revoked_at: new Date().toISOString(),
},
},
{ onConflict: 'user_id,extension_id,key' }
)
}
@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import JSZip from 'jszip'
import { generateFullArchive } from '../full-archive-export'
import { createQueuedMockSupabase } from '@/tests/helpers'
vi.mock('../sie-export', () => ({
generateSIEExport: vi.fn().mockResolvedValue('#FLAGGA 0\n#PROGRAM "ERPBase"'),
}))
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn().mockResolvedValue({
rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true,
}),
}))
vi.mock('../income-statement', () => ({
generateIncomeStatement: vi.fn().mockResolvedValue({
sections: [], netResult: 0, period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../balance-sheet', () => ({
generateBalanceSheet: vi.fn().mockResolvedValue({
asset_sections: [], equity_liability_sections: [],
total_assets: 0, total_equity_liabilities: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../general-ledger', () => ({
generateGeneralLedger: vi.fn().mockResolvedValue({
accounts: [], period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../journal-register', () => ({
generateJournalRegister: vi.fn().mockResolvedValue({
entries: [], total_entries: 0, total_debit: 0, total_credit: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
}),
}))
vi.mock('../vat-declaration', () => ({
calculateVatDeclaration: vi.fn().mockResolvedValue({
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
rutor: {
ruta05: 0, ruta06: 0, ruta07: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta39: 0, ruta40: 0, ruta48: 0, ruta49: 0,
},
invoiceCount: 0, transactionCount: 0,
breakdown: {
invoices: { ruta05: 0, ruta06: 0, ruta07: 0, ruta10: 0, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 0, base12: 0, base6: 0 },
transactions: { ruta48: 0 },
receipts: { ruta48: 0 },
},
}),
}))
vi.mock('@/lib/core/audit/audit-service', () => ({
getAuditLog: vi.fn().mockResolvedValue({ data: [], count: 0 }),
}))
describe('generateFullArchive', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
beforeEach(() => {
vi.clearAllMocks()
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
})
function enqueueStandardResponses(opts?: { includeDocuments?: boolean }) {
// 1. fiscal_periods query
enqueueMany([
{
data: {
id: 'period-1',
period_start: '2024-01-01',
period_end: '2024-12-31',
user_id: 'user-1',
},
},
// 2. company_settings query
{
data: {
company_name: 'Test AB',
org_number: '5566778899',
moms_period: 'quarterly',
},
},
])
if (opts?.includeDocuments !== false) {
enqueueMany([
// 3. document_attachments query
{ data: [] },
])
}
}
it('generates a ZIP with expected file structure', async () => {
enqueueStandardResponses()
const buffer = await generateFullArchive(supabase as any, 'user-1', {
period_id: 'period-1',
})
const zip = await JSZip.loadAsync(buffer)
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('rapporter/saldobalans.json')).not.toBeNull()
expect(zip.file('rapporter/resultatrakning.json')).not.toBeNull()
expect(zip.file('rapporter/balansrakning.json')).not.toBeNull()
expect(zip.file('rapporter/huvudbok.json')).not.toBeNull()
expect(zip.file('rapporter/grundbok.json')).not.toBeNull()
expect(zip.file('rapporter/momsdeklaration.json')).not.toBeNull()
expect(zip.file('dokument/manifest.json')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
})
it('handles missing documents gracefully', async () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
enqueueMany([
// fiscal_periods
{
data: {
id: 'period-1',
period_start: '2024-01-01',
period_end: '2024-12-31',
user_id: 'user-1',
},
},
// company_settings
{
data: {
company_name: 'Test AB',
org_number: '5566778899',
moms_period: 'quarterly',
},
},
// document_attachments — one document
{
data: [
{
id: 'doc-1',
file_name: 'receipt.pdf',
storage_path: 'documents/user-1/receipt.pdf',
journal_entry_id: 'entry-1',
},
],
},
// journal_entries in period
{
data: [{ id: 'entry-1' }],
},
])
// Mock storage download to fail
supabase.storage.from = vi.fn().mockReturnValue({
download: vi.fn().mockResolvedValue({
data: null,
error: { message: 'File not found' },
}),
})
const buffer = await generateFullArchive(supabase as any, 'user-1', {
period_id: 'period-1',
})
const zip = await JSZip.loadAsync(buffer)
const manifestFile = zip.file('dokument/manifest.json')
expect(manifestFile).not.toBeNull()
const manifest = JSON.parse(await manifestFile!.async('text'))
expect(manifest).toHaveLength(1)
expect(manifest[0].status).toBe('error')
expect(manifest[0].error).toBe('File not found')
})
it('skips documents when include_documents is false', async () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
enqueueMany([
// fiscal_periods
{
data: {
id: 'period-1',
period_start: '2024-01-01',
period_end: '2024-12-31',
user_id: 'user-1',
},
},
// company_settings
{
data: {
company_name: 'Test AB',
org_number: '5566778899',
moms_period: 'quarterly',
},
},
])
const buffer = await generateFullArchive(supabase as any, 'user-1', {
period_id: 'period-1',
include_documents: false,
})
const zip = await JSZip.loadAsync(buffer)
// Should not have dokument folder
expect(zip.file('dokument/manifest.json')).toBeNull()
// Should still have other files
expect(zip.file('bokforing.se')).not.toBeNull()
expect(zip.file('revision/behandlingshistorik.json')).not.toBeNull()
})
it('throws when fiscal period not found', async () => {
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
enqueueMany([{ data: null }])
await expect(
generateFullArchive(supabase as any, 'user-1', { period_id: 'nonexistent' })
).rejects.toThrow('Fiscal period not found')
})
})
+33 -27
View File
@@ -95,21 +95,21 @@ describe('getVatDeclarationSummary', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 2500,
ruta05: 10000, // domestic taxable sales
ruta06: 0,
ruta07: 0,
ruta10: 10000,
ruta10: 2500, // output VAT 25%
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 1000,
ruta49: 1500,
ruta49: 1500, // 2500 - 1000
},
invoiceCount: 5,
transactionCount: 10,
breakdown: {
invoices: { ruta05: 2500, ruta06: 0, ruta07: 0, ruta10: 10000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
invoices: { ruta05: 10000, ruta06: 0, ruta07: 0, ruta10: 2500, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 10000, base12: 0, base6: 0 },
transactions: { ruta48: 1000 },
receipts: { ruta48: 0 },
},
@@ -126,21 +126,21 @@ describe('getVatDeclarationSummary', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 500,
ruta05: 2000, // domestic taxable sales
ruta06: 0,
ruta07: 0,
ruta10: 2000,
ruta10: 500, // output VAT 25%
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 3000,
ruta49: -2500,
ruta49: -2500, // 500 - 3000
},
invoiceCount: 1,
transactionCount: 20,
breakdown: {
invoices: { ruta05: 500, ruta06: 0, ruta07: 0, ruta10: 2000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
invoices: { ruta05: 2000, ruta06: 0, ruta07: 0, ruta10: 500, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0, base25: 2000, base12: 0, base6: 0 },
transactions: { ruta48: 3000 },
receipts: { ruta48: 0 },
},
@@ -170,15 +170,16 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta06).toBe(0)
expect(result.rutor.ruta07).toBe(0)
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta11).toBe(0)
expect(result.rutor.ruta12).toBe(0)
expect(result.rutor.ruta48).toBe(0)
expect(result.rutor.ruta49).toBe(0)
expect(result.invoiceCount).toBe(0)
expect(result.transactionCount).toBe(0)
})
it('sums output VAT from 2611/2621/2631 credit balances', async () => {
it('sums output VAT to ruta10/11/12 and revenue to ruta05', async () => {
results = [
{
data: [
@@ -196,12 +197,16 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta07).toBe(180)
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
// Per-rate base amounts in breakdown
expect(result.breakdown.invoices.base25).toBe(10000)
expect(result.breakdown.invoices.base12).toBe(5000)
expect(result.breakdown.invoices.base6).toBe(3000)
expect(result.invoiceCount).toBe(2)
})
@@ -277,9 +282,9 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
// Net: 2500 - 625 = 1875 output VAT, 10000 - 2500 = 7500 revenue
expect(result.rutor.ruta05).toBe(1875)
expect(result.rutor.ruta10).toBe(7500)
// Net: 2500 - 625 = 1875 output VAT in ruta10, 10000 - 2500 = 7500 revenue in ruta05
expect(result.rutor.ruta10).toBe(1875)
expect(result.rutor.ruta05).toBe(7500)
expect(result.invoiceCount).toBe(2)
})
@@ -298,7 +303,8 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta05).toBe(10000)
expect(result.rutor.ruta48).toBe(350)
expect(result.rutor.ruta49).toBe(2150) // 2500 - 350
})
@@ -354,12 +360,12 @@ describe('calculateVatDeclaration', () => {
const result = await calculateVatDeclaration(supabase, 'user-1', 'quarterly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta07).toBe(180)
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
expect(result.rutor.ruta48).toBe(1000)
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
expect(result.rutor.ruta49).toBe(2280)
+191
View File
@@ -0,0 +1,191 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import JSZip from 'jszip'
import { generateSIEExport } from './sie-export'
import { generateTrialBalance } from './trial-balance'
import { generateIncomeStatement } from './income-statement'
import { generateBalanceSheet } from './balance-sheet'
import { generateGeneralLedger } from './general-ledger'
import { generateJournalRegister } from './journal-register'
import { calculateVatDeclaration } from './vat-declaration'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import type { AuditLogEntry } from '@/types'
export interface FullArchiveOptions {
period_id: string
include_documents?: boolean
}
interface DocumentManifestEntry {
file_name: string
storage_path: string
status: 'downloaded' | 'missing' | 'error'
error?: string
}
/**
* Generate a full archive ZIP for a fiscal period.
*
* Contains SIE4 file, all financial reports, attached documents, and audit trail.
* This fulfills the Swedish accounting law (BFL) requirement for complete archives.
*/
export async function generateFullArchive(
supabase: SupabaseClient,
userId: string,
options: FullArchiveOptions
): Promise<ArrayBuffer> {
const { period_id, include_documents = true } = options
// Fetch fiscal period
const { data: period } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', period_id)
.eq('user_id', userId)
.single()
if (!period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: company } = await supabase
.from('company_settings')
.select('company_name, org_number, moms_period')
.eq('user_id', userId)
.single()
if (!company) {
throw new Error('Company settings not found')
}
const zip = new JSZip()
// 1. SIE4 export
const sieContent = await generateSIEExport(supabase, userId, {
fiscal_period_id: period_id,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
program_name: 'ERPBase',
})
zip.file('bokforing.se', sieContent)
// 2. Reports folder
const rapporter = zip.folder('rapporter')!
const [trialBalance, incomeStatement, balanceSheet, generalLedger, journalRegister] =
await Promise.all([
generateTrialBalance(supabase, userId, period_id),
generateIncomeStatement(supabase, userId, period_id),
generateBalanceSheet(supabase, userId, period_id),
generateGeneralLedger(supabase, userId, period_id),
generateJournalRegister(supabase, userId, period_id),
])
rapporter.file('saldobalans.json', JSON.stringify(trialBalance, null, 2))
rapporter.file('resultatrakning.json', JSON.stringify(incomeStatement, null, 2))
rapporter.file('balansrakning.json', JSON.stringify(balanceSheet, null, 2))
rapporter.file('huvudbok.json', JSON.stringify(generalLedger, null, 2))
rapporter.file('grundbok.json', JSON.stringify(journalRegister, null, 2))
// VAT declaration — calculate for the full fiscal period as yearly
try {
const startDate = new Date(period.period_start)
const vatDeclaration = await calculateVatDeclaration(
supabase,
userId,
'yearly',
startDate.getFullYear(),
1
)
rapporter.file('momsdeklaration.json', JSON.stringify(vatDeclaration, null, 2))
} catch {
// VAT declaration may fail if no relevant entries exist — skip gracefully
}
// 3. Documents folder
if (include_documents) {
const dokument = zip.folder('dokument')!
const manifest: DocumentManifestEntry[] = []
// Fetch document attachments linked to journal entries in this period
const { data: documents } = await supabase
.from('document_attachments')
.select('id, file_name, storage_path, journal_entry_id')
.eq('user_id', userId)
.not('journal_entry_id', 'is', null)
if (documents && documents.length > 0) {
// Filter to entries in this period
const { data: periodEntryIds } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.eq('fiscal_period_id', period_id)
.in('status', ['posted', 'reversed'])
const periodEntryIdSet = new Set((periodEntryIds || []).map((e: { id: string }) => e.id))
const periodDocuments = documents.filter(
(d: { journal_entry_id: string | null }) => d.journal_entry_id && periodEntryIdSet.has(d.journal_entry_id)
)
for (const doc of periodDocuments) {
try {
const { data: fileData, error } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (error || !fileData) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'error',
error: error?.message || 'Download returned no data',
})
continue
}
const buffer = await fileData.arrayBuffer()
dokument.file(doc.file_name, buffer)
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'downloaded',
})
} catch (err) {
manifest.push({
file_name: doc.file_name,
storage_path: doc.storage_path,
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
}
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
// 4. Audit trail
const revision = zip.folder('revision')!
const allAuditEntries: AuditLogEntry[] = []
let page = 1
const pageSize = 500
while (true) {
const result = await getAuditLog(supabase, userId, {
from_date: period.period_start,
to_date: period.period_end,
page,
pageSize,
})
allAuditEntries.push(...result.data)
if (allAuditEntries.length >= result.count || result.data.length < pageSize) {
break
}
page++
}
revision.file('behandlingshistorik.json', JSON.stringify(allAuditEntries, null, 2))
return zip.generateAsync({ type: 'arraybuffer' })
}
@@ -0,0 +1,257 @@
import { describe, it, expect } from 'vitest'
import { INK2_ACCOUNT_MAPPINGS, isAccountInMapping } from '../ink2-engine'
import type { INK2AccountMapping, INK2SRUCode } from '../types'
/**
* Helper to find which SRU code an account maps to
*/
function findSRUCodeForAccount(accountNumber: string): INK2SRUCode | null {
for (const mapping of INK2_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) {
return mapping.sruCode
}
}
return null
}
describe('INK2 Account Mappings', () => {
describe('completeness', () => {
it('has 19 mappings covering all INK2 fields', () => {
expect(INK2_ACCOUNT_MAPPINGS).toHaveLength(19)
})
it('covers all SRU codes', () => {
const codes = INK2_ACCOUNT_MAPPINGS.map(m => m.sruCode)
const expectedCodes: INK2SRUCode[] = [
'7201', '7202', '7203', '7210', '7211', '7212',
'7220', '7221', '7222', '7230', '7231',
'7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380',
]
expect(codes).toEqual(expectedCodes)
})
})
describe('Balance sheet - Assets', () => {
it('1000-1099 -> 7201 (Immateriella AT)', () => {
expect(findSRUCodeForAccount('1000')).toBe('7201')
expect(findSRUCodeForAccount('1050')).toBe('7201')
expect(findSRUCodeForAccount('1099')).toBe('7201')
})
it('1100-1299 -> 7202 (Materiella AT)', () => {
expect(findSRUCodeForAccount('1100')).toBe('7202')
expect(findSRUCodeForAccount('1210')).toBe('7202')
expect(findSRUCodeForAccount('1299')).toBe('7202')
})
it('1300-1399 -> 7203 (Finansiella AT)', () => {
expect(findSRUCodeForAccount('1300')).toBe('7203')
expect(findSRUCodeForAccount('1350')).toBe('7203')
expect(findSRUCodeForAccount('1399')).toBe('7203')
})
it('1400-1499 -> 7210 (Varulager)', () => {
expect(findSRUCodeForAccount('1400')).toBe('7210')
expect(findSRUCodeForAccount('1460')).toBe('7210')
expect(findSRUCodeForAccount('1499')).toBe('7210')
})
it('1500-1599 -> 7211 (Kundfordringar)', () => {
expect(findSRUCodeForAccount('1500')).toBe('7211')
expect(findSRUCodeForAccount('1510')).toBe('7211')
expect(findSRUCodeForAccount('1599')).toBe('7211')
})
it('1600-1999 -> 7212 (Övriga OT)', () => {
expect(findSRUCodeForAccount('1600')).toBe('7212')
expect(findSRUCodeForAccount('1930')).toBe('7212')
expect(findSRUCodeForAccount('1999')).toBe('7212')
})
})
describe('Balance sheet - Equity & Liabilities', () => {
it('2081 -> 7220 (Aktiekapital)', () => {
expect(findSRUCodeForAccount('2081')).toBe('7220')
})
it('2081 does NOT go to 7221', () => {
expect(findSRUCodeForAccount('2081')).not.toBe('7221')
})
it('2000-2080 -> 7221 (Övrigt EK)', () => {
expect(findSRUCodeForAccount('2000')).toBe('7221')
expect(findSRUCodeForAccount('2010')).toBe('7221')
expect(findSRUCodeForAccount('2080')).toBe('7221')
})
it('2082-2098 -> 7221 (Övrigt EK)', () => {
expect(findSRUCodeForAccount('2082')).toBe('7221')
expect(findSRUCodeForAccount('2090')).toBe('7221')
expect(findSRUCodeForAccount('2098')).toBe('7221')
})
it('2099 -> 7222 (Årets resultat)', () => {
expect(findSRUCodeForAccount('2099')).toBe('7222')
})
it('2099 does NOT go to 7221', () => {
expect(findSRUCodeForAccount('2099')).not.toBe('7221')
})
it('2100-2499 -> 7230 (Obeskattade reserver, avsättningar, skulder)', () => {
expect(findSRUCodeForAccount('2100')).toBe('7230')
expect(findSRUCodeForAccount('2150')).toBe('7230') // Obeskattade reserver
expect(findSRUCodeForAccount('2250')).toBe('7230') // Avsättningar
expect(findSRUCodeForAccount('2440')).toBe('7230') // Leverantörsskulder
expect(findSRUCodeForAccount('2499')).toBe('7230')
})
it('2500-2999 -> 7231 (Övriga skulder)', () => {
expect(findSRUCodeForAccount('2500')).toBe('7231')
expect(findSRUCodeForAccount('2611')).toBe('7231') // Utgående moms
expect(findSRUCodeForAccount('2710')).toBe('7231') // Personalens källskatt
expect(findSRUCodeForAccount('2999')).toBe('7231')
})
})
describe('Income statement', () => {
it('3000-3999 -> 7310 (Nettoomsättning)', () => {
expect(findSRUCodeForAccount('3000')).toBe('7310')
expect(findSRUCodeForAccount('3001')).toBe('7310')
expect(findSRUCodeForAccount('3100')).toBe('7310')
expect(findSRUCodeForAccount('3999')).toBe('7310')
})
it('4000-4999 -> 7320 (Varuinköp)', () => {
expect(findSRUCodeForAccount('4000')).toBe('7320')
expect(findSRUCodeForAccount('4010')).toBe('7320')
expect(findSRUCodeForAccount('4999')).toBe('7320')
})
it('5000-6999 -> 7330 (Övriga externa kostnader)', () => {
expect(findSRUCodeForAccount('5000')).toBe('7330')
expect(findSRUCodeForAccount('5460')).toBe('7330')
expect(findSRUCodeForAccount('6200')).toBe('7330')
expect(findSRUCodeForAccount('6999')).toBe('7330')
})
it('7000-7699 -> 7340 (Personalkostnader)', () => {
expect(findSRUCodeForAccount('7000')).toBe('7340')
expect(findSRUCodeForAccount('7210')).toBe('7340')
expect(findSRUCodeForAccount('7699')).toBe('7340')
})
it('7700-7899 -> 7350 (Avskrivningar)', () => {
expect(findSRUCodeForAccount('7700')).toBe('7350')
expect(findSRUCodeForAccount('7820')).toBe('7350')
expect(findSRUCodeForAccount('7899')).toBe('7350')
})
it('7900-7999 -> 7360 (Övriga rörelsekostnader)', () => {
expect(findSRUCodeForAccount('7900')).toBe('7360')
expect(findSRUCodeForAccount('7970')).toBe('7360')
expect(findSRUCodeForAccount('7999')).toBe('7360')
})
it('8000-8499 -> 7370 (Finansiella poster)', () => {
expect(findSRUCodeForAccount('8000')).toBe('7370')
expect(findSRUCodeForAccount('8310')).toBe('7370') // Ränteintäkter
expect(findSRUCodeForAccount('8400')).toBe('7370') // Räntekostnader
expect(findSRUCodeForAccount('8499')).toBe('7370')
})
it('8500-8999 -> 7380 (Extraordinära poster)', () => {
expect(findSRUCodeForAccount('8500')).toBe('7380')
expect(findSRUCodeForAccount('8910')).toBe('7380') // Skatt
expect(findSRUCodeForAccount('8999')).toBe('7380')
})
})
describe('no overlap between mappings', () => {
it('each account matches exactly one mapping', () => {
// Test a representative sample across boundaries
const testAccounts = [
'1099', '1100', // 7201/7202 boundary
'1299', '1300', // 7202/7203 boundary
'1399', '1400', // 7203/7210 boundary
'1499', '1500', // 7210/7211 boundary
'1599', '1600', // 7211/7212 boundary
'1999', '2000', // 7212/7221 boundary
'2080', '2081', '2082', // 7221/7220/7221
'2098', '2099', '2100', // 7221/7222/7230 boundary
'2499', '2500', // 7230/7231 boundary
'2999', '3000', // 7231/7310 boundary
'3999', '4000', // 7310/7320 boundary
'4999', '5000', // 7320/7330 boundary
'6999', '7000', // 7330/7340 boundary
'7699', '7700', // 7340/7350 boundary
'7899', '7900', // 7350/7360 boundary
'7999', '8000', // 7360/7370 boundary
'8499', '8500', // 7370/7380 boundary
]
for (const account of testAccounts) {
let matchCount = 0
for (const mapping of INK2_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(account, mapping)) {
matchCount++
}
}
expect(matchCount).toBe(1)
}
})
})
describe('section assignments', () => {
it('asset mappings have section "assets"', () => {
const assetMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'assets')
expect(assetMappings.map(m => m.sruCode)).toEqual(['7201', '7202', '7203', '7210', '7211', '7212'])
})
it('equity/liability mappings have section "equity_liabilities"', () => {
const eqMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'equity_liabilities')
expect(eqMappings.map(m => m.sruCode)).toEqual(['7220', '7221', '7222', '7230', '7231'])
})
it('income statement mappings have section "income_statement"', () => {
const isMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'income_statement')
expect(isMappings.map(m => m.sruCode)).toEqual(['7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380'])
})
})
describe('normal balance assignments', () => {
it('asset accounts are debit-normal', () => {
const assetMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'assets')
for (const m of assetMappings) {
expect(m.normalBalance).toBe('debit')
}
})
it('equity/liability accounts are credit-normal', () => {
const eqMappings = INK2_ACCOUNT_MAPPINGS.filter(m => m.section === 'equity_liabilities')
for (const m of eqMappings) {
expect(m.normalBalance).toBe('credit')
}
})
it('revenue (7310) is credit-normal', () => {
const revenue = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === '7310')
expect(revenue?.normalBalance).toBe('credit')
})
it('expense accounts (7320-7360) are debit-normal', () => {
const expenseCodes: INK2SRUCode[] = ['7320', '7330', '7340', '7350', '7360']
for (const code of expenseCodes) {
const mapping = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === code)
expect(mapping?.normalBalance).toBe('debit')
}
})
it('financial and extraordinary items (7370, 7380) are net', () => {
const financial = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === '7370')
const extraordinary = INK2_ACCOUNT_MAPPINGS.find(m => m.sruCode === '7380')
expect(financial?.normalBalance).toBe('net')
expect(extraordinary?.normalBalance).toBe('net')
})
})
})
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest'
import { generateSRUFile, sruFileToString, validateSRUFile, getSRUFilename } from '../sru-generator'
import type { INK2Declaration } from '../types'
function makeDeclaration(overrides?: Partial<INK2Declaration>): INK2Declaration {
return {
fiscalYear: {
id: 'period-1',
name: 'Räkenskapsår 2025',
start: '2025-01-01',
end: '2025-12-31',
isClosed: true,
},
rutor: {
'7201': 0, '7202': 50000, '7203': 0,
'7210': 10000, '7211': 25000, '7212': 100000,
'7220': 50000, '7221': 20000, '7222': 15000,
'7230': 30000, '7231': 70000,
'7310': 500000, '7320': 200000, '7330': 100000,
'7340': 80000, '7350': 10000, '7360': 5000,
'7370': -3000, '7380': 0,
},
breakdown: {} as INK2Declaration['breakdown'],
totals: {
totalAssets: 185000,
totalEquityLiabilities: 185000,
operatingResult: 105000,
resultAfterFinancial: 102000,
},
companyInfo: {
companyName: 'Test AB',
orgNumber: '556677-8899',
},
warnings: [],
...overrides,
}
}
describe('INK2 SRU Generator', () => {
describe('generateSRUFile', () => {
it('produces valid SRU file structure', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const validation = validateSRUFile(sruFile)
expect(validation.isValid).toBe(true)
expect(validation.errors).toEqual([])
})
it('uses #BLANKETT INK2', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const blankettRecord = sruFile.records.find(r => r.fieldCode === 'BLANKETT')
expect(blankettRecord?.value).toBe('INK2')
})
it('includes only non-zero field values', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const uppgiftRecords = sruFile.records.filter(r => r.fieldCode === 'UPPGIFT')
// 7000 (fiscal year) + non-zero rutor
// Zero rutor: 7201, 7203, 7380 = 3 zero fields
// Non-zero: 16 fields
// Total UPPGIFT records: 1 (fiscal year) + 16 (non-zero values)
expect(uppgiftRecords).toHaveLength(17)
// Verify zero fields are excluded
const fieldCodes = uppgiftRecords.map(r => String(r.value).split(' ')[0])
expect(fieldCodes).not.toContain('7201')
expect(fieldCodes).not.toContain('7203')
expect(fieldCodes).not.toContain('7380')
})
it('includes fiscal year as field 7000', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const fiscalYearRecord = sruFile.records.find(
r => r.fieldCode === 'UPPGIFT' && String(r.value).startsWith('7000')
)
expect(fiscalYearRecord).toBeDefined()
expect(fiscalYearRecord?.value).toBe('7000 20250101-20251231')
})
it('handles negative values (financial items)', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const financialRecord = sruFile.records.find(
r => r.fieldCode === 'UPPGIFT' && String(r.value).startsWith('7370')
)
expect(financialRecord?.value).toBe('7370 -3000')
})
it('strips dashes from org number', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const identityRecord = sruFile.records.find(r => r.fieldCode === 'IDENTITET')
expect(identityRecord?.value).toBe('5566778899')
})
})
describe('sruFileToString', () => {
it('formats records as #FIELD value lines', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const content = sruFileToString(sruFile)
expect(content).toContain('#BLANKETT INK2')
expect(content).toContain('#IDENTITET 5566778899')
expect(content).toContain('#BLANKETTSLUT')
})
it('uses CRLF line endings', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const content = sruFileToString(sruFile)
expect(content).toContain('\r\n')
})
it('ends with newline', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const content = sruFileToString(sruFile)
expect(content.endsWith('\r\n')).toBe(true)
})
})
describe('getSRUFilename', () => {
it('returns correct filename format', () => {
const declaration = makeDeclaration()
expect(getSRUFilename(declaration)).toBe('INK2_5566778899_2025.sru')
})
it('handles missing org number', () => {
const declaration = makeDeclaration({
companyInfo: { companyName: 'Test AB', orgNumber: null },
})
expect(getSRUFilename(declaration)).toBe('INK2_unknown_2025.sru')
})
})
describe('validateSRUFile', () => {
it('validates a correct SRU file', () => {
const declaration = makeDeclaration()
const sruFile = generateSRUFile(declaration)
const result = validateSRUFile(sruFile)
expect(result.isValid).toBe(true)
})
it('detects missing PRODUKT header', () => {
const result = validateSRUFile({
records: [
{ fieldCode: 'BLANKETT', value: 'INK2' },
{ fieldCode: 'BLANKETTSLUT', value: '' },
],
generatedAt: new Date().toISOString(),
})
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Missing PRODUKT header')
})
it('detects wrong blankett type', () => {
const result = validateSRUFile({
records: [
{ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' },
{ fieldCode: 'BLANKETT', value: 'NE' },
{ fieldCode: 'BLANKETTSLUT', value: '' },
],
generatedAt: new Date().toISOString(),
})
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Expected BLANKETT INK2, got NE')
})
})
})
+386
View File
@@ -0,0 +1,386 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
FiscalPeriod,
JournalEntry,
JournalEntryLine,
} from '@/types'
import type {
INK2Declaration,
INK2DeclarationRutor,
INK2AccountMapping,
INK2SRUCode,
} from './types'
/**
* INK2 (Aktiebolag / Limited Company Declaration)
*
* Maps BAS account balances to INK2 declaration fields (SRU 7201-7380)
* for tax reporting to Skatteverket.
*
* This generates the bokföringsmässigt resultat (accounting result).
* Skattemässiga justeringar (INK2S) are handled by the accountant.
*
* Account mappings use engine-internal range-based logic, NOT the DB
* sru_code column, because the DB column is NE-biased for class 3-8.
*/
/**
* Account mapping configuration for INK2 declaration
*/
export const INK2_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
// Balance sheet - Assets
{
sruCode: '7201',
description: 'Immateriella anläggningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1000', end: '1099' }],
},
{
sruCode: '7202',
description: 'Materiella anläggningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1100', end: '1299' }],
},
{
sruCode: '7203',
description: 'Finansiella anläggningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1300', end: '1399' }],
},
{
sruCode: '7210',
description: 'Varulager m.m.',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1400', end: '1499' }],
},
{
sruCode: '7211',
description: 'Kundfordringar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1500', end: '1599' }],
},
{
sruCode: '7212',
description: 'Övriga omsättningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1600', end: '1999' }],
},
// Balance sheet - Equity & Liabilities
{
sruCode: '7220',
description: 'Aktiekapital',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2081', end: '2081' }],
},
{
sruCode: '7221',
description: 'Övrigt eget kapital',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [
{ start: '2000', end: '2080' },
{ start: '2082', end: '2098' },
],
},
{
sruCode: '7222',
description: 'Årets resultat',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2099', end: '2099' }],
},
{
sruCode: '7230',
description: 'Obeskattade reserver, avsättningar och skulder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2100', end: '2499' }],
},
{
sruCode: '7231',
description: 'Övriga skulder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2500', end: '2999' }],
},
// Income statement
{
sruCode: '7310',
description: 'Nettoomsättning',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '3000', end: '3999' }],
},
{
sruCode: '7320',
description: 'Varuinköp/direkta kostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '4000', end: '4999' }],
},
{
sruCode: '7330',
description: 'Övriga externa kostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '5000', end: '6999' }],
},
{
sruCode: '7340',
description: 'Personalkostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7000', end: '7699' }],
},
{
sruCode: '7350',
description: 'Avskrivningar',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7700', end: '7899' }],
},
{
sruCode: '7360',
description: 'Övriga rörelsekostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7900', end: '7999' }],
},
{
sruCode: '7370',
description: 'Finansiella poster (netto)',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8000', end: '8499' }],
},
{
sruCode: '7380',
description: 'Extraordinära poster (netto)',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8500', end: '8999' }],
},
]
/**
* Check if an account number falls within a mapping's ranges
*/
export function isAccountInMapping(accountNumber: string, mapping: INK2AccountMapping): boolean {
for (const range of mapping.accountRanges) {
if (accountNumber >= range.start && accountNumber <= range.end) {
if (range.exclude && range.exclude.includes(accountNumber)) {
continue
}
return true
}
}
return false
}
/**
* Round to nearest krona (whole number) for INK2 declaration
*/
function roundToKrona(value: number): number {
return Math.round(value)
}
/**
* Generate INK2 declaration for a fiscal period
*/
export async function generateINK2Declaration(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<INK2Declaration> {
// Fetch fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type')
.eq('user_id', userId)
.single()
// Validate entity type
if (settings?.entity_type !== 'aktiebolag') {
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate balances per account (debit - credit)
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Initialize rutor
const rutor: INK2DeclarationRutor = {
'7201': 0, '7202': 0, '7203': 0, '7210': 0, '7211': 0, '7212': 0,
'7220': 0, '7221': 0, '7222': 0, '7230': 0, '7231': 0,
'7310': 0, '7320': 0, '7330': 0, '7340': 0, '7350': 0, '7360': 0, '7370': 0, '7380': 0,
}
const allCodes: INK2SRUCode[] = Object.keys(rutor) as INK2SRUCode[]
const breakdown = {} as INK2Declaration['breakdown']
for (const code of allCodes) {
breakdown[code] = { accounts: [], total: 0 }
}
const warnings: string[] = []
// Process each account balance
for (const [accountNumber, balance] of accountBalances) {
if (Math.abs(balance) < 0.01) continue
for (const mapping of INK2_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) {
let amount: number
if (mapping.normalBalance === 'debit') {
// Asset/expense accounts: debit normal, balance is already positive for debit
amount = balance
} else if (mapping.normalBalance === 'credit') {
// Equity/liability/revenue accounts: credit normal, negate to show as positive
amount = -balance
} else {
// Net fields (7370, 7380): negate so positive = net income, negative = net cost
amount = -balance
}
rutor[mapping.sruCode] += amount
breakdown[mapping.sruCode].accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: roundToKrona(amount),
})
break
}
}
}
// Round all rutor to whole krona
for (const code of allCodes) {
rutor[code] = roundToKrona(rutor[code])
breakdown[code].total = rutor[code]
}
// Calculate derived totals
const totalAssets = rutor['7201'] + rutor['7202'] + rutor['7203'] +
rutor['7210'] + rutor['7211'] + rutor['7212']
const totalEquityLiabilities = rutor['7220'] + rutor['7221'] + rutor['7222'] +
rutor['7230'] + rutor['7231']
// Operating result = revenue - operating costs
const operatingResult = rutor['7310'] -
rutor['7320'] - rutor['7330'] - rutor['7340'] -
rutor['7350'] - rutor['7360']
// Result after financial items
const resultAfterFinancial = operatingResult + rutor['7370'] + rutor['7380']
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (totalAssets === 0 && totalEquityLiabilities === 0 && rutor['7310'] === 0) {
warnings.push('Inga bokförda transaktioner hittades för perioden.')
}
const balanceDiff = Math.abs(totalAssets - totalEquityLiabilities)
if (balanceDiff > 0 && totalAssets > 0) {
warnings.push(
`Balansräkningen är inte i balans. Tillgångar: ${totalAssets} kr, Eget kapital och skulder: ${totalEquityLiabilities} kr (differens: ${balanceDiff} kr).`
)
}
return {
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
isClosed: period.is_closed,
},
rutor,
breakdown,
totals: {
totalAssets,
totalEquityLiabilities,
operatingResult,
resultAfterFinancial,
},
companyInfo: {
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
},
warnings,
}
}
/**
* Get totals for display
*/
export function getINK2DeclarationTotals(declaration: INK2Declaration): {
totalAssets: number
totalEquityLiabilities: number
operatingResult: number
resultAfterFinancial: number
} {
return declaration.totals
}
+158
View File
@@ -0,0 +1,158 @@
import type { INK2Declaration, INK2SRUCode, SRUFile, SRURecord } from './types'
/**
* SRU File Generator for INK2
*
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
* submission to Skatteverket. The SRU format is used for tax declarations.
*
* INK2 field codes are the SRU codes directly (7201-7380).
*/
/** All INK2 SRU field codes in order */
const INK2_FIELD_CODES: INK2SRUCode[] = [
'7201', '7202', '7203', '7210', '7211', '7212',
'7220', '7221', '7222', '7230', '7231',
'7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380',
]
/**
* Generate SRU file content from INK2 declaration
*/
export function generateSRUFile(declaration: INK2Declaration): SRUFile {
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({
fieldCode: 'SKAPAT',
value: formatSRUDate(now),
})
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: 'INK2' })
// Company identification
if (declaration.companyInfo.orgNumber) {
const cleanOrgNumber = declaration.companyInfo.orgNumber.replace(/-/g, '')
records.push({
fieldCode: 'IDENTITET',
value: cleanOrgNumber,
})
}
// Fiscal year
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${formatSRUDateRange(declaration.fiscalYear.start, declaration.fiscalYear.end)}`,
})
// INK2 field values
for (const code of INK2_FIELD_CODES) {
const value = declaration.rutor[code]
if (value !== 0) {
records.push({
fieldCode: 'UPPGIFT',
value: `${code} ${formatSRUAmount(value)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Convert SRU file to string content
*/
export function sruFileToString(sruFile: SRUFile): string {
const lines: string[] = []
for (const record of sruFile.records) {
if (record.value === '') {
lines.push(`#${record.fieldCode}`)
} else {
lines.push(`#${record.fieldCode} ${record.value}`)
}
}
return lines.join('\r\n') + '\r\n'
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
/**
* Format date string (YYYY-MM-DD) to SRU format (YYYYMMDD)
*/
function dateStringToSRU(dateStr: string): string {
return dateStr.replace(/-/g, '')
}
/**
* Format fiscal year date range for SRU
*/
function formatSRUDateRange(startDate: string, endDate: string): string {
return `${dateStringToSRU(startDate)}-${dateStringToSRU(endDate)}`
}
/**
* Format amount for SRU: whole numbers, no thousands separator, negative with minus
*/
function formatSRUAmount(amount: number): string {
return Math.round(amount).toString()
}
/**
* Validate SRU file content
*/
export function validateSRUFile(sruFile: SRUFile): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
const hasHeader = sruFile.records.some(r => r.fieldCode === 'PRODUKT')
const hasBlankett = sruFile.records.some(r => r.fieldCode === 'BLANKETT')
const hasBlankettslut = sruFile.records.some(r => r.fieldCode === 'BLANKETTSLUT')
if (!hasHeader) errors.push('Missing PRODUKT header')
if (!hasBlankett) errors.push('Missing BLANKETT declaration')
if (!hasBlankettslut) errors.push('Missing BLANKETTSLUT')
// Verify it's INK2
const blankettRecord = sruFile.records.find(r => r.fieldCode === 'BLANKETT')
if (blankettRecord && blankettRecord.value !== 'INK2') {
errors.push(`Expected BLANKETT INK2, got ${blankettRecord.value}`)
}
return {
isValid: errors.length === 0,
errors,
}
}
/**
* Get filename for SRU file download
*/
export function getSRUFilename(declaration: INK2Declaration): string {
const year = declaration.fiscalYear.start.substring(0, 4)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
return `INK2_${orgNumber}_${year}.sru`
}
+104
View File
@@ -0,0 +1,104 @@
// INK2 declaration rutor (fields) keyed by SRU code
export interface INK2DeclarationRutor {
// Balance sheet - Assets
'7201': number // Immateriella anläggningstillgångar
'7202': number // Materiella anläggningstillgångar
'7203': number // Finansiella anläggningstillgångar
'7210': number // Varulager m.m.
'7211': number // Kundfordringar
'7212': number // Övriga omsättningstillgångar
// Balance sheet - Equity & Liabilities
'7220': number // Aktiekapital
'7221': number // Övrigt eget kapital
'7222': number // Årets resultat
'7230': number // Obeskattade reserver, avsättningar och skulder
'7231': number // Övriga skulder
// Income statement
'7310': number // Nettoomsättning
'7320': number // Varuinköp/direkta kostnader
'7330': number // Övriga externa kostnader
'7340': number // Personalkostnader
'7350': number // Avskrivningar
'7360': number // Övriga rörelsekostnader
'7370': number // Finansiella poster (netto)
'7380': number // Extraordinära poster (netto)
}
export type INK2SRUCode = keyof INK2DeclarationRutor
// Account mapping configuration for INK2 declaration
export interface INK2AccountMapping {
sruCode: INK2SRUCode
description: string
section: 'assets' | 'equity_liabilities' | 'income_statement'
normalBalance: 'debit' | 'credit' | 'net'
accountRanges: Array<{
start: string
end: string
exclude?: string[]
}>
}
// INK2 declaration response
export interface INK2Declaration {
fiscalYear: {
id: string
name: string
start: string
end: string
isClosed: boolean
}
rutor: INK2DeclarationRutor
breakdown: Record<INK2SRUCode, {
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
total: number
}>
totals: {
totalAssets: number
totalEquityLiabilities: number
operatingResult: number
resultAfterFinancial: number
}
companyInfo: {
companyName: string
orgNumber: string | null
}
warnings: string[]
}
// Reuse SRU file types from NE-bilaga
export type { SRURecord, SRUFile } from '@/lib/reports/ne-bilaga/types'
// Labels for INK2 rutor
export const INK2_RUTA_LABELS: Record<INK2SRUCode, string> = {
'7201': 'Immateriella anläggningstillgångar',
'7202': 'Materiella anläggningstillgångar',
'7203': 'Finansiella anläggningstillgångar',
'7210': 'Varulager m.m.',
'7211': 'Kundfordringar',
'7212': 'Övriga omsättningstillgångar',
'7220': 'Aktiekapital',
'7221': 'Övrigt eget kapital',
'7222': 'Årets resultat',
'7230': 'Obeskattade reserver, avsättningar och skulder',
'7231': 'Övriga skulder',
'7310': 'Nettoomsättning',
'7320': 'Varuinköp/direkta kostnader',
'7330': 'Övriga externa kostnader',
'7340': 'Personalkostnader',
'7350': 'Avskrivningar',
'7360': 'Övriga rörelsekostnader',
'7370': 'Finansiella poster (netto)',
'7380': 'Extraordinära poster (netto)',
}
// Section groupings for UI display
export const INK2_ASSET_CODES: INK2SRUCode[] = ['7201', '7202', '7203', '7210', '7211', '7212']
export const INK2_EQUITY_LIABILITY_CODES: INK2SRUCode[] = ['7220', '7221', '7222', '7230', '7231']
export const INK2_INCOME_STATEMENT_CODES: INK2SRUCode[] = ['7310', '7320', '7330', '7340', '7350', '7360', '7370', '7380']
+35 -16
View File
@@ -20,21 +20,26 @@ import type {
*/
/**
* Account-to-ruta mapping for the Swedish momsdeklaration.
* Account-to-ruta mapping for the Swedish momsdeklaration (SKV 4700).
*
* Output VAT (26xx): net credit balance feeds output VAT boxes.
* Revenue (3001/3002/3003): net credit balance feeds ruta 05 (total domestic taxable sales).
* Output VAT (2611/2621/2631): net credit balance feeds ruta 10/11/12 (output VAT per rate).
* Input VAT (2641/2645): net debit balance feeds ruta 48.
* Revenue (3xxx): net credit balance feeds underlag (basis) boxes.
* EU/Export (3308/3305): net credit balance feeds ruta 39/40.
*/
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
'2611': { box: 'ruta05', side: 'credit' },
'2621': { box: 'ruta06', side: 'credit' },
'2631': { box: 'ruta07', side: 'credit' },
// Output VAT accounts → ruta 10/11/12
'2611': { box: 'ruta10', side: 'credit' },
'2621': { box: 'ruta11', side: 'credit' },
'2631': { box: 'ruta12', side: 'credit' },
// Input VAT → ruta 48
'2641': { box: 'ruta48', side: 'debit' },
'2645': { box: 'ruta48', side: 'debit' },
'3001': { box: 'ruta10', side: 'credit' },
'3002': { box: 'ruta11', side: 'credit' },
'3003': { box: 'ruta12', side: 'credit' },
// Revenue accounts → ruta 05 (all domestic taxable sales combined)
'3001': { box: 'ruta05', side: 'credit' },
'3002': { box: 'ruta05', side: 'credit' },
'3003': { box: 'ruta05', side: 'credit' },
// EU/Export → ruta 39/40
'3305': { box: 'ruta40', side: 'credit' },
'3308': { box: 'ruta39', side: 'credit' },
}
@@ -103,11 +108,11 @@ function round(value: number): number {
* Calculate VAT declaration from the general ledger.
*
* Sums posted journal entry lines on 26xx and 3xxx accounts:
* - 2611/2621/2631 credit balance -> ruta 05/06/07 (output VAT)
* - 3001/3002/3003 credit balance -> ruta 05 (total domestic taxable sales)
* - 2611/2621/2631 credit balance -> ruta 10/11/12 (output VAT per rate)
* - 2641/2645 debit balance -> ruta 48 (input VAT)
* - 3001/3002/3003 credit balance -> ruta 10/11/12 (revenue basis)
* - 3308/3305 credit balance -> ruta 39/40 (EU/export)
* - ruta 49 = (05 + 06 + 07) - 48
* - ruta 49 = (10 + 11 + 12) - 48
*
* The accounting method parameter is accepted for backward compatibility
* but not used — the method is already baked into journal entry timing.
@@ -170,7 +175,18 @@ export async function calculateVatDeclaration(
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
rutor.ruta49 = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07 - rutor.ruta48)
rutor.ruta49 = round(rutor.ruta10 + rutor.ruta11 + rutor.ruta12 - rutor.ruta48)
// Compute per-rate base amounts from individual revenue accounts
const revenueByRate = {
base25: 0, // 3001
base12: 0, // 3002
base6: 0, // 3003
}
for (const [account, rate] of [['3001', 'base25'], ['3002', 'base12'], ['3003', 'base6']] as const) {
const t = totals.get(account)
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
@@ -206,6 +222,9 @@ export async function calculateVatDeclaration(
ruta12: rutor.ruta12,
ruta39: rutor.ruta39,
ruta40: rutor.ruta40,
base25: revenueByRate.base25,
base12: revenueByRate.base12,
base6: revenueByRate.base6,
},
transactions: { ruta48: rutor.ruta48 },
receipts: { ruta48: 0 },
@@ -223,9 +242,9 @@ export function getVatDeclarationSummary(declaration: VatDeclaration): {
isRefund: boolean
} {
const totalOutputVat = round(
declaration.rutor.ruta05 +
declaration.rutor.ruta06 +
declaration.rutor.ruta07
declaration.rutor.ruta10 +
declaration.rutor.ruta11 +
declaration.rutor.ruta12
)
const totalInputVat = declaration.rutor.ruta48