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:
co-authored by
Claude Opus 4.6
parent
50bf7b3b0f
commit
29240738fa
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user