feat: implement 4 export extensions with engines, API routes, and workspaces

- EU Sales List: engine, CSV/XML generators, Skatteverket format, tests
- VAT Monitor: moms box mapping, revenue breakdown, validation, tests
- Intrastat: product registry, SCB CSV generator, threshold tracking, tests
- Currency Receivables: FX exposure, unrealized gain/loss calc, tests
- Shared utilities: EU country list, moms box mapping
- API routes for report generation and file downloads
- Workspace UI components for all 4 extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-24 14:10:16 +01:00
co-authored by Claude Opus 4.6
parent e8042381f7
commit 0764bc11a6
30 changed files with 8138 additions and 0 deletions
@@ -0,0 +1,18 @@
import type { Extension } from '@/lib/extensions/types'
/**
* Multi-Currency Receivables Manager / Valutafordringar Extension
*
* Dashboard showing foreign currency exposure from open receivables.
* Calculates unrealized FX gains/losses using current Riksbanken rates
* compared to booking rates.
*
* Shows realized FX gains (account 3960) and losses (account 7960) per
* period, with monthly trend analysis. Provides period-end revaluation
* preview for informational purposes (does not create journal entries).
*/
export const currencyReceivablesExtension: Extension = {
id: 'currency-receivables',
name: 'Valutafordringar',
version: '1.0.0',
}
@@ -0,0 +1,465 @@
import { describe, it, expect } from 'vitest'
import {
generateReceivablesReport,
type ReceivableInvoice,
type ReceivableCustomer,
type GLLine,
type ExchangeRateInfo,
type ReceivablesOptions,
} from '../receivables-engine'
// ── Fixtures ────────────────────────────────────────────────
function makeInvoice(overrides: Partial<ReceivableInvoice> = {}): ReceivableInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
invoice_date: '2026-01-15',
due_date: '2026-02-15',
status: 'sent',
currency: 'EUR',
total: 10000,
total_sek: 114200,
exchange_rate: 11.42,
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<ReceivableCustomer> = {}): ReceivableCustomer {
return {
id: 'cust-1',
name: 'Müller GmbH',
country: 'DE',
...overrides,
}
}
function makeRate(overrides: Partial<ExchangeRateInfo> = {}): ExchangeRateInfo {
return {
currency: 'EUR',
rate: 11.50,
date: '2026-03-15',
...overrides,
}
}
function makeGLLine(overrides: Partial<GLLine> = {}): GLLine {
return {
account_number: '3960',
debit: 0,
credit: 0,
entry_date: '2026-01-31',
...overrides,
}
}
const BASE_OPTIONS: ReceivablesOptions = {
invoices: [],
customers: [],
currentRates: [makeRate()],
realizedFXLines: [],
referenceDate: '2026-03-15',
year: 2026,
}
// ── Basic report tests ──────────────────────────────────────
describe('generateReceivablesReport', () => {
it('generates empty report when no invoices', () => {
const report = generateReceivablesReport(BASE_OPTIONS)
expect(report.receivables).toHaveLength(0)
expect(report.exposureByCurrency).toHaveLength(0)
expect(report.totals.receivableCount).toBe(0)
expect(report.totals.currencyCount).toBe(0)
expect(report.totals.totalUnrealizedGainLoss).toBe(0)
expect(report.referenceDate).toBe('2026-03-15')
})
it('calculates unrealized gain when rate increases', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({
total: 10000,
total_sek: 114200, // Booked at 11.42
exchange_rate: 11.42,
})],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })], // Current rate higher
})
expect(report.receivables).toHaveLength(1)
const r = report.receivables[0]
expect(r.bookedSekAmount).toBe(114200)
expect(r.currentSekAmount).toBe(115000) // 10000 × 11.50
expect(r.unrealizedGainLoss).toBe(800) // 115000 - 114200
expect(r.bookedRate).toBe(11.42)
expect(r.currentRate).toBe(11.50)
})
it('calculates unrealized loss when rate decreases', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({
total: 10000,
total_sek: 114200,
exchange_rate: 11.42,
})],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.00 })], // Current rate lower
})
const r = report.receivables[0]
expect(r.currentSekAmount).toBe(110000)
expect(r.unrealizedGainLoss).toBe(-4200) // 110000 - 114200
})
it('aggregates exposure by currency', () => {
const invoices = [
makeInvoice({ id: 'inv-1', total: 5000, total_sek: 57100, exchange_rate: 11.42 }),
makeInvoice({ id: 'inv-2', total: 3000, total_sek: 34260, exchange_rate: 11.42 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
expect(report.exposureByCurrency).toHaveLength(1)
const exp = report.exposureByCurrency[0]
expect(exp.currency).toBe('EUR')
expect(exp.totalForeignAmount).toBe(8000)
expect(exp.bookedSekValue).toBe(91360) // 57100 + 34260
expect(exp.currentSekValue).toBe(92000) // 8000 × 11.50
expect(exp.invoiceCount).toBe(2)
expect(exp.averageBookedRate).toBe(11.42) // 91360 / 8000
expect(exp.currentRate).toBe(11.50)
})
it('handles multiple currencies', () => {
const invoices = [
makeInvoice({ id: 'inv-1', currency: 'EUR', total: 5000, total_sek: 57100 }),
makeInvoice({ id: 'inv-2', currency: 'USD', total: 8000, total_sek: 84000, customer_id: 'cust-2' }),
]
const customers = [
makeCustomer({ id: 'cust-1' }),
makeCustomer({ id: 'cust-2', name: 'Smith Inc', country: 'US' }),
]
const rates = [
makeRate({ currency: 'EUR', rate: 11.50 }),
makeRate({ currency: 'USD', rate: 10.60 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers,
currentRates: rates,
})
expect(report.exposureByCurrency).toHaveLength(2)
expect(report.totals.currencyCount).toBe(2)
expect(report.totals.receivableCount).toBe(2)
const eurExp = report.exposureByCurrency.find(e => e.currency === 'EUR')!
const usdExp = report.exposureByCurrency.find(e => e.currency === 'USD')!
expect(eurExp.currentSekValue).toBe(57500) // 5000 × 11.50
expect(usdExp.currentSekValue).toBe(84800) // 8000 × 10.60
})
// ── Filtering ─────────────────────────────────────────────
it('excludes paid invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'paid' })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(0)
})
it('excludes SEK invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ currency: 'SEK', total: 10000, total_sek: 10000 })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(0)
})
it('excludes zero-amount invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 0 })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(0)
})
it('includes overdue invoices', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'overdue' })],
customers: [makeCustomer()],
})
expect(report.receivables).toHaveLength(1)
})
// ── Booked value fallback ─────────────────────────────────
it('falls back to total × exchange_rate when total_sek is null', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 5000, total_sek: null, exchange_rate: 11.42 })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
const r = report.receivables[0]
expect(r.bookedSekAmount).toBe(57100) // 5000 × 11.42
expect(r.currentSekAmount).toBe(57500) // 5000 × 11.50
expect(r.unrealizedGainLoss).toBe(400)
})
it('uses 0 booked value when no exchange rate info', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 5000, total_sek: null, exchange_rate: null })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
const r = report.receivables[0]
expect(r.bookedSekAmount).toBe(0)
expect(r.currentSekAmount).toBe(57500)
})
// ── Days outstanding ──────────────────────────────────────
it('calculates days outstanding correctly', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ invoice_date: '2026-03-01' })],
customers: [makeCustomer()],
referenceDate: '2026-03-15',
})
expect(report.receivables[0].daysOutstanding).toBe(14)
})
// ── Realized FX from GL ───────────────────────────────────
it('calculates realized FX gains from account 3960 credits', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '3960', credit: 5000, debit: 0, entry_date: '2026-01-31' }),
makeGLLine({ account_number: '3960', credit: 3000, debit: 0, entry_date: '2026-02-28' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
expect(report.realizedGainLoss.gains).toBe(8000)
expect(report.realizedGainLoss.losses).toBe(0)
expect(report.realizedGainLoss.net).toBe(8000)
})
it('calculates realized FX losses from account 7960 debits', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '7960', debit: 3200, credit: 0, entry_date: '2026-01-31' }),
makeGLLine({ account_number: '7960', debit: 1800, credit: 0, entry_date: '2026-02-28' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
expect(report.realizedGainLoss.gains).toBe(0)
expect(report.realizedGainLoss.losses).toBe(5000)
expect(report.realizedGainLoss.net).toBe(-5000)
})
it('calculates net realized FX with both gains and losses', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '3960', credit: 8500, debit: 0, entry_date: '2026-01-31' }),
makeGLLine({ account_number: '7960', debit: 3200, credit: 0, entry_date: '2026-01-31' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
expect(report.realizedGainLoss.gains).toBe(8500)
expect(report.realizedGainLoss.losses).toBe(3200)
expect(report.realizedGainLoss.net).toBe(5300)
})
// ── Monthly trend ─────────────────────────────────────────
it('generates monthly trend with all 12 months', () => {
const report = generateReceivablesReport(BASE_OPTIONS)
expect(report.monthlyTrend).toHaveLength(12)
expect(report.monthlyTrend[0].month).toBe('2026-01')
expect(report.monthlyTrend[11].month).toBe('2026-12')
})
it('distributes realized FX to correct months', () => {
const lines: GLLine[] = [
makeGLLine({ account_number: '3960', credit: 5000, entry_date: '2026-01-15' }),
makeGLLine({ account_number: '7960', debit: 2000, entry_date: '2026-01-20' }),
makeGLLine({ account_number: '3960', credit: 8000, entry_date: '2026-03-10' }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
realizedFXLines: lines,
})
const jan = report.monthlyTrend.find(t => t.month === '2026-01')!
expect(jan.realizedGains).toBe(5000)
expect(jan.realizedLosses).toBe(2000)
expect(jan.netRealized).toBe(3000)
const feb = report.monthlyTrend.find(t => t.month === '2026-02')!
expect(feb.realizedGains).toBe(0)
expect(feb.realizedLosses).toBe(0)
expect(feb.netRealized).toBe(0)
const mar = report.monthlyTrend.find(t => t.month === '2026-03')!
expect(mar.realizedGains).toBe(8000)
expect(mar.realizedLosses).toBe(0)
expect(mar.netRealized).toBe(8000)
})
// ── Revaluation preview ───────────────────────────────────
it('shows revaluation preview with net gain', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 10000, total_sek: 114200 })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
expect(report.revalPreview.totalUnrealizedGainLoss).toBe(800)
expect(report.revalPreview.gains).toBe(800)
expect(report.revalPreview.losses).toBe(0)
})
it('shows revaluation preview with net loss', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ total: 10000, total_sek: 114200 })],
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.00 })],
})
expect(report.revalPreview.totalUnrealizedGainLoss).toBe(-4200)
expect(report.revalPreview.gains).toBe(0)
expect(report.revalPreview.losses).toBe(4200)
})
// ── Customer data ─────────────────────────────────────────
it('uses customer name and country from customer lookup', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ name: 'Acme Corp', country: 'FI' })],
})
expect(report.receivables[0].customerName).toBe('Acme Corp')
expect(report.receivables[0].customerCountry).toBe('FI')
})
it('falls back to unknown customer when not found', () => {
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ customer_id: 'unknown' })],
customers: [makeCustomer()], // cust-1, not 'unknown'
})
expect(report.receivables[0].customerName).toBe('Okänd kund')
})
// ── Totals ────────────────────────────────────────────────
it('calculates correct totals across all receivables', () => {
const invoices = [
makeInvoice({ id: 'inv-1', currency: 'EUR', total: 5000, total_sek: 57100 }),
makeInvoice({ id: 'inv-2', currency: 'USD', total: 3000, total_sek: 31500, customer_id: 'cust-2' }),
]
const customers = [
makeCustomer({ id: 'cust-1' }),
makeCustomer({ id: 'cust-2', name: 'Smith Inc', country: 'US' }),
]
const rates = [
makeRate({ currency: 'EUR', rate: 11.50 }),
makeRate({ currency: 'USD', rate: 10.60 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers,
currentRates: rates,
})
// EUR: 5000 × 11.50 = 57500, USD: 3000 × 10.60 = 31800
expect(report.totals.bookedSekValue).toBe(88600) // 57100 + 31500
expect(report.totals.currentSekValue).toBe(89300) // 57500 + 31800
expect(report.totals.totalUnrealizedGainLoss).toBe(700) // 89300 - 88600
expect(report.totals.receivableCount).toBe(2)
expect(report.totals.currencyCount).toBe(2)
})
// ── Period info ───────────────────────────────────────────
it('includes year and exchange rate info', () => {
const rates = [
makeRate({ currency: 'EUR', rate: 11.50 }),
makeRate({ currency: 'USD', rate: 10.60 }),
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
currentRates: rates,
})
expect(report.realizedGainLoss.year).toBe(2026)
expect(report.exchangeRates).toHaveLength(2)
expect(report.exchangeRates.find(r => r.currency === 'EUR')?.rate).toBe(11.50)
})
// ── Sorting ───────────────────────────────────────────────
it('sorts receivables by absolute unrealized gain/loss descending', () => {
const invoices = [
makeInvoice({ id: 'inv-1', total: 1000, total_sek: 11420 }), // gain: 80
makeInvoice({ id: 'inv-2', total: 5000, total_sek: 57100, customer_id: 'cust-1' }), // gain: 400
makeInvoice({ id: 'inv-3', total: 2000, total_sek: 22840, customer_id: 'cust-1' }), // gain: 160
]
const report = generateReceivablesReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
currentRates: [makeRate({ rate: 11.50 })],
})
expect(report.receivables[0].foreignAmount).toBe(5000) // largest gain
expect(report.receivables[1].foreignAmount).toBe(2000)
expect(report.receivables[2].foreignAmount).toBe(1000) // smallest gain
})
})
@@ -0,0 +1,359 @@
/**
* Multi-Currency Receivables Engine
*
* Pure-function engine that calculates foreign currency exposure from
* open receivables, unrealized FX gains/losses vs. current Riksbanken
* rates, and realized FX gains/losses from GL accounts 3960/7960.
*
* This extension is READ-ONLY — it does not create journal entries.
* The revaluation preview is informational only.
*/
// ── Types ──────────────────────────────────────────────────────
/** Minimal invoice data needed by the engine */
export interface ReceivableInvoice {
id: string
invoice_number: string
invoice_date: string
due_date: string
status: string
currency: string
total: number
total_sek: number | null
exchange_rate: number | null
customer_id: string
}
/** Minimal customer data */
export interface ReceivableCustomer {
id: string
name: string
country: string
}
/** GL line for realized FX */
export interface GLLine {
account_number: string
debit: number
credit: number
entry_date: string
}
/** Current exchange rate */
export interface ExchangeRateInfo {
currency: string
rate: number
date: string
}
/** Engine options */
export interface ReceivablesOptions {
/** Open (unpaid) foreign-currency invoices */
invoices: ReceivableInvoice[]
/** Customers for those invoices */
customers: ReceivableCustomer[]
/** Current exchange rates from Riksbanken */
currentRates: ExchangeRateInfo[]
/** GL lines on accounts 3960 and 7960 for the selected year */
realizedFXLines: GLLine[]
/** Reference date (for days outstanding calculation) */
referenceDate: string
/** Selected year for realized FX */
year: number
}
// ── Output types ───────────────────────────────────────────────
export interface CurrencyExposure {
currency: string
totalForeignAmount: number
bookedSekValue: number
currentSekValue: number
unrealizedGainLoss: number
invoiceCount: number
averageBookedRate: number
currentRate: number
}
export interface ForeignReceivable {
invoiceId: string
invoiceNumber: string
customerName: string
customerCountry: string
currency: string
foreignAmount: number
bookedSekAmount: number
bookedRate: number
currentSekAmount: number
currentRate: number
unrealizedGainLoss: number
invoiceDate: string
dueDate: string
daysOutstanding: number
}
export interface MonthlyFXTrend {
month: string // 'YYYY-MM'
realizedGains: number
realizedLosses: number
netRealized: number
}
export interface RevalPreview {
totalUnrealizedGainLoss: number
gains: number // Positive: amount for account 3969
losses: number // Positive: amount for account 7969
}
export interface ReceivablesReport {
referenceDate: string
exchangeRates: ExchangeRateInfo[]
exposureByCurrency: CurrencyExposure[]
receivables: ForeignReceivable[]
realizedGainLoss: {
year: number
gains: number
losses: number
net: number
}
monthlyTrend: MonthlyFXTrend[]
revalPreview: RevalPreview
totals: {
bookedSekValue: number
currentSekValue: number
totalUnrealizedGainLoss: number
receivableCount: number
currencyCount: number
}
}
// ── Constants ──────────────────────────────────────────────────
const ACCOUNT_FX_GAINS = '3960'
const ACCOUNT_FX_LOSSES = '7960'
const OPEN_STATUSES = ['sent', 'overdue']
// ── Engine ─────────────────────────────────────────────────────
export function generateReceivablesReport(options: ReceivablesOptions): ReceivablesReport {
const { invoices, customers, currentRates, realizedFXLines, referenceDate, year } = options
const rateMap = new Map<string, ExchangeRateInfo>()
for (const r of currentRates) {
rateMap.set(r.currency.toUpperCase(), r)
}
const customerMap = new Map<string, ReceivableCustomer>()
for (const c of customers) {
customerMap.set(c.id, c)
}
// Filter to open foreign-currency invoices
const foreignInvoices = invoices.filter(inv =>
OPEN_STATUSES.includes(inv.status) &&
inv.currency !== 'SEK' &&
inv.total > 0
)
// Build receivable details
const receivables: ForeignReceivable[] = []
for (const inv of foreignInvoices) {
const customer = customerMap.get(inv.customer_id)
const rateInfo = rateMap.get(inv.currency.toUpperCase())
const currentRate = rateInfo?.rate ?? 0
const bookedSekAmount = getBookedSekAmount(inv)
const bookedRate = inv.total > 0 ? bookedSekAmount / inv.total : 0
const currentSekAmount = round2(inv.total * currentRate)
const unrealizedGainLoss = round2(currentSekAmount - bookedSekAmount)
const daysOutstanding = daysBetween(inv.invoice_date, referenceDate)
receivables.push({
invoiceId: inv.id,
invoiceNumber: inv.invoice_number,
customerName: customer?.name ?? 'Okänd kund',
customerCountry: customer?.country ?? '',
currency: inv.currency,
foreignAmount: inv.total,
bookedSekAmount,
bookedRate: round4(bookedRate),
currentSekAmount,
currentRate: round4(currentRate),
unrealizedGainLoss,
invoiceDate: inv.invoice_date,
dueDate: inv.due_date,
daysOutstanding,
})
}
// Sort by unrealized gain/loss (largest absolute first)
receivables.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
// Aggregate by currency
const exposureMap = new Map<string, {
totalForeign: number
bookedSek: number
currentSek: number
count: number
}>()
for (const r of receivables) {
const existing = exposureMap.get(r.currency) || { totalForeign: 0, bookedSek: 0, currentSek: 0, count: 0 }
existing.totalForeign += r.foreignAmount
existing.bookedSek += r.bookedSekAmount
existing.currentSek += r.currentSekAmount
existing.count += 1
exposureMap.set(r.currency, existing)
}
const exposureByCurrency: CurrencyExposure[] = []
for (const [currency, agg] of exposureMap.entries()) {
const rateInfo = rateMap.get(currency.toUpperCase())
exposureByCurrency.push({
currency,
totalForeignAmount: round2(agg.totalForeign),
bookedSekValue: round2(agg.bookedSek),
currentSekValue: round2(agg.currentSek),
unrealizedGainLoss: round2(agg.currentSek - agg.bookedSek),
invoiceCount: agg.count,
averageBookedRate: agg.totalForeign > 0 ? round4(agg.bookedSek / agg.totalForeign) : 0,
currentRate: rateInfo?.rate ?? 0,
})
}
// Sort exposures by absolute unrealized gain/loss descending
exposureByCurrency.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
// Calculate realized FX from GL
const { gains, losses } = calculateRealizedFX(realizedFXLines)
// Monthly trend
const monthlyTrend = calculateMonthlyTrend(realizedFXLines, year)
// Revaluation preview
const totalBooked = receivables.reduce((sum, r) => sum + r.bookedSekAmount, 0)
const totalCurrent = receivables.reduce((sum, r) => sum + r.currentSekAmount, 0)
const totalUnrealized = round2(totalCurrent - totalBooked)
const revalPreview: RevalPreview = {
totalUnrealizedGainLoss: totalUnrealized,
gains: round2(Math.max(0, totalUnrealized)),
losses: round2(Math.abs(Math.min(0, totalUnrealized))),
}
const currencies = new Set(receivables.map(r => r.currency))
return {
referenceDate,
exchangeRates: currentRates,
exposureByCurrency,
receivables,
realizedGainLoss: {
year,
gains: round2(gains),
losses: round2(losses),
net: round2(gains - losses),
},
monthlyTrend,
revalPreview,
totals: {
bookedSekValue: round2(totalBooked),
currentSekValue: round2(totalCurrent),
totalUnrealizedGainLoss: totalUnrealized,
receivableCount: receivables.length,
currencyCount: currencies.size,
},
}
}
// ── Helpers ────────────────────────────────────────────────────
function getBookedSekAmount(inv: ReceivableInvoice): number {
// Prefer the explicitly stored SEK amount
if (inv.total_sek !== null && inv.total_sek !== undefined) {
return round2(inv.total_sek)
}
// Fallback: total × exchange_rate at invoice date
if (inv.exchange_rate !== null && inv.exchange_rate !== undefined) {
return round2(inv.total * inv.exchange_rate)
}
// No rate info — return 0 (will show as warning in UI)
return 0
}
function calculateRealizedFX(lines: GLLine[]): { gains: number; losses: number } {
let gains = 0
let losses = 0
for (const line of lines) {
if (line.account_number === ACCOUNT_FX_GAINS) {
// FX gains are booked as credits on 3960
gains += line.credit - line.debit
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
// FX losses are booked as debits on 7960
losses += line.debit - line.credit
}
}
return { gains: Math.max(0, gains), losses: Math.max(0, losses) }
}
function calculateMonthlyTrend(lines: GLLine[], year: number): MonthlyFXTrend[] {
const monthData = new Map<string, { gains: number; losses: number }>()
// Initialize all 12 months
for (let m = 1; m <= 12; m++) {
const key = `${year}-${String(m).padStart(2, '0')}`
monthData.set(key, { gains: 0, losses: 0 })
}
for (const line of lines) {
const monthKey = line.entry_date.substring(0, 7) // 'YYYY-MM'
const data = monthData.get(monthKey)
if (!data) continue
if (line.account_number === ACCOUNT_FX_GAINS) {
data.gains += line.credit - line.debit
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
data.losses += line.debit - line.credit
}
}
const trend: MonthlyFXTrend[] = []
for (const [month, data] of monthData.entries()) {
const gains = Math.max(0, round2(data.gains))
const losses = Math.max(0, round2(data.losses))
trend.push({
month,
realizedGains: gains,
realizedLosses: losses,
netRealized: round2(gains - losses),
})
}
// Sort chronologically
trend.sort((a, b) => a.month.localeCompare(b.month))
return trend
}
function daysBetween(dateStr: string, refDateStr: string): number {
const d1 = new Date(dateStr)
const d2 = new Date(refDateStr)
const diffMs = d2.getTime() - d1.getTime()
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)))
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
function round4(n: number): number {
return Math.round(n * 10000) / 10000
}
+18
View File
@@ -0,0 +1,18 @@
import type { Extension } from '@/lib/extensions/types'
/**
* EU Sales List / Periodisk Sammanställning Extension
*
* Generates the mandatory EC Sales List (periodisk sammanställning) report
* for Skatteverket. Aggregates intra-community B2B sales by customer VAT
* number, separating goods (box 35) from services (box 39).
*
* Outputs downloadable CSV/XML files for upload to Skatteverket's e-service.
* Validates customer VAT numbers via VIES and cross-checks against
* momsdeklaration box totals.
*/
export const euSalesListExtension: Extension = {
id: 'eu-sales-list',
name: 'Periodisk sammanställning',
version: '1.0.0',
}
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest'
import { generateCSV, generateCSVFilename } from '../csv-generator'
import type { ECSalesListReport } from '../eu-sales-list-engine'
function makeReport(overrides: Partial<ECSalesListReport> = {}): ECSalesListReport {
return {
period: { year: 2026, quarter: 1 },
filingType: 'quarterly',
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
lines: [
{
customerVatNumber: 'DE123456789',
customerName: 'Acme GmbH',
customerCountry: 'DE',
customerId: 'cust-1',
goodsAmount: 50000,
servicesAmount: 30000,
triangulationAmount: 0,
invoiceCount: 3,
},
],
totals: { goods: 50000, services: 30000, triangulation: 0, total: 80000 },
warnings: [],
crossCheck: null,
invoiceCount: 3,
customerCount: 1,
...overrides,
}
}
describe('generateCSV', () => {
it('starts with UTF-8 BOM', () => {
const csv = generateCSV(makeReport())
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('uses semicolons as delimiter', () => {
const csv = generateCSV(makeReport())
const headerLine = csv.split('\r\n')[0].replace('\uFEFF', '')
expect(headerLine).toContain(';')
expect(headerLine).not.toContain(',')
})
it('has correct header columns', () => {
const csv = generateCSV(makeReport())
const header = csv.split('\r\n')[0].replace('\uFEFF', '')
expect(header).toBe('Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)')
})
it('includes customer data rows', () => {
const csv = generateCSV(makeReport())
const lines = csv.split('\r\n')
expect(lines[1]).toBe('DE;DE123456789;50000;30000;0')
})
it('rounds amounts to whole SEK', () => {
const report = makeReport({
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 12345.67,
servicesAmount: 89012.34,
triangulationAmount: 0,
invoiceCount: 1,
}],
totals: { goods: 12345.67, services: 89012.34, triangulation: 0, total: 101358.01 },
})
const csv = generateCSV(report)
const dataLine = csv.split('\r\n')[1]
expect(dataLine).toBe('DE;DE123;12346;89012;0')
})
it('includes summary row', () => {
const csv = generateCSV(makeReport())
const lines = csv.split('\r\n')
const summaryIndex = lines.findIndex(l => l.startsWith('Summa'))
expect(summaryIndex).toBeGreaterThan(0)
expect(lines[summaryIndex]).toBe('Summa;;50000;30000;0')
})
it('uses CRLF line endings', () => {
const csv = generateCSV(makeReport())
expect(csv).toContain('\r\n')
})
it('handles multiple customers', () => {
const report = makeReport({
lines: [
{
customerVatNumber: 'DE111',
customerName: 'A',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 10000,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
},
{
customerVatNumber: 'FR222',
customerName: 'B',
customerCountry: 'FR',
customerId: 'c-2',
goodsAmount: 0,
servicesAmount: 20000,
triangulationAmount: 0,
invoiceCount: 2,
},
],
})
const csv = generateCSV(report)
const lines = csv.split('\r\n')
expect(lines[1]).toBe('DE;DE111;10000;0;0')
expect(lines[2]).toBe('FR;FR222;0;20000;0')
})
})
describe('generateCSVFilename', () => {
it('generates quarterly filename', () => {
const filename = generateCSVFilename(makeReport())
expect(filename).toBe('PS_SE556677889901_2026-Q1.csv')
})
it('generates monthly filename', () => {
const report = makeReport({ period: { year: 2026, month: 3 }, filingType: 'monthly' })
const filename = generateCSVFilename(report)
expect(filename).toBe('PS_SE556677889901_2026-03.csv')
})
it('strips spaces from VAT number', () => {
const report = makeReport({ reporterVatNumber: 'SE 5566 7788 9901' })
const filename = generateCSVFilename(report)
expect(filename).toBe('PS_SE556677889901_2026-Q1.csv')
})
})
@@ -0,0 +1,559 @@
import { describe, it, expect } from 'vitest'
import {
generateECSalesListReport,
getMonthPeriod,
getQuarterPeriod,
getFilingDeadline,
daysUntilDeadline,
type ECSalesListInvoice,
type ECSalesListCustomer,
type GLAccountTotal,
} from '../eu-sales-list-engine'
// ── Test fixtures ─────────────────────────────────────────────
function makeInvoice(overrides: Partial<ECSalesListInvoice> = {}): ECSalesListInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
invoice_date: '2026-01-15',
status: 'sent',
currency: 'EUR',
total: 10000,
total_sek: 112000,
subtotal: 10000,
subtotal_sek: 112000,
vat_treatment: 'reverse_charge',
moms_ruta: '35',
document_type: 'invoice',
credited_invoice_id: null,
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<ECSalesListCustomer> = {}): ECSalesListCustomer {
return {
id: 'cust-1',
name: 'Acme GmbH',
country: 'DE',
customer_type: 'eu_business',
vat_number: 'DE123456789',
vat_number_validated: true,
...overrides,
}
}
const BASE_OPTIONS = {
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
year: 2026,
month: 1 as number | undefined,
quarter: undefined as number | undefined,
}
// ── Report generation tests ───────────────────────────────────
describe('generateECSalesListReport', () => {
it('generates empty report when no invoices', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
})
expect(report.lines).toHaveLength(0)
expect(report.totals.goods).toBe(0)
expect(report.totals.services).toBe(0)
expect(report.totals.total).toBe(0)
expect(report.invoiceCount).toBe(0)
expect(report.customerCount).toBe(0)
expect(report.warnings).toHaveLength(0)
})
it('aggregates goods invoice correctly (moms_ruta 35)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '35', subtotal_sek: 50000 })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].goodsAmount).toBe(50000)
expect(report.lines[0].servicesAmount).toBe(0)
expect(report.totals.goods).toBe(50000)
expect(report.invoiceCount).toBe(1)
})
it('aggregates service invoice correctly (moms_ruta 39)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '39', subtotal_sek: 30000 })],
customers: [makeCustomer()],
})
expect(report.lines[0].servicesAmount).toBe(30000)
expect(report.lines[0].goodsAmount).toBe(0)
expect(report.totals.services).toBe(30000)
})
it('aggregates triangulation invoice correctly (moms_ruta 38)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '38', subtotal_sek: 20000 })],
customers: [makeCustomer()],
})
expect(report.lines[0].triangulationAmount).toBe(20000)
expect(report.totals.triangulation).toBe(20000)
})
it('defaults to services when moms_ruta is null', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: null, subtotal_sek: 15000 })],
customers: [makeCustomer()],
})
expect(report.lines[0].servicesAmount).toBe(15000)
expect(report.lines[0].goodsAmount).toBe(0)
})
it('groups multiple invoices by customer VAT number', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 10000, moms_ruta: '35' }),
makeInvoice({ id: 'inv-2', subtotal_sek: 20000, moms_ruta: '35' }),
makeInvoice({ id: 'inv-3', subtotal_sek: 5000, moms_ruta: '39' }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].goodsAmount).toBe(30000)
expect(report.lines[0].servicesAmount).toBe(5000)
expect(report.lines[0].invoiceCount).toBe(3)
})
it('separates different customers into different lines', () => {
const invoices = [
makeInvoice({ id: 'inv-1', customer_id: 'cust-1', subtotal_sek: 10000 }),
makeInvoice({ id: 'inv-2', customer_id: 'cust-2', subtotal_sek: 20000 }),
]
const customers = [
makeCustomer({ id: 'cust-1', vat_number: 'DE111111111', country: 'DE' }),
makeCustomer({ id: 'cust-2', vat_number: 'FR222222222', country: 'FR', name: 'Fromage SARL' }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers,
})
expect(report.lines).toHaveLength(2)
expect(report.customerCount).toBe(2)
// Sorted by country, so DE comes before FR
expect(report.lines[0].customerCountry).toBe('DE')
expect(report.lines[1].customerCountry).toBe('FR')
})
it('handles credit notes (subtracts from customer total)', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, moms_ruta: '35' }),
makeInvoice({
id: 'inv-2',
subtotal_sek: 10000,
moms_ruta: '35',
credited_invoice_id: 'inv-1',
}),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(40000) // 50000 - 10000
expect(report.invoiceCount).toBe(2)
})
it('uses subtotal (not total) for amounts', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 80000, total_sek: 100000, moms_ruta: '35' })],
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(80000)
})
it('falls back to subtotal when subtotal_sek is null (SEK invoices)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: null, subtotal: 45000, moms_ruta: '35' })],
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(45000)
})
// ── Filtering tests ──────────────────────────────────────
it('excludes draft invoices', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'draft' })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(0)
expect(report.invoiceCount).toBe(0)
})
it('excludes non-reverse-charge invoices', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ vat_treatment: 'standard_25' })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(0)
})
it('includes paid and overdue invoices', () => {
const invoices = [
makeInvoice({ id: 'inv-1', status: 'paid', subtotal_sek: 10000 }),
makeInvoice({ id: 'inv-2', status: 'overdue', subtotal_sek: 20000 }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
})
expect(report.invoiceCount).toBe(2)
})
it('excludes proforma documents without credit note link', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ document_type: 'proforma', credited_invoice_id: null })],
customers: [makeCustomer()],
})
expect(report.lines).toHaveLength(0)
})
it('includes credit notes even with non-invoice document type', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({
document_type: 'credit_note',
credited_invoice_id: 'inv-original',
subtotal_sek: 5000,
moms_ruta: '35',
})],
customers: [makeCustomer()],
})
// Credit note creates a line with negative amount
expect(report.lines).toHaveLength(1)
expect(report.lines[0].goodsAmount).toBe(-5000)
})
// ── Warning tests ────────────────────────────────────────
it('warns on missing VAT number (error severity)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ vat_number: null })],
})
expect(report.warnings).toHaveLength(1)
expect(report.warnings[0].type).toBe('missing_vat_number')
expect(report.warnings[0].severity).toBe('error')
// Invoice is excluded from lines when VAT number missing
expect(report.lines).toHaveLength(0)
})
it('warns on unvalidated VAT number (warning severity)', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ vat_number_validated: false })],
})
const unvalidatedWarnings = report.warnings.filter(w => w.type === 'unvalidated_vat_number')
expect(unvalidatedWarnings).toHaveLength(1)
expect(unvalidatedWarnings[0].severity).toBe('warning')
// Invoice is still included in report
expect(report.lines).toHaveLength(1)
})
it('warns on non-EU country', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ country: 'US' })],
})
expect(report.warnings).toHaveLength(1)
expect(report.warnings[0].type).toBe('non_eu_country')
expect(report.lines).toHaveLength(0)
})
it('excludes Sweden (SE) as non-intra-community', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer({ country: 'SE' })],
})
expect(report.warnings).toHaveLength(1)
expect(report.warnings[0].type).toBe('non_eu_country')
expect(report.lines).toHaveLength(0)
})
// ── Cross-check tests ────────────────────────────────────
it('cross-check passes when GL matches report totals', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 50000 },
{ account_number: '3308', credit: 30000 },
]
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, moms_ruta: '35' }),
makeInvoice({ id: 'inv-2', subtotal_sek: 30000, moms_ruta: '39' }),
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices,
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck).not.toBeNull()
expect(report.crossCheck!.box35Match).toBe(true)
expect(report.crossCheck!.box39Match).toBe(true)
})
it('cross-check fails when GL does not match', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 99999 },
{ account_number: '3308', credit: 30000 },
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck!.box35Match).toBe(false)
const mismatchWarnings = report.warnings.filter(w => w.type === 'cross_check_mismatch')
expect(mismatchWarnings.length).toBeGreaterThan(0)
})
it('cross-check allows 1 SEK rounding tolerance', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 50000.50 },
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck!.box35Match).toBe(true)
})
it('cross-check sums multiple accounts for box 35 (3108 + 3521)', () => {
const glTotals: GLAccountTotal[] = [
{ account_number: '3108', credit: 40000 },
{ account_number: '3521', credit: 10000 },
]
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
customers: [makeCustomer()],
glTotals,
})
expect(report.crossCheck!.box35Match).toBe(true)
expect(report.crossCheck!.box35GLTotal).toBe(50000)
})
it('skips cross-check when no GL data provided', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
customers: [makeCustomer()],
})
expect(report.crossCheck).toBeNull()
})
// ── Period and metadata tests ────────────────────────────
it('sets filingType to monthly when month is provided', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
month: 3,
quarter: undefined,
})
expect(report.filingType).toBe('monthly')
expect(report.period.month).toBe(3)
})
it('sets filingType to quarterly when quarter is provided', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
month: undefined,
quarter: 2,
})
expect(report.filingType).toBe('quarterly')
expect(report.period.quarter).toBe(2)
})
it('includes reporter info in report', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [],
customers: [],
})
expect(report.reporterVatNumber).toBe('SE556677889901')
expect(report.reporterName).toBe('Test AB')
})
// ── Monetary precision tests ─────────────────────────────
it('rounds amounts to 2 decimal places', () => {
const report = generateECSalesListReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 33333.335, moms_ruta: '35' })],
customers: [makeCustomer()],
})
expect(report.lines[0].goodsAmount).toBe(33333.34)
})
})
// ── Period helper tests ───────────────────────────────────────
describe('getMonthPeriod', () => {
it('returns correct start and end for January', () => {
const { start, end } = getMonthPeriod(2026, 1)
expect(start).toBe('2026-01-01')
expect(end).toBe('2026-01-31')
})
it('handles February in a non-leap year', () => {
const { start, end } = getMonthPeriod(2027, 2)
expect(start).toBe('2027-02-01')
expect(end).toBe('2027-02-28')
})
it('handles February in a leap year', () => {
const { start, end } = getMonthPeriod(2028, 2)
expect(start).toBe('2028-02-01')
expect(end).toBe('2028-02-29')
})
it('returns correct dates for December', () => {
const { start, end } = getMonthPeriod(2026, 12)
expect(start).toBe('2026-12-01')
expect(end).toBe('2026-12-31')
})
})
describe('getQuarterPeriod', () => {
it('returns Q1 dates', () => {
const { start, end } = getQuarterPeriod(2026, 1)
expect(start).toBe('2026-01-01')
expect(end).toBe('2026-03-31')
})
it('returns Q2 dates', () => {
const { start, end } = getQuarterPeriod(2026, 2)
expect(start).toBe('2026-04-01')
expect(end).toBe('2026-06-30')
})
it('returns Q3 dates', () => {
const { start, end } = getQuarterPeriod(2026, 3)
expect(start).toBe('2026-07-01')
expect(end).toBe('2026-09-30')
})
it('returns Q4 dates', () => {
const { start, end } = getQuarterPeriod(2026, 4)
expect(start).toBe('2026-10-01')
expect(end).toBe('2026-12-31')
})
})
describe('getFilingDeadline', () => {
it('returns 25th of following month for monthly', () => {
expect(getFilingDeadline(2026, 1)).toBe('2026-02-25')
expect(getFilingDeadline(2026, 6)).toBe('2026-07-25')
})
it('rolls over to next year for December', () => {
expect(getFilingDeadline(2026, 12)).toBe('2027-01-25')
})
it('returns correct deadline for quarterly', () => {
expect(getFilingDeadline(2026, undefined, 1)).toBe('2026-04-25')
expect(getFilingDeadline(2026, undefined, 2)).toBe('2026-07-25')
expect(getFilingDeadline(2026, undefined, 3)).toBe('2026-10-25')
})
it('rolls over to next year for Q4', () => {
expect(getFilingDeadline(2026, undefined, 4)).toBe('2027-01-25')
})
it('throws when neither month nor quarter provided', () => {
expect(() => getFilingDeadline(2026)).toThrow()
})
})
describe('daysUntilDeadline', () => {
it('returns positive number for future deadlines', () => {
const future = new Date()
future.setDate(future.getDate() + 10)
const dateStr = future.toISOString().slice(0, 10)
expect(daysUntilDeadline(dateStr)).toBe(10)
})
it('returns 0 for today', () => {
const today = new Date().toISOString().slice(0, 10)
expect(daysUntilDeadline(today)).toBe(0)
})
it('returns negative number for past deadlines', () => {
const past = new Date()
past.setDate(past.getDate() - 5)
const dateStr = past.toISOString().slice(0, 10)
expect(daysUntilDeadline(dateStr)).toBe(-5)
})
})
@@ -0,0 +1,189 @@
import { describe, it, expect } from 'vitest'
import { generateSKVXml, generateXMLFilename } from '../skv-xml-generator'
import type { ECSalesListReport } from '../eu-sales-list-engine'
function makeReport(overrides: Partial<ECSalesListReport> = {}): ECSalesListReport {
return {
period: { year: 2026, quarter: 1 },
filingType: 'quarterly',
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
lines: [
{
customerVatNumber: 'DE123456789',
customerName: 'Acme GmbH',
customerCountry: 'DE',
customerId: 'cust-1',
goodsAmount: 50000,
servicesAmount: 30000,
triangulationAmount: 0,
invoiceCount: 3,
},
],
totals: { goods: 50000, services: 30000, triangulation: 0, total: 80000 },
warnings: [],
crossCheck: null,
invoiceCount: 3,
customerCount: 1,
...overrides,
}
}
describe('generateSKVXml', () => {
it('starts with XML declaration', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/)
})
it('wraps content in KVPS root element', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<KVPS>')
expect(xml).toContain('</KVPS>')
})
it('includes reporter VAT number', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<Momsregistreringsnummer>SE556677889901</Momsregistreringsnummer>')
})
it('includes reporter name', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<Namn>Test AB</Namn>')
})
it('includes quarterly period info', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<Ar>2026</Ar>')
expect(xml).toContain('<Kvartal>1</Kvartal>')
expect(xml).toContain('<Redovisningstyp>Kvartal</Redovisningstyp>')
})
it('includes monthly period info', () => {
const report = makeReport({ period: { year: 2026, month: 3 }, filingType: 'monthly' })
const xml = generateSKVXml(report)
expect(xml).toContain('<Manad>03</Manad>')
expect(xml).toContain('<Redovisningstyp>Manad</Redovisningstyp>')
})
it('includes customer line with goods and services', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<KopareVATnr>DE123456789</KopareVATnr>')
expect(xml).toContain('<KopareLand>DE</KopareLand>')
expect(xml).toContain('<VarorBeloppSEK>50000</VarorBeloppSEK>')
expect(xml).toContain('<TjansterBeloppSEK>30000</TjansterBeloppSEK>')
})
it('omits zero amount elements', () => {
const xml = generateSKVXml(makeReport())
// Triangulation is 0, should not appear in line
expect(xml).not.toContain('<TriangelhandelBeloppSEK>0</TriangelhandelBeloppSEK>')
})
it('includes triangulation when non-zero', () => {
const report = makeReport({
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 0,
servicesAmount: 0,
triangulationAmount: 15000,
invoiceCount: 1,
}],
})
const xml = generateSKVXml(report)
expect(xml).toContain('<TriangelhandelBeloppSEK>15000</TriangelhandelBeloppSEK>')
})
it('skips lines with all zero amounts', () => {
const report = makeReport({
lines: [
{
customerVatNumber: 'DE111',
customerName: 'Zero',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 0,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 0,
},
{
customerVatNumber: 'FR222',
customerName: 'NonZero',
customerCountry: 'FR',
customerId: 'c-2',
goodsAmount: 10000,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
},
],
})
const xml = generateSKVXml(report)
expect(xml).not.toContain('DE111')
expect(xml).toContain('FR222')
})
it('includes totals section', () => {
const xml = generateSKVXml(makeReport())
expect(xml).toContain('<VarorTotaltSEK>50000</VarorTotaltSEK>')
expect(xml).toContain('<TjansterTotaltSEK>30000</TjansterTotaltSEK>')
expect(xml).toContain('<TotaltSEK>80000</TotaltSEK>')
})
it('rounds amounts to whole SEK', () => {
const report = makeReport({
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 12345.67,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
}],
totals: { goods: 12345.67, services: 0, triangulation: 0, total: 12345.67 },
})
const xml = generateSKVXml(report)
expect(xml).toContain('<VarorBeloppSEK>12346</VarorBeloppSEK>')
})
it('escapes XML special characters in names', () => {
const report = makeReport({
reporterName: 'Foo & Bar <AB>',
lines: [{
customerVatNumber: 'DE123',
customerName: 'Test',
customerCountry: 'DE',
customerId: 'c-1',
goodsAmount: 1000,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 1,
}],
})
const xml = generateSKVXml(report)
expect(xml).toContain('Foo &amp; Bar &lt;AB&gt;')
expect(xml).not.toContain('Foo & Bar <AB>')
})
})
describe('generateXMLFilename', () => {
it('generates quarterly filename', () => {
const filename = generateXMLFilename(makeReport())
expect(filename).toBe('KVPS_SE556677889901_2026-Q1.xml')
})
it('generates monthly filename', () => {
const report = makeReport({ period: { year: 2026, month: 11 }, filingType: 'monthly' })
const filename = generateXMLFilename(report)
expect(filename).toBe('KVPS_SE556677889901_2026-11.xml')
})
})
@@ -0,0 +1,65 @@
/**
* EU Sales List CSV Generator
*
* Generates a semicolon-separated CSV file (UTF-8 with BOM) for the
* periodisk sammanställning report. Compatible with Excel and Skatteverket's
* import tools.
*
* Format: Semicolon-delimited, UTF-8 BOM, whole SEK amounts.
*/
import type { ECSalesListReport } from './eu-sales-list-engine'
/** UTF-8 BOM for Excel compatibility */
const UTF8_BOM = '\uFEFF'
/**
* Generate a CSV string for the EC Sales List report.
*
* Columns:
* Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)
*
* Amounts are rounded to whole SEK (öre removed) as required by Skatteverket.
*/
export function generateCSV(report: ECSalesListReport): string {
const header = 'Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)'
const rows = report.lines.map(line => {
const goods = Math.round(line.goodsAmount)
const services = Math.round(line.servicesAmount)
const triangulation = Math.round(line.triangulationAmount)
return `${line.customerCountry};${line.customerVatNumber};${goods};${services};${triangulation}`
})
// Summary row
const totalGoods = Math.round(report.totals.goods)
const totalServices = Math.round(report.totals.services)
const totalTriangulation = Math.round(report.totals.triangulation)
rows.push('')
rows.push(`Summa;;${totalGoods};${totalServices};${totalTriangulation}`)
return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'
}
/**
* Generate a filename for the CSV download.
*
* Format: PS_<VAT>_<period>.csv
* Example: PS_SE556677889901_2026-Q1.csv or PS_SE556677889901_2026-03.csv
*/
export function generateCSVFilename(report: ECSalesListReport): string {
const vat = report.reporterVatNumber.replace(/\s/g, '')
const period = formatPeriod(report)
return `PS_${vat}_${period}.csv`
}
function formatPeriod(report: ECSalesListReport): string {
const { year, month, quarter } = report.period
if (month !== undefined) {
return `${year}-${String(month).padStart(2, '0')}`
}
if (quarter !== undefined) {
return `${year}-Q${quarter}`
}
return `${year}`
}
@@ -0,0 +1,450 @@
/**
* EU Sales List / Periodisk Sammanställning Engine
*
* Core business logic for generating EC Sales List reports.
* Pure functions — no Supabase, no React, no side effects.
*
* Aggregates intra-community B2B sales by customer VAT number,
* separating goods (account 3108, box 35) from services (account 3308, box 39).
*
* Reference: Skatteverket SKV 5740
* Filing: Monthly for goods, quarterly for services
* Deadline: 25th of the month following the reporting period
*/
import { isEUCountry } from '@/extensions/export/shared/eu-countries'
// ── Types ────────────────────────────────────────────────────
/** Invoice data needed for EC Sales List generation */
export interface ECSalesListInvoice {
id: string
invoice_number: string
invoice_date: string
status: string
currency: string
total: number
total_sek: number | null
subtotal: number
subtotal_sek: number | null
vat_treatment: string
moms_ruta: string | null
document_type: string
credited_invoice_id: string | null
customer_id: string
}
/** Customer data needed for EC Sales List */
export interface ECSalesListCustomer {
id: string
name: string
country: string
customer_type: string
vat_number: string | null
vat_number_validated: boolean
}
/** Journal entry line data for cross-checking */
export interface GLAccountTotal {
account_number: string
credit: number
}
/** Aggregated line in the EC Sales List report */
export interface ECSalesListLine {
customerVatNumber: string
customerName: string
customerCountry: string
customerId: string
goodsAmount: number
servicesAmount: number
triangulationAmount: number
invoiceCount: number
}
/** Warning about data quality */
export interface ECSalesListWarning {
type:
| 'missing_vat_number'
| 'unvalidated_vat_number'
| 'missing_moms_ruta'
| 'non_eu_country'
| 'cross_check_mismatch'
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
customerId?: string
customerName?: string
message: string
}
/** Cross-check result comparing report totals vs GL */
export interface CrossCheckResult {
box35Match: boolean
box35ReportTotal: number
box35GLTotal: number
box39Match: boolean
box39ReportTotal: number
box39GLTotal: number
}
/** Complete EC Sales List report */
export interface ECSalesListReport {
period: {
year: number
month?: number
quarter?: number
}
filingType: 'monthly' | 'quarterly'
reporterVatNumber: string
reporterName: string
lines: ECSalesListLine[]
totals: {
goods: number
services: number
triangulation: number
total: number
}
warnings: ECSalesListWarning[]
crossCheck: CrossCheckResult | null
invoiceCount: number
customerCount: number
}
/** Options for generating the report */
export interface GenerateReportOptions {
invoices: ECSalesListInvoice[]
customers: ECSalesListCustomer[]
glTotals?: GLAccountTotal[]
reporterVatNumber: string
reporterName: string
year: number
month?: number
quarter?: number
}
// ── Revenue account classification ─────────────────────────
/** Accounts that represent EU goods sales (box 35) */
const GOODS_ACCOUNTS = ['3108', '3521']
/** Accounts that represent EU service sales (box 39) */
const SERVICE_ACCOUNTS = ['3308']
/** Accounts for triangular trade (box 38) */
const TRIANGULATION_ACCOUNTS = ['3109']
// ── Core engine ──────────────────────────────────────────────
/**
* Generate an EC Sales List report from invoice and customer data.
*
* Steps:
* 1. Filter invoices to EU B2B reverse charge only
* 2. Join with customers
* 3. Validate data quality (VAT numbers, country codes)
* 4. Group by customer VAT number
* 5. Classify as goods or services based on moms_ruta / vat_treatment
* 6. Cross-check against GL account totals if provided
*/
export function generateECSalesListReport(options: GenerateReportOptions): ECSalesListReport {
const {
invoices,
customers,
glTotals,
reporterVatNumber,
reporterName,
year,
month,
quarter,
} = options
const warnings: ECSalesListWarning[] = []
const customerMap = new Map(customers.map(c => [c.id, c]))
// Step 1: Filter to relevant invoices
const relevantInvoices = invoices.filter(inv => {
// Only sent, paid, or overdue invoices (not drafts)
if (!['sent', 'paid', 'overdue'].includes(inv.status)) return false
// Only actual invoices (not proforma/delivery notes) — except credit notes
if (inv.document_type !== 'invoice' && inv.credited_invoice_id === null) return false
// Must be reverse charge (EU B2B)
if (inv.vat_treatment !== 'reverse_charge') return false
return true
})
// Step 2: Build aggregation map (keyed by customer VAT number)
const aggregation = new Map<string, ECSalesListLine>()
for (const invoice of relevantInvoices) {
const customer = customerMap.get(invoice.customer_id)
if (!customer) continue
// Validate: customer should be in an EU country (not Sweden)
if (!isEUCountry(customer.country)) {
warnings.push({
type: 'non_eu_country',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerId: customer.id,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} till ${customer.name} har omvänd skattskyldighet men kunden är i ${customer.country} (ej EU).`,
})
continue
}
// Validate: VAT number must exist
if (!customer.vat_number) {
warnings.push({
type: 'missing_vat_number',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerId: customer.id,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} till ${customer.name} saknar momsregistreringsnummer (VAT-nummer). Krävs för periodisk sammanställning.`,
})
continue
}
// Validate: VAT number should be validated via VIES
if (!customer.vat_number_validated) {
warnings.push({
type: 'unvalidated_vat_number',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerId: customer.id,
customerName: customer.name,
message: `VAT-nummer ${customer.vat_number} för ${customer.name} har inte validerats via VIES.`,
})
}
// Determine amount in SEK (use total_sek if available, else total for SEK invoices)
const amountSek = getAmountSek(invoice)
// Determine if this is a credit note (negative amount)
const isCreditNote = invoice.credited_invoice_id !== null
const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek
// Classify: goods (box 35) or services (box 39)
const classification = classifyInvoice(invoice)
// Get or create aggregation line
const vatNumber = customer.vat_number
const key = vatNumber
if (!aggregation.has(key)) {
aggregation.set(key, {
customerVatNumber: vatNumber,
customerName: customer.name,
customerCountry: customer.country,
customerId: customer.id,
goodsAmount: 0,
servicesAmount: 0,
triangulationAmount: 0,
invoiceCount: 0,
})
}
const line = aggregation.get(key)!
line.invoiceCount++
if (classification === 'triangulation') {
line.triangulationAmount = round2(line.triangulationAmount + effectiveAmount)
} else if (classification === 'goods') {
line.goodsAmount = round2(line.goodsAmount + effectiveAmount)
} else {
line.servicesAmount = round2(line.servicesAmount + effectiveAmount)
}
}
// Step 3: Build sorted output
const lines = Array.from(aggregation.values())
.sort((a, b) => a.customerCountry.localeCompare(b.customerCountry) || a.customerVatNumber.localeCompare(b.customerVatNumber))
// Step 4: Calculate totals
const totals = {
goods: round2(lines.reduce((sum, l) => sum + l.goodsAmount, 0)),
services: round2(lines.reduce((sum, l) => sum + l.servicesAmount, 0)),
triangulation: round2(lines.reduce((sum, l) => sum + l.triangulationAmount, 0)),
total: 0,
}
totals.total = round2(totals.goods + totals.services + totals.triangulation)
// Step 5: Cross-check against GL if data provided
let crossCheck: CrossCheckResult | null = null
if (glTotals && glTotals.length > 0) {
crossCheck = performCrossCheck(totals, glTotals, warnings)
}
return {
period: { year, month, quarter },
filingType: month !== undefined ? 'monthly' : 'quarterly',
reporterVatNumber,
reporterName,
lines,
totals,
warnings,
crossCheck,
invoiceCount: relevantInvoices.length,
customerCount: lines.length,
}
}
// ── Classification helpers ───────────────────────────────────
type InvoiceClassification = 'goods' | 'services' | 'triangulation'
/**
* Classify an invoice as goods, services, or triangulation.
*
* Uses moms_ruta as primary signal (most reliable, set during invoice creation).
* Falls back to vat_treatment if moms_ruta is not set.
*
* Box 35 = goods to EU (account 3108)
* Box 38 = triangular trade (account 3109)
* Box 39 = services to EU (account 3308)
*/
function classifyInvoice(invoice: ECSalesListInvoice): InvoiceClassification {
const ruta = invoice.moms_ruta
if (ruta === '35') return 'goods'
if (ruta === '38') return 'triangulation'
if (ruta === '39') return 'services'
// Fallback: EU B2B reverse charge defaults to services (box 39)
// since the current VAT rules assign '39' for all reverse charge.
// Goods classification requires explicit moms_ruta = '35'.
return 'services'
}
// ── Cross-check ──────────────────────────────────────────────
/**
* Compare report totals against GL account credit totals.
*
* Box 35 total should match credit sum on accounts 3108 + 3521
* Box 39 total should match credit sum on account 3308
*
* A tolerance of 1 SEK is allowed for rounding differences.
*/
function performCrossCheck(
totals: { goods: number; services: number },
glTotals: GLAccountTotal[],
warnings: ECSalesListWarning[]
): CrossCheckResult {
const glMap = new Map(glTotals.map(t => [t.account_number, t.credit]))
const box35GL = round2(
GOODS_ACCOUNTS.reduce((sum, acc) => sum + (glMap.get(acc) ?? 0), 0)
)
const box39GL = round2(
SERVICE_ACCOUNTS.reduce((sum, acc) => sum + (glMap.get(acc) ?? 0), 0)
)
const TOLERANCE = 1 // Allow 1 SEK rounding difference
const box35Match = Math.abs(totals.goods - box35GL) <= TOLERANCE
const box39Match = Math.abs(totals.services - box39GL) <= TOLERANCE
if (!box35Match) {
warnings.push({
type: 'cross_check_mismatch',
severity: 'warning',
message: `Ruta 35 (varuförsäljning EU): rapporten visar ${totals.goods} SEK men huvudboken visar ${box35GL} SEK (differens ${round2(totals.goods - box35GL)} SEK).`,
})
}
if (!box39Match) {
warnings.push({
type: 'cross_check_mismatch',
severity: 'warning',
message: `Ruta 39 (tjänsteförsäljning EU): rapporten visar ${totals.services} SEK men huvudboken visar ${box39GL} SEK (differens ${round2(totals.services - box39GL)} SEK).`,
})
}
return {
box35Match,
box35ReportTotal: totals.goods,
box35GLTotal: box35GL,
box39Match,
box39ReportTotal: totals.services,
box39GLTotal: box39GL,
}
}
// ── Amount helpers ───────────────────────────────────────────
/**
* Get the SEK amount for an invoice.
* Uses subtotal_sek (excluding VAT) for the EC Sales List,
* since reverse charge invoices have 0 VAT and subtotal === total.
* Falls back to subtotal if SEK amounts are not populated.
*/
function getAmountSek(invoice: ECSalesListInvoice): number {
// For reverse charge invoices, subtotal_sek is the correct base
if (invoice.subtotal_sek !== null) return round2(invoice.subtotal_sek)
// If no SEK conversion exists, the invoice is already in SEK
return round2(invoice.subtotal)
}
/** Round to 2 decimal places (monetary standard) */
function round2(value: number): number {
return Math.round(value * 100) / 100
}
// ── Period helpers ───────────────────────────────────────────
/** Get the start and end dates for a monthly period */
export function getMonthPeriod(year: number, month: number): { start: string; end: string } {
const start = `${year}-${String(month).padStart(2, '0')}-01`
const lastDay = new Date(year, month, 0).getDate()
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
return { start, end }
}
/** Get the start and end dates for a quarterly period */
export function getQuarterPeriod(year: number, quarter: number): { start: string; end: string } {
const startMonth = (quarter - 1) * 3 + 1
const endMonth = startMonth + 2
const start = `${year}-${String(startMonth).padStart(2, '0')}-01`
const lastDay = new Date(year, endMonth, 0).getDate()
const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
return { start, end }
}
/** Get the filing deadline for a period (25th of the month after period end) */
export function getFilingDeadline(year: number, month?: number, quarter?: number): string {
let deadlineMonth: number
let deadlineYear = year
if (month !== undefined) {
// Monthly filing: due 25th of the following month
deadlineMonth = month + 1
if (deadlineMonth > 12) {
deadlineMonth = 1
deadlineYear++
}
} else if (quarter !== undefined) {
// Quarterly filing: due 25th of the month after quarter end
deadlineMonth = quarter * 3 + 1
if (deadlineMonth > 12) {
deadlineMonth = 1
deadlineYear++
}
} else {
throw new Error('Either month or quarter must be provided')
}
return `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-25`
}
/** Calculate days remaining until a deadline */
export function daysUntilDeadline(deadline: string): number {
const deadlineDate = new Date(deadline)
const today = new Date()
today.setHours(0, 0, 0, 0)
deadlineDate.setHours(0, 0, 0, 0)
const diffMs = deadlineDate.getTime() - today.getTime()
return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
}
@@ -0,0 +1,127 @@
/**
* EU Sales List XML Generator (SKV 5740 format)
*
* Generates XML compatible with Skatteverket's e-filing system for
* periodisk sammanställning (EC Sales List / recapitulative statement).
*
* Reference: Skatteverket SKV 5740, KVPS XML schema
* Filing: Monthly for goods, quarterly for services
*
* The XML structure follows Skatteverket's KVPS (Kvartalsvis Periodisk
* Sammanställning) format with elements for reporter info, period, and
* per-customer goods/services/triangulation amounts in whole SEK.
*/
import type { ECSalesListReport } from './eu-sales-list-engine'
/**
* Generate SKV-compatible XML for the EC Sales List report.
*
* Structure:
* <KVPS>
* <Avsandare> — reporter/sender information
* <Period> — reporting period
* <Rad> — one per customer VAT number
* <KopareVATnr> — buyer VAT number
* <KopareLand> — buyer country code
* <VarorBeloppSEK> — goods amount (box 35)
* <TjansterBeloppSEK> — services amount (box 39)
* <TriangelhandelBeloppSEK> — triangulation (box 38)
* </Rad>
* </KVPS>
*
* All amounts are rounded to whole SEK (no decimals).
*/
export function generateSKVXml(report: ECSalesListReport): string {
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<KVPS>')
// Reporter/sender info
lines.push(' <Avsandare>')
lines.push(` <Momsregistreringsnummer>${escapeXml(report.reporterVatNumber)}</Momsregistreringsnummer>`)
lines.push(` <Namn>${escapeXml(report.reporterName)}</Namn>`)
lines.push(' </Avsandare>')
// Period info
lines.push(' <Period>')
lines.push(` <Ar>${report.period.year}</Ar>`)
if (report.period.month !== undefined) {
lines.push(` <Manad>${String(report.period.month).padStart(2, '0')}</Manad>`)
}
if (report.period.quarter !== undefined) {
lines.push(` <Kvartal>${report.period.quarter}</Kvartal>`)
}
lines.push(` <Redovisningstyp>${report.filingType === 'monthly' ? 'Manad' : 'Kvartal'}</Redovisningstyp>`)
lines.push(' </Period>')
// Customer lines
for (const line of report.lines) {
const goods = Math.round(line.goodsAmount)
const services = Math.round(line.servicesAmount)
const triangulation = Math.round(line.triangulationAmount)
// Skip lines with all zero amounts
if (goods === 0 && services === 0 && triangulation === 0) continue
lines.push(' <Rad>')
lines.push(` <KopareVATnr>${escapeXml(line.customerVatNumber)}</KopareVATnr>`)
lines.push(` <KopareLand>${escapeXml(line.customerCountry)}</KopareLand>`)
if (goods !== 0) {
lines.push(` <VarorBeloppSEK>${goods}</VarorBeloppSEK>`)
}
if (services !== 0) {
lines.push(` <TjansterBeloppSEK>${services}</TjansterBeloppSEK>`)
}
if (triangulation !== 0) {
lines.push(` <TriangelhandelBeloppSEK>${triangulation}</TriangelhandelBeloppSEK>`)
}
lines.push(' </Rad>')
}
// Totals
lines.push(' <Summa>')
lines.push(` <VarorTotaltSEK>${Math.round(report.totals.goods)}</VarorTotaltSEK>`)
lines.push(` <TjansterTotaltSEK>${Math.round(report.totals.services)}</TjansterTotaltSEK>`)
lines.push(` <TriangelhandelTotaltSEK>${Math.round(report.totals.triangulation)}</TriangelhandelTotaltSEK>`)
lines.push(` <TotaltSEK>${Math.round(report.totals.total)}</TotaltSEK>`)
lines.push(' </Summa>')
lines.push('</KVPS>')
return lines.join('\n') + '\n'
}
/**
* Generate a filename for the XML download.
*
* Format: KVPS_<VAT>_<period>.xml
* Example: KVPS_SE556677889901_2026-Q1.xml
*/
export function generateXMLFilename(report: ECSalesListReport): string {
const vat = report.reporterVatNumber.replace(/\s/g, '')
const period = formatPeriod(report)
return `KVPS_${vat}_${period}.xml`
}
function formatPeriod(report: ECSalesListReport): string {
const { year, month, quarter } = report.period
if (month !== undefined) {
return `${year}-${String(month).padStart(2, '0')}`
}
if (quarter !== undefined) {
return `${year}-Q${quarter}`
}
return `${year}`
}
/** Escape special XML characters to prevent injection */
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
+17
View File
@@ -0,0 +1,17 @@
import type { Extension } from '@/lib/extensions/types'
/**
* Intrastat Generator Extension
*
* Generates monthly Intrastat dispatch declarations for reporting to SCB
* (Statistics Sweden). Manages product metadata (CN commodity codes, weights,
* country of origin) and aggregates EU goods dispatches.
*
* Outputs SCB IDEP.web compatible CSV files. Monitors the SEK 12M dispatch
* threshold and alerts when the reporting obligation is triggered.
*/
export const intrastatExtension: Extension = {
id: 'intrastat',
name: 'Intrastat-generator',
version: '1.0.0',
}
@@ -0,0 +1,417 @@
import { describe, it, expect } from 'vitest'
import {
generateIntrastatReport,
getIntrastatDeadline,
type IntrastatInvoice,
type IntrastatCustomer,
type IntrastatInvoiceItem,
type ProductMetadata,
type IntrastatOptions,
} from '../intrastat-engine'
// ── Fixtures ────────────────────────────────────────────────
function makeInvoice(overrides: Partial<IntrastatInvoice> = {}): IntrastatInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
invoice_date: '2026-01-15',
status: 'sent',
vat_treatment: 'reverse_charge',
moms_ruta: '35',
currency: 'EUR',
total_sek: 100000,
subtotal_sek: 100000,
subtotal: 9000,
document_type: 'invoice',
credited_invoice_id: null,
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<IntrastatCustomer> = {}): IntrastatCustomer {
return {
id: 'cust-1',
name: 'Acme GmbH',
country: 'DE',
vat_number: 'DE123456789',
...overrides,
}
}
function makeItem(overrides: Partial<IntrastatInvoiceItem> = {}): IntrastatInvoiceItem {
return {
id: 'item-1',
invoice_id: 'inv-1',
description: 'Stålbalk M8',
quantity: 100,
unit_price: 90,
total: 9000,
total_sek: 100000,
...overrides,
}
}
function makeProduct(overrides: Partial<ProductMetadata> = {}): ProductMetadata {
return {
productId: 'stålbalk m8',
cnCode: '72163100',
description: 'Stålbalk M8',
netWeightKg: 45.5,
countryOfOrigin: 'SE',
supplementaryUnit: null,
supplementaryUnitType: null,
...overrides,
}
}
const BASE_OPTIONS: IntrastatOptions = {
invoices: [],
invoiceItems: [],
customers: [],
products: [],
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
year: 2026,
month: 1,
}
// ── Basic report tests ──────────────────────────────────────
describe('generateIntrastatReport', () => {
it('generates empty report when no invoices', () => {
const report = generateIntrastatReport(BASE_OPTIONS)
expect(report.lines).toHaveLength(0)
expect(report.totals.invoicedValue).toBe(0)
expect(report.totals.netMass).toBe(0)
expect(report.invoiceCount).toBe(0)
expect(report.flowType).toBe('dispatch')
})
it('generates line from matched invoice + product', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].cnCode).toBe('72163100')
expect(report.lines[0].partnerCountry).toBe('DE')
expect(report.lines[0].countryOfOrigin).toBe('SE')
expect(report.lines[0].invoicedValue).toBe(100000)
expect(report.lines[0].netMass).toBe(4550) // 45.5 kg × 100 units
expect(report.lines[0].partnerVatId).toBe('DE123456789')
})
it('aggregates multiple invoices with same CN code + country', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, total_sek: 50000 }),
makeInvoice({ id: 'inv-2', subtotal_sek: 30000, total_sek: 30000 }),
]
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', total_sek: 50000, quantity: 50 }),
makeItem({ id: 'item-2', invoice_id: 'inv-2', total_sek: 30000, quantity: 30 }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices,
invoiceItems: items,
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(1)
expect(report.lines[0].invoicedValue).toBe(80000)
expect(report.lines[0].netMass).toBe(3640) // 45.5 × (50 + 30)
expect(report.invoiceCount).toBe(2)
})
it('separates lines by different partner countries', () => {
const invoices = [
makeInvoice({ id: 'inv-1', customer_id: 'cust-1' }),
makeInvoice({ id: 'inv-2', customer_id: 'cust-2' }),
]
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', quantity: 10 }),
makeItem({ id: 'item-2', invoice_id: 'inv-2', quantity: 20 }),
]
const customers = [
makeCustomer({ id: 'cust-1', country: 'DE' }),
makeCustomer({ id: 'cust-2', country: 'FI', vat_number: 'FI12345678', name: 'Finnish Co' }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices,
invoiceItems: items,
customers,
products: [makeProduct()],
})
expect(report.lines).toHaveLength(2)
expect(report.lines[0].partnerCountry).toBe('DE')
expect(report.lines[1].partnerCountry).toBe('FI')
})
it('separates lines by different CN codes', () => {
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', description: 'Stålbalk M8', quantity: 10, total_sek: 50000 }),
makeItem({ id: 'item-2', invoice_id: 'inv-1', description: 'Ventil DN50', quantity: 5, total_sek: 50000 }),
]
const products = [
makeProduct({ productId: 'stålbalk m8', cnCode: '72163100', description: 'Stålbalk M8' }),
makeProduct({ productId: 'ventil dn50', cnCode: '84818019', description: 'Ventil DN50', netWeightKg: 2.3 }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: items,
customers: [makeCustomer()],
products,
})
expect(report.lines).toHaveLength(2)
const codes = report.lines.map(l => l.cnCode).sort()
expect(codes).toEqual(['72163100', '84818019'])
})
// ── Credit notes ──────────────────────────────────────────
it('handles credit notes (subtracts from totals)', () => {
const invoices = [
makeInvoice({ id: 'inv-1', subtotal_sek: 100000, total_sek: 100000 }),
makeInvoice({
id: 'inv-2',
subtotal_sek: 20000,
total_sek: 20000,
credited_invoice_id: 'inv-1',
}),
]
const items = [
makeItem({ id: 'item-1', invoice_id: 'inv-1', total_sek: 100000, quantity: 100 }),
makeItem({ id: 'item-2', invoice_id: 'inv-2', total_sek: 20000, quantity: 20 }),
]
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices,
invoiceItems: items,
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines[0].invoicedValue).toBe(80000)
expect(report.lines[0].netMass).toBe(3640) // 45.5 × (100 - 20)
})
// ── Filtering ─────────────────────────────────────────────
it('excludes non-reverse-charge invoices', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ vat_treatment: 'standard_25' })],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes services (moms_ruta 39)', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ moms_ruta: '39' })],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes non-EU countries', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer({ country: 'US' })],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes Sweden', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer({ country: 'SE' })],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
it('excludes draft invoices', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ status: 'draft' })],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines).toHaveLength(0)
})
// ── Warnings ──────────────────────────────────────────────
it('warns when product has no CN code', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct({ cnCode: null })],
})
const cnWarnings = report.warnings.filter(w => w.type === 'missing_cn_code')
expect(cnWarnings.length).toBeGreaterThan(0)
})
it('warns when product has no weight', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct({ netWeightKg: null })],
})
const weightWarnings = report.warnings.filter(w => w.type === 'missing_weight')
expect(weightWarnings.length).toBeGreaterThan(0)
})
it('warns when no product match found for invoice line', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem({ description: 'Unknown Product XYZ' })],
customers: [makeCustomer()],
products: [makeProduct()], // won't match 'Unknown Product XYZ'
})
const cnWarnings = report.warnings.filter(w => w.type === 'missing_cn_code')
expect(cnWarnings.length).toBeGreaterThan(0)
})
// ── Threshold ─────────────────────────────────────────────
it('calculates threshold status with prior cumulative', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 500000 })],
invoiceItems: [makeItem({ total_sek: 500000 })],
customers: [makeCustomer()],
products: [makeProduct()],
priorCumulativeValue: 11_000_000,
})
expect(report.thresholdStatus.cumulativeValue).toBe(11_500_000)
expect(report.thresholdStatus.isObligated).toBe(false)
expect(report.thresholdStatus.percentageUsed).toBeCloseTo(95.83, 1)
})
it('flags when threshold is exceeded', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 2_000_000 })],
invoiceItems: [makeItem({ total_sek: 2_000_000 })],
customers: [makeCustomer()],
products: [makeProduct()],
priorCumulativeValue: 11_000_000,
})
expect(report.thresholdStatus.isObligated).toBe(true)
const thresholdWarnings = report.warnings.filter(w => w.type === 'threshold_exceeded')
expect(thresholdWarnings).toHaveLength(1)
expect(thresholdWarnings[0].severity).toBe('error')
})
it('warns when threshold is approaching (80%+)', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice({ subtotal_sek: 100000 })],
invoiceItems: [makeItem({ total_sek: 100000 })],
customers: [makeCustomer()],
products: [makeProduct()],
priorCumulativeValue: 10_000_000,
})
const approaching = report.warnings.filter(w => w.type === 'threshold_approaching')
expect(approaching).toHaveLength(1)
expect(approaching[0].severity).toBe('warning')
})
// ── Default values ────────────────────────────────────────
it('uses custom default transaction nature and delivery terms', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
defaultTransactionNature: '31',
defaultDeliveryTerms: 'DAP',
})
expect(report.lines[0].transactionNature).toBe('31')
expect(report.lines[0].deliveryTerms).toBe('DAP')
})
it('defaults to transaction nature 11 and FCA', () => {
const report = generateIntrastatReport({
...BASE_OPTIONS,
invoices: [makeInvoice()],
invoiceItems: [makeItem()],
customers: [makeCustomer()],
products: [makeProduct()],
})
expect(report.lines[0].transactionNature).toBe('11')
expect(report.lines[0].deliveryTerms).toBe('FCA')
})
// ── Period info ───────────────────────────────────────────
it('includes period and reporter info', () => {
const report = generateIntrastatReport(BASE_OPTIONS)
expect(report.period).toEqual({ year: 2026, month: 1 })
expect(report.reporterVatNumber).toBe('SE556677889901')
expect(report.reporterName).toBe('Test AB')
})
})
// ── Deadline tests ──────────────────────────────────────────
describe('getIntrastatDeadline', () => {
it('returns 14th of following month', () => {
expect(getIntrastatDeadline(2026, 1)).toBe('2026-02-14')
expect(getIntrastatDeadline(2026, 6)).toBe('2026-07-14')
})
it('rolls over to next year for December', () => {
expect(getIntrastatDeadline(2026, 12)).toBe('2027-01-14')
})
})
@@ -0,0 +1,203 @@
import { describe, it, expect } from 'vitest'
import { generateSCBCsv, generateSCBFilename } from '../scb-csv-generator'
import type { IntrastatReport, IntrastatLine } from '../intrastat-engine'
// ── Fixtures ────────────────────────────────────────────────
function makeLine(overrides: Partial<IntrastatLine> = {}): IntrastatLine {
return {
cnCode: '72163100',
partnerCountry: 'DE',
countryOfOrigin: 'SE',
transactionNature: '11',
deliveryTerms: 'FCA',
invoicedValue: 100000,
netMass: 4550,
supplementaryUnit: null,
supplementaryUnitType: null,
partnerVatId: 'DE123456789',
...overrides,
}
}
function makeReport(overrides: Partial<IntrastatReport> = {}): IntrastatReport {
return {
period: { year: 2026, month: 1 },
reporterVatNumber: 'SE556677889901',
reporterName: 'Test AB',
flowType: 'dispatch',
lines: [makeLine()],
totals: { invoicedValue: 100000, netMass: 4550, lineCount: 1 },
thresholdStatus: {
cumulativeValue: 100000,
threshold: 12000000,
isObligated: false,
percentageUsed: 0.83,
},
warnings: [],
invoiceCount: 1,
...overrides,
}
}
// ── CSV generation ──────────────────────────────────────────
describe('generateSCBCsv', () => {
it('starts with UTF-8 BOM', () => {
const csv = generateSCBCsv(makeReport())
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('has correct header row', () => {
const csv = generateSCBCsv(makeReport())
const lines = csv.replace('\uFEFF', '').split('\r\n')
expect(lines[0]).toBe(
'CN-kod;Partnerland;Ursprungsland;Transaktionstyp;Leveransvillkor;Fakturerat värde (SEK);Nettovikt (kg);Kompletterande enhet;Partner-VAT'
)
})
it('uses semicolons as delimiter', () => {
const csv = generateSCBCsv(makeReport())
const lines = csv.replace('\uFEFF', '').split('\r\n')
// Header has 9 columns = 8 semicolons
expect(lines[0].split(';')).toHaveLength(9)
// Data row also has 9 columns
expect(lines[1].split(';')).toHaveLength(9)
})
it('uses CRLF line endings', () => {
const csv = generateSCBCsv(makeReport())
expect(csv).toContain('\r\n')
// Should not contain lone LF without preceding CR
const withoutCRLF = csv.replace(/\r\n/g, '')
expect(withoutCRLF).not.toContain('\n')
})
it('ends with CRLF', () => {
const csv = generateSCBCsv(makeReport())
expect(csv.endsWith('\r\n')).toBe(true)
})
it('renders data row with correct values', () => {
const csv = generateSCBCsv(makeReport())
const lines = csv.replace('\uFEFF', '').split('\r\n')
const cols = lines[1].split(';')
expect(cols[0]).toBe('72163100') // CN-kod
expect(cols[1]).toBe('DE') // Partnerland
expect(cols[2]).toBe('SE') // Ursprungsland
expect(cols[3]).toBe('11') // Transaktionstyp
expect(cols[4]).toBe('FCA') // Leveransvillkor
expect(cols[5]).toBe('100000') // Fakturerat värde
expect(cols[6]).toBe('4550') // Nettovikt
expect(cols[7]).toBe('') // Kompletterande enhet (null)
expect(cols[8]).toBe('DE123456789') // Partner-VAT
})
it('rounds invoiced value to whole SEK', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ invoicedValue: 123456.78 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const value = lines[1].split(';')[5]
expect(value).toBe('123457') // Rounded up
})
it('formats integer mass without decimals', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 4550 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('4550')
})
it('formats fractional mass with up to 3 decimal places', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 45.123 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('45.123')
})
it('removes trailing zeros from mass', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 45.1 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('45.1')
})
it('rounds mass to max 3 decimal places', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ netMass: 45.12345 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const mass = lines[1].split(';')[6]
expect(mass).toBe('45.123')
})
it('includes supplementary unit when present', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ supplementaryUnit: 150 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const suppUnit = lines[1].split(';')[7]
expect(suppUnit).toBe('150')
})
it('rounds supplementary unit to whole number', () => {
const csv = generateSCBCsv(makeReport({
lines: [makeLine({ supplementaryUnit: 150.7 })],
}))
const lines = csv.replace('\uFEFF', '').split('\r\n')
const suppUnit = lines[1].split(';')[7]
expect(suppUnit).toBe('151')
})
it('renders multiple data rows', () => {
const report = makeReport({
lines: [
makeLine({ cnCode: '72163100', partnerCountry: 'DE' }),
makeLine({ cnCode: '84818019', partnerCountry: 'FI', partnerVatId: 'FI12345678' }),
],
})
const csv = generateSCBCsv(report)
const lines = csv.replace('\uFEFF', '').split('\r\n').filter(l => l.length > 0)
expect(lines).toHaveLength(3) // header + 2 data rows
expect(lines[1].split(';')[0]).toBe('72163100')
expect(lines[2].split(';')[0]).toBe('84818019')
})
it('generates empty CSV with only header when no lines', () => {
const csv = generateSCBCsv(makeReport({ lines: [] }))
const lines = csv.replace('\uFEFF', '').split('\r\n').filter(l => l.length > 0)
expect(lines).toHaveLength(1) // header only
expect(lines[0]).toContain('CN-kod')
})
})
// ── Filename generation ─────────────────────────────────────
describe('generateSCBFilename', () => {
it('generates correct filename', () => {
const filename = generateSCBFilename(makeReport())
expect(filename).toBe('INTRASTAT_SE556677889901_2026-01.csv')
})
it('pads single-digit month', () => {
const filename = generateSCBFilename(makeReport({ period: { year: 2026, month: 3 } }))
expect(filename).toBe('INTRASTAT_SE556677889901_2026-03.csv')
})
it('does not pad double-digit month', () => {
const filename = generateSCBFilename(makeReport({ period: { year: 2026, month: 12 } }))
expect(filename).toBe('INTRASTAT_SE556677889901_2026-12.csv')
})
it('strips whitespace from VAT number', () => {
const filename = generateSCBFilename(makeReport({ reporterVatNumber: 'SE 5566 7788 9901' }))
expect(filename).toBe('INTRASTAT_SE556677889901_2026-01.csv')
})
})
@@ -0,0 +1,465 @@
/**
* Intrastat Generator Engine
*
* Aggregates EU B2B goods sales into Intrastat declaration lines
* for reporting to SCB (Statistiska Centralbyrån) via IDEP.web.
*
* Pure functions — no Supabase, no React, no side effects.
*
* Only covers dispatches (utförsel) — goods sent from Sweden to
* other EU member states. Services are excluded from Intrastat.
*
* Threshold: SEK 12,000,000 cumulative dispatches over 12 months
* determines mandatory reporting obligation.
*
* Reference: SCB Intrastat guidelines, Combined Nomenclature (CN)
*/
import { isEUCountry } from '@/extensions/export/shared/eu-countries'
// ── Types ────────────────────────────────────────────────────
/** Invoice data needed for Intrastat */
export interface IntrastatInvoice {
id: string
invoice_number: string
invoice_date: string
status: string
vat_treatment: string
moms_ruta: string | null
currency: string
total_sek: number | null
subtotal_sek: number | null
subtotal: number
document_type: string
credited_invoice_id: string | null
customer_id: string
}
/** Customer data for Intrastat */
export interface IntrastatCustomer {
id: string
name: string
country: string
vat_number: string | null
}
/** Product metadata stored in extension_data */
export interface ProductMetadata {
productId: string
cnCode: string | null
description: string
netWeightKg: number | null
countryOfOrigin: string
supplementaryUnit: number | null
supplementaryUnitType: string | null
}
/** Invoice line item for matching to products */
export interface IntrastatInvoiceItem {
id: string
invoice_id: string
description: string
quantity: number
unit_price: number
total: number
total_sek: number | null
}
/** Aggregated Intrastat declaration line */
export interface IntrastatLine {
cnCode: string
partnerCountry: string
countryOfOrigin: string
transactionNature: string
deliveryTerms: string
invoicedValue: number
netMass: number
supplementaryUnit: number | null
supplementaryUnitType: string | null
partnerVatId: string
}
/** Warning about data quality */
export interface IntrastatWarning {
type:
| 'missing_cn_code'
| 'missing_weight'
| 'missing_origin'
| 'threshold_approaching'
| 'threshold_exceeded'
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
productId?: string
message: string
}
/** Threshold monitoring status */
export interface ThresholdStatus {
cumulativeValue: number
threshold: number
isObligated: boolean
percentageUsed: number
}
/** Complete Intrastat report */
export interface IntrastatReport {
period: { year: number; month: number }
reporterVatNumber: string
reporterName: string
flowType: 'dispatch'
lines: IntrastatLine[]
totals: {
invoicedValue: number
netMass: number
lineCount: number
}
thresholdStatus: ThresholdStatus
warnings: IntrastatWarning[]
invoiceCount: number
}
/** Options for generating the Intrastat report */
export interface IntrastatOptions {
invoices: IntrastatInvoice[]
invoiceItems: IntrastatInvoiceItem[]
customers: IntrastatCustomer[]
products: ProductMetadata[]
reporterVatNumber: string
reporterName: string
year: number
month: number
defaultTransactionNature?: string
defaultDeliveryTerms?: string
/** Cumulative dispatch value from prior months in the rolling 12-month window */
priorCumulativeValue?: number
}
// ── Constants ───────────────────────────────────────────────
/** SCB Intrastat reporting threshold in SEK (12 million) */
const INTRASTAT_THRESHOLD = 12_000_000
/** Goods revenue accounts that trigger Intrastat reporting */
const GOODS_ACCOUNTS_RUTA = ['35']
// ── Core engine ──────────────────────────────────────────────
export function generateIntrastatReport(options: IntrastatOptions): IntrastatReport {
const {
invoices,
invoiceItems,
customers,
products,
reporterVatNumber,
reporterName,
year,
month,
defaultTransactionNature = '11',
defaultDeliveryTerms = 'FCA',
priorCumulativeValue = 0,
} = options
const warnings: IntrastatWarning[] = []
const customerMap = new Map(customers.map(c => [c.id, c]))
const productMap = buildProductMap(products)
// Step 1: Filter to EU B2B goods invoices
const relevantInvoices = invoices.filter(inv => {
if (!['sent', 'paid', 'overdue'].includes(inv.status)) return false
if (inv.document_type !== 'invoice' && inv.credited_invoice_id === null) return false
if (inv.vat_treatment !== 'reverse_charge') return false
// Only goods (moms_ruta 35) — services are excluded from Intrastat
if (inv.moms_ruta && !GOODS_ACCOUNTS_RUTA.includes(inv.moms_ruta)) return false
// Verify customer is in EU (not Sweden)
const customer = customerMap.get(inv.customer_id)
if (!customer || !isEUCountry(customer.country)) return false
return true
})
// Step 2: Build items index by invoice_id
const itemsByInvoice = new Map<string, IntrastatInvoiceItem[]>()
for (const item of invoiceItems) {
const list = itemsByInvoice.get(item.invoice_id) ?? []
list.push(item)
itemsByInvoice.set(item.invoice_id, list)
}
// Step 3: Build aggregation map
// Key: cnCode|partnerCountry|countryOfOrigin|transactionNature|deliveryTerms
const aggregation = new Map<string, IntrastatLine>()
for (const invoice of relevantInvoices) {
const customer = customerMap.get(invoice.customer_id)!
const isCreditNote = invoice.credited_invoice_id !== null
const items = itemsByInvoice.get(invoice.id) ?? []
if (items.length === 0) {
// No line items — use invoice-level amount with unknown product
const amountSek = getInvoiceAmountSek(invoice)
const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek
warnings.push({
type: 'missing_cn_code',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Faktura ${invoice.invoice_number} saknar fakturarader — kan inte tilldela CN-kod.`,
})
const key = buildAggKey('00000000', customer.country, 'SE', defaultTransactionNature, defaultDeliveryTerms)
addToAggregation(aggregation, key, {
cnCode: '00000000',
partnerCountry: customer.country,
countryOfOrigin: 'SE',
transactionNature: defaultTransactionNature,
deliveryTerms: defaultDeliveryTerms,
invoicedValue: effectiveAmount,
netMass: 0,
supplementaryUnit: null,
supplementaryUnitType: null,
partnerVatId: customer.vat_number ?? '',
})
continue
}
// Process each line item
for (const item of items) {
const product = matchProduct(item.description, productMap)
const amountSek = item.total_sek ?? item.total
const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek
if (!product) {
// No product metadata found — use defaults and warn
warnings.push({
type: 'missing_cn_code',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Faktura ${invoice.invoice_number}, rad "${item.description}" — ingen matchande produkt med CN-kod hittad.`,
})
const key = buildAggKey('00000000', customer.country, 'SE', defaultTransactionNature, defaultDeliveryTerms)
addToAggregation(aggregation, key, {
cnCode: '00000000',
partnerCountry: customer.country,
countryOfOrigin: 'SE',
transactionNature: defaultTransactionNature,
deliveryTerms: defaultDeliveryTerms,
invoicedValue: effectiveAmount,
netMass: 0,
supplementaryUnit: null,
supplementaryUnitType: null,
partnerVatId: customer.vat_number ?? '',
})
continue
}
// Validate product metadata
if (!product.cnCode) {
warnings.push({
type: 'missing_cn_code',
severity: 'error',
productId: product.productId,
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Produkt "${product.description}" saknar CN-kod.`,
})
}
if (product.netWeightKg === null) {
warnings.push({
type: 'missing_weight',
severity: 'warning',
productId: product.productId,
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Produkt "${product.description}" saknar nettovikt.`,
})
}
const cnCode = product.cnCode ?? '00000000'
const origin = product.countryOfOrigin || 'SE'
const mass = (product.netWeightKg ?? 0) * item.quantity
const suppUnit = product.supplementaryUnit !== null
? product.supplementaryUnit * item.quantity
: null
const key = buildAggKey(cnCode, customer.country, origin, defaultTransactionNature, defaultDeliveryTerms)
addToAggregation(aggregation, key, {
cnCode,
partnerCountry: customer.country,
countryOfOrigin: origin,
transactionNature: defaultTransactionNature,
deliveryTerms: defaultDeliveryTerms,
invoicedValue: effectiveAmount,
netMass: isCreditNote ? -Math.abs(mass) : mass,
supplementaryUnit: suppUnit !== null ? (isCreditNote ? -Math.abs(suppUnit) : suppUnit) : null,
supplementaryUnitType: product.supplementaryUnitType,
partnerVatId: customer.vat_number ?? '',
})
}
}
// Step 4: Build sorted output
const lines = Array.from(aggregation.values())
.map(line => ({
...line,
invoicedValue: round2(line.invoicedValue),
netMass: round3(line.netMass),
}))
.sort((a, b) =>
a.cnCode.localeCompare(b.cnCode) ||
a.partnerCountry.localeCompare(b.partnerCountry)
)
// Step 5: Calculate totals
const totals = {
invoicedValue: round2(lines.reduce((sum, l) => sum + l.invoicedValue, 0)),
netMass: round3(lines.reduce((sum, l) => sum + l.netMass, 0)),
lineCount: lines.length,
}
// Step 6: Threshold status
const cumulativeValue = round2(priorCumulativeValue + totals.invoicedValue)
const percentageUsed = INTRASTAT_THRESHOLD > 0
? round2((cumulativeValue / INTRASTAT_THRESHOLD) * 100)
: 0
const thresholdStatus: ThresholdStatus = {
cumulativeValue,
threshold: INTRASTAT_THRESHOLD,
isObligated: cumulativeValue >= INTRASTAT_THRESHOLD,
percentageUsed,
}
if (percentageUsed >= 100) {
warnings.push({
type: 'threshold_exceeded',
severity: 'error',
message: `Tröskelvärdet för Intrastat (${formatSEK(INTRASTAT_THRESHOLD)} SEK) har överskridits. Rapportering till SCB är obligatorisk.`,
})
} else if (percentageUsed >= 80) {
warnings.push({
type: 'threshold_approaching',
severity: 'warning',
message: `Ackumulerad utförsel är ${formatSEK(cumulativeValue)} SEK (${percentageUsed}% av tröskelvärdet). Rapporteringsskyldighet kan uppstå snart.`,
})
}
return {
period: { year, month },
reporterVatNumber,
reporterName,
flowType: 'dispatch',
lines,
totals,
thresholdStatus,
warnings,
invoiceCount: relevantInvoices.length,
}
}
// ── Product matching ────────────────────────────────────────
/**
* Build a map for fast product lookup by description (case-insensitive).
* Products are matched by exact description or by productId.
*/
function buildProductMap(products: ProductMetadata[]): Map<string, ProductMetadata> {
const map = new Map<string, ProductMetadata>()
for (const product of products) {
map.set(product.productId.toLowerCase(), product)
if (product.description) {
map.set(product.description.toLowerCase(), product)
}
}
return map
}
/**
* Match an invoice line description to a product.
* Tries exact match first, then checks if description starts with a product key.
*/
function matchProduct(description: string, productMap: Map<string, ProductMetadata>): ProductMetadata | null {
const lower = description.toLowerCase().trim()
// Exact match
if (productMap.has(lower)) return productMap.get(lower)!
// Prefix match (invoice description may contain extra details)
for (const [key, product] of productMap) {
if (lower.startsWith(key) || key.startsWith(lower)) {
return product
}
}
return null
}
// ── Aggregation helpers ─────────────────────────────────────
function buildAggKey(
cnCode: string,
partnerCountry: string,
origin: string,
transactionNature: string,
deliveryTerms: string,
): string {
return `${cnCode}|${partnerCountry}|${origin}|${transactionNature}|${deliveryTerms}`
}
function addToAggregation(
map: Map<string, IntrastatLine>,
key: string,
line: IntrastatLine,
): void {
const existing = map.get(key)
if (existing) {
existing.invoicedValue += line.invoicedValue
existing.netMass += line.netMass
if (existing.supplementaryUnit !== null && line.supplementaryUnit !== null) {
existing.supplementaryUnit += line.supplementaryUnit
}
// Keep the first partner VAT ID encountered
} else {
map.set(key, { ...line })
}
}
// ── Amount helpers ──────────────────────────────────────────
function getInvoiceAmountSek(invoice: IntrastatInvoice): number {
if (invoice.subtotal_sek !== null) return round2(invoice.subtotal_sek)
return round2(invoice.subtotal)
}
function round2(value: number): number {
return Math.round(value * 100) / 100
}
function round3(value: number): number {
return Math.round(value * 1000) / 1000
}
function formatSEK(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
// ── Period helpers ───────────────────────────────────────────
/**
* Get the Intrastat filing deadline for a month.
* The deadline is the 10th business day of the following month.
* For simplicity, we approximate as the 14th of the following month.
*/
export function getIntrastatDeadline(year: number, month: number): string {
let deadlineMonth = month + 1
let deadlineYear = year
if (deadlineMonth > 12) {
deadlineMonth = 1
deadlineYear++
}
return `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-14`
}
@@ -0,0 +1,74 @@
/**
* Intrastat SCB CSV Generator
*
* Generates a semicolon-separated CSV file (UTF-8 with BOM)
* compatible with SCB's IDEP.web upload for Intrastat declarations.
*
* Format: Semicolon-delimited, UTF-8 BOM, whole SEK amounts,
* net mass in kg with up to 3 decimal places.
*
* Reference: SCB IDEP.web filformat, Intrastat utförsel
*/
import type { IntrastatReport } from './intrastat-engine'
const UTF8_BOM = '\uFEFF'
/**
* Generate an IDEP.web-compatible CSV for the Intrastat report.
*
* Columns (matching SCB IDEP.web format):
* CN-kod;Partnerland;Ursprungsland;Transaktionstyp;Leveransvillkor;
* Fakturerat värde (SEK);Nettovikt (kg);Kompletterande enhet;Partner-VAT
*/
export function generateSCBCsv(report: IntrastatReport): string {
const header = [
'CN-kod',
'Partnerland',
'Ursprungsland',
'Transaktionstyp',
'Leveransvillkor',
'Fakturerat värde (SEK)',
'Nettovikt (kg)',
'Kompletterande enhet',
'Partner-VAT',
].join(';')
const rows = report.lines.map(line => {
const value = Math.round(line.invoicedValue)
const mass = roundMass(line.netMass)
const suppUnit = line.supplementaryUnit !== null ? String(Math.round(line.supplementaryUnit)) : ''
return [
line.cnCode,
line.partnerCountry,
line.countryOfOrigin,
line.transactionNature,
line.deliveryTerms,
String(value),
String(mass),
suppUnit,
line.partnerVatId,
].join(';')
})
return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'
}
/**
* Generate a filename for the IDEP.web CSV download.
*
* Format: INTRASTAT_<VAT>_<YYYY>-<MM>.csv
*/
export function generateSCBFilename(report: IntrastatReport): string {
const vat = report.reporterVatNumber.replace(/\s/g, '')
const period = `${report.period.year}-${String(report.period.month).padStart(2, '0')}`
return `INTRASTAT_${vat}_${period}.csv`
}
/** Round mass to max 3 decimal places, removing trailing zeros */
function roundMass(kg: number): string {
const rounded = Math.round(kg * 1000) / 1000
if (rounded === Math.floor(rounded)) return String(rounded)
return rounded.toFixed(3).replace(/0+$/, '')
}
+79
View File
@@ -0,0 +1,79 @@
/**
* EU member states reference data.
*
* Used by 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)
/** Check if a country code is an EU member state (excluding Sweden) */
export function isEUCountry(countryCode: string): boolean {
return EU_COUNTRY_CODES_EXCL_SE.includes(countryCode.toUpperCase())
}
/** Check if a country code is an EU member state (including Sweden) */
export function isEUCountryIncludingSE(countryCode: string): boolean {
return EU_COUNTRY_CODES.includes(countryCode.toUpperCase())
}
/** Get EU country data by ISO code */
export function getEUCountry(countryCode: string): EUCountry | undefined {
return EU_COUNTRIES.find(c => c.code === countryCode.toUpperCase())
}
/** 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
}
@@ -0,0 +1,132 @@
/**
* Momsdeklaration box mapping for Swedish VAT returns.
*
* Maps BAS revenue accounts to the correct box (ruta) in the
* momsdeklaration filed with Skatteverket. Used by:
* - Export VAT Monitor (full box overview)
* - EU Sales List (cross-validation against box 35/39)
*
* Reference: Skatteverket momsdeklaration (SKV 4700)
* https://www.skatteverket.se/foretag/moms/deklareramoms/fyllaimomsdeklarationen
*/
/** Momsdeklaration box number */
export type MomsBox =
| '05' // Momspliktig forsaljning (taxable sales)
| '06' // Momspliktiga uttag (taxable withdrawals)
| '07' // Vinstmarginalbeskattning (margin scheme)
| '08' // Hyresinkomster frivillig beskattning (rental)
| '10' // Utgaende moms 25%
| '11' // Utgaende moms 12%
| '12' // Utgaende moms 6%
| '20' // Inkop varor fran EU
| '21' // Inkop tjanster fran EU
| '22' // Inkop tjanster utanfor EU
| '23' // Inkop varor Sverige omvand skattskyldighet
| '24' // Inkop tjanster Sverige omvand skattskyldighet
| '30' // Utgaende moms inkop 25%
| '31' // Utgaende moms inkop 12%
| '32' // Utgaende moms inkop 6%
| '35' // Varuforssaljning till annat EU-land
| '36' // Varuforssaljning utanfor EU (export)
| '37' // Mellanmans inkop trepartshandel
| '38' // Mellanmans forsaljning trepartshandel
| '39' // Tjansteforssaljning EU (huvudregeln)
| '40' // Ovrig forsaljning av tjanster utomlands
| '41' // Forsaljning omvand skattskyldighet Sverige
| '42' // Ovrig forsaljning m.m.
| '48' // Ingaende moms att dra av
| '49' // Moms att betala eller fa tillbaka
| '50' // Importbeskattningsunderlag
| '60' // Importmoms 25%
| '61' // Importmoms 12%
| '62' // Importmoms 6%
/** Map BAS revenue account to momsdeklaration box */
export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
// Domestic revenue (taxable) → Box 05
'3001': '05', // Forsaljning varor/tjanster 25%
'3002': '05', // Forsaljning varor/tjanster 12%
'3003': '05', // Forsaljning varor/tjanster 6%
// EU goods (reverse charge, VAT-free) → Box 35
'3108': '35', // Forsaljning varor till annat EU-land
'3521': '35', // Fakturerade frakter EU (follows goods treatment)
// Non-EU goods export (zero-rated) → Box 36
'3105': '36', // Forsaljning varor export utanfor EU
'3522': '36', // Fakturerade frakter export
// Triangular trade → Box 38
'3109': '38', // Mellanmans forsaljning trepartshandel
// EU services (reverse charge, main rule) → Box 39
'3308': '39', // Forsaljning tjanster EU
// Non-EU services → Box 40
'3305': '40', // Forsaljning tjanster export utanfor EU
// Output VAT → Boxes 10, 11, 12
'2611': '10', // Utgaende moms 25%
'2621': '11', // Utgaende moms 12%
'2631': '12', // Utgaende moms 6%
// Input VAT → Box 48
'2641': '48', // Ingaende moms
'2645': '48', // Beraknad ingaende moms (EU forvarv)
}
/** Swedish labels for each momsdeklaration box */
export const BOX_LABELS: Record<MomsBox, string> = {
'05': 'Momspliktig försäljning',
'06': 'Momspliktiga uttag',
'07': 'Vinstmarginalbeskattning',
'08': 'Hyresinkomster (frivillig beskattning)',
'10': 'Utgående moms 25%',
'11': 'Utgående moms 12%',
'12': 'Utgående moms 6%',
'20': 'Inköp varor från EU',
'21': 'Inköp tjänster från EU',
'22': 'Inköp tjänster utanför EU',
'23': 'Inköp varor Sverige (omvänd skattskyldighet)',
'24': 'Inköp tjänster Sverige (omvänd skattskyldighet)',
'30': 'Utgående moms på inköp 25%',
'31': 'Utgående moms på inköp 12%',
'32': 'Utgående moms på inköp 6%',
'35': 'Varuförsäljning till annat EU-land',
'36': 'Varuförsäljning utanför EU (export)',
'37': 'Mellanmans inköp vid trepartshandel',
'38': 'Mellanmans försäljning vid trepartshandel',
'39': 'Tjänsteförsäljning till EU (huvudregeln)',
'40': 'Övrig försäljning av tjänster utomlands',
'41': 'Försäljning med omvänd skattskyldighet (Sverige)',
'42': 'Övrig försäljning m.m.',
'48': 'Ingående moms att dra av',
'49': 'Moms att betala eller få tillbaka',
'50': 'Beskattningsunderlag vid import',
'60': 'Importmoms 25%',
'61': 'Importmoms 12%',
'62': 'Importmoms 6%',
}
/** Get the momsdeklaration box for a BAS account number */
export function getBoxForAccount(accountNumber: string): MomsBox | undefined {
return ACCOUNT_TO_BOX[accountNumber]
}
/** Get the Swedish label for a momsdeklaration box */
export function getBoxLabel(box: MomsBox): string {
return BOX_LABELS[box]
}
/** Boxes that represent VAT-exempt export/EU sales (no output VAT) */
export const EXPORT_BOXES: MomsBox[] = ['35', '36', '38', '39', '40']
/** Boxes that represent taxable domestic sales (have output VAT) */
export const DOMESTIC_BOXES: MomsBox[] = ['05', '06', '07', '08']
/** Boxes that represent output VAT */
export const OUTPUT_VAT_BOXES: MomsBox[] = ['10', '11', '12']
/** Boxes that represent input VAT */
export const INPUT_VAT_BOXES: MomsBox[] = ['48']
+17
View File
@@ -0,0 +1,17 @@
import type { Extension } from '@/lib/extensions/types'
/**
* Export VAT Monitor / Exportmoms-monitor Extension
*
* Dashboard that maps GL revenue accounts to Swedish momsdeklaration boxes
* (05, 35, 36, 39, 40). Shows revenue breakdown by destination type:
* domestic, EU B2B (reverse charge), and non-EU export.
*
* Validates VAT treatment consistency and flags potential errors before
* the user files their momsdeklaration.
*/
export const vatMonitorExtension: Extension = {
id: 'vat-monitor',
name: 'Exportmoms-monitor',
version: '1.0.0',
}
@@ -0,0 +1,477 @@
import { describe, it, expect } from 'vitest'
import {
generateVatMonitorReport,
type GLLine,
type VatMonitorInvoice,
type VatMonitorCustomer,
type VatMonitorOptions,
} from '../vat-monitor-engine'
// ── Helpers ─────────────────────────────────────────────────
/** Create a credit GL line (revenue is credit-side) */
function creditLine(account: string, amount: number): GLLine {
return { account_number: account, debit_amount: 0, credit_amount: amount }
}
/** Create a debit GL line (input VAT is debit-side) */
function debitLine(account: string, amount: number): GLLine {
return { account_number: account, debit_amount: amount, credit_amount: 0 }
}
function makeInvoice(overrides: Partial<VatMonitorInvoice> = {}): VatMonitorInvoice {
return {
id: 'inv-1',
invoice_number: 'F2026-001',
vat_treatment: 'reverse_charge',
moms_ruta: '35',
customer_id: 'cust-1',
...overrides,
}
}
function makeCustomer(overrides: Partial<VatMonitorCustomer> = {}): VatMonitorCustomer {
return {
id: 'cust-1',
name: 'Acme GmbH',
country: 'DE',
vat_number: 'DE123456789',
vat_number_validated: true,
...overrides,
}
}
const BASE_OPTIONS: VatMonitorOptions = {
glLines: [],
year: 2026,
month: 1,
}
// ── Box mapping tests ───────────────────────────────────────
describe('box mapping', () => {
it('maps domestic revenue (3001) to box 05', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 100000)],
})
const box05 = report.boxes.find(b => b.boxNumber === '05')
expect(box05).toBeDefined()
expect(box05!.amount).toBe(100000)
})
it('maps EU goods (3108) to box 35', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 50000)],
})
const box35 = report.boxes.find(b => b.boxNumber === '35')
expect(box35).toBeDefined()
expect(box35!.amount).toBe(50000)
})
it('maps EU freight (3521) to box 35 alongside 3108', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 40000), creditLine('3521', 10000)],
})
const box35 = report.boxes.find(b => b.boxNumber === '35')
expect(box35!.amount).toBe(50000)
expect(box35!.accounts).toContain('3108')
expect(box35!.accounts).toContain('3521')
})
it('maps export goods (3105) to box 36', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3105', 80000)],
})
const box36 = report.boxes.find(b => b.boxNumber === '36')
expect(box36!.amount).toBe(80000)
})
it('maps export freight (3522) to box 36', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3522', 15000)],
})
const box36 = report.boxes.find(b => b.boxNumber === '36')
expect(box36!.amount).toBe(15000)
})
it('maps triangulation (3109) to box 38', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3109', 25000)],
})
const box38 = report.boxes.find(b => b.boxNumber === '38')
expect(box38!.amount).toBe(25000)
})
it('maps EU services (3308) to box 39', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3308', 30000)],
})
const box39 = report.boxes.find(b => b.boxNumber === '39')
expect(box39!.amount).toBe(30000)
})
it('maps export services (3305) to box 40', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3305', 20000)],
})
const box40 = report.boxes.find(b => b.boxNumber === '40')
expect(box40!.amount).toBe(20000)
})
it('maps output VAT (2611) to box 10', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('2611', 25000)],
})
const box10 = report.boxes.find(b => b.boxNumber === '10')
expect(box10!.amount).toBe(25000)
})
it('maps input VAT (2641) to box 48', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [debitLine('2641', 15000)],
})
const box48 = report.boxes.find(b => b.boxNumber === '48')
expect(box48!.amount).toBe(15000)
})
})
// ── Revenue breakdown tests ─────────────────────────────────
describe('revenue breakdown', () => {
it('calculates correct breakdown for mixed revenue', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('3001', 200000), // domestic
creditLine('3108', 100000), // EU goods
creditLine('3308', 50000), // EU services
creditLine('3105', 75000), // export goods
creditLine('3305', 25000), // export services
],
})
const rb = report.revenueBreakdown
expect(rb.totalRevenue).toBe(450000)
expect(rb.domestic.amount).toBe(200000)
expect(rb.euGoods.amount).toBe(100000)
expect(rb.euServices.amount).toBe(50000)
expect(rb.exportGoods.amount).toBe(75000)
expect(rb.exportServices.amount).toBe(25000)
})
it('calculates correct percentages', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('3001', 50000),
creditLine('3108', 50000),
],
})
expect(report.revenueBreakdown.domestic.percentage).toBe(50)
expect(report.revenueBreakdown.euGoods.percentage).toBe(50)
})
it('returns 0% when no revenue', () => {
const report = generateVatMonitorReport(BASE_OPTIONS)
expect(report.revenueBreakdown.totalRevenue).toBe(0)
expect(report.revenueBreakdown.domestic.percentage).toBe(0)
})
it('only domestic sales — export categories are zero', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 100000)],
})
expect(report.revenueBreakdown.domestic.amount).toBe(100000)
expect(report.revenueBreakdown.euGoods.amount).toBe(0)
expect(report.revenueBreakdown.exportGoods.amount).toBe(0)
})
})
// ── VAT summary tests ───────────────────────────────────────
describe('VAT summary', () => {
it('calculates net VAT: output - input = box 49', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2611', 50000), // output 25%
creditLine('2621', 12000), // output 12%
creditLine('2631', 3000), // output 6%
debitLine('2641', 30000), // input VAT
],
})
expect(report.vatSummary.outputVat25).toBe(50000)
expect(report.vatSummary.outputVat12).toBe(12000)
expect(report.vatSummary.outputVat6).toBe(3000)
expect(report.vatSummary.totalOutputVat).toBe(65000)
expect(report.vatSummary.inputVat).toBe(30000)
expect(report.vatSummary.netVat).toBe(35000)
expect(report.vatSummary.isRefund).toBe(false)
})
it('identifies refund when input > output', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2611', 10000),
debitLine('2641', 50000),
],
})
expect(report.vatSummary.netVat).toBe(-40000)
expect(report.vatSummary.isRefund).toBe(true)
})
it('box 49 appears in boxes list', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2611', 25000),
debitLine('2641', 10000),
],
})
const box49 = report.boxes.find(b => b.boxNumber === '49')
expect(box49).toBeDefined()
expect(box49!.amount).toBe(15000)
expect(box49!.label).toContain('betala')
})
})
// ── Invoice validation tests ────────────────────────────────
describe('invoice validation', () => {
it('warns when reverse_charge invoice has customer without VAT number', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 10000)],
invoices: [makeInvoice({ vat_treatment: 'reverse_charge' })],
customers: [makeCustomer({ vat_number: null })],
})
const missingVat = report.warnings.filter(w => w.type === 'missing_vat_number')
expect(missingVat).toHaveLength(1)
expect(missingVat[0].severity).toBe('error')
})
it('warns when reverse_charge invoice has unvalidated VAT number', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 10000)],
invoices: [makeInvoice({ vat_treatment: 'reverse_charge' })],
customers: [makeCustomer({ vat_number_validated: false })],
})
const unvalidated = report.warnings.filter(w => w.type === 'unvalidated_vat_number')
expect(unvalidated).toHaveLength(1)
expect(unvalidated[0].severity).toBe('warning')
})
it('warns when moms_ruta does not match vat_treatment', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [],
invoices: [makeInvoice({ vat_treatment: 'standard_25', moms_ruta: '35' })],
customers: [makeCustomer()],
})
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
expect(mismatch).toHaveLength(1)
})
it('no warning when moms_ruta matches reverse_charge (35, 38, or 39)', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [],
invoices: [
makeInvoice({ id: 'i1', vat_treatment: 'reverse_charge', moms_ruta: '35' }),
makeInvoice({ id: 'i2', vat_treatment: 'reverse_charge', moms_ruta: '39' }),
makeInvoice({ id: 'i3', vat_treatment: 'reverse_charge', moms_ruta: '38' }),
],
customers: [makeCustomer()],
})
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
expect(mismatch).toHaveLength(0)
})
it('no warning when moms_ruta matches export (36 or 40)', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [],
invoices: [
makeInvoice({ id: 'i1', vat_treatment: 'export', moms_ruta: '36' }),
makeInvoice({ id: 'i2', vat_treatment: 'export', moms_ruta: '40' }),
],
customers: [makeCustomer()],
})
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
expect(mismatch).toHaveLength(0)
})
it('no validation warnings when no invoices provided', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 10000)],
})
expect(report.warnings).toHaveLength(0)
})
})
// ── Period comparison tests ─────────────────────────────────
describe('period comparison', () => {
it('calculates delta between current and previous period', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 200000)],
previousGlLines: [creditLine('3001', 150000)],
})
expect(report.comparison).not.toBeNull()
expect(report.comparison!.domestic.current).toBe(200000)
expect(report.comparison!.domestic.previous).toBe(150000)
expect(report.comparison!.domestic.change).toBe(50000)
expect(report.comparison!.domestic.changePercent).toBeCloseTo(33.33, 1)
})
it('returns null changePercent when previous is zero', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3108', 50000)],
previousGlLines: [],
})
expect(report.comparison!.euGoods.changePercent).toBeNull()
})
it('shows negative delta when revenue decreased', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 80000)],
previousGlLines: [creditLine('3001', 100000)],
})
expect(report.comparison!.domestic.change).toBe(-20000)
expect(report.comparison!.domestic.changePercent).toBe(-20)
})
it('no comparison when previousGlLines not provided', () => {
const report = generateVatMonitorReport(BASE_OPTIONS)
expect(report.comparison).toBeNull()
})
it('includes netVat comparison', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('2611', 50000), debitLine('2641', 20000)],
previousGlLines: [creditLine('2611', 40000), debitLine('2641', 25000)],
})
expect(report.comparison!.netVat.current).toBe(30000)
expect(report.comparison!.netVat.previous).toBe(15000)
expect(report.comparison!.netVat.change).toBe(15000)
})
})
// ── Empty / edge case tests ─────────────────────────────────
describe('edge cases', () => {
it('generates empty report with no GL data', () => {
const report = generateVatMonitorReport(BASE_OPTIONS)
expect(report.boxes).toHaveLength(1) // Only box 49 (net = 0)
expect(report.revenueBreakdown.totalRevenue).toBe(0)
expect(report.vatSummary.netVat).toBe(0)
expect(report.warnings).toHaveLength(0)
})
it('ignores irrelevant accounts', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('1510', 100000), // accounts receivable — not relevant
creditLine('3001', 50000), // domestic revenue — relevant
],
})
expect(report.revenueBreakdown.totalRevenue).toBe(50000)
})
it('handles mixed debit/credit on same account', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('3001', 100000),
{ account_number: '3001', debit_amount: 10000, credit_amount: 0 }, // credit note reversal
],
})
expect(report.revenueBreakdown.domestic.amount).toBe(90000)
})
it('rounds amounts to 2 decimal places', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [creditLine('3001', 33333.335)],
})
expect(report.revenueBreakdown.domestic.amount).toBe(33333.34)
})
it('boxes are sorted by box number', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
glLines: [
creditLine('2641', 0), // We need debit line for 2641
debitLine('2641', 10000),
creditLine('2611', 25000),
creditLine('3001', 100000),
creditLine('3108', 50000),
],
})
const boxNumbers = report.boxes.map(b => b.boxNumber)
const sorted = [...boxNumbers].sort()
expect(boxNumbers).toEqual(sorted)
})
it('includes period info in report', () => {
const report = generateVatMonitorReport({
...BASE_OPTIONS,
year: 2026,
month: 3,
quarter: undefined,
})
expect(report.period.year).toBe(2026)
expect(report.period.month).toBe(3)
})
})
@@ -0,0 +1,453 @@
/**
* Export VAT Monitor Engine
*
* Analyses journal entry data to produce a momsdeklaration preview
* focused on export and intra-community trade. Maps BAS accounts to
* momsdeklaration boxes, breaks down revenue by destination type,
* and validates invoice-to-account consistency.
*
* Pure functions — no Supabase, no React, no side effects.
*
* Complements the core VatDeclaration report by adding:
* - Revenue breakdown by destination (domestic / EU / non-EU)
* - Invoice-level validation (vat_treatment vs account)
* - Period-over-period comparison
*
* Reference: Skatteverket momsdeklaration (SKV 4700)
*/
import {
ACCOUNT_TO_BOX,
BOX_LABELS,
type MomsBox,
} from '@/extensions/export/shared/moms-box-mapping'
// ── Types ────────────────────────────────────────────────────
/** Journal entry line data pre-fetched from GL */
export interface GLLine {
account_number: string
debit_amount: number
credit_amount: number
}
/** Invoice data for validation (subset of core Invoice type) */
export interface VatMonitorInvoice {
id: string
invoice_number: string
vat_treatment: string
moms_ruta: string | null
customer_id: string
}
/** Customer data for validation */
export interface VatMonitorCustomer {
id: string
name: string
country: string
vat_number: string | null
vat_number_validated: boolean
}
/** A single momsdeklaration box result */
export interface VatBoxData {
boxNumber: MomsBox
label: string
amount: number
accounts: string[]
}
/** Revenue breakdown by destination type */
export interface RevenueBreakdown {
domestic: { amount: number; percentage: number }
euGoods: { amount: number; percentage: number }
euServices: { amount: number; percentage: number }
exportGoods: { amount: number; percentage: number }
exportServices: { amount: number; percentage: number }
triangular: { amount: number; percentage: number }
totalRevenue: number
}
/** Warning about VAT data quality */
export interface VatMonitorWarning {
type:
| 'vat_treatment_mismatch'
| 'missing_vat_number'
| 'unvalidated_vat_number'
| 'wrong_account'
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
customerName?: string
message: string
}
/** Period comparison delta for a destination type */
export interface PeriodDelta {
current: number
previous: number
change: number
changePercent: number | null
}
/** Complete VAT Monitor report */
export interface VatMonitorReport {
period: { year: number; month?: number; quarter?: number }
boxes: VatBoxData[]
revenueBreakdown: RevenueBreakdown
vatSummary: {
outputVat25: number
outputVat12: number
outputVat6: number
totalOutputVat: number
inputVat: number
netVat: number
isRefund: boolean
}
warnings: VatMonitorWarning[]
comparison: PeriodComparison | null
}
/** Period-over-period comparison */
export interface PeriodComparison {
domestic: PeriodDelta
euGoods: PeriodDelta
euServices: PeriodDelta
exportGoods: PeriodDelta
exportServices: PeriodDelta
triangular: PeriodDelta
totalRevenue: PeriodDelta
netVat: PeriodDelta
}
/** Options for generating the VAT Monitor report */
export interface VatMonitorOptions {
glLines: GLLine[]
invoices?: VatMonitorInvoice[]
customers?: VatMonitorCustomer[]
year: number
month?: number
quarter?: number
previousGlLines?: GLLine[]
}
// ── Revenue account groups ──────────────────────────────────
const DOMESTIC_ACCOUNTS = ['3001', '3002', '3003']
const EU_GOODS_ACCOUNTS = ['3108', '3521']
const EU_SERVICES_ACCOUNTS = ['3308']
const EXPORT_GOODS_ACCOUNTS = ['3105', '3522']
const EXPORT_SERVICES_ACCOUNTS = ['3305']
const TRIANGULATION_ACCOUNTS = ['3109']
const OUTPUT_VAT_ACCOUNTS: Record<string, string> = {
'2611': '25%',
'2621': '12%',
'2631': '6%',
}
const INPUT_VAT_ACCOUNTS = ['2641', '2645']
// All accounts this engine cares about
const ALL_ACCOUNTS = [
...DOMESTIC_ACCOUNTS,
...EU_GOODS_ACCOUNTS,
...EU_SERVICES_ACCOUNTS,
...EXPORT_GOODS_ACCOUNTS,
...EXPORT_SERVICES_ACCOUNTS,
...TRIANGULATION_ACCOUNTS,
...Object.keys(OUTPUT_VAT_ACCOUNTS),
...INPUT_VAT_ACCOUNTS,
]
// ── Expected vat_treatment for revenue account groups ───────
const ACCOUNT_EXPECTED_VAT_TREATMENT: Record<string, string[]> = {
'3001': ['standard_25'],
'3002': ['reduced_12'],
'3003': ['reduced_6'],
'3108': ['reverse_charge'],
'3521': ['reverse_charge'],
'3109': ['reverse_charge'],
'3308': ['reverse_charge'],
'3105': ['export'],
'3522': ['export'],
'3305': ['export'],
}
// ── Core engine ──────────────────────────────────────────────
/**
* Generate a VAT Monitor report from pre-fetched GL data.
*/
export function generateVatMonitorReport(options: VatMonitorOptions): VatMonitorReport {
const { glLines, invoices, customers, year, month, quarter, previousGlLines } = options
const warnings: VatMonitorWarning[] = []
// Step 1: Aggregate GL lines into account credit balances
const accountBalances = aggregateGLLines(glLines)
// Step 2: Map accounts to boxes
const boxes = buildBoxes(accountBalances)
// Step 3: Calculate revenue breakdown
const revenueBreakdown = calculateRevenueBreakdown(accountBalances)
// Step 4: Calculate VAT summary
const vatSummary = calculateVatSummary(accountBalances)
// Step 5: Validate invoices if provided
if (invoices && customers) {
validateInvoices(invoices, customers, warnings)
}
// Step 6: Calculate period comparison if previous data provided
let comparison: PeriodComparison | null = null
if (previousGlLines) {
const prevBalances = aggregateGLLines(previousGlLines)
const prevBreakdown = calculateRevenueBreakdown(prevBalances)
const prevVat = calculateVatSummary(prevBalances)
comparison = buildComparison(revenueBreakdown, prevBreakdown, vatSummary.netVat, prevVat.netVat)
}
return {
period: { year, month, quarter },
boxes,
revenueBreakdown,
vatSummary,
warnings,
comparison,
}
}
// ── GL aggregation ──────────────────────────────────────────
/**
* Aggregate GL lines into net balances per account.
* Revenue accounts: credit balance (credit - debit).
* VAT accounts: see sign per account type.
*/
function aggregateGLLines(lines: GLLine[]): Map<string, number> {
const balances = new Map<string, number>()
for (const line of lines) {
if (!ALL_ACCOUNTS.includes(line.account_number)) continue
const credit = Number(line.credit_amount) || 0
const debit = Number(line.debit_amount) || 0
// Input VAT is debit-side, all others are credit-side
const isInputVat = INPUT_VAT_ACCOUNTS.includes(line.account_number)
const balance = isInputVat ? (debit - credit) : (credit - debit)
balances.set(
line.account_number,
round2((balances.get(line.account_number) ?? 0) + balance),
)
}
return balances
}
/** Sum balances for a set of accounts */
function sumAccounts(balances: Map<string, number>, accounts: string[]): number {
return round2(accounts.reduce((sum, acc) => sum + (balances.get(acc) ?? 0), 0))
}
// ── Box building ────────────────────────────────────────────
function buildBoxes(balances: Map<string, number>): VatBoxData[] {
// Group accounts by box
const boxAccounts = new Map<MomsBox, string[]>()
const boxAmounts = new Map<MomsBox, number>()
for (const [account, balance] of balances) {
const box = ACCOUNT_TO_BOX[account]
if (!box) continue
if (!boxAccounts.has(box)) boxAccounts.set(box, [])
boxAccounts.get(box)!.push(account)
boxAmounts.set(box, round2((boxAmounts.get(box) ?? 0) + balance))
}
// Build sorted box list
const boxes: VatBoxData[] = []
for (const [box, amount] of boxAmounts) {
boxes.push({
boxNumber: box,
label: BOX_LABELS[box],
amount: round2(amount),
accounts: boxAccounts.get(box) ?? [],
})
}
// Add box 49 (net VAT = output - input)
const outputVat = sumAccounts(balances, Object.keys(OUTPUT_VAT_ACCOUNTS))
const inputVat = sumAccounts(balances, INPUT_VAT_ACCOUNTS)
const netVat = round2(outputVat - inputVat)
boxes.push({
boxNumber: '49',
label: BOX_LABELS['49'],
amount: netVat,
accounts: [...Object.keys(OUTPUT_VAT_ACCOUNTS), ...INPUT_VAT_ACCOUNTS],
})
// Sort by box number
boxes.sort((a, b) => a.boxNumber.localeCompare(b.boxNumber))
return boxes
}
// ── Revenue breakdown ───────────────────────────────────────
function calculateRevenueBreakdown(balances: Map<string, number>): RevenueBreakdown {
const domestic = sumAccounts(balances, DOMESTIC_ACCOUNTS)
const euGoods = sumAccounts(balances, EU_GOODS_ACCOUNTS)
const euServices = sumAccounts(balances, EU_SERVICES_ACCOUNTS)
const exportGoods = sumAccounts(balances, EXPORT_GOODS_ACCOUNTS)
const exportServices = sumAccounts(balances, EXPORT_SERVICES_ACCOUNTS)
const triangular = sumAccounts(balances, TRIANGULATION_ACCOUNTS)
const totalRevenue = round2(domestic + euGoods + euServices + exportGoods + exportServices + triangular)
const pct = (amount: number) => totalRevenue > 0 ? round2((amount / totalRevenue) * 100) : 0
return {
domestic: { amount: domestic, percentage: pct(domestic) },
euGoods: { amount: euGoods, percentage: pct(euGoods) },
euServices: { amount: euServices, percentage: pct(euServices) },
exportGoods: { amount: exportGoods, percentage: pct(exportGoods) },
exportServices: { amount: exportServices, percentage: pct(exportServices) },
triangular: { amount: triangular, percentage: pct(triangular) },
totalRevenue,
}
}
// ── VAT summary ─────────────────────────────────────────────
function calculateVatSummary(balances: Map<string, number>) {
const outputVat25 = balances.get('2611') ?? 0
const outputVat12 = balances.get('2621') ?? 0
const outputVat6 = balances.get('2631') ?? 0
const totalOutputVat = round2(outputVat25 + outputVat12 + outputVat6)
const inputVat = sumAccounts(balances, INPUT_VAT_ACCOUNTS)
const netVat = round2(totalOutputVat - inputVat)
return {
outputVat25: round2(outputVat25),
outputVat12: round2(outputVat12),
outputVat6: round2(outputVat6),
totalOutputVat,
inputVat: round2(inputVat),
netVat,
isRefund: netVat < 0,
}
}
// ── Invoice validation ──────────────────────────────────────
function validateInvoices(
invoices: VatMonitorInvoice[],
customers: VatMonitorCustomer[],
warnings: VatMonitorWarning[],
) {
const customerMap = new Map(customers.map(c => [c.id, c]))
for (const invoice of invoices) {
const customer = customerMap.get(invoice.customer_id)
// Check: reverse_charge invoices should have EU customer with VAT number
if (invoice.vat_treatment === 'reverse_charge' && customer) {
if (!customer.vat_number) {
warnings.push({
type: 'missing_vat_number',
severity: 'error',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} (${customer.name}) har omvänd skattskyldighet men kunden saknar VAT-nummer.`,
})
} else if (!customer.vat_number_validated) {
warnings.push({
type: 'unvalidated_vat_number',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
customerName: customer.name,
message: `Faktura ${invoice.invoice_number} (${customer.name}): VAT-nummer ${customer.vat_number} har inte validerats via VIES.`,
})
}
}
// Check: moms_ruta consistency with vat_treatment
if (invoice.moms_ruta && invoice.vat_treatment) {
const expectedBoxes = getExpectedBoxesForTreatment(invoice.vat_treatment)
if (expectedBoxes.length > 0 && !expectedBoxes.includes(invoice.moms_ruta)) {
warnings.push({
type: 'vat_treatment_mismatch',
severity: 'warning',
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
message: `Faktura ${invoice.invoice_number} har momsbehandling "${invoice.vat_treatment}" men moms_ruta "${invoice.moms_ruta}" (förväntat: ${expectedBoxes.join('/')}).`,
})
}
}
}
}
/** Get expected momsdeklaration boxes for a vat_treatment value */
function getExpectedBoxesForTreatment(vatTreatment: string): string[] {
switch (vatTreatment) {
case 'standard_25':
case 'reduced_12':
case 'reduced_6':
return ['05']
case 'reverse_charge':
return ['35', '38', '39']
case 'export':
return ['36', '40']
default:
return []
}
}
// ── Period comparison ───────────────────────────────────────
function buildComparison(
current: RevenueBreakdown,
previous: RevenueBreakdown,
currentNetVat: number,
previousNetVat: number,
): PeriodComparison {
return {
domestic: makeDelta(current.domestic.amount, previous.domestic.amount),
euGoods: makeDelta(current.euGoods.amount, previous.euGoods.amount),
euServices: makeDelta(current.euServices.amount, previous.euServices.amount),
exportGoods: makeDelta(current.exportGoods.amount, previous.exportGoods.amount),
exportServices: makeDelta(current.exportServices.amount, previous.exportServices.amount),
triangular: makeDelta(current.triangular.amount, previous.triangular.amount),
totalRevenue: makeDelta(current.totalRevenue, previous.totalRevenue),
netVat: makeDelta(currentNetVat, previousNetVat),
}
}
function makeDelta(current: number, previous: number): PeriodDelta {
const change = round2(current - previous)
const changePercent = previous !== 0 ? round2((change / Math.abs(previous)) * 100) : null
return { current, previous, change, changePercent }
}
// ── Helpers ─────────────────────────────────────────────────
function round2(value: number): number {
return Math.round(value * 100) / 100
}
// ── Exported constants for API route ────────────────────────
/** All BAS accounts that the VAT Monitor needs from GL */
export const VAT_MONITOR_ACCOUNTS = ALL_ACCOUNTS