feat: consolidate booking templates from 100 to 48 and improve codebase
Reduce booking template library to eliminate duplicate suggestions when users describe transactions. Templates with identical accounting treatment (same account + VAT) are merged, keywords consolidated, and the entire subscriptions group is eliminated. Also includes prior work on reports, extensions, and transaction improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9d684d7e9f
commit
6956a757f3
@@ -17,8 +17,8 @@ import {
|
||||
// ============================================================
|
||||
|
||||
describe('BOOKING_TEMPLATES data integrity', () => {
|
||||
it('has exactly 100 templates', () => {
|
||||
expect(BOOKING_TEMPLATES).toHaveLength(100)
|
||||
it('has exactly 48 templates', () => {
|
||||
expect(BOOKING_TEMPLATES).toHaveLength(48)
|
||||
})
|
||||
|
||||
it('all template IDs are unique', () => {
|
||||
@@ -86,7 +86,7 @@ describe('getTemplateById', () => {
|
||||
it('returns correct template for known ID', () => {
|
||||
const t = getTemplateById('it_saas_subscription')
|
||||
expect(t).toBeDefined()
|
||||
expect(t!.name_sv).toBe('Programvara / SaaS-prenumeration')
|
||||
expect(t!.name_sv).toBe('Programvara / SaaS')
|
||||
expect(t!.debit_account).toBe('5420')
|
||||
})
|
||||
|
||||
@@ -122,9 +122,9 @@ describe('getTemplatesByMcc', () => {
|
||||
})
|
||||
|
||||
describe('getTemplateGroups', () => {
|
||||
it('returns all 18 groups', () => {
|
||||
it('returns all 17 groups', () => {
|
||||
const groups = getTemplateGroups()
|
||||
expect(groups).toHaveLength(18)
|
||||
expect(groups).toHaveLength(17)
|
||||
for (const g of groups) {
|
||||
expect(g.group).toBeTruthy()
|
||||
expect(g.label_sv).toBeTruthy()
|
||||
@@ -136,7 +136,7 @@ describe('getTemplateGroups', () => {
|
||||
it('every template is in exactly one group', () => {
|
||||
const groups = getTemplateGroups()
|
||||
const allTemplates = groups.flatMap((g) => g.templates)
|
||||
expect(allTemplates).toHaveLength(100)
|
||||
expect(allTemplates).toHaveLength(48)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('searchTemplates', () => {
|
||||
})
|
||||
|
||||
it('supports multi-token search', () => {
|
||||
const results = searchTemplates('digital annons')
|
||||
const results = searchTemplates('annonsering marknadsföring')
|
||||
expect(results.some((t) => t.id === 'marketing_online_ads')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -242,14 +242,14 @@ describe('findMatchingTemplates', () => {
|
||||
expect(matches.every((m) => m.template.direction !== 'expense')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns max 5 results', () => {
|
||||
it('returns max 10 results', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -100,
|
||||
description: 'software subscription cloud hosting domain',
|
||||
mcc_code: 5817,
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches.length).toBeLessThanOrEqual(20)
|
||||
expect(matches.length).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('results are sorted by confidence descending', () => {
|
||||
@@ -303,7 +303,7 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
})
|
||||
|
||||
it('produces valid MappingResult for expense with 6% VAT', () => {
|
||||
const template = getTemplate('travel_train')
|
||||
const template = getTemplate('travel_transport')
|
||||
const tx = makeTransaction({ amount: -530 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
})
|
||||
|
||||
it('produces output VAT lines for income with 25% VAT', () => {
|
||||
const template = getTemplate('revenue_services_25')
|
||||
const template = getTemplate('revenue_standard_25')
|
||||
const tx = makeTransaction({ amount: 12500 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
@@ -356,7 +356,7 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
})
|
||||
|
||||
it('produces output VAT lines for income with 12% VAT', () => {
|
||||
const template = getTemplate('revenue_products_12')
|
||||
const template = getTemplate('revenue_reduced_12')
|
||||
const tx = makeTransaction({ amount: 1120 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
@@ -365,16 +365,6 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
expect(result.vat_lines[0].credit_amount).toBe(120)
|
||||
})
|
||||
|
||||
it('produces output VAT lines for income with 6% VAT', () => {
|
||||
const template = getTemplate('revenue_products_6')
|
||||
const tx = makeTransaction({ amount: 1060 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2631')
|
||||
expect(result.vat_lines[0].credit_amount).toBe(60)
|
||||
})
|
||||
|
||||
it('resolves AB-specific accounts for aktiebolag', () => {
|
||||
const template = getTemplate('education_course')
|
||||
const tx = makeTransaction({ amount: -5000 })
|
||||
@@ -411,6 +401,6 @@ describe('buildMappingResultFromTemplate', () => {
|
||||
const tx = makeTransaction({ amount: -800, description: 'OKQ8 tankstation' })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.description).toBe('Drivmedel: OKQ8 tankstation')
|
||||
expect(result.description).toBe('Drivmedel & Laddning: OKQ8 tankstation')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('template-embeddings', () => {
|
||||
expect(text).toContain('non_deductible')
|
||||
})
|
||||
|
||||
it('generates text for all 100 templates without error', async () => {
|
||||
it('generates text for all 45 templates without error', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
for (const template of BOOKING_TEMPLATES) {
|
||||
|
||||
+113
-1286
File diff suppressed because it is too large
Load Diff
@@ -90,9 +90,8 @@ export async function findFiscalPeriod(
|
||||
): Promise<string | null> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Use limit(1) instead of single() to handle overlapping fiscal periods
|
||||
// gracefully. When multiple periods cover the same date, we pick the one
|
||||
// with the latest start date (most specific / narrowest period).
|
||||
// Overlapping periods are prevented by a DB exclusion constraint
|
||||
// (migration 042). limit(1) is kept as a defensive measure.
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
|
||||
@@ -25,7 +25,7 @@ import { createHash } from 'crypto'
|
||||
|
||||
export const EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
const EMBEDDING_LOGIC_VERSION = '1'
|
||||
const MATCH_COUNT = 20
|
||||
const MATCH_COUNT = 5
|
||||
const MATCH_THRESHOLD = 0.5
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,21 +128,10 @@ export async function createNextPeriod(
|
||||
throw new Error('Current fiscal period not found')
|
||||
}
|
||||
|
||||
// Check if next period already exists
|
||||
// Compute next period start (day after current end)
|
||||
const nextStart = new Date(current.period_end)
|
||||
nextStart.setDate(nextStart.getDate() + 1)
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('period_start', nextStart.toISOString().split('T')[0])
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Next fiscal period already exists')
|
||||
}
|
||||
|
||||
// Compute period length from current period to handle broken fiscal years
|
||||
const currentStart = new Date(current.period_start)
|
||||
const currentEnd = new Date(current.period_end)
|
||||
@@ -168,6 +157,19 @@ export async function createNextPeriod(
|
||||
throw new Error(durationError)
|
||||
}
|
||||
|
||||
// Check for overlapping periods
|
||||
const { data: overlapping } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.lte('period_start', nextEndStr)
|
||||
.gte('period_end', nextStartStr)
|
||||
.limit(1)
|
||||
|
||||
if (overlapping && overlapping.length > 0) {
|
||||
throw new Error('Next fiscal period already exists or overlaps with an existing period')
|
||||
}
|
||||
|
||||
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
|
||||
const startYear = nextStart.getFullYear()
|
||||
const endYear = nextEnd.getFullYear()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
formatExtractionSummary,
|
||||
} from '../invoice-inbox-utils'
|
||||
import type { InboxItemStatus } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
describe('getStatusLabel', () => {
|
||||
const cases: [InboxItemStatus, string][] = [
|
||||
|
||||
@@ -11,8 +11,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(7)
|
||||
})
|
||||
|
||||
it('should have 22 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(22)
|
||||
it('should have 24 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(24)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { InboxItemStatus } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import type { InboxItemStatus, InvoiceExtractionResult } from '@/types'
|
||||
|
||||
const STATUS_LABELS: Record<InboxItemStatus, string> = {
|
||||
pending: 'Väntar',
|
||||
|
||||
@@ -2,8 +2,6 @@ import { extensionRegistry } from './registry'
|
||||
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
|
||||
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
|
||||
import { pushNotificationsExtension } from '@/extensions/general/push-notifications'
|
||||
import { sruExportExtension } from '@/extensions/sru-export'
|
||||
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { calendarExtension } from '@/extensions/general/calendar'
|
||||
@@ -26,8 +24,6 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
receiptOcrExtension,
|
||||
aiCategorizationExtension,
|
||||
pushNotificationsExtension,
|
||||
sruExportExtension,
|
||||
neBilagaExtension,
|
||||
aiChatExtension,
|
||||
invoiceInboxExtension,
|
||||
calendarExtension,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* Settings panel registry
|
||||
*
|
||||
* Maps extension IDs to dynamically imported settings panel components.
|
||||
* This allows the core settings page to render extension-provided settings
|
||||
* panels without directly importing from extension directories.
|
||||
*/
|
||||
|
||||
const SETTINGS_PANELS: Record<string, ComponentType> = {
|
||||
'push-notifications': dynamic(
|
||||
() => import('@/extensions/general/push-notifications/NotificationSettings').then(m => ({ default: m.NotificationSettings }))
|
||||
),
|
||||
'enable-banking': dynamic(
|
||||
() => import('@/extensions/general/enable-banking/components/BankingSettingsPanel')
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings panel component for an extension.
|
||||
* Returns null if the extension has no registered settings panel.
|
||||
*/
|
||||
export function getSettingsPanel(extensionId: string): ComponentType | null {
|
||||
return SETTINGS_PANELS[extensionId] ?? null
|
||||
}
|
||||
@@ -101,8 +101,8 @@ async function ensureFiscalPeriod(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<string> {
|
||||
// Check for existing fiscal period
|
||||
const { data: existing } = await supabase
|
||||
// Check for an existing period that contains the SIE date range
|
||||
const { data: containing } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
@@ -110,8 +110,23 @@ async function ensureFiscalPeriod(
|
||||
.gte('period_end', formatDate(endDate))
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
return existing.id
|
||||
if (containing) {
|
||||
return containing.id
|
||||
}
|
||||
|
||||
// Check for any overlapping period (DB exclusion constraint would reject
|
||||
// a new insert that overlaps). Use the overlapping period instead.
|
||||
const { data: overlapping } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.lte('period_start', formatDate(endDate))
|
||||
.gte('period_end', formatDate(startDate))
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (overlapping && overlapping.length > 0) {
|
||||
return overlapping[0].id
|
||||
}
|
||||
|
||||
// Create new fiscal period
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
|
||||
import type { NEAccountMapping } from '@/lib/reports/ne-bilaga/types'
|
||||
|
||||
/**
|
||||
* Helper to check if an account falls into a specific ruta
|
||||
*/
|
||||
function findRutaForAccount(accountNumber: string): string | null {
|
||||
for (const mapping of NE_ACCOUNT_MAPPINGS) {
|
||||
for (const range of mapping.accountRanges) {
|
||||
if (accountNumber >= range.start && accountNumber <= range.end) {
|
||||
if (range.exclude && range.exclude.includes(accountNumber)) {
|
||||
continue
|
||||
}
|
||||
return mapping.ruta
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe('NE Account Mappings', () => {
|
||||
describe('R1 - Försäljning med moms', () => {
|
||||
it('includes standard revenue accounts 3001-3003', () => {
|
||||
expect(findRutaForAccount('3001')).toBe('R1')
|
||||
expect(findRutaForAccount('3002')).toBe('R1')
|
||||
expect(findRutaForAccount('3003')).toBe('R1')
|
||||
})
|
||||
|
||||
it('excludes 3100 (momsfria intäkter)', () => {
|
||||
expect(findRutaForAccount('3100')).not.toBe('R1')
|
||||
})
|
||||
|
||||
it('includes 3500 (Fakturerade kostnader)', () => {
|
||||
expect(findRutaForAccount('3500')).toBe('R1')
|
||||
})
|
||||
|
||||
it('includes 3500-3599 range', () => {
|
||||
expect(findRutaForAccount('3510')).toBe('R1')
|
||||
expect(findRutaForAccount('3599')).toBe('R1')
|
||||
})
|
||||
|
||||
it('includes 3700-3799 (Lämnade rabatter)', () => {
|
||||
expect(findRutaForAccount('3700')).toBe('R1')
|
||||
expect(findRutaForAccount('3731')).toBe('R1')
|
||||
expect(findRutaForAccount('3799')).toBe('R1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R2 - Momsfria intäkter', () => {
|
||||
it('includes 3100', () => {
|
||||
expect(findRutaForAccount('3100')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3900 (Övriga rörelseintäkter)', () => {
|
||||
expect(findRutaForAccount('3900')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3910 (Hyresintäkter)', () => {
|
||||
expect(findRutaForAccount('3910')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3920 (Provisionsintäkter)', () => {
|
||||
expect(findRutaForAccount('3920')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3950 (Återvunna kundfordringar)', () => {
|
||||
expect(findRutaForAccount('3950')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3960 (Valutakursvinster)', () => {
|
||||
expect(findRutaForAccount('3960')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3970-3980 range', () => {
|
||||
expect(findRutaForAccount('3970')).toBe('R2')
|
||||
expect(findRutaForAccount('3980')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3981-3999 range', () => {
|
||||
expect(findRutaForAccount('3981')).toBe('R2')
|
||||
expect(findRutaForAccount('3990')).toBe('R2')
|
||||
expect(findRutaForAccount('3999')).toBe('R2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accounts are not silently dropped', () => {
|
||||
it('3500 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3500')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3700 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3700')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3910 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3910')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3960 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3960')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3990 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3990')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('no overlap between R1 and R2', () => {
|
||||
it('3100 is in R2 not R1', () => {
|
||||
expect(findRutaForAccount('3100')).toBe('R2')
|
||||
})
|
||||
|
||||
it('3001 is in R1 not R2', () => {
|
||||
expect(findRutaForAccount('3001')).toBe('R1')
|
||||
})
|
||||
|
||||
it('3900 is in R2 not R1', () => {
|
||||
expect(findRutaForAccount('3900')).toBe('R2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type {
|
||||
FiscalPeriod,
|
||||
JournalEntry,
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
import type {
|
||||
NEDeclaration,
|
||||
NEDeclarationRutor,
|
||||
NEAccountMapping,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* NE-bilaga (Enskild Firma / Sole Proprietorship Declaration)
|
||||
*
|
||||
* Maps BAS account balances to NE declaration rutor (R1-R11) for
|
||||
* tax reporting to Skatteverket.
|
||||
*
|
||||
* Account mappings:
|
||||
* R1: Försäljning med moms (3000-3599 excl 3100, 3700-3799)
|
||||
* R2: Momsfria intäkter (3100, 3900-3969, 3970-3980, 3981-3999) - inkl gåvor utan motprestation
|
||||
* R3: Bil/bostadsförmån (3200)
|
||||
* R4: Ränteintäkter (8310-8330)
|
||||
* R5: Varuinköp (4000-4990)
|
||||
* R6: Övriga kostnader (5000-6990, 7970) - inkl avdragsgilla gåvor (5460)
|
||||
* R7: Lönekostnader (7000-7699)
|
||||
* R8: Räntekostnader (8400-8499)
|
||||
* R9: Avskrivningar fastighet (7820)
|
||||
* R10: Avskrivningar övrigt (7700-7899 excl 7820)
|
||||
* R11: Årets resultat (calculated)
|
||||
*
|
||||
* Gift handling:
|
||||
* - Gåvor MED motprestation: R1 (momspliktig bytestransaktion)
|
||||
* - Gåvor UTAN motprestation: R2 via konto 3900
|
||||
* - Avdragsgilla gåvor: R6 via konto 5460
|
||||
*/
|
||||
|
||||
/**
|
||||
* Account mapping configuration for NE declaration
|
||||
*/
|
||||
export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
|
||||
{
|
||||
ruta: 'R1',
|
||||
description: 'Försäljning med moms (25%)',
|
||||
accountRanges: [
|
||||
{ start: '3000', end: '3599', exclude: ['3100'] },
|
||||
{ start: '3700', end: '3799' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R2',
|
||||
description: 'Momsfria intäkter',
|
||||
accountRanges: [
|
||||
{ start: '3100', end: '3100' },
|
||||
{ start: '3900', end: '3969' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
|
||||
{ start: '3970', end: '3980' },
|
||||
{ start: '3981', end: '3999' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R3',
|
||||
description: 'Bil/bostadsförmån',
|
||||
accountRanges: [
|
||||
{ start: '3200', end: '3299' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R4',
|
||||
description: 'Ränteintäkter',
|
||||
accountRanges: [
|
||||
{ start: '8310', end: '8330' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R5',
|
||||
description: 'Varuinköp',
|
||||
accountRanges: [
|
||||
{ start: '4000', end: '4990' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R6',
|
||||
description: 'Övriga kostnader',
|
||||
accountRanges: [
|
||||
{ start: '5000', end: '6990' },
|
||||
{ start: '7970', end: '7970' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R7',
|
||||
description: 'Lönekostnader',
|
||||
accountRanges: [
|
||||
{ start: '7000', end: '7699' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R8',
|
||||
description: 'Räntekostnader',
|
||||
accountRanges: [
|
||||
{ start: '8400', end: '8499' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R9',
|
||||
description: 'Avskrivningar fastighet',
|
||||
accountRanges: [
|
||||
{ start: '7820', end: '7820' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R10',
|
||||
description: 'Avskrivningar övrigt',
|
||||
accountRanges: [
|
||||
{ start: '7700', end: '7899', exclude: ['7820'] },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if an account number falls within a mapping's ranges
|
||||
*/
|
||||
function isAccountInMapping(accountNumber: string, mapping: NEAccountMapping): boolean {
|
||||
for (const range of mapping.accountRanges) {
|
||||
const num = accountNumber
|
||||
if (num >= range.start && num <= range.end) {
|
||||
// Check exclusions
|
||||
if (range.exclude && range.exclude.includes(num)) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Round to nearest krona (whole number) for NE declaration
|
||||
*/
|
||||
function roundToKrona(value: number): number {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate NE declaration for a fiscal period
|
||||
*/
|
||||
export async function generateNEDeclaration(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<NEDeclaration> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// 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()
|
||||
|
||||
// Check if it's enskild firma
|
||||
if (settings?.entity_type !== 'enskild_firma') {
|
||||
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
// Net amount: debit - credit
|
||||
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
|
||||
accountBalances.set(line.account_number, current + netAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// Map account balances to NE rutor
|
||||
const rutor: NEDeclarationRutor = {
|
||||
R1: 0,
|
||||
R2: 0,
|
||||
R3: 0,
|
||||
R4: 0,
|
||||
R5: 0,
|
||||
R6: 0,
|
||||
R7: 0,
|
||||
R8: 0,
|
||||
R9: 0,
|
||||
R10: 0,
|
||||
R11: 0,
|
||||
}
|
||||
|
||||
const breakdown: Record<keyof NEDeclarationRutor, {
|
||||
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
|
||||
total: number
|
||||
}> = {
|
||||
R1: { accounts: [], total: 0 },
|
||||
R2: { accounts: [], total: 0 },
|
||||
R3: { accounts: [], total: 0 },
|
||||
R4: { accounts: [], total: 0 },
|
||||
R5: { accounts: [], total: 0 },
|
||||
R6: { accounts: [], total: 0 },
|
||||
R7: { accounts: [], total: 0 },
|
||||
R8: { accounts: [], total: 0 },
|
||||
R9: { accounts: [], total: 0 },
|
||||
R10: { accounts: [], total: 0 },
|
||||
R11: { accounts: [], total: 0 },
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
|
||||
// Process each account balance
|
||||
for (const [accountNumber, balance] of accountBalances) {
|
||||
// Skip zero balances
|
||||
if (Math.abs(balance) < 0.01) continue
|
||||
|
||||
// Find which ruta this account belongs to
|
||||
for (const mapping of NE_ACCOUNT_MAPPINGS) {
|
||||
if (isAccountInMapping(accountNumber, mapping)) {
|
||||
// For revenue accounts (credit normal), negate the balance
|
||||
// For expense accounts (debit normal), use as-is
|
||||
// Net balance is debit - credit, so:
|
||||
// - Revenue accounts have negative net balance (credit > debit)
|
||||
// - Expense accounts have positive net balance (debit > credit)
|
||||
const amount = mapping.isExpense ? balance : -balance
|
||||
|
||||
rutor[mapping.ruta] += amount
|
||||
|
||||
breakdown[mapping.ruta].accounts.push({
|
||||
accountNumber,
|
||||
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
|
||||
amount: roundToKrona(amount),
|
||||
})
|
||||
|
||||
break // Account matched, no need to check other mappings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Round all rutor to whole numbers
|
||||
for (const key of Object.keys(rutor) as (keyof NEDeclarationRutor)[]) {
|
||||
if (key !== 'R11') {
|
||||
rutor[key] = roundToKrona(rutor[key])
|
||||
breakdown[key].total = rutor[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate R11 (Årets resultat)
|
||||
// Result = Revenue (R1+R2+R3+R4) - Expenses (R5+R6+R7+R8+R9+R10)
|
||||
const totalRevenue = rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4
|
||||
const totalExpenses = rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10
|
||||
rutor.R11 = totalRevenue - totalExpenses
|
||||
breakdown.R11.total = rutor.R11
|
||||
|
||||
// Add warnings
|
||||
if (!(period as FiscalPeriod).is_closed) {
|
||||
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
|
||||
}
|
||||
|
||||
if (rutor.R11 === 0 && totalRevenue === 0) {
|
||||
warnings.push('Inga bokförda intäkter eller kostnader hittades för perioden.')
|
||||
}
|
||||
|
||||
return {
|
||||
fiscalYear: {
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
start: period.period_start,
|
||||
end: period.period_end,
|
||||
isClosed: period.is_closed,
|
||||
},
|
||||
rutor,
|
||||
breakdown,
|
||||
companyInfo: {
|
||||
companyName: settings?.company_name || 'Okänt företag',
|
||||
orgNumber: settings?.org_number || null,
|
||||
},
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get totals for display
|
||||
*/
|
||||
export function getNEDeclarationTotals(declaration: NEDeclaration): {
|
||||
totalRevenue: number
|
||||
totalExpenses: number
|
||||
netResult: number
|
||||
} {
|
||||
const { rutor } = declaration
|
||||
|
||||
return {
|
||||
totalRevenue: rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4,
|
||||
totalExpenses: rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10,
|
||||
netResult: rutor.R11,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// NE-bilaga rutor (NE appendix boxes)
|
||||
export interface NEDeclarationRutor {
|
||||
R1: number // Försäljning med 25% moms (3000-3499 excl 3100)
|
||||
R2: number // Momsfria intäkter (3100, 3970, 3980)
|
||||
R3: number // Bil/bostadsförmån (3200)
|
||||
R4: number // Ränteintäkter (8310-8330)
|
||||
R5: number // Varuinköp (4000-4990)
|
||||
R6: number // Övriga kostnader (5000-6990, 7970)
|
||||
R7: number // Lönekostnader (7000-7699)
|
||||
R8: number // Räntekostnader (8400-8499)
|
||||
R9: number // Avskrivningar fastighet (7820)
|
||||
R10: number // Avskrivningar övrigt (7700-7899 excl 7820)
|
||||
R11: number // Årets resultat (beräknat)
|
||||
}
|
||||
|
||||
// NE account mapping configuration
|
||||
export interface NEAccountMapping {
|
||||
ruta: keyof NEDeclarationRutor
|
||||
description: string
|
||||
accountRanges: Array<{
|
||||
start: string
|
||||
end: string
|
||||
exclude?: string[]
|
||||
}>
|
||||
isExpense: boolean // true = debit normal, false = credit normal
|
||||
}
|
||||
|
||||
// NE declaration response
|
||||
export interface NEDeclaration {
|
||||
fiscalYear: {
|
||||
id: string
|
||||
name: string
|
||||
start: string
|
||||
end: string
|
||||
isClosed: boolean
|
||||
}
|
||||
rutor: NEDeclarationRutor
|
||||
// Detailed breakdown per ruta
|
||||
breakdown: Record<keyof NEDeclarationRutor, {
|
||||
accounts: Array<{
|
||||
accountNumber: string
|
||||
accountName: string
|
||||
amount: number
|
||||
}>
|
||||
total: number
|
||||
}>
|
||||
// Company info for SRU
|
||||
companyInfo: {
|
||||
companyName: string
|
||||
orgNumber: string | null
|
||||
}
|
||||
// Warnings
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
// SRU file format types
|
||||
export interface SRURecord {
|
||||
fieldCode: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export interface SRUFile {
|
||||
records: SRURecord[]
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
// Labels for NE rutor
|
||||
export const NE_RUTA_LABELS: Record<keyof NEDeclarationRutor, string> = {
|
||||
R1: 'Försäljning med moms (25%)',
|
||||
R2: 'Momsfria intäkter',
|
||||
R3: 'Bil/bostadsförmån',
|
||||
R4: 'Ränteintäkter',
|
||||
R5: 'Varuinköp',
|
||||
R6: 'Övriga kostnader',
|
||||
R7: 'Lönekostnader',
|
||||
R8: 'Räntekostnader',
|
||||
R9: 'Avskrivningar fastighet',
|
||||
R10: 'Avskrivningar övriga tillgångar',
|
||||
R11: 'Årets resultat'
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
/**
|
||||
* SRU aggregation engine
|
||||
*
|
||||
* Fetches posted journal entries for a fiscal period, computes net balance
|
||||
* per account, and groups by sru_code from chart_of_accounts.
|
||||
*/
|
||||
|
||||
export interface SRUBalance {
|
||||
sruCode: string
|
||||
amount: number
|
||||
accounts: Array<{
|
||||
accountNumber: string
|
||||
accountName: string
|
||||
amount: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface SRUCoverageStats {
|
||||
totalAccounts: number
|
||||
accountsWithSRU: number
|
||||
accountsWithoutSRU: number
|
||||
coveragePercent: number
|
||||
missingAccounts: Array<{
|
||||
accountNumber: string
|
||||
accountName: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate account balances by SRU code for a given fiscal period.
|
||||
* Returns a Map of sru_code → summed amount, plus per-account detail.
|
||||
*/
|
||||
export async function aggregateBalancesBySRU(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<Map<string, SRUBalance>> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// 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 with SRU codes
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null; normal_balance: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, sru_code, normal_balance')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Build lookup maps
|
||||
const accountSRUMap = new Map<string, string>()
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts) {
|
||||
if (acc.sru_code) {
|
||||
accountSRUMap.set(acc.account_number, acc.sru_code)
|
||||
}
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
// Calculate net 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Group balances by SRU code
|
||||
const sruBalances = new Map<string, SRUBalance>()
|
||||
|
||||
for (const [accountNumber, balance] of accountBalances) {
|
||||
if (Math.abs(balance) < 0.01) continue
|
||||
|
||||
const sruCode = accountSRUMap.get(accountNumber)
|
||||
if (!sruCode) continue
|
||||
|
||||
let entry = sruBalances.get(sruCode)
|
||||
if (!entry) {
|
||||
entry = { sruCode, amount: 0, accounts: [] }
|
||||
sruBalances.set(sruCode, entry)
|
||||
}
|
||||
|
||||
entry.amount += balance
|
||||
entry.accounts.push({
|
||||
accountNumber,
|
||||
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
|
||||
amount: Math.round(balance),
|
||||
})
|
||||
}
|
||||
|
||||
// Round totals
|
||||
for (const entry of sruBalances.values()) {
|
||||
entry.amount = Math.round(entry.amount)
|
||||
}
|
||||
|
||||
return sruBalances
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SRU code coverage stats for a user's chart of accounts.
|
||||
* Returns how many accounts have vs lack SRU codes.
|
||||
*/
|
||||
export async function getSRUCoverage(userId: string): Promise<SRUCoverageStats> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, sru_code')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const withSRU = accounts.filter((a) => a.sru_code)
|
||||
const withoutSRU = accounts.filter((a) => !a.sru_code)
|
||||
|
||||
return {
|
||||
totalAccounts: accounts.length,
|
||||
accountsWithSRU: withSRU.length,
|
||||
accountsWithoutSRU: withoutSRU.length,
|
||||
coveragePercent: accounts.length > 0
|
||||
? Math.round((withSRU.length / accounts.length) * 100)
|
||||
: 0,
|
||||
missingAccounts: withoutSRU.map((a) => ({
|
||||
accountNumber: a.account_number,
|
||||
accountName: a.account_name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { NEDeclaration, SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types'
|
||||
|
||||
/**
|
||||
* SRU File Generator
|
||||
*
|
||||
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
|
||||
* submission to Skatteverket. The SRU format is used for tax declarations.
|
||||
*
|
||||
* Format specification:
|
||||
* - Each line starts with # followed by field code
|
||||
* - Values follow the field code
|
||||
* - File must be plain text (ISO-8859-1 encoding traditionally, but UTF-8 is often accepted)
|
||||
*
|
||||
* NE-bilaga field codes:
|
||||
* - #BLANKETT NE - Declares this is an NE form
|
||||
* - #IDENTITET - Organization number + name
|
||||
* - #UPPGIFT - Individual field values
|
||||
*/
|
||||
|
||||
/**
|
||||
* SRU field codes for NE declaration
|
||||
* These are the official Skatteverket field codes for NE-bilaga
|
||||
*/
|
||||
const NE_SRU_FIELD_CODES: Record<string, string> = {
|
||||
// Company identification
|
||||
ORG_NUMBER: '201',
|
||||
COMPANY_NAME: '202',
|
||||
|
||||
// NE rutor - Income
|
||||
R1: '7310', // Försäljning och andra intäkter med moms
|
||||
R2: '7311', // Momsfria intäkter (ej skattepliktiga)
|
||||
R3: '7312', // Bil- och bostadsförmån m.m.
|
||||
R4: '7313', // Ränteintäkter
|
||||
|
||||
// NE rutor - Expenses
|
||||
R5: '7320', // Varuinköp
|
||||
R6: '7321', // Övriga externa kostnader
|
||||
R7: '7322', // Anställdas löner
|
||||
R8: '7323', // Räntekostnader
|
||||
R9: '7324', // Avskrivningar på byggnader och markanläggningar
|
||||
R10: '7325', // Avskrivningar på maskiner och inventarier
|
||||
|
||||
// Result
|
||||
R11: '7350', // Årets resultat
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SRU file content from NE declaration
|
||||
*/
|
||||
export function generateSRUFile(declaration: NEDeclaration): 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: 'NE' })
|
||||
|
||||
// Company identification
|
||||
if (declaration.companyInfo.orgNumber) {
|
||||
// Remove any dashes from org number
|
||||
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)}`,
|
||||
})
|
||||
|
||||
// NE rutor values
|
||||
const rutaEntries: [keyof typeof NE_SRU_FIELD_CODES, number][] = [
|
||||
['R1', declaration.rutor.R1],
|
||||
['R2', declaration.rutor.R2],
|
||||
['R3', declaration.rutor.R3],
|
||||
['R4', declaration.rutor.R4],
|
||||
['R5', declaration.rutor.R5],
|
||||
['R6', declaration.rutor.R6],
|
||||
['R7', declaration.rutor.R7],
|
||||
['R8', declaration.rutor.R8],
|
||||
['R9', declaration.rutor.R9],
|
||||
['R10', declaration.rutor.R10],
|
||||
['R11', declaration.rutor.R11],
|
||||
]
|
||||
|
||||
for (const [ruta, value] of rutaEntries) {
|
||||
// Only include non-zero values
|
||||
if (value !== 0) {
|
||||
const fieldCode = NE_SRU_FIELD_CODES[ruta]
|
||||
records.push({
|
||||
fieldCode: 'UPPGIFT',
|
||||
value: `${fieldCode} ${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}`)
|
||||
}
|
||||
}
|
||||
|
||||
// SRU files should end with a newline
|
||||
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 decimals)
|
||||
* - No thousands separator
|
||||
* - Negative values with minus sign
|
||||
*/
|
||||
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[] = []
|
||||
|
||||
// Check for required records
|
||||
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')
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename for SRU file download
|
||||
*/
|
||||
export function getSRUFilename(declaration: NEDeclaration): string {
|
||||
const year = declaration.fiscalYear.start.substring(0, 4)
|
||||
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
|
||||
return `NE_${orgNumber}_${year}.sru`
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types'
|
||||
import { sruFileToString, validateSRUFile } from './sru-generator'
|
||||
import type { SRUBalance } from './sru-engine'
|
||||
|
||||
/**
|
||||
* Generic SRU file generator
|
||||
*
|
||||
* Generates SRU files from aggregated SRU balances for any form type
|
||||
* (NE for enskild firma, INK2 for aktiebolag).
|
||||
*
|
||||
* Reuses sruFileToString() and validateSRUFile() from the existing
|
||||
* NE-specific generator.
|
||||
*/
|
||||
|
||||
export type SRUFormType = 'NE' | 'INK2'
|
||||
|
||||
export interface GenericSRUParams {
|
||||
formType: SRUFormType
|
||||
orgNumber: string | null
|
||||
companyName: string
|
||||
fiscalYearStart: string // YYYY-MM-DD
|
||||
fiscalYearEnd: string // YYYY-MM-DD
|
||||
sruBalances: Map<string, SRUBalance>
|
||||
}
|
||||
|
||||
/**
|
||||
* SRU code descriptions for display
|
||||
*/
|
||||
export const SRU_CODE_DESCRIPTIONS: Record<string, string> = {
|
||||
// NE form (EF)
|
||||
'7310': 'Försäljning med moms',
|
||||
'7311': 'Momsfria intäkter',
|
||||
'7312': 'Bil/bostadsförmån',
|
||||
'7313': 'Ränteintäkter',
|
||||
'7320': 'Varuinköp',
|
||||
'7321': 'Övriga kostnader',
|
||||
'7322': 'Lönekostnader',
|
||||
'7323': 'Räntekostnader',
|
||||
'7324': 'Avskrivningar fastighet',
|
||||
'7325': 'Avskrivningar övrigt',
|
||||
'7350': 'Årets resultat',
|
||||
// INK2 form (AB) — balance sheet
|
||||
'7201': 'Immateriella anläggningstillgångar',
|
||||
'7202': 'Materiella anläggningstillgångar',
|
||||
'7203': 'Finansiella anläggningstillgångar',
|
||||
'7210': 'Varulager',
|
||||
'7211': 'Kundfordringar',
|
||||
'7212': 'Övriga omsättningstillgångar',
|
||||
'7220': 'Aktiekapital',
|
||||
'7221': 'Övrigt eget kapital',
|
||||
'7222': 'Årets resultat',
|
||||
'7230': 'Skulder',
|
||||
'7231': 'Övriga skulder',
|
||||
// INK2 form (AB) — income statement
|
||||
'7330': 'Övriga externa kostnader',
|
||||
'7340': 'Personalkostnader',
|
||||
'7360': 'Övriga rörelsekostnader',
|
||||
'7370': 'Finansiella poster',
|
||||
'7380': 'Extraordinära poster',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a generic SRU file from aggregated SRU balances.
|
||||
*/
|
||||
export function generateGenericSRU(params: GenericSRUParams): SRUFile {
|
||||
const { formType, orgNumber, fiscalYearStart, fiscalYearEnd, sruBalances } = params
|
||||
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: formType })
|
||||
|
||||
// Company identification
|
||||
if (orgNumber) {
|
||||
const cleanOrgNumber = orgNumber.replace(/-/g, '')
|
||||
records.push({ fieldCode: 'IDENTITET', value: cleanOrgNumber })
|
||||
}
|
||||
|
||||
// Fiscal year
|
||||
const startSRU = fiscalYearStart.replace(/-/g, '')
|
||||
const endSRU = fiscalYearEnd.replace(/-/g, '')
|
||||
records.push({
|
||||
fieldCode: 'UPPGIFT',
|
||||
value: `7000 ${startSRU}-${endSRU}`,
|
||||
})
|
||||
|
||||
// SRU balance entries — one #UPPGIFT per non-zero SRU code
|
||||
const sortedEntries = Array.from(sruBalances.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
for (const [sruCode, balance] of sortedEntries) {
|
||||
if (balance.amount !== 0) {
|
||||
records.push({
|
||||
fieldCode: 'UPPGIFT',
|
||||
value: `${sruCode} ${Math.round(balance.amount)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// End of form
|
||||
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
|
||||
|
||||
return {
|
||||
records,
|
||||
generatedAt: now.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename for generic SRU file download
|
||||
*/
|
||||
export function getGenericSRUFilename(
|
||||
formType: SRUFormType,
|
||||
orgNumber: string | null,
|
||||
fiscalYearStart: string
|
||||
): string {
|
||||
const year = fiscalYearStart.substring(0, 4)
|
||||
const cleanOrg = orgNumber?.replace(/-/g, '') || 'unknown'
|
||||
return `${formType}_${cleanOrg}_${year}.sru`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`
|
||||
}
|
||||
|
||||
// Re-export helpers from the existing SRU generator
|
||||
export { sruFileToString, validateSRUFile }
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
// Generic SRU export types
|
||||
export interface SRUExportResult {
|
||||
formType: 'NE' | 'INK2'
|
||||
entityType: EntityType
|
||||
companyName: string | null
|
||||
orgNumber: string | null
|
||||
fiscalYear: {
|
||||
id: string
|
||||
name: string
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
balances: Array<{
|
||||
sruCode: string
|
||||
description: string
|
||||
amount: number
|
||||
accounts: Array<{
|
||||
accountNumber: string
|
||||
accountName: string
|
||||
amount: number
|
||||
}>
|
||||
}>
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export { type SRUCoverageStats } from './sru-engine'
|
||||
@@ -132,6 +132,8 @@ describe('ingestTransactions', () => {
|
||||
const raw = makeRaw({ amount: -100 })
|
||||
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
|
||||
|
||||
// Booked transaction map query (no booked transactions)
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check returns null (no existing row)
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
@@ -154,6 +156,8 @@ describe('ingestTransactions', () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw()
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check returns an existing record
|
||||
enqueue({ data: { id: 'existing-tx-1' }, error: null })
|
||||
|
||||
@@ -171,6 +175,8 @@ describe('ingestTransactions', () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw()
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert fails
|
||||
@@ -195,6 +201,8 @@ describe('ingestTransactions', () => {
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
@@ -233,6 +241,8 @@ describe('ingestTransactions', () => {
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -260,6 +270,8 @@ describe('ingestTransactions', () => {
|
||||
})
|
||||
const journalEntry = makeJournalEntry({ id: 'je-1' })
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -294,6 +306,8 @@ describe('ingestTransactions', () => {
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -321,6 +335,8 @@ describe('ingestTransactions', () => {
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -347,6 +363,8 @@ describe('ingestTransactions', () => {
|
||||
const inserted1 = makeTransaction({ id: 'tx-a', amount: -100 })
|
||||
const inserted2 = makeTransaction({ id: 'tx-b', amount: -200 })
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Transaction 1: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted1, error: null })
|
||||
@@ -382,6 +400,8 @@ describe('ingestTransactions', () => {
|
||||
external_id: 'ext-new',
|
||||
})
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Transaction rawNew: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: insertedNew, error: null })
|
||||
@@ -452,6 +472,8 @@ describe('ingestTransactions', () => {
|
||||
const raw = makeRaw({ amount: 1000 })
|
||||
const inserted = makeTransaction({ id: 'tx-inv-err', amount: 1000 })
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
@@ -474,6 +496,8 @@ describe('ingestTransactions', () => {
|
||||
const raw = makeRaw({ amount: -400 })
|
||||
const inserted = makeTransaction({ id: 'tx-cat-err', amount: -400 })
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
@@ -522,6 +546,8 @@ describe('ingestTransactions', () => {
|
||||
confidence: 0.95,
|
||||
})
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -562,6 +588,8 @@ describe('ingestTransactions', () => {
|
||||
])
|
||||
mockTryReconcileTransaction.mockReturnValue(null)
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -587,6 +615,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
mockFetchUnlinkedGLLines.mockRejectedValue(new Error('RPC error'))
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -600,4 +630,112 @@ describe('ingestTransactions', () => {
|
||||
expect(result.reconciled).toBe(0)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Content-based dedup: cross-source duplicate detection
|
||||
// -----------------------------------------------------------------------
|
||||
it('skips transactions that match already-booked ones by date+amount', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
external_id: 'psd2_conn123_tx456',
|
||||
date: '2024-06-15',
|
||||
amount: -250,
|
||||
})
|
||||
|
||||
// Booked transaction map returns a booked tx with same date+amount
|
||||
enqueue({
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
error: null,
|
||||
})
|
||||
// external_id dedup: no match (different source)
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
})
|
||||
|
||||
it('imports transactions when booked ones have different amounts', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
external_id: 'psd2_conn123_tx789',
|
||||
date: '2024-06-15',
|
||||
amount: -300,
|
||||
})
|
||||
const inserted = makeTransaction({ id: 'tx-new', amount: -300 })
|
||||
|
||||
// Booked transaction map: same date but different amount
|
||||
enqueue({
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
error: null,
|
||||
})
|
||||
// external_id dedup: no match
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it('handles multiple booked transactions with same date+amount correctly', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
// Three incoming transactions with the same date+amount
|
||||
const raw1 = makeRaw({ external_id: 'psd2_a', date: '2024-06-15', amount: -100 })
|
||||
const raw2 = makeRaw({ external_id: 'psd2_b', date: '2024-06-15', amount: -100 })
|
||||
const raw3 = makeRaw({ external_id: 'psd2_c', date: '2024-06-15', amount: -100 })
|
||||
|
||||
const inserted = makeTransaction({ id: 'tx-new', amount: -100 })
|
||||
|
||||
// Booked map: 2 existing booked transactions with same date+amount
|
||||
// So 2 of the 3 incoming should be skipped, 1 should be imported
|
||||
enqueue({
|
||||
data: [
|
||||
{ date: '2024-06-15', amount: -100 },
|
||||
{ date: '2024-06-15', amount: -100 },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// raw1: external_id dedup (no match) -> content dedup matches (bookedCount=2 -> 1)
|
||||
enqueue({ data: null, error: null })
|
||||
// raw2: external_id dedup (no match) -> content dedup matches (bookedCount=1 -> 0)
|
||||
enqueue({ data: null, error: null })
|
||||
// raw3: external_id dedup (no match) -> content dedup exhausted -> insert
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw1, raw2, raw3])
|
||||
|
||||
expect(result.duplicates).toBe(2)
|
||||
expect(result.imported).toBe(1)
|
||||
})
|
||||
|
||||
it('continues normally when booked transaction map query fails', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -200 })
|
||||
const inserted = makeTransaction({ id: 'tx-mapfail', amount: -200 })
|
||||
|
||||
// Booked map query throws (caught by try/catch in buildBookedTransactionMap)
|
||||
enqueue({ error: { message: 'Query failed' } })
|
||||
// external_id dedup: no match
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,11 +9,53 @@ import type { Transaction, RawTransaction, IngestResult } from '@/types'
|
||||
// Re-export types for backward compatibility
|
||||
export type { RawTransaction, IngestResult } from '@/types'
|
||||
|
||||
/**
|
||||
* Build a lookup map of already-booked transactions keyed by "date|amount".
|
||||
* Each key maps to the number of booked transactions with that date+amount
|
||||
* combination, allowing correct dedup when multiple transactions share
|
||||
* the same date and amount.
|
||||
*/
|
||||
async function buildBookedTransactionMap(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
rawTransactions: RawTransaction[]
|
||||
): Promise<Map<string, number>> {
|
||||
const map = new Map<string, number>()
|
||||
if (rawTransactions.length === 0) return map
|
||||
|
||||
const dates = rawTransactions.map((t) => t.date).sort()
|
||||
const dateFrom = dates[0]
|
||||
const dateTo = dates[dates.length - 1]
|
||||
|
||||
try {
|
||||
const { data: booked } = await supabase
|
||||
.from('transactions')
|
||||
.select('date, amount')
|
||||
.eq('user_id', userId)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.gte('date', dateFrom)
|
||||
.lte('date', dateTo)
|
||||
|
||||
if (booked) {
|
||||
for (const tx of booked) {
|
||||
const key = `${tx.date}|${tx.amount}`
|
||||
map.set(key, (map.get(key) || 0) + 1)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — content-based dedup will be skipped
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic transaction ingestion pipeline.
|
||||
*
|
||||
* Handles:
|
||||
* 1. Deduplication via external_id
|
||||
* 1b. Content-based dedup via date+amount against already-booked transactions
|
||||
* (catches cross-source duplicates, e.g. CSV import then PSD2 sync)
|
||||
* 2. Insert into transactions table
|
||||
* 3. OCR/reference-based invoice matching (highest confidence)
|
||||
* 4. Amount+customer fallback invoice matching
|
||||
@@ -37,6 +79,11 @@ export async function ingestTransactions(
|
||||
transaction_ids: [],
|
||||
}
|
||||
|
||||
// Pre-fetch booked transactions for content-based dedup (date+amount)
|
||||
// This catches cross-source duplicates (e.g. same transaction imported
|
||||
// via CSV and then again via PSD2 with different external_id)
|
||||
const bookedMap = await buildBookedTransactionMap(supabase, userId, rawTransactions)
|
||||
|
||||
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
|
||||
let glLinePool: UnlinkedGLLine[] = []
|
||||
try {
|
||||
@@ -59,6 +106,16 @@ export async function ingestTransactions(
|
||||
continue
|
||||
}
|
||||
|
||||
// 1b. Content-based dedup: skip if an already-booked transaction
|
||||
// exists with the same date and amount (cross-source duplicate)
|
||||
const contentKey = `${raw.date}|${raw.amount}`
|
||||
const bookedCount = bookedMap.get(contentKey) || 0
|
||||
if (bookedCount > 0) {
|
||||
bookedMap.set(contentKey, bookedCount - 1)
|
||||
result.duplicates++
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Insert new transaction
|
||||
const { data: newTransaction, error: insertError } = await supabase
|
||||
.from('transactions')
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* EU member states reference data.
|
||||
*
|
||||
* Used by core VAT logic and export extensions for:
|
||||
* - Filtering EU vs non-EU customers
|
||||
* - VIES VAT number validation (country prefix)
|
||||
* - Intrastat partner country lookup
|
||||
* - EC Sales List country grouping
|
||||
*/
|
||||
|
||||
export interface EUCountry {
|
||||
code: string // ISO 3166-1 alpha-2
|
||||
name: string // Swedish name
|
||||
nameEn: string // English name
|
||||
vatPrefix: string // VIES VAT number prefix
|
||||
currency: string // Primary currency
|
||||
}
|
||||
|
||||
/**
|
||||
* All 27 EU member states (as of 2025).
|
||||
* Sweden (SE) is included but should be filtered out for intra-community checks.
|
||||
*/
|
||||
export const EU_COUNTRIES: EUCountry[] = [
|
||||
{ code: 'AT', name: 'Österrike', nameEn: 'Austria', vatPrefix: 'AT', currency: 'EUR' },
|
||||
{ code: 'BE', name: 'Belgien', nameEn: 'Belgium', vatPrefix: 'BE', currency: 'EUR' },
|
||||
{ code: 'BG', name: 'Bulgarien', nameEn: 'Bulgaria', vatPrefix: 'BG', currency: 'BGN' },
|
||||
{ code: 'HR', name: 'Kroatien', nameEn: 'Croatia', vatPrefix: 'HR', currency: 'EUR' },
|
||||
{ code: 'CY', name: 'Cypern', nameEn: 'Cyprus', vatPrefix: 'CY', currency: 'EUR' },
|
||||
{ code: 'CZ', name: 'Tjeckien', nameEn: 'Czech Republic', vatPrefix: 'CZ', currency: 'CZK' },
|
||||
{ code: 'DK', name: 'Danmark', nameEn: 'Denmark', vatPrefix: 'DK', currency: 'DKK' },
|
||||
{ code: 'EE', name: 'Estland', nameEn: 'Estonia', vatPrefix: 'EE', currency: 'EUR' },
|
||||
{ code: 'FI', name: 'Finland', nameEn: 'Finland', vatPrefix: 'FI', currency: 'EUR' },
|
||||
{ code: 'FR', name: 'Frankrike', nameEn: 'France', vatPrefix: 'FR', currency: 'EUR' },
|
||||
{ code: 'DE', name: 'Tyskland', nameEn: 'Germany', vatPrefix: 'DE', currency: 'EUR' },
|
||||
{ code: 'GR', name: 'Grekland', nameEn: 'Greece', vatPrefix: 'EL', currency: 'EUR' },
|
||||
{ code: 'HU', name: 'Ungern', nameEn: 'Hungary', vatPrefix: 'HU', currency: 'HUF' },
|
||||
{ code: 'IE', name: 'Irland', nameEn: 'Ireland', vatPrefix: 'IE', currency: 'EUR' },
|
||||
{ code: 'IT', name: 'Italien', nameEn: 'Italy', vatPrefix: 'IT', currency: 'EUR' },
|
||||
{ code: 'LV', name: 'Lettland', nameEn: 'Latvia', vatPrefix: 'LV', currency: 'EUR' },
|
||||
{ code: 'LT', name: 'Litauen', nameEn: 'Lithuania', vatPrefix: 'LT', currency: 'EUR' },
|
||||
{ code: 'LU', name: 'Luxemburg', nameEn: 'Luxembourg', vatPrefix: 'LU', currency: 'EUR' },
|
||||
{ code: 'MT', name: 'Malta', nameEn: 'Malta', vatPrefix: 'MT', currency: 'EUR' },
|
||||
{ code: 'NL', name: 'Nederländerna', nameEn: 'Netherlands', vatPrefix: 'NL', currency: 'EUR' },
|
||||
{ code: 'PL', name: 'Polen', nameEn: 'Poland', vatPrefix: 'PL', currency: 'PLN' },
|
||||
{ code: 'PT', name: 'Portugal', nameEn: 'Portugal', vatPrefix: 'PT', currency: 'EUR' },
|
||||
{ code: 'RO', name: 'Rumänien', nameEn: 'Romania', vatPrefix: 'RO', currency: 'RON' },
|
||||
{ code: 'SK', name: 'Slovakien', nameEn: 'Slovakia', vatPrefix: 'SK', currency: 'EUR' },
|
||||
{ code: 'SI', name: 'Slovenien', nameEn: 'Slovenia', vatPrefix: 'SI', currency: 'EUR' },
|
||||
{ code: 'ES', name: 'Spanien', nameEn: 'Spain', vatPrefix: 'ES', currency: 'EUR' },
|
||||
{ code: 'SE', name: 'Sverige', nameEn: 'Sweden', vatPrefix: 'SE', currency: 'SEK' },
|
||||
]
|
||||
|
||||
/** EU country codes excluding Sweden (for intra-community checks) */
|
||||
export const EU_COUNTRY_CODES_EXCL_SE = EU_COUNTRIES
|
||||
.filter(c => c.code !== 'SE')
|
||||
.map(c => c.code)
|
||||
|
||||
/** All EU country codes including Sweden */
|
||||
export const EU_COUNTRY_CODES = EU_COUNTRIES.map(c => c.code)
|
||||
|
||||
/**
|
||||
* Build a lookup set of all known names/codes for EU countries (excluding Sweden).
|
||||
* Handles ISO codes, English names, and Swedish names — all uppercased for matching.
|
||||
*/
|
||||
const EU_LOOKUP_EXCL_SE = new Set(
|
||||
EU_COUNTRIES
|
||||
.filter(c => c.code !== 'SE')
|
||||
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
|
||||
)
|
||||
|
||||
const EU_LOOKUP_INCL_SE = new Set(
|
||||
EU_COUNTRIES
|
||||
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if a country value is an EU member state (excluding Sweden).
|
||||
* Accepts ISO codes ("DE"), English names ("Germany"), or Swedish names ("Tyskland").
|
||||
*/
|
||||
export function isEUCountry(country: string): boolean {
|
||||
return EU_LOOKUP_EXCL_SE.has(country.trim().toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a country value is an EU member state (including Sweden).
|
||||
* Accepts ISO codes, English names, or Swedish names.
|
||||
*/
|
||||
export function isEUCountryIncludingSE(country: string): boolean {
|
||||
return EU_LOOKUP_INCL_SE.has(country.trim().toUpperCase())
|
||||
}
|
||||
|
||||
/** Get EU country data by ISO code, English name, or Swedish name */
|
||||
export function getEUCountry(country: string): EUCountry | undefined {
|
||||
const upper = country.trim().toUpperCase()
|
||||
return EU_COUNTRIES.find(
|
||||
c => c.code === upper || c.name.toUpperCase() === upper || c.nameEn.toUpperCase() === upper
|
||||
)
|
||||
}
|
||||
|
||||
/** Get the VIES VAT prefix for a country (note: Greece uses 'EL' not 'GR') */
|
||||
export function getVatPrefix(countryCode: string): string | undefined {
|
||||
return getEUCountry(countryCode)?.vatPrefix
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a country value to its ISO 3166-1 alpha-2 code.
|
||||
* Accepts ISO codes, English names, or Swedish names.
|
||||
* Returns the input uppercased if no match is found.
|
||||
*/
|
||||
export function toCountryCode(country: string): string {
|
||||
const found = getEUCountry(country)
|
||||
return found ? found.code : country.trim().toUpperCase()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { EU_COUNTRIES } from '@/extensions/export/shared/eu-countries'
|
||||
import { EU_COUNTRIES } from '@/lib/vat/eu-countries'
|
||||
import type { VatValidationResult } from '@/types'
|
||||
|
||||
const log = createLogger('vies-client')
|
||||
|
||||
Reference in New Issue
Block a user