Files
accounted/extensions/export/intrastat/lib/__tests__/intrastat-engine.test.ts
T
EmilandClaude Opus 4.6 0764bc11a6 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>
2026-02-24 14:10:16 +01:00

418 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})
})