/** * 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, toCountryCode } 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() 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() for (const invoice of relevantInvoices) { const customer = customerMap.get(invoice.customer_id)! const partnerCountryCode = toCountryCode(customer.country) 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', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms) addToAggregation(aggregation, key, { cnCode: '00000000', partnerCountry: partnerCountryCode, 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', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms) addToAggregation(aggregation, key, { cnCode: '00000000', partnerCountry: partnerCountryCode, 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, partnerCountryCode, origin, defaultTransactionNature, defaultDeliveryTerms) addToAggregation(aggregation, key, { cnCode, partnerCountry: partnerCountryCode, 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 { const map = new Map() 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): 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, 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` }