diff --git a/app/api/extensions/export/currency-receivables/report/route.ts b/app/api/extensions/export/currency-receivables/report/route.ts new file mode 100644 index 00000000..19c1028f --- /dev/null +++ b/app/api/extensions/export/currency-receivables/report/route.ts @@ -0,0 +1,157 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + generateReceivablesReport, + type ReceivableInvoice, + type ReceivableCustomer, + type GLLine, + type ExchangeRateInfo, +} from '@/extensions/export/currency-receivables/lib/receivables-engine' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import type { Currency } from '@/types' + +const SUPPORTED_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK'] +const FX_ACCOUNTS = ['3960', '7960'] + +/** + * GET /api/extensions/export/currency-receivables/report + * + * Generate a multi-currency receivables report showing FX exposure, + * unrealized gains/losses, and realized FX from GL. + * + * Query params: + * year (optional) — Year for realized FX trend (default: current year) + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const yearStr = searchParams.get('year') + const year = yearStr ? parseInt(yearStr, 10) : new Date().getFullYear() + + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) + } + + const referenceDate = new Date().toISOString().split('T')[0] + + try { + // Fetch open foreign-currency invoices + const invoices = await fetchAllRows(({ from, to }) => + supabase + .from('invoices') + .select('id, invoice_number, invoice_date, due_date, status, currency, total, total_sek, exchange_rate, customer_id') + .eq('user_id', user.id) + .in('status', ['sent', 'overdue']) + .neq('currency', 'SEK') + .range(from, to) + ) + + // Fetch customers + const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] + let customers: ReceivableCustomer[] = [] + if (customerIds.length > 0) { + const { data: customerData } = await supabase + .from('customers') + .select('id, name, country') + .eq('user_id', user.id) + .in('id', customerIds) + + customers = (customerData || []) as ReceivableCustomer[] + } + + // Fetch current Riksbanken rates for all supported currencies + const currentRates: ExchangeRateInfo[] = [] + const ratePromises = SUPPORTED_CURRENCIES.map(async (currency) => { + const rate = await fetchExchangeRate(currency) + if (rate) { + currentRates.push({ + currency: rate.currency, + rate: rate.rate, + date: rate.date, + }) + } + }) + await Promise.all(ratePromises) + + // Fetch realized FX GL lines for the year + const realizedFXLines = await fetchFXLines(supabase, user.id, year) + + const report = generateReceivablesReport({ + invoices, + customers, + currentRates, + realizedFXLines, + referenceDate, + year, + }) + + return NextResponse.json({ data: report }) + } catch (err) { + console.error('Error generating currency receivables report:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate report' }, + { status: 500 }, + ) + } +} + +/** + * Fetch GL lines on accounts 3960 (FX gains) and 7960 (FX losses) + * for posted journal entries in the given year. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function fetchFXLines(supabase: any, userId: string, year: number): Promise { + const startDate = `${year}-01-01` + const endDate = `${year}-12-31` + + // Get posted journal entry IDs in the year + const { data: entries, error: entriesError } = await supabase + .from('journal_entries') + .select('id, entry_date') + .eq('user_id', userId) + .eq('status', 'posted') + .gte('entry_date', startDate) + .lte('entry_date', endDate) + + if (entriesError || !entries || entries.length === 0) return [] + + const entryDateMap = new Map() + for (const e of entries as Array<{ id: string; entry_date: string }>) { + entryDateMap.set(e.id, e.entry_date) + } + + const entryIds = entries.map((e: { id: string }) => e.id) + + // Fetch lines in batches + const BATCH_SIZE = 200 + const allLines: GLLine[] = [] + + for (let i = 0; i < entryIds.length; i += BATCH_SIZE) { + const batch = entryIds.slice(i, i + BATCH_SIZE) + const { data: lines } = await supabase + .from('journal_entry_lines') + .select('journal_entry_id, account_number, debit_amount, credit_amount') + .in('journal_entry_id', batch) + .in('account_number', FX_ACCOUNTS) + + if (lines) { + for (const line of lines as Array<{ journal_entry_id: string; account_number: string; debit_amount: number; credit_amount: number }>) { + allLines.push({ + account_number: line.account_number, + debit: Number(line.debit_amount) || 0, + credit: Number(line.credit_amount) || 0, + entry_date: entryDateMap.get(line.journal_entry_id) || startDate, + }) + } + } + } + + return allLines +} diff --git a/app/api/extensions/export/eu-sales-list/download/route.ts b/app/api/extensions/export/eu-sales-list/download/route.ts new file mode 100644 index 00000000..be11ba4d --- /dev/null +++ b/app/api/extensions/export/eu-sales-list/download/route.ts @@ -0,0 +1,166 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + generateECSalesListReport, + getMonthPeriod, + getQuarterPeriod, + type ECSalesListInvoice, + type ECSalesListCustomer, + type GLAccountTotal, +} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' +import { generateCSV, generateCSVFilename } from '@/extensions/export/eu-sales-list/lib/csv-generator' +import { generateSKVXml, generateXMLFilename } from '@/extensions/export/eu-sales-list/lib/skv-xml-generator' + +/** + * GET /api/extensions/export/eu-sales-list/download + * + * Download an EC Sales List (periodisk sammanställning) as CSV or XML. + * + * Query params: + * year (required) — Fiscal year + * month (optional) — 1-12, for monthly filing + * quarter (optional) — 1-4, for quarterly filing + * format (required) — 'csv' or 'xml' + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Parse query parameters + const { searchParams } = new URL(request.url) + const yearStr = searchParams.get('year') + const monthStr = searchParams.get('month') + const quarterStr = searchParams.get('quarter') + const format = searchParams.get('format') + + if (!yearStr) { + return NextResponse.json({ error: 'year is required' }, { status: 400 }) + } + + if (!format || !['csv', 'xml'].includes(format)) { + return NextResponse.json({ error: 'format is required and must be csv or xml' }, { status: 400 }) + } + + const year = parseInt(yearStr, 10) + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) + } + + if (!monthStr && !quarterStr) { + return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 }) + } + + if (monthStr && quarterStr) { + return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 }) + } + + let month: number | undefined + let quarter: number | undefined + + if (monthStr) { + month = parseInt(monthStr, 10) + if (isNaN(month) || month < 1 || month > 12) { + return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) + } + } + + if (quarterStr) { + quarter = parseInt(quarterStr, 10) + if (isNaN(quarter) || quarter < 1 || quarter > 4) { + return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 }) + } + } + + const period = month !== undefined + ? getMonthPeriod(year, month) + : getQuarterPeriod(year, quarter!) + + try { + // Fetch company settings + const { data: company, error: companyError } = await supabase + .from('company_settings') + .select('company_name, org_number, vat_number') + .eq('user_id', user.id) + .single() + + if (companyError || !company) { + return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) + } + + const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` + + // Fetch invoices + const invoices = await fetchAllRows(({ from, to }) => + supabase + .from('invoices') + .select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id') + .eq('user_id', user.id) + .gte('invoice_date', period.start) + .lte('invoice_date', period.end) + .in('status', ['sent', 'paid', 'overdue']) + .eq('vat_treatment', 'reverse_charge') + .range(from, to) + ) + + // Fetch customers + const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] + let customers: ECSalesListCustomer[] = [] + if (customerIds.length > 0) { + const { data: customerData } = await supabase + .from('customers') + .select('id, name, country, customer_type, vat_number, vat_number_validated') + .eq('user_id', user.id) + .in('id', customerIds) + + customers = (customerData || []) as ECSalesListCustomer[] + } + + // Generate report + const report = generateECSalesListReport({ + invoices, + customers, + reporterVatNumber, + reporterName: company.company_name || '', + year, + month, + quarter, + }) + + // Generate file content + if (format === 'csv') { + const content = generateCSV(report) + const filename = generateCSVFilename(report) + + return new NextResponse(content, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } + + // XML format + const content = generateSKVXml(report) + const filename = generateXMLFilename(report) + + return new NextResponse(content, { + status: 200, + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (err) { + console.error('Error generating EC Sales List download:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate download' }, + { status: 500 }, + ) + } +} diff --git a/app/api/extensions/export/eu-sales-list/report/route.ts b/app/api/extensions/export/eu-sales-list/report/route.ts new file mode 100644 index 00000000..09301eb5 --- /dev/null +++ b/app/api/extensions/export/eu-sales-list/report/route.ts @@ -0,0 +1,212 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + generateECSalesListReport, + getMonthPeriod, + getQuarterPeriod, + getFilingDeadline, + daysUntilDeadline, + type ECSalesListInvoice, + type ECSalesListCustomer, + type GLAccountTotal, +} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' + +/** + * GET /api/extensions/export/eu-sales-list/report + * + * Generate an EC Sales List (periodisk sammanställning) report. + * + * Query params: + * year (required) — Fiscal year, e.g. 2026 + * month (optional) — 1-12, for monthly filing (goods) + * quarter (optional) — 1-4, for quarterly filing (services) + * + * Either month or quarter must be provided, not both. + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Parse query parameters + const { searchParams } = new URL(request.url) + const yearStr = searchParams.get('year') + const monthStr = searchParams.get('month') + const quarterStr = searchParams.get('quarter') + + if (!yearStr) { + return NextResponse.json({ error: 'year is required' }, { status: 400 }) + } + + const year = parseInt(yearStr, 10) + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json({ error: 'Invalid year. Must be between 2000 and 2100' }, { status: 400 }) + } + + if (!monthStr && !quarterStr) { + return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 }) + } + + if (monthStr && quarterStr) { + return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 }) + } + + let month: number | undefined + let quarter: number | undefined + + if (monthStr) { + month = parseInt(monthStr, 10) + if (isNaN(month) || month < 1 || month > 12) { + return NextResponse.json({ error: 'Invalid month. Must be 1-12' }, { status: 400 }) + } + } + + if (quarterStr) { + quarter = parseInt(quarterStr, 10) + if (isNaN(quarter) || quarter < 1 || quarter > 4) { + return NextResponse.json({ error: 'Invalid quarter. Must be 1-4' }, { status: 400 }) + } + } + + // Determine date range + const period = month !== undefined + ? getMonthPeriod(year, month) + : getQuarterPeriod(year, quarter!) + + try { + // Fetch company settings for reporter info + const { data: company, error: companyError } = await supabase + .from('company_settings') + .select('company_name, org_number, vat_number') + .eq('user_id', user.id) + .single() + + if (companyError || !company) { + return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) + } + + const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` + + // Fetch invoices for the period + const invoices = await fetchAllRows(({ from, to }) => + supabase + .from('invoices') + .select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id') + .eq('user_id', user.id) + .gte('invoice_date', period.start) + .lte('invoice_date', period.end) + .in('status', ['sent', 'paid', 'overdue']) + .eq('vat_treatment', 'reverse_charge') + .range(from, to) + ) + + // Collect unique customer IDs + const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] + + // Fetch customers (only if we have invoices) + let customers: ECSalesListCustomer[] = [] + if (customerIds.length > 0) { + const { data: customerData, error: customerError } = await supabase + .from('customers') + .select('id, name, country, customer_type, vat_number, vat_number_validated') + .eq('user_id', user.id) + .in('id', customerIds) + + if (customerError) { + return NextResponse.json({ error: 'Failed to fetch customers' }, { status: 500 }) + } + + customers = (customerData || []) as ECSalesListCustomer[] + } + + // Fetch GL account totals for cross-check + // Query posted journal entries in the period, then sum credit amounts + // on the relevant revenue accounts (3108, 3308, 3109, 3521) + const glTotals = await fetchGLTotals(supabase, user.id, period.start, period.end) + + // Generate report + const report = generateECSalesListReport({ + invoices, + customers, + glTotals: glTotals.length > 0 ? glTotals : undefined, + reporterVatNumber, + reporterName: company.company_name || '', + year, + month, + quarter, + }) + + // Add deadline info + const deadline = getFilingDeadline(year, month, quarter) + const daysLeft = daysUntilDeadline(deadline) + + return NextResponse.json({ + data: { + ...report, + deadline, + daysUntilDeadline: daysLeft, + }, + }) + } catch (err) { + console.error('Error generating EC Sales List report:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate report' }, + { status: 500 }, + ) + } +} + +// ── GL cross-check helper ─────────────────────────────────── + +const CROSS_CHECK_ACCOUNTS = ['3108', '3109', '3308', '3521'] + +/** + * Fetch credit totals for cross-check accounts from posted journal entries. + * Mirrors the approach used by /api/bookkeeping/account-totals. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function fetchGLTotals(supabase: any, userId: string, startDate: string, endDate: string): Promise { + // Get posted journal entry IDs in the period + const { data: entries, error: entriesError } = await supabase + .from('journal_entries') + .select('id') + .eq('user_id', userId) + .eq('status', 'posted') + .gte('entry_date', startDate) + .lte('entry_date', endDate) + + if (entriesError || !entries || entries.length === 0) return [] + + const entryIds = entries.map((e: { id: string }) => e.id) + + // Fetch lines in batches (same pattern as account-totals route) + const BATCH_SIZE = 200 + const allLines: Array<{ account_number: string; credit_amount: number }> = [] + + for (let i = 0; i < entryIds.length; i += BATCH_SIZE) { + const batch = entryIds.slice(i, i + BATCH_SIZE) + const { data: lines } = await supabase + .from('journal_entry_lines') + .select('account_number, credit_amount') + .in('journal_entry_id', batch) + .in('account_number', CROSS_CHECK_ACCOUNTS) + + if (lines) allLines.push(...lines) + } + + // Aggregate credits per account + const totals = new Map() + for (const line of allLines) { + const credit = Number(line.credit_amount) || 0 + totals.set(line.account_number, (totals.get(line.account_number) ?? 0) + credit) + } + + return Array.from(totals.entries()).map(([account_number, credit]) => ({ + account_number, + credit: Math.round(credit * 100) / 100, + })) +} diff --git a/app/api/extensions/export/intrastat/download/route.ts b/app/api/extensions/export/intrastat/download/route.ts new file mode 100644 index 00000000..06173319 --- /dev/null +++ b/app/api/extensions/export/intrastat/download/route.ts @@ -0,0 +1,165 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + generateIntrastatReport, + type IntrastatInvoice, + type IntrastatCustomer, + type IntrastatInvoiceItem, + type ProductMetadata, +} from '@/extensions/export/intrastat/lib/intrastat-engine' +import { generateSCBCsv, generateSCBFilename } from '@/extensions/export/intrastat/lib/scb-csv-generator' +import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' + +/** + * GET /api/extensions/export/intrastat/download + * + * Download an Intrastat declaration as SCB-compatible CSV. + * + * Query params: + * year (required) — Fiscal year + * month (required) — 1-12 + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const yearStr = searchParams.get('year') + const monthStr = searchParams.get('month') + + if (!yearStr || !monthStr) { + return NextResponse.json({ error: 'year and month are required' }, { status: 400 }) + } + + const year = parseInt(yearStr, 10) + const month = parseInt(monthStr, 10) + + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) + } + if (isNaN(month) || month < 1 || month > 12) { + return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) + } + + const period = getMonthPeriod(year, month) + + try { + const { data: company } = await supabase + .from('company_settings') + .select('company_name, org_number, vat_number') + .eq('user_id', user.id) + .single() + + if (!company) { + return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) + } + + const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` + + // Fetch invoices + const invoices = await fetchAllRows(({ from, to }) => + supabase + .from('invoices') + .select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id') + .eq('user_id', user.id) + .gte('invoice_date', period.start) + .lte('invoice_date', period.end) + .in('status', ['sent', 'paid', 'overdue']) + .eq('vat_treatment', 'reverse_charge') + .range(from, to) + ) + + // Fetch invoice items + const invoiceIds = invoices.map(inv => inv.id) + let invoiceItems: IntrastatInvoiceItem[] = [] + if (invoiceIds.length > 0) { + const BATCH_SIZE = 200 + for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) { + const batch = invoiceIds.slice(i, i + BATCH_SIZE) + const { data: items } = await supabase + .from('invoice_items') + .select('id, invoice_id, description, quantity, unit_price, total, total_sek') + .in('invoice_id', batch) + + if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[])) + } + } + + // Fetch customers + const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] + let customers: IntrastatCustomer[] = [] + if (customerIds.length > 0) { + const { data: customerData } = await supabase + .from('customers') + .select('id, name, country, vat_number') + .eq('user_id', user.id) + .in('id', customerIds) + + customers = (customerData || []) as IntrastatCustomer[] + } + + // Fetch product metadata + const { data: productData } = await supabase + .from('extension_data') + .select('key, value') + .eq('user_id', user.id) + .eq('extension_id', 'export/intrastat') + .ilike('key', 'product:%') + + const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record }) => ({ + productId: d.key.replace('product:', ''), + cnCode: (d.value.cn_code as string) || null, + description: (d.value.description as string) || '', + netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null, + countryOfOrigin: (d.value.country_of_origin as string) || 'SE', + supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null, + supplementaryUnitType: (d.value.supplementary_unit_type as string) || null, + })) + + // Fetch settings + const { data: settingsData } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', 'export/intrastat') + .eq('key', 'settings') + .maybeSingle() + + const settings = settingsData?.value as Record | undefined + + const report = generateIntrastatReport({ + invoices, + invoiceItems, + customers, + products, + reporterVatNumber, + reporterName: company.company_name || '', + year, + month, + defaultTransactionNature: (settings?.default_transaction_nature as string) || '11', + defaultDeliveryTerms: (settings?.default_delivery_terms as string) || 'FCA', + }) + + const content = generateSCBCsv(report) + const filename = generateSCBFilename(report) + + return new NextResponse(content, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (err) { + console.error('Error generating Intrastat download:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate download' }, + { status: 500 }, + ) + } +} diff --git a/app/api/extensions/export/intrastat/report/route.ts b/app/api/extensions/export/intrastat/report/route.ts new file mode 100644 index 00000000..46393b25 --- /dev/null +++ b/app/api/extensions/export/intrastat/report/route.ts @@ -0,0 +1,214 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + generateIntrastatReport, + type IntrastatInvoice, + type IntrastatCustomer, + type IntrastatInvoiceItem, + type ProductMetadata, +} from '@/extensions/export/intrastat/lib/intrastat-engine' +import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' + +/** + * GET /api/extensions/export/intrastat/report + * + * Generate an Intrastat declaration report for the specified month. + * + * Query params: + * year (required) — Fiscal year + * month (required) — 1-12 (Intrastat is always monthly) + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const yearStr = searchParams.get('year') + const monthStr = searchParams.get('month') + + if (!yearStr || !monthStr) { + return NextResponse.json({ error: 'year and month are required' }, { status: 400 }) + } + + const year = parseInt(yearStr, 10) + const month = parseInt(monthStr, 10) + + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) + } + if (isNaN(month) || month < 1 || month > 12) { + return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) + } + + const period = getMonthPeriod(year, month) + + try { + // Fetch company settings + const { data: company } = await supabase + .from('company_settings') + .select('company_name, org_number, vat_number') + .eq('user_id', user.id) + .single() + + if (!company) { + return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) + } + + const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01` + + // Fetch reverse-charge invoices for the period + const invoices = await fetchAllRows(({ from, to }) => + supabase + .from('invoices') + .select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id') + .eq('user_id', user.id) + .gte('invoice_date', period.start) + .lte('invoice_date', period.end) + .in('status', ['sent', 'paid', 'overdue']) + .eq('vat_treatment', 'reverse_charge') + .range(from, to) + ) + + // Fetch invoice items for those invoices + const invoiceIds = invoices.map(inv => inv.id) + let invoiceItems: IntrastatInvoiceItem[] = [] + if (invoiceIds.length > 0) { + const BATCH_SIZE = 200 + for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) { + const batch = invoiceIds.slice(i, i + BATCH_SIZE) + const { data: items } = await supabase + .from('invoice_items') + .select('id, invoice_id, description, quantity, unit_price, total, total_sek') + .in('invoice_id', batch) + + if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[])) + } + } + + // Fetch customers + const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] + let customers: IntrastatCustomer[] = [] + if (customerIds.length > 0) { + const { data: customerData } = await supabase + .from('customers') + .select('id, name, country, vat_number') + .eq('user_id', user.id) + .in('id', customerIds) + + customers = (customerData || []) as IntrastatCustomer[] + } + + // Fetch product metadata from extension_data + const { data: productData } = await supabase + .from('extension_data') + .select('key, value') + .eq('user_id', user.id) + .eq('extension_id', 'export/intrastat') + .ilike('key', 'product:%') + + const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record }) => ({ + productId: d.key.replace('product:', ''), + cnCode: (d.value.cn_code as string) || null, + description: (d.value.description as string) || '', + netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null, + countryOfOrigin: (d.value.country_of_origin as string) || 'SE', + supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null, + supplementaryUnitType: (d.value.supplementary_unit_type as string) || null, + })) + + // Fetch extension settings + const { data: settingsData } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', 'export/intrastat') + .eq('key', 'settings') + .maybeSingle() + + const settings = settingsData?.value as Record | undefined + const defaultTransactionNature = (settings?.default_transaction_nature as string) || '11' + const defaultDeliveryTerms = (settings?.default_delivery_terms as string) || 'FCA' + + // Calculate prior cumulative value (rolling 12 months excluding current) + const priorCumulativeValue = await calculatePriorCumulative(supabase, user.id, year, month) + + const report = generateIntrastatReport({ + invoices, + invoiceItems, + customers, + products, + reporterVatNumber, + reporterName: company.company_name || '', + year, + month, + defaultTransactionNature, + defaultDeliveryTerms, + priorCumulativeValue, + }) + + return NextResponse.json({ data: report }) + } catch (err) { + console.error('Error generating Intrastat report:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate report' }, + { status: 500 }, + ) + } +} + +/** + * Calculate the cumulative dispatch value for the 11 months prior to the + * current period (rolling 12-month window for threshold monitoring). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function calculatePriorCumulative(supabase: any, userId: string, year: number, month: number): Promise { + // Calculate 11-month lookback window + let startMonth = month - 11 + let startYear = year + while (startMonth < 1) { + startMonth += 12 + startYear-- + } + const startDate = `${startYear}-${String(startMonth).padStart(2, '0')}-01` + + // End date is the day before the current period + let prevMonth = month - 1 + let prevYear = year + if (prevMonth < 1) { + prevMonth = 12 + prevYear-- + } + const lastDay = new Date(prevYear, prevMonth, 0).getDate() + const endDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + + if (startDate > endDate) return 0 + + // Sum total_sek for reverse_charge invoices to EU in the lookback window + const { data, error } = await supabase + .from('invoices') + .select('total_sek, subtotal_sek, subtotal') + .eq('user_id', userId) + .gte('invoice_date', startDate) + .lte('invoice_date', endDate) + .in('status', ['sent', 'paid', 'overdue']) + .eq('vat_treatment', 'reverse_charge') + .eq('moms_ruta', '35') + + if (error || !data) return 0 + + let total = 0 + for (const inv of data) { + if (inv.subtotal_sek !== null) { + total += Number(inv.subtotal_sek) || 0 + } else { + total += Number(inv.subtotal) || 0 + } + } + + return Math.round(total * 100) / 100 +} diff --git a/app/api/extensions/export/vat-monitor/report/route.ts b/app/api/extensions/export/vat-monitor/report/route.ts new file mode 100644 index 00000000..3898b217 --- /dev/null +++ b/app/api/extensions/export/vat-monitor/report/route.ts @@ -0,0 +1,188 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { + generateVatMonitorReport, + VAT_MONITOR_ACCOUNTS, + type GLLine, + type VatMonitorInvoice, + type VatMonitorCustomer, +} from '@/extensions/export/vat-monitor/lib/vat-monitor-engine' +import { + getMonthPeriod, + getQuarterPeriod, +} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine' + +/** + * GET /api/extensions/export/vat-monitor/report + * + * Generate a VAT Monitor report for the specified period. + * + * Query params: + * year (required) — Fiscal year + * month (optional) — 1-12 + * quarter (optional) — 1-4 + * compare (optional) — 'previous' to include period comparison + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const yearStr = searchParams.get('year') + const monthStr = searchParams.get('month') + const quarterStr = searchParams.get('quarter') + const compare = searchParams.get('compare') + + if (!yearStr) { + return NextResponse.json({ error: 'year is required' }, { status: 400 }) + } + + const year = parseInt(yearStr, 10) + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json({ error: 'Invalid year' }, { status: 400 }) + } + + if (!monthStr && !quarterStr) { + return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 }) + } + + if (monthStr && quarterStr) { + return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 }) + } + + let month: number | undefined + let quarter: number | undefined + + if (monthStr) { + month = parseInt(monthStr, 10) + if (isNaN(month) || month < 1 || month > 12) { + return NextResponse.json({ error: 'Invalid month' }, { status: 400 }) + } + } + + if (quarterStr) { + quarter = parseInt(quarterStr, 10) + if (isNaN(quarter) || quarter < 1 || quarter > 4) { + return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 }) + } + } + + const period = month !== undefined + ? getMonthPeriod(year, month) + : getQuarterPeriod(year, quarter!) + + try { + // Fetch GL lines for current period + const glLines = await fetchGLLines(supabase, user.id, period.start, period.end) + + // Fetch invoices for validation + const invoices = await fetchAllRows(({ from, to }) => + supabase + .from('invoices') + .select('id, invoice_number, vat_treatment, moms_ruta, customer_id') + .eq('user_id', user.id) + .gte('invoice_date', period.start) + .lte('invoice_date', period.end) + .in('status', ['sent', 'paid', 'overdue']) + .range(from, to) + ) + + // Fetch customers for those invoices + const customerIds = [...new Set(invoices.map(inv => inv.customer_id))] + let customers: VatMonitorCustomer[] = [] + if (customerIds.length > 0) { + const { data: customerData } = await supabase + .from('customers') + .select('id, name, country, vat_number, vat_number_validated') + .eq('user_id', user.id) + .in('id', customerIds) + + customers = (customerData || []) as VatMonitorCustomer[] + } + + // Fetch previous period GL lines for comparison + let previousGlLines: GLLine[] | undefined + if (compare === 'previous') { + const prevPeriod = getPreviousPeriod(year, month, quarter) + previousGlLines = await fetchGLLines(supabase, user.id, prevPeriod.start, prevPeriod.end) + } + + const report = generateVatMonitorReport({ + glLines, + invoices, + customers, + year, + month, + quarter, + previousGlLines, + }) + + return NextResponse.json({ data: report }) + } catch (err) { + console.error('Error generating VAT Monitor report:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate report' }, + { status: 500 }, + ) + } +} + +// ── Helpers ───────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function fetchGLLines(supabase: any, userId: string, startDate: string, endDate: string): Promise { + // Fetch posted journal entry IDs for the period + const { data: entries, error: entriesError } = await supabase + .from('journal_entries') + .select('id') + .eq('user_id', userId) + .eq('status', 'posted') + .gte('entry_date', startDate) + .lte('entry_date', endDate) + + if (entriesError || !entries || entries.length === 0) return [] + + const entryIds = entries.map((e: { id: string }) => e.id) + + // Fetch lines in batches + const BATCH_SIZE = 200 + const allLines: GLLine[] = [] + + for (let i = 0; i < entryIds.length; i += BATCH_SIZE) { + const batch = entryIds.slice(i, i + BATCH_SIZE) + const { data: lines } = await supabase + .from('journal_entry_lines') + .select('account_number, debit_amount, credit_amount') + .in('journal_entry_id', batch) + .in('account_number', VAT_MONITOR_ACCOUNTS) + + if (lines) allLines.push(...lines) + } + + return allLines +} + +function getPreviousPeriod(year: number, month?: number, quarter?: number): { start: string; end: string } { + if (month !== undefined) { + let prevMonth = month - 1 + let prevYear = year + if (prevMonth < 1) { + prevMonth = 12 + prevYear-- + } + return getMonthPeriod(prevYear, prevMonth) + } + + let prevQuarter = quarter! - 1 + let prevYear = year + if (prevQuarter < 1) { + prevQuarter = 4 + prevYear-- + } + return getQuarterPeriod(prevYear, prevQuarter) +} diff --git a/components/extensions/export/CurrencyReceivablesWorkspace.tsx b/components/extensions/export/CurrencyReceivablesWorkspace.tsx new file mode 100644 index 00000000..2242f84f --- /dev/null +++ b/components/extensions/export/CurrencyReceivablesWorkspace.tsx @@ -0,0 +1,542 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + TrendingUp, TrendingDown, RefreshCw, Info, ArrowUpDown, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +// ── Types ───────────────────────────────────────────────────── + +interface CurrencyExposure { + currency: string + totalForeignAmount: number + bookedSekValue: number + currentSekValue: number + unrealizedGainLoss: number + invoiceCount: number + averageBookedRate: number + currentRate: number +} + +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 +} + +interface MonthlyFXTrend { + month: string + realizedGains: number + realizedLosses: number + netRealized: number +} + +interface ExchangeRateInfo { + currency: string + rate: number + date: string +} + +interface RevalPreview { + totalUnrealizedGainLoss: number + gains: number + losses: number +} + +interface ReportData { + 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 + } +} + +// ── Helpers ─────────────────────────────────────────────────── + +function formatSEK(amount: number): string { + return Math.round(amount).toLocaleString('sv-SE') +} + +function formatAmount(amount: number, decimals = 2): string { + return amount.toLocaleString('sv-SE', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) +} + +const CURRENCY_SYMBOLS: Record = { + EUR: '€', USD: '$', GBP: '£', NOK: 'kr', DKK: 'kr', SEK: 'kr', +} + +function currencySymbol(code: string): string { + return CURRENCY_SYMBOLS[code] || code +} + +const MONTH_NAMES = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', + 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec', +] + +function monthLabel(monthKey: string): string { + const m = parseInt(monthKey.split('-')[1], 10) + return MONTH_NAMES[m - 1] || monthKey +} + +function currentYear(): number { return new Date().getFullYear() } + +type SortField = 'unrealizedGainLoss' | 'foreignAmount' | 'daysOutstanding' | 'customerName' +type SortDir = 'asc' | 'desc' + +// ── Component ───────────────────────────────────────────────── + +export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + const [year, setYear] = useState(currentYear()) + const [report, setReport] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + + const [sortField, setSortField] = useState('unrealizedGainLoss') + const [sortDir, setSortDir] = useState('desc') + + const years = [currentYear(), currentYear() - 1, currentYear() - 2] + + const fetchReport = useCallback(async () => { + setIsLoading(true) + setError(null) + try { + const params = new URLSearchParams({ year: String(year) }) + const res = await fetch(`/api/extensions/export/currency-receivables/report?${params}`) + if (!res.ok) { + const json = await res.json() + setError(json.error || 'Kunde inte hämta rapporten') + setReport(null) + return + } + const json = await res.json() + setReport(json.data) + } catch { + setError('Nätverksfel') + setReport(null) + } finally { + setIsLoading(false) + setRefreshing(false) + } + }, [year]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + const handleRefresh = () => { + setRefreshing(true) + fetchReport() + } + + const toggleSort = (field: SortField) => { + if (sortField === field) { + setSortDir(d => d === 'asc' ? 'desc' : 'asc') + } else { + setSortField(field) + setSortDir('desc') + } + } + + const sortedReceivables = report?.receivables.slice().sort((a, b) => { + const mul = sortDir === 'asc' ? 1 : -1 + switch (sortField) { + case 'unrealizedGainLoss': return mul * (Math.abs(a.unrealizedGainLoss) - Math.abs(b.unrealizedGainLoss)) + case 'foreignAmount': return mul * (a.foreignAmount - b.foreignAmount) + case 'daysOutstanding': return mul * (a.daysOutstanding - b.daysOutstanding) + case 'customerName': return mul * a.customerName.localeCompare(b.customerName) + default: return 0 + } + }) || [] + + // Only show trend months that have data or are <= current month + const activeTrend = report?.monthlyTrend.filter(t => { + const m = parseInt(t.month.split('-')[1], 10) + const trendYear = parseInt(t.month.split('-')[0], 10) + if (trendYear < currentYear()) return true + return m <= new Date().getMonth() + 1 + }) || [] + + if (isLoading && !report) { + return + } + + return ( +
+ {/* ── Header ─────────────────────────────────────── */} +
+
+ + +
+ +
+ + {error && ( + + +

{error}

+
+
+ )} + + {report && ( + <> + {/* ── Exchange Rates ──────────────────────────── */} + + +
+

Växelkurser

+ + {report.referenceDate} + +
+
+ {report.exchangeRates.map(r => ( +
+ {r.currency}: + {formatAmount(r.rate, 4)} +
+ ))} +
+
+
+ + {/* ── Exposure Cards ─────────────────────────── */} + {report.exposureByCurrency.length > 0 ? ( +
+ {report.exposureByCurrency.map(exp => ( + + ))} + + {/* Total card */} + + +
+ Totalt + {report.totals.receivableCount} fakturor +
+

+ {formatSEK(report.totals.currentSekValue)} +

+

SEK (aktuell kurs)

+
+ +
+
+
+
+ ) : ( + + +

+ Inga öppna fordringar i utländsk valuta. +

+
+
+ )} + + {/* ── Receivables Table ──────────────────────── */} + {sortedReceivables.length > 0 && ( + + + Öppna fordringar + + +
+ + + + Faktura + + Valuta + + Bokfört (SEK) + Aktuellt (SEK) + + + + + + {sortedReceivables.map(r => ( + + {r.invoiceNumber} + +
+ {r.customerName} + {r.customerCountry && ( + {r.customerCountry} + )} +
+
+ + {r.currency} + + + {currencySymbol(r.currency)}{formatAmount(r.foreignAmount)} + + + {formatSEK(r.bookedSekAmount)} + + + {formatSEK(r.currentSekAmount)} + + + + + + 30 ? 'text-destructive font-medium' : + r.daysOutstanding > 14 ? 'text-warning-foreground' : '' + )}> + {r.daysOutstanding} + + +
+ ))} +
+
+
+
+
+ )} + + {/* ── Realized FX Trend ──────────────────────── */} + + + + Realiserade kursdifferenser {year} + + + + {activeTrend.length === 0 ? ( +

+ Inga realiserade kursdifferenser för {year}. +

+ ) : ( +
+ + + + Månad + Vinst (3960) + Förlust (7960) + Netto + + + + {activeTrend.map(t => ( + + {monthLabel(t.month)} + + {t.realizedGains > 0 ? `+${formatSEK(t.realizedGains)}` : '—'} + + + {t.realizedLosses > 0 ? `-${formatSEK(t.realizedLosses)}` : '—'} + + + + + + ))} + {/* Totals row */} + + Totalt {year} + + +{formatSEK(report.realizedGainLoss.gains)} + + + -{formatSEK(report.realizedGainLoss.losses)} + + + + + + +
+
+ )} +
+
+ + {/* ── Revaluation Preview ────────────────────── */} + {report.receivables.length > 0 && ( + + +
+ +
+

Omvärdering vid periodbokslut

+

+ Om bokslut görs idag: netto orealiserad{' '} + = 0 ? 'text-green-600' : 'text-red-600' + )}> + {report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'vinst' : 'förlust'}{' '} + {report.revalPreview.totalUnrealizedGainLoss >= 0 ? '+' : ''} + {formatSEK(report.revalPreview.totalUnrealizedGainLoss)} SEK + +

+ {report.revalPreview.gains > 0 && ( +

+ Konto 3969 (orealiserad kursvinst): {formatSEK(report.revalPreview.gains)} kr +

+ )} + {report.revalPreview.losses > 0 && ( +

+ Konto 7969 (orealiserad kursförlust): {formatSEK(report.revalPreview.losses)} kr +

+ )} +

+ Bokföringsposterna skapas inte av detta tillägg. Använd värdena ovan som underlag vid periodbokslut. +

+
+
+
+
+ )} + + )} +
+ ) +} + +// ── Sub-components ──────────────────────────────────────────── + +function ExposureCard({ exposure }: { exposure: CurrencyExposure }) { + const sym = currencySymbol(exposure.currency) + return ( + + +
+ {exposure.currency} + {exposure.invoiceCount} fakturor +
+

+ {sym}{formatAmount(exposure.totalForeignAmount)} +

+

+ {formatSEK(exposure.currentSekValue)} SEK +

+
+ +
+ Bokförd kurs: {formatAmount(exposure.averageBookedRate, 4)} + Aktuell: {formatAmount(exposure.currentRate, 4)} +
+
+
+
+ ) +} + +function FXIndicator({ label, amount }: { label: string; amount: number }) { + const isGain = amount >= 0 + return ( +
+ {label} +
+ {isGain ? : } + {isGain ? '+' : ''}{formatSEK(amount)} kr +
+
+ ) +} + +function FXBadge({ amount }: { amount: number }) { + if (amount === 0) return — + const isGain = amount > 0 + return ( + + {isGain ? '+' : ''}{formatSEK(amount)} + + ) +} + +function SortableHead({ + field, label, current, dir, onSort, className, +}: { + field: SortField + label: string + current: SortField + dir: SortDir + onSort: (f: SortField) => void + className?: string +}) { + const isActive = current === field + return ( + + + + ) +} diff --git a/components/extensions/export/EuSalesListWorkspace.tsx b/components/extensions/export/EuSalesListWorkspace.tsx new file mode 100644 index 00000000..8d356d8c --- /dev/null +++ b/components/extensions/export/EuSalesListWorkspace.tsx @@ -0,0 +1,648 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import KPICard from '@/components/extensions/shared/KPICard' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + AlertTriangle, CheckCircle2, FileSpreadsheet, FileCode, + Clock, ChevronDown, ChevronUp, Users, Package, Briefcase, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +// ── Types ───────────────────────────────────────────────────── + +interface ECSalesListLine { + customerVatNumber: string + customerName: string + customerCountry: string + customerId: string + goodsAmount: number + servicesAmount: number + triangulationAmount: number + invoiceCount: number +} + +interface ECSalesListWarning { + type: string + severity: 'error' | 'warning' + invoiceId?: string + invoiceNumber?: string + customerId?: string + customerName?: string + message: string +} + +interface CrossCheckResult { + box35Match: boolean + box35ReportTotal: number + box35GLTotal: number + box39Match: boolean + box39ReportTotal: number + box39GLTotal: number +} + +interface ReportData { + 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 + deadline: string + daysUntilDeadline: number +} + +type SortField = 'country' | 'vatNumber' | 'goods' | 'services' | 'invoices' +type SortDir = 'asc' | 'desc' + +// ── Helpers ─────────────────────────────────────────────────── + +function formatSEK(amount: number): string { + return Math.round(amount).toLocaleString('sv-SE') +} + +function formatDeadlineDate(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00') + return d.toLocaleDateString('sv-SE', { year: 'numeric', month: 'long', day: 'numeric' }) +} + +const MONTHS = [ + 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', + 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', +] + +const QUARTERS = ['Q1 (jan–mar)', 'Q2 (apr–jun)', 'Q3 (jul–sep)', 'Q4 (okt–dec)'] + +function currentYear(): number { + return new Date().getFullYear() +} + +function currentMonth(): number { + return new Date().getMonth() + 1 +} + +function currentQuarter(): number { + return Math.ceil(currentMonth() / 3) +} + +// ── Component ───────────────────────────────────────────────── + +export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + // Period selection state + const [year, setYear] = useState(currentYear()) + const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly') + const [month, setMonth] = useState(currentMonth()) + const [quarter, setQuarter] = useState(currentQuarter()) + + // Report state + const [report, setReport] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Table sort + const [sortField, setSortField] = useState('country') + const [sortDir, setSortDir] = useState('asc') + + // Warning expansion + const [warningsExpanded, setWarningsExpanded] = useState(false) + + // Download state + const [downloading, setDownloading] = useState<'csv' | 'xml' | null>(null) + + // Available years (current year and 2 previous) + const years = useMemo(() => { + const cy = currentYear() + return [cy, cy - 1, cy - 2] + }, []) + + // Fetch report + const fetchReport = useCallback(async () => { + setIsLoading(true) + setError(null) + + const params = new URLSearchParams({ year: String(year) }) + if (periodType === 'monthly') { + params.set('month', String(month)) + } else { + params.set('quarter', String(quarter)) + } + + try { + const res = await fetch(`/api/extensions/export/eu-sales-list/report?${params}`) + if (!res.ok) { + const json = await res.json() + setError(json.error || 'Kunde inte generera rapporten') + setReport(null) + return + } + const json = await res.json() + setReport(json.data) + } catch { + setError('Nätverksfel — kunde inte hämta rapporten') + setReport(null) + } finally { + setIsLoading(false) + } + }, [year, month, quarter, periodType]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + // Sort lines + const sortedLines = useMemo(() => { + if (!report) return [] + const lines = [...report.lines] + lines.sort((a, b) => { + let cmp = 0 + switch (sortField) { + case 'country': + cmp = a.customerCountry.localeCompare(b.customerCountry) + break + case 'vatNumber': + cmp = a.customerVatNumber.localeCompare(b.customerVatNumber) + break + case 'goods': + cmp = a.goodsAmount - b.goodsAmount + break + case 'services': + cmp = a.servicesAmount - b.servicesAmount + break + case 'invoices': + cmp = a.invoiceCount - b.invoiceCount + break + } + return sortDir === 'asc' ? cmp : -cmp + }) + return lines + }, [report, sortField, sortDir]) + + // Toggle sort + const toggleSort = (field: SortField) => { + if (sortField === field) { + setSortDir(d => d === 'asc' ? 'desc' : 'asc') + } else { + setSortField(field) + setSortDir('asc') + } + } + + // Download handler + const handleDownload = async (format: 'csv' | 'xml') => { + setDownloading(format) + const params = new URLSearchParams({ year: String(year), format }) + if (periodType === 'monthly') { + params.set('month', String(month)) + } else { + params.set('quarter', String(quarter)) + } + + try { + const res = await fetch(`/api/extensions/export/eu-sales-list/download?${params}`) + if (!res.ok) { + const json = await res.json() + setError(json.error || 'Kunde inte ladda ner filen') + return + } + + const blob = await res.blob() + const disposition = res.headers.get('Content-Disposition') || '' + const filenameMatch = disposition.match(/filename="(.+)"/) + const filename = filenameMatch ? filenameMatch[1] : `PS_${year}.${format}` + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } catch { + setError('Kunde inte ladda ner filen') + } finally { + setDownloading(null) + } + } + + // Derived counts + const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 + const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 + + if (isLoading && !report) { + return + } + + return ( +
+ {/* ── Period Selector ─────────────────────────────────── */} +
+
+ + +
+ +
+ + +
+ +
+ + {periodType === 'monthly' ? ( + + ) : ( + + )} +
+ + {/* Download buttons */} +
+ + +
+
+ + {/* ── Error state ────────────────────────────────────── */} + {error && ( + + +

{error}

+
+
+ )} + + {report && ( + <> + {/* ── KPI Cards ────────────────────────────────────── */} +
+ + + + +
+ + {/* ── Deadline + Cross-Check Row ────────────────────── */} +
+ {/* Deadline */} + + +
+ +
+

Inlämningsdeadline

+

+ {formatDeadlineDate(report.deadline)} +

+

+ {report.daysUntilDeadline > 0 + ? `${report.daysUntilDeadline} dagar kvar` + : report.daysUntilDeadline === 0 + ? 'Deadline idag!' + : `${Math.abs(report.daysUntilDeadline)} dagar försenad` + } +

+
+
+
+
+ + {/* Cross-check */} + + +

Avstämning mot huvudbok

+ {report.crossCheck ? ( +
+ + +
+ ) : ( +

+ Ingen bokföringsdata tillgänglig för perioden. +

+ )} +
+
+
+ + {/* ── Warnings ─────────────────────────────────────── */} + {report.warnings.length > 0 && ( + 0 ? 'border-l-destructive' : 'border-l-warning' + )}> + + + + {warningsExpanded && ( +
+ {report.warnings.map((w, i) => ( +
+ + {w.message} +
+ ))} +
+ )} +
+
+ )} + + {/* ── Customer Table ────────────────────────────────── */} + + + + + Kunder per land + + + + {sortedLines.length === 0 ? ( +

+ Inga EU-försäljningar hittades för vald period. +

+ ) : ( +
+ + + + + Land + + + VAT-nummer + + Kund + + + + Varor (ruta 35) + + + + + + Tjänster (ruta 39) + + + + Fakturor + + + + + {sortedLines.map(line => ( + + + + {line.customerCountry} + + + + {line.customerVatNumber} + + {line.customerName} + + {line.goodsAmount !== 0 ? formatSEK(line.goodsAmount) : '—'} + + + {line.servicesAmount !== 0 ? formatSEK(line.servicesAmount) : '—'} + + + {line.invoiceCount} + + + ))} + + {/* Totals row */} + + + Summa ({sortedLines.length} kunder) + + + {formatSEK(report.totals.goods)} + + + {formatSEK(report.totals.services)} + + + {report.invoiceCount} + + + +
+
+ )} +
+
+ + {/* ── Filing Info Footer ────────────────────────────── */} +
+ + Uppgiftslämnare: {report.reporterName} ({report.reporterVatNumber}) + + + Redovisningsperiod: {report.period.year} + {report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`} + {report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`} + +
+ + )} +
+ ) +} + +// ── Sub-components ──────────────────────────────────────────── + +function CrossCheckRow({ + label, + reportTotal, + glTotal, + match, +}: { + label: string + reportTotal: number + glTotal: number + match: boolean +}) { + const diff = Math.round(reportTotal * 100) / 100 - Math.round(glTotal * 100) / 100 + + return ( +
+ {match ? ( + + ) : ( + + )} + {label} + + {formatSEK(reportTotal)} SEK + + {!match && ( + + (diff: {diff > 0 ? '+' : ''}{formatSEK(diff)}) + + )} +
+ ) +} + +function SortableHead({ + field, + current, + dir, + onSort, + className, + children, +}: { + field: SortField + current: SortField + dir: SortDir + onSort: (field: SortField) => void + className?: string + children: React.ReactNode +}) { + const isActive = current === field + return ( + onSort(field)}> + + {children} + {isActive && ( + dir === 'asc' + ? + : + )} + + + ) +} diff --git a/components/extensions/export/IntrastatWorkspace.tsx b/components/extensions/export/IntrastatWorkspace.tsx new file mode 100644 index 00000000..2930125c --- /dev/null +++ b/components/extensions/export/IntrastatWorkspace.tsx @@ -0,0 +1,625 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import { useExtensionData } from '@/lib/extensions/use-extension-data' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Progress } from '@/components/ui/progress' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog' +import { + AlertTriangle, Plus, Pencil, Trash2, FileSpreadsheet, Clock, + ChevronDown, ChevronUp, Package, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +// ── Types ───────────────────────────────────────────────────── + +interface IntrastatLine { + cnCode: string + partnerCountry: string + countryOfOrigin: string + transactionNature: string + deliveryTerms: string + invoicedValue: number + netMass: number + supplementaryUnit: number | null + supplementaryUnitType: string | null + partnerVatId: string +} + +interface ThresholdStatus { + cumulativeValue: number + threshold: number + isObligated: boolean + percentageUsed: number +} + +interface IntrastatWarning { + type: string + severity: 'error' | 'warning' + invoiceId?: string + invoiceNumber?: string + productId?: string + message: string +} + +interface ReportData { + period: { year: number; month: number } + reporterVatNumber: string + reporterName: string + lines: IntrastatLine[] + totals: { invoicedValue: number; netMass: number; lineCount: number } + thresholdStatus: ThresholdStatus + warnings: IntrastatWarning[] + invoiceCount: number +} + +interface ProductRecord { + key: string + productId: string + description: string + cn_code: string | null + net_weight_kg: number | null + country_of_origin: string +} + +interface ProductForm { + productId: string + description: string + cnCode: string + netWeightKg: string + countryOfOrigin: string +} + +// ── Helpers ─────────────────────────────────────────────────── + +function formatSEK(amount: number): string { + return Math.round(amount).toLocaleString('sv-SE') +} + +const MONTHS = [ + 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', + 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', +] + +function currentYear(): number { return new Date().getFullYear() } +function currentMonth(): number { return new Date().getMonth() + 1 } + +const EMPTY_PRODUCT: ProductForm = { + productId: '', description: '', cnCode: '', netWeightKg: '', countryOfOrigin: 'SE', +} + +// ── Component ───────────────────────────────────────────────── + +export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + const [year, setYear] = useState(currentYear()) + const [month, setMonth] = useState(currentMonth()) + + const [report, setReport] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [downloading, setDownloading] = useState(false) + + const [warningsExpanded, setWarningsExpanded] = useState(false) + + // Product CRUD + const { data: extData, save, remove, isLoading: productsLoading } = useExtensionData('export', 'intrastat') + const [productDialogOpen, setProductDialogOpen] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + const [productForm, setProductForm] = useState(EMPTY_PRODUCT) + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const years = useMemo(() => { + const cy = currentYear() + return [cy, cy - 1, cy - 2] + }, []) + + // Parse products from extension data + const products: ProductRecord[] = useMemo(() => { + return extData + .filter(d => d.key.startsWith('product:')) + .map(d => ({ + key: d.key, + productId: d.key.replace('product:', ''), + description: String(d.value.description || ''), + cn_code: d.value.cn_code ? String(d.value.cn_code) : null, + net_weight_kg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null, + country_of_origin: String(d.value.country_of_origin || 'SE'), + })) + .sort((a, b) => a.description.localeCompare(b.description)) + }, [extData]) + + // Fetch report + const fetchReport = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const params = new URLSearchParams({ year: String(year), month: String(month) }) + const res = await fetch(`/api/extensions/export/intrastat/report?${params}`) + if (!res.ok) { + const json = await res.json() + setError(json.error || 'Kunde inte generera rapporten') + setReport(null) + return + } + const json = await res.json() + setReport(json.data) + } catch { + setError('Nätverksfel') + setReport(null) + } finally { + setIsLoading(false) + } + }, [year, month]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + // Product CRUD handlers + const openNewProduct = () => { + setEditingProduct(null) + setProductForm(EMPTY_PRODUCT) + setProductDialogOpen(true) + } + + const openEditProduct = (productId: string) => { + const product = products.find(p => p.productId === productId) + if (!product) return + setEditingProduct(productId) + setProductForm({ + productId, + description: product.description, + cnCode: product.cn_code || '', + netWeightKg: product.net_weight_kg !== null ? String(product.net_weight_kg) : '', + countryOfOrigin: product.country_of_origin, + }) + setProductDialogOpen(true) + } + + const saveProduct = async () => { + const id = editingProduct || productForm.productId.trim() + if (!id) return + + await save(`product:${id}`, { + description: productForm.description.trim(), + cn_code: productForm.cnCode.trim() || null, + net_weight_kg: productForm.netWeightKg ? parseFloat(productForm.netWeightKg) : null, + country_of_origin: productForm.countryOfOrigin || 'SE', + }) + + setProductDialogOpen(false) + // Refresh report to pick up new product metadata + fetchReport() + } + + const deleteProduct = async (productId: string) => { + await remove(`product:${productId}`) + setDeleteConfirm(null) + fetchReport() + } + + // Download handler + const handleDownload = async () => { + setDownloading(true) + try { + const params = new URLSearchParams({ year: String(year), month: String(month) }) + const res = await fetch(`/api/extensions/export/intrastat/download?${params}`) + if (!res.ok) { + setError('Kunde inte ladda ner filen') + return + } + const blob = await res.blob() + const disposition = res.headers.get('Content-Disposition') || '' + const match = disposition.match(/filename="(.+)"/) + const filename = match ? match[1] : `INTRASTAT_${year}-${String(month).padStart(2, '0')}.csv` + + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } catch { + setError('Kunde inte ladda ner filen') + } finally { + setDownloading(false) + } + } + + const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 + const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 + + if ((isLoading || productsLoading) && !report) { + return + } + + return ( +
+ {/* ── Period Selector ─────────────────────────────────── */} +
+
+ + +
+
+ + +
+
+ +
+
+ + {error && ( + + +

{error}

+
+
+ )} + + {report && ( + <> + {/* ── Threshold Progress ───────────────────────────── */} + + +
+

Tröskelvärde Intrastat (utförsel)

+ + {report.thresholdStatus.isObligated ? 'Obligatorisk rapportering' : 'Frivillig rapportering'} + +
+ +
+ + Ackumulerat (12 mån): {formatSEK(report.thresholdStatus.cumulativeValue)} SEK + + + {report.thresholdStatus.percentageUsed}% av {formatSEK(report.thresholdStatus.threshold)} SEK + +
+
+
+ + {/* ── KPI Row ──────────────────────────────────────── */} +
+ + +

Fakturerat värde

+

{formatSEK(report.totals.invoicedValue)}

+

SEK

+
+
+ + +

Nettovikt

+

{report.totals.netMass.toLocaleString('sv-SE')}

+

kg

+
+
+ + +

Deklarationsrader

+

{report.totals.lineCount}

+

{report.invoiceCount} fakturor

+
+
+
+ + {/* ── Product Registry ─────────────────────────────── */} + + +
+ + + Produktregister + + +
+
+ + {products.length === 0 ? ( +

+ Inga produkter registrerade. Lägg till produkter med CN-kod och vikt för att generera Intrastat-deklarationer. +

+ ) : ( +
+ + + + Produkt + CN-kod + Vikt (kg) + Ursprung + + + + + {products.map(p => ( + + +
+ {p.description || p.productId} + {p.productId !== p.description && ( + ({p.productId}) + )} +
+
+ + {p.cn_code ? ( + {p.cn_code} + ) : ( + + Saknas + + )} + + + {p.net_weight_kg !== null + ? String(p.net_weight_kg) + : — + } + + + + {p.country_of_origin} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+
+ + {/* ── Declaration Table ─────────────────────────────── */} + + + + Deklaration {MONTHS[month - 1]} {year} + + + + {report.lines.length === 0 ? ( +

+ Inga EU-varuförsäljningar hittades för perioden. +

+ ) : ( +
+ + + + CN-kod + Land + Urspr. + Värde (SEK) + Vikt (kg) + Partner-VAT + + + + {report.lines.map((line, i) => ( + + + + {line.cnCode} + + + + {line.partnerCountry} + + {line.countryOfOrigin} + + {formatSEK(line.invoicedValue)} + + + {line.netMass > 0 ? line.netMass.toLocaleString('sv-SE') : '—'} + + {line.partnerVatId || '—'} + + ))} + + Summa + + {formatSEK(report.totals.invoicedValue)} + + + {report.totals.netMass.toLocaleString('sv-SE')} + + + + +
+
+ )} +
+
+ + {/* ── Warnings ─────────────────────────────────────── */} + {report.warnings.length > 0 && ( + 0 ? 'border-l-destructive' : 'border-l-warning')}> + + + {warningsExpanded && ( +
+ {report.warnings.map((w, i) => ( +
+ + {w.message} +
+ ))} +
+ )} +
+
+ )} + + {/* ── Deadline Footer ───────────────────────────────── */} +
+ + + Deadline: 10:e arbetsdagen efter redovisningsperiodens slut + +
+ + )} + + {/* ── Product Dialog ────────────────────────────────────── */} + + + + {editingProduct ? 'Redigera produkt' : 'Lägg till produkt'} + +
+ {!editingProduct && ( +
+ + setProductForm(f => ({ ...f, productId: e.target.value }))} + placeholder="T.ex. STALBALK-M8" + /> +
+ )} +
+ + setProductForm(f => ({ ...f, description: e.target.value }))} + placeholder="T.ex. Stålbalk M8 200mm" + /> +
+
+ + setProductForm(f => ({ ...f, cnCode: e.target.value.replace(/\D/g, '').slice(0, 8) }))} + placeholder="T.ex. 72163100" + maxLength={8} + className="font-mono" + /> +
+
+
+ + setProductForm(f => ({ ...f, netWeightKg: e.target.value }))} + placeholder="45.5" + /> +
+
+ + setProductForm(f => ({ ...f, countryOfOrigin: e.target.value.toUpperCase().slice(0, 2) }))} + placeholder="SE" + maxLength={2} + /> +
+
+
+ + + + +
+
+ + {/* ── Delete Confirmation ───────────────────────────────── */} + setDeleteConfirm(null)}> + + + Ta bort produkt? + +

+ Är du säker på att du vill ta bort produkten “{deleteConfirm}”? Denna åtgärd kan inte ångras. +

+ + + + +
+
+
+ ) +} diff --git a/components/extensions/export/VatMonitorWorkspace.tsx b/components/extensions/export/VatMonitorWorkspace.tsx new file mode 100644 index 00000000..11747bcc --- /dev/null +++ b/components/extensions/export/VatMonitorWorkspace.tsx @@ -0,0 +1,497 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import KPICard from '@/components/extensions/shared/KPICard' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, + ArrowUp, ArrowDown, Minus, BarChart3, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +// ── Types ───────────────────────────────────────────────────── + +interface VatBoxData { + boxNumber: string + label: string + amount: number + accounts: string[] +} + +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 +} + +interface PeriodDelta { + current: number + previous: number + change: number + changePercent: number | null +} + +interface PeriodComparison { + domestic: PeriodDelta + euGoods: PeriodDelta + euServices: PeriodDelta + exportGoods: PeriodDelta + exportServices: PeriodDelta + triangular: PeriodDelta + totalRevenue: PeriodDelta + netVat: PeriodDelta +} + +interface VatMonitorWarning { + type: string + severity: 'error' | 'warning' + invoiceId?: string + invoiceNumber?: string + customerName?: string + message: string +} + +interface ReportData { + 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 +} + +// ── Helpers ─────────────────────────────────────────────────── + +function formatSEK(amount: number): string { + return Math.round(amount).toLocaleString('sv-SE') +} + +const MONTHS = [ + 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', + 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', +] + +const QUARTERS = ['Q1 (jan–mar)', 'Q2 (apr–jun)', 'Q3 (jul–sep)', 'Q4 (okt–dec)'] + +function currentYear(): number { + return new Date().getFullYear() +} + +function currentMonth(): number { + return new Date().getMonth() + 1 +} + +function currentQuarter(): number { + return Math.ceil(currentMonth() / 3) +} + +// Revenue breakdown cards config +const REVENUE_CARDS: { key: keyof Omit; label: string; compKey: keyof PeriodComparison }[] = [ + { key: 'domestic', label: 'Inrikes', compKey: 'domestic' }, + { key: 'euGoods', label: 'EU varor', compKey: 'euGoods' }, + { key: 'euServices', label: 'EU tjänster', compKey: 'euServices' }, + { key: 'exportGoods', label: 'Export varor', compKey: 'exportGoods' }, + { key: 'exportServices', label: 'Export tjänster', compKey: 'exportServices' }, + { key: 'triangular', label: 'Trepartshandel', compKey: 'triangular' }, +] + +// Box display order (only show relevant ones) +const DISPLAY_BOX_ORDER = ['05', '10', '11', '12', '35', '36', '38', '39', '40', '48', '49'] + +// ── Component ───────────────────────────────────────────────── + +export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + const [year, setYear] = useState(currentYear()) + const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('monthly') + const [month, setMonth] = useState(currentMonth()) + const [quarter, setQuarter] = useState(currentQuarter()) + const [compareEnabled, setCompareEnabled] = useState(true) + + const [report, setReport] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const [warningsExpanded, setWarningsExpanded] = useState(false) + + const years = useMemo(() => { + const cy = currentYear() + return [cy, cy - 1, cy - 2] + }, []) + + const fetchReport = useCallback(async () => { + setIsLoading(true) + setError(null) + + const params = new URLSearchParams({ year: String(year) }) + if (periodType === 'monthly') { + params.set('month', String(month)) + } else { + params.set('quarter', String(quarter)) + } + if (compareEnabled) { + params.set('compare', 'previous') + } + + try { + const res = await fetch(`/api/extensions/export/vat-monitor/report?${params}`) + if (!res.ok) { + const json = await res.json() + setError(json.error || 'Kunde inte generera rapporten') + setReport(null) + return + } + const json = await res.json() + setReport(json.data) + } catch { + setError('Nätverksfel — kunde inte hämta rapporten') + setReport(null) + } finally { + setIsLoading(false) + } + }, [year, month, quarter, periodType, compareEnabled]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 + const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 + + // Filter boxes to only show ones in display order that have data or are always shown + const displayBoxes = useMemo(() => { + if (!report) return [] + const boxMap = new Map(report.boxes.map(b => [b.boxNumber, b])) + return DISPLAY_BOX_ORDER + .map(num => boxMap.get(num)) + .filter((b): b is VatBoxData => b !== undefined) + }, [report]) + + if (isLoading && !report) { + return + } + + return ( +
+ {/* ── Period Selector ─────────────────────────────────── */} +
+
+ + +
+ +
+ + +
+ +
+ + {periodType === 'monthly' ? ( + + ) : ( + + )} +
+ +
+ +
+
+ + {/* ── Error state ────────────────────────────────────── */} + {error && ( + + +

{error}

+
+
+ )} + + {report && ( + <> + {/* ── Revenue Breakdown Cards ──────────────────────── */} +
+

Intäktsfördelning

+
+ {REVENUE_CARDS.map(({ key, label, compKey }) => { + const data = report.revenueBreakdown[key] + const delta = report.comparison?.[compKey] + return ( + + +

{label}

+

+ {formatSEK(data.amount)} +

+
+ + {data.percentage}% + + {delta && } +
+
+
+ ) + })} +
+
+ + {/* ── VAT Summary + Moms Box Table ─────────────────── */} +
+ {/* VAT Summary cards */} +
+

Moms

+ + + + +

+ {report.vatSummary.isRefund ? 'Moms att få tillbaka' : 'Moms att betala'} +

+
+ + {formatSEK(Math.abs(report.vatSummary.netVat))} + + SEK +
+ {report.comparison && ( +
+ +
+ )} +
+
+
+ + {/* Momsdeklaration preview table */} +
+

+ Momsdeklaration (förhandsvisning) +

+ + + + + + Ruta + Beskrivning + Belopp (SEK) + + + + {displayBoxes.map(box => { + const isNetVat = box.boxNumber === '49' + const isInputVat = box.boxNumber === '48' + return ( + + + + {box.boxNumber} + + + {box.label} + + {formatSEK(box.amount)} + + + ) + })} + {displayBoxes.length === 0 && ( + + + Ingen bokföringsdata för perioden. + + + )} + +
+
+
+
+
+ + {/* ── Warnings ─────────────────────────────────────── */} + {report.warnings.length > 0 && ( + 0 ? 'border-l-destructive' : 'border-l-warning' + )}> + + + + {warningsExpanded && ( +
+ {report.warnings.map((w, i) => ( +
+ + {w.message} +
+ ))} +
+ )} +
+
+ )} + + {/* ── Total Revenue Footer ─────────────────────────── */} +
+ + Total omsättning: {formatSEK(report.revenueBreakdown.totalRevenue)} SEK + + + {report.period.year} + {report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`} + {report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`} + +
+ + )} +
+ ) +} + +// ── Sub-components ──────────────────────────────────────────── + +function DeltaIndicator({ delta, invert = false }: { delta: PeriodDelta; invert?: boolean }) { + if (delta.changePercent === null || delta.change === 0) { + return ( + + + + ) + } + + // For most metrics, positive = green (revenue growing) + // For netVat (invert=true), positive = red (paying more VAT) + const isPositive = delta.change > 0 + const isGood = invert ? !isPositive : isPositive + + return ( + + {isPositive + ? + : + } + {delta.changePercent > 0 ? '+' : ''}{delta.changePercent}% + + ) +} diff --git a/extensions/export/currency-receivables/index.ts b/extensions/export/currency-receivables/index.ts new file mode 100644 index 00000000..e8adb2b9 --- /dev/null +++ b/extensions/export/currency-receivables/index.ts @@ -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', +} diff --git a/extensions/export/currency-receivables/lib/__tests__/receivables-engine.test.ts b/extensions/export/currency-receivables/lib/__tests__/receivables-engine.test.ts new file mode 100644 index 00000000..fefbb3db --- /dev/null +++ b/extensions/export/currency-receivables/lib/__tests__/receivables-engine.test.ts @@ -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 { + 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 { + return { + id: 'cust-1', + name: 'Müller GmbH', + country: 'DE', + ...overrides, + } +} + +function makeRate(overrides: Partial = {}): ExchangeRateInfo { + return { + currency: 'EUR', + rate: 11.50, + date: '2026-03-15', + ...overrides, + } +} + +function makeGLLine(overrides: Partial = {}): 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 + }) +}) diff --git a/extensions/export/currency-receivables/lib/receivables-engine.ts b/extensions/export/currency-receivables/lib/receivables-engine.ts new file mode 100644 index 00000000..37106870 --- /dev/null +++ b/extensions/export/currency-receivables/lib/receivables-engine.ts @@ -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() + for (const r of currentRates) { + rateMap.set(r.currency.toUpperCase(), r) + } + + const customerMap = new Map() + 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() + + 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() + + // 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 +} diff --git a/extensions/export/eu-sales-list/index.ts b/extensions/export/eu-sales-list/index.ts new file mode 100644 index 00000000..1d35b646 --- /dev/null +++ b/extensions/export/eu-sales-list/index.ts @@ -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', +} diff --git a/extensions/export/eu-sales-list/lib/__tests__/csv-generator.test.ts b/extensions/export/eu-sales-list/lib/__tests__/csv-generator.test.ts new file mode 100644 index 00000000..306725e3 --- /dev/null +++ b/extensions/export/eu-sales-list/lib/__tests__/csv-generator.test.ts @@ -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 { + 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') + }) +}) diff --git a/extensions/export/eu-sales-list/lib/__tests__/eu-sales-list-engine.test.ts b/extensions/export/eu-sales-list/lib/__tests__/eu-sales-list-engine.test.ts new file mode 100644 index 00000000..497f346b --- /dev/null +++ b/extensions/export/eu-sales-list/lib/__tests__/eu-sales-list-engine.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/extensions/export/eu-sales-list/lib/__tests__/skv-xml-generator.test.ts b/extensions/export/eu-sales-list/lib/__tests__/skv-xml-generator.test.ts new file mode 100644 index 00000000..fe67b4ea --- /dev/null +++ b/extensions/export/eu-sales-list/lib/__tests__/skv-xml-generator.test.ts @@ -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 { + 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('') + expect(xml).toContain('') + }) + + it('includes reporter VAT number', () => { + const xml = generateSKVXml(makeReport()) + expect(xml).toContain('SE556677889901') + }) + + it('includes reporter name', () => { + const xml = generateSKVXml(makeReport()) + expect(xml).toContain('Test AB') + }) + + it('includes quarterly period info', () => { + const xml = generateSKVXml(makeReport()) + expect(xml).toContain('2026') + expect(xml).toContain('1') + expect(xml).toContain('Kvartal') + }) + + it('includes monthly period info', () => { + const report = makeReport({ period: { year: 2026, month: 3 }, filingType: 'monthly' }) + const xml = generateSKVXml(report) + expect(xml).toContain('03') + expect(xml).toContain('Manad') + }) + + it('includes customer line with goods and services', () => { + const xml = generateSKVXml(makeReport()) + expect(xml).toContain('DE123456789') + expect(xml).toContain('DE') + expect(xml).toContain('50000') + expect(xml).toContain('30000') + }) + + it('omits zero amount elements', () => { + const xml = generateSKVXml(makeReport()) + // Triangulation is 0, should not appear in line + expect(xml).not.toContain('0') + }) + + 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('15000') + }) + + 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('50000') + expect(xml).toContain('30000') + expect(xml).toContain('80000') + }) + + 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('12346') + }) + + it('escapes XML special characters in names', () => { + const report = makeReport({ + reporterName: 'Foo & Bar ', + 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 & Bar <AB>') + expect(xml).not.toContain('Foo & Bar ') + }) +}) + +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') + }) +}) diff --git a/extensions/export/eu-sales-list/lib/csv-generator.ts b/extensions/export/eu-sales-list/lib/csv-generator.ts new file mode 100644 index 00000000..ccefbae5 --- /dev/null +++ b/extensions/export/eu-sales-list/lib/csv-generator.ts @@ -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__.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}` +} diff --git a/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts b/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts new file mode 100644 index 00000000..80f7294f --- /dev/null +++ b/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts @@ -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() + + 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)) +} diff --git a/extensions/export/eu-sales-list/lib/skv-xml-generator.ts b/extensions/export/eu-sales-list/lib/skv-xml-generator.ts new file mode 100644 index 00000000..0d378ac0 --- /dev/null +++ b/extensions/export/eu-sales-list/lib/skv-xml-generator.ts @@ -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: + * + * — reporter/sender information + * — reporting period + * — one per customer VAT number + * — buyer VAT number + * — buyer country code + * — goods amount (box 35) + * — services amount (box 39) + * — triangulation (box 38) + * + * + * + * All amounts are rounded to whole SEK (no decimals). + */ +export function generateSKVXml(report: ECSalesListReport): string { + const lines: string[] = [] + + lines.push('') + lines.push('') + + // Reporter/sender info + lines.push(' ') + lines.push(` ${escapeXml(report.reporterVatNumber)}`) + lines.push(` ${escapeXml(report.reporterName)}`) + lines.push(' ') + + // Period info + lines.push(' ') + lines.push(` ${report.period.year}`) + if (report.period.month !== undefined) { + lines.push(` ${String(report.period.month).padStart(2, '0')}`) + } + if (report.period.quarter !== undefined) { + lines.push(` ${report.period.quarter}`) + } + lines.push(` ${report.filingType === 'monthly' ? 'Manad' : 'Kvartal'}`) + lines.push(' ') + + // 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(' ') + lines.push(` ${escapeXml(line.customerVatNumber)}`) + lines.push(` ${escapeXml(line.customerCountry)}`) + if (goods !== 0) { + lines.push(` ${goods}`) + } + if (services !== 0) { + lines.push(` ${services}`) + } + if (triangulation !== 0) { + lines.push(` ${triangulation}`) + } + lines.push(' ') + } + + // Totals + lines.push(' ') + lines.push(` ${Math.round(report.totals.goods)}`) + lines.push(` ${Math.round(report.totals.services)}`) + lines.push(` ${Math.round(report.totals.triangulation)}`) + lines.push(` ${Math.round(report.totals.total)}`) + lines.push(' ') + + lines.push('') + + return lines.join('\n') + '\n' +} + +/** + * Generate a filename for the XML download. + * + * Format: KVPS__.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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/extensions/export/intrastat/index.ts b/extensions/export/intrastat/index.ts new file mode 100644 index 00000000..fdb2f47c --- /dev/null +++ b/extensions/export/intrastat/index.ts @@ -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', +} diff --git a/extensions/export/intrastat/lib/__tests__/intrastat-engine.test.ts b/extensions/export/intrastat/lib/__tests__/intrastat-engine.test.ts new file mode 100644 index 00000000..a609725c --- /dev/null +++ b/extensions/export/intrastat/lib/__tests__/intrastat-engine.test.ts @@ -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 { + 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 { + return { + id: 'cust-1', + name: 'Acme GmbH', + country: 'DE', + vat_number: 'DE123456789', + ...overrides, + } +} + +function makeItem(overrides: Partial = {}): 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 { + 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') + }) +}) diff --git a/extensions/export/intrastat/lib/__tests__/scb-csv-generator.test.ts b/extensions/export/intrastat/lib/__tests__/scb-csv-generator.test.ts new file mode 100644 index 00000000..53904b2b --- /dev/null +++ b/extensions/export/intrastat/lib/__tests__/scb-csv-generator.test.ts @@ -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 { + 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 { + 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') + }) +}) diff --git a/extensions/export/intrastat/lib/intrastat-engine.ts b/extensions/export/intrastat/lib/intrastat-engine.ts new file mode 100644 index 00000000..e5e9296d --- /dev/null +++ b/extensions/export/intrastat/lib/intrastat-engine.ts @@ -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() + 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 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 { + 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` +} diff --git a/extensions/export/intrastat/lib/scb-csv-generator.ts b/extensions/export/intrastat/lib/scb-csv-generator.ts new file mode 100644 index 00000000..2c3c189c --- /dev/null +++ b/extensions/export/intrastat/lib/scb-csv-generator.ts @@ -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__-.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+$/, '') +} diff --git a/extensions/export/shared/eu-countries.ts b/extensions/export/shared/eu-countries.ts new file mode 100644 index 00000000..00012531 --- /dev/null +++ b/extensions/export/shared/eu-countries.ts @@ -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 +} diff --git a/extensions/export/shared/moms-box-mapping.ts b/extensions/export/shared/moms-box-mapping.ts new file mode 100644 index 00000000..d0c899d2 --- /dev/null +++ b/extensions/export/shared/moms-box-mapping.ts @@ -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 = { + // 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 = { + '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'] diff --git a/extensions/export/vat-monitor/index.ts b/extensions/export/vat-monitor/index.ts new file mode 100644 index 00000000..c7f1190a --- /dev/null +++ b/extensions/export/vat-monitor/index.ts @@ -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', +} diff --git a/extensions/export/vat-monitor/lib/__tests__/vat-monitor-engine.test.ts b/extensions/export/vat-monitor/lib/__tests__/vat-monitor-engine.test.ts new file mode 100644 index 00000000..3874a528 --- /dev/null +++ b/extensions/export/vat-monitor/lib/__tests__/vat-monitor-engine.test.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/extensions/export/vat-monitor/lib/vat-monitor-engine.ts b/extensions/export/vat-monitor/lib/vat-monitor-engine.ts new file mode 100644 index 00000000..be40a36f --- /dev/null +++ b/extensions/export/vat-monitor/lib/vat-monitor-engine.ts @@ -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 = { + '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 = { + '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 { + const balances = new Map() + + 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, accounts: string[]): number { + return round2(accounts.reduce((sum, acc) => sum + (balances.get(acc) ?? 0), 0)) +} + +// ── Box building ──────────────────────────────────────────── + +function buildBoxes(balances: Map): VatBoxData[] { + // Group accounts by box + const boxAccounts = new Map() + const boxAmounts = new Map() + + 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): 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) { + 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