diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 41132c0c..7d45a202 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,18 @@ "Bash(git commit:*)", "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:support.fortnox.se)", - "Bash(npx vitest run:*)" + "Bash(npx vitest run:*)", + "WebFetch(domain:www.bjornlunden.se)", + "WebFetch(domain:www.scb.se)", + "WebFetch(domain:stripe.com)", + "WebFetch(domain:tullify.se)", + "WebFetch(domain:www.momsens.se)", + "WebFetch(domain:www.bokforingstips.se)", + "WebFetch(domain:rattsakuten.se)", + "WebFetch(domain:www.faronline.se)", + "WebFetch(domain:www.worldstopexports.com)", + "WebFetch(domain:www.riksbank.se)", + "WebFetch(domain:www.avalara.com)" ] } } diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 8dfc0977..04511729 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -2,6 +2,10 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateCustomerSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' +import { createLogger } from '@/lib/logger' + +const log = createLogger('api/customers/[id]') export async function GET( request: Request, @@ -95,6 +99,56 @@ export async function PATCH( return NextResponse.json({ error: error.message }, { status: 500 }) } + // Auto-validate VAT number when it changes on an EU business customer (non-blocking) + const isEuBusiness = (body.customer_type || data.customer_type) === 'eu_business' + if (body.vat_number !== undefined && isEuBusiness) { + try { + if (body.vat_number) { + const vatResult = await validateVatNumber(body.vat_number) + if (vatResult.valid) { + await supabase + .from('customers') + .update({ + vat_number_validated: true, + vat_number_validated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('user_id', user.id) + + data.vat_number_validated = true + data.vat_number_validated_at = new Date().toISOString() + } else { + await supabase + .from('customers') + .update({ + vat_number_validated: false, + vat_number_validated_at: null, + }) + .eq('id', id) + .eq('user_id', user.id) + + data.vat_number_validated = false + data.vat_number_validated_at = null + } + } else { + // VAT number cleared + await supabase + .from('customers') + .update({ + vat_number_validated: false, + vat_number_validated_at: null, + }) + .eq('id', id) + .eq('user_id', user.id) + + data.vat_number_validated = false + data.vat_number_validated_at = null + } + } catch (err) { + log.warn('Auto-VIES validation failed on customer update:', err) + } + } + return NextResponse.json({ data }) } diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index d22153d1..2eb12d97 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -4,8 +4,12 @@ import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateCustomerSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' +import { createLogger } from '@/lib/logger' import type { Customer } from '@/types' +const log = createLogger('api/customers') + ensureInitialized() export async function GET() { @@ -68,6 +72,28 @@ export async function POST(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }) } + // Auto-validate VAT number for EU business customers (non-blocking) + if (body.customer_type === 'eu_business' && body.vat_number) { + try { + const vatResult = await validateVatNumber(body.vat_number) + if (vatResult.valid) { + await supabase + .from('customers') + .update({ + vat_number_validated: true, + vat_number_validated_at: new Date().toISOString(), + }) + .eq('id', data.id) + .eq('user_id', user.id) + + data.vat_number_validated = true + data.vat_number_validated_at = new Date().toISOString() + } + } catch (err) { + log.warn('Auto-VIES validation failed on customer create:', err) + } + } + await eventBus.emit({ type: 'customer.created', payload: { customer: data as Customer, userId: user.id }, 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..fa77eed9 --- /dev/null +++ b/app/api/extensions/export/currency-receivables/report/route.ts @@ -0,0 +1,156 @@ +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 { fetchMultipleRates } 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 rateMap = await fetchMultipleRates(SUPPORTED_CURRENCIES) + const currentRates: ExchangeRateInfo[] = [] + for (const [, rate] of rateMap) { + if (rate.currency !== 'SEK') { + currentRates.push({ + currency: rate.currency, + rate: rate.rate, + date: rate.date, + }) + } + } + + // 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/app/api/vat/validate/__tests__/route.test.ts b/app/api/vat/validate/__tests__/route.test.ts new file mode 100644 index 00000000..77bd2d2c --- /dev/null +++ b/app/api/vat/validate/__tests__/route.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +// Mock Supabase +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +// Mock VIES client +const mockValidateVatNumber = vi.fn() +vi.mock('@/lib/vat/vies-client', () => ({ + validateVatNumber: (...args: unknown[]) => mockValidateVatNumber(...args), +})) + +import { createClient } from '@/lib/supabase/server' +import { POST } from '../route' + +const mockCreateClient = vi.mocked(createClient) + +describe('POST /api/vat/validate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE123456789' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when vat_number is missing', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: {}, + }) + + const res = await POST(req) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(400) + }) + + it('returns 400 when vat_number is too short', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE' }, + }) + + const res = await POST(req) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(400) + }) + + it('returns valid result from VIES', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: true, + name: 'Test GmbH', + address: 'Berlin', + country_code: 'DE', + vat_number: 'DE123456789', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE123456789' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toEqual({ + valid: true, + name: 'Test GmbH', + address: 'Berlin', + country_code: 'DE', + vat_number: 'DE123456789', + }) + }) + + it('returns invalid result from VIES', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: false, + country_code: 'DE', + vat_number: 'DE000000000', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE000000000' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: false }) + }) + + it('updates customer when customer_id provided and valid', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: true, + name: 'Test GmbH', + country_code: 'DE', + vat_number: 'DE123456789', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { + vat_number: 'DE123456789', + customer_id: '550e8400-e29b-41d4-a716-446655440000', + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: true }) + }) + + it('does not update customer when validation fails', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: false, + error: 'Invalid VAT number format', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { + vat_number: 'DE12345', + customer_id: '550e8400-e29b-41d4-a716-446655440000', + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: false }) + }) + + it('handles VIES service error gracefully', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: false, + error: 'Could not verify VAT number. Service temporarily unavailable.', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE123456789' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: false, error: expect.stringContaining('unavailable') }) + }) +}) diff --git a/app/api/vat/validate/route.ts b/app/api/vat/validate/route.ts index 3e98ea4b..5c71d493 100644 --- a/app/api/vat/validate/route.ts +++ b/app/api/vat/validate/route.ts @@ -1,12 +1,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { validateBody } from '@/lib/api/validate' +import { ValidateVatNumberSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' -/** - * Validate EU VAT number using VIES (VAT Information Exchange System) - * - * The EU provides a SOAP-based API, but we'll use a REST wrapper - * In production, you might want to use the official SOAP API or a dedicated service - */ export async function POST(request: Request) { const supabase = await createClient() @@ -16,113 +13,24 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { vat_number, customer_id } = await request.json() + const result = await validateBody(request, ValidateVatNumberSchema) + if (!result.success) return result.response + const { vat_number, customer_id } = result.data - if (!vat_number) { - return NextResponse.json({ error: 'VAT number is required' }, { status: 400 }) - } + const validation = await validateVatNumber(vat_number) - // Extract country code and number - const countryCode = vat_number.substring(0, 2).toUpperCase() - const vatNumber = vat_number.substring(2).replace(/\s/g, '') - - try { - // Use the EU VIES validation API - // Note: In production, you should use the official SOAP API or a reliable service - const response = await fetch( - `https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${countryCode}/vat/${vatNumber}`, - { - method: 'GET', - headers: { - Accept: 'application/json', - }, - } - ) - - if (!response.ok) { - // If VIES is unavailable, return a soft error - return NextResponse.json({ - valid: false, - error: 'VAT validation service unavailable. Please try again later.', + // Update customer record if customer_id provided and VAT is valid + if (customer_id && validation.valid) { + await supabase + .from('customers') + .update({ + vat_number: validation.vat_number, + vat_number_validated: true, + vat_number_validated_at: new Date().toISOString(), }) - } - - const data = await response.json() - - const isValid = data.isValid === true - - // Update customer if customer_id provided - if (customer_id && isValid) { - await supabase - .from('customers') - .update({ - vat_number: vat_number.toUpperCase(), - vat_number_validated: true, - vat_number_validated_at: new Date().toISOString(), - }) - .eq('id', customer_id) - .eq('user_id', user.id) - } - - return NextResponse.json({ - valid: isValid, - name: data.name || null, - address: data.address || null, - country_code: countryCode, - vat_number: vat_number.toUpperCase(), - }) - } catch (error) { - console.error('VAT validation error:', error) - - // Fallback: basic format validation - const isValidFormat = validateVatNumberFormat(countryCode, vatNumber) - - return NextResponse.json({ - valid: false, - error: 'Could not verify VAT number. Service temporarily unavailable.', - format_valid: isValidFormat, - }) + .eq('id', customer_id) + .eq('user_id', user.id) } -} - -/** - * Basic VAT number format validation by country - */ -function validateVatNumberFormat(countryCode: string, vatNumber: string): boolean { - const patterns: Record = { - AT: /^U\d{8}$/, - BE: /^0\d{9}$/, - BG: /^\d{9,10}$/, - CY: /^\d{8}[A-Z]$/, - CZ: /^\d{8,10}$/, - DE: /^\d{9}$/, - DK: /^\d{8}$/, - EE: /^\d{9}$/, - EL: /^\d{9}$/, // Greece - ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/, - FI: /^\d{8}$/, - FR: /^[A-Z0-9]{2}\d{9}$/, - HR: /^\d{11}$/, - HU: /^\d{8}$/, - IE: /^[0-9A-Z]{8,9}$/, - IT: /^\d{11}$/, - LT: /^\d{9,12}$/, - LU: /^\d{8}$/, - LV: /^\d{11}$/, - MT: /^\d{8}$/, - NL: /^\d{9}B\d{2}$/, - PL: /^\d{10}$/, - PT: /^\d{9}$/, - RO: /^\d{2,10}$/, - SE: /^\d{12}$/, - SI: /^\d{8}$/, - SK: /^\d{10}$/, - } - - const pattern = patterns[countryCode] - if (!pattern) { - return false - } - - return pattern.test(vatNumber) + + return NextResponse.json(validation) } diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index e29394fa..a3b7d8b6 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' @@ -74,7 +74,13 @@ export default function DashboardNav({ companyName, entityType, enabledExtension const isOnOvrigtPage = ['/import', '/help', '/settings'].some(p => pathname.startsWith(p)) const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false) const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded - const [isTillaggExpanded, setIsTillaggExpanded] = useState(false) + // Auto-expand Tillägg when on an extension page, or when manually toggled (persisted) + const isOnExtensionPage = pathname.startsWith('/e/') + const [manualTillaggExpanded, setManualTillaggExpanded] = useState(() => { + if (typeof window === 'undefined') return false + return localStorage.getItem('tillagg-expanded') === 'true' + }) + const isTillaggExpanded = isOnExtensionPage || manualTillaggExpanded const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? []) const fetchExtensions = useCallback(async () => { @@ -89,9 +95,16 @@ export default function DashboardNav({ companyName, entityType, enabledExtension } }, []) + // Fetch extensions on mount if Tillägg starts expanded + useEffect(() => { + if (isTillaggExpanded) fetchExtensions() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const toggleTillagg = () => { const next = !isTillaggExpanded - setIsTillaggExpanded(next) + setManualTillaggExpanded(next) + localStorage.setItem('tillagg-expanded', String(next)) if (next) fetchExtensions() } diff --git a/components/extensions/SectorCard.tsx b/components/extensions/SectorCard.tsx index 9394ee75..77a4d4c8 100644 --- a/components/extensions/SectorCard.tsx +++ b/components/extensions/SectorCard.tsx @@ -7,8 +7,8 @@ export default function SectorCard({ sector }: { sector: Sector }) { const Icon = resolveIcon(sector.icon) return ( - - + +
@@ -18,7 +18,7 @@ export default function SectorCard({ sector }: { sector: Sector }) {

{sector.name}

-

{sector.description}

+

{sector.description}

{sector.extensions.length} tillägg

diff --git a/components/extensions/export/CurrencyReceivablesWorkspace.tsx b/components/extensions/export/CurrencyReceivablesWorkspace.tsx new file mode 100644 index 00000000..2cdd1e00 --- /dev/null +++ b/components/extensions/export/CurrencyReceivablesWorkspace.tsx @@ -0,0 +1,715 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import { useMockData } from '@/lib/extensions/use-mock-data' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import MockDataBanner from '@/components/extensions/shared/MockDataBanner' +import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' +import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' +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, FlaskConical, +} 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' + +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'invoiceNumber', label: 'Fakturanummer', required: true }, + { key: 'customerName', label: 'Kund', required: true }, + { key: 'currency', label: 'Valuta', required: true }, + { key: 'foreignAmount', label: 'Belopp (utl. valuta)', required: true }, + { key: 'bookedSekAmount', label: 'Bokfört (SEK)' }, + { key: 'bookedRate', label: 'Bokförd kurs' }, + { key: 'currentSekAmount', label: 'Aktuellt (SEK)' }, + { key: 'currentRate', label: 'Aktuell kurs' }, + { key: 'invoiceDate', label: 'Fakturadatum' }, + { key: 'dueDate', label: 'Förfallodatum' }, +] + +const MOCK_CSV_TEMPLATE = `invoiceNumber;customerName;currency;foreignAmount;bookedSekAmount;bookedRate;currentSekAmount;currentRate;invoiceDate;dueDate +1001;Beispiel GmbH;EUR;10000;112500;11.25;114200;11.42;2025-01-15;2025-02-15 +1002;Example Corp;USD;25000;262500;10.50;260000;10.40;2025-01-20;2025-02-20 +1003;London Ltd;GBP;8000;106400;13.30;108000;13.50;2025-02-01;2025-03-01` + +function parseMockCsvRows(rows: Record[]): ReportData { + const today = new Date().toISOString().slice(0, 10) + const receivables: ForeignReceivable[] = rows.map(r => { + const foreignAmount = parseFloat(r.foreignAmount || '0') || 0 + const bookedRate = parseFloat(r.bookedRate || '0') || 0 + const currentRate = parseFloat(r.currentRate || '0') || bookedRate + const bookedSek = parseFloat(r.bookedSekAmount || '0') || Math.round(foreignAmount * bookedRate * 100) / 100 + const currentSek = parseFloat(r.currentSekAmount || '0') || Math.round(foreignAmount * currentRate * 100) / 100 + const invoiceDate = r.invoiceDate || today + const dueDate = r.dueDate || today + const daysOutstanding = Math.max(0, Math.floor((Date.now() - new Date(invoiceDate).getTime()) / 86400000)) + + return { + invoiceId: r.invoiceNumber || '', + invoiceNumber: r.invoiceNumber || '', + customerName: r.customerName || '', + customerCountry: '', + currency: r.currency || 'EUR', + foreignAmount, + bookedSekAmount: bookedSek, + bookedRate, + currentSekAmount: currentSek, + currentRate, + unrealizedGainLoss: Math.round((currentSek - bookedSek) * 100) / 100, + invoiceDate, + dueDate, + daysOutstanding, + } + }) + + // Group by currency for exposure + const currencyMap = new Map() + for (const r of receivables) { + const existing = currencyMap.get(r.currency) + if (existing) { + existing.totalForeignAmount += r.foreignAmount + existing.bookedSekValue += r.bookedSekAmount + existing.currentSekValue += r.currentSekAmount + existing.unrealizedGainLoss += r.unrealizedGainLoss + existing.invoiceCount++ + } else { + currencyMap.set(r.currency, { + currency: r.currency, + totalForeignAmount: r.foreignAmount, + bookedSekValue: r.bookedSekAmount, + currentSekValue: r.currentSekAmount, + unrealizedGainLoss: r.unrealizedGainLoss, + invoiceCount: 1, + averageBookedRate: r.bookedRate, + currentRate: r.currentRate, + }) + } + } + + const exposureByCurrency = Array.from(currencyMap.values()) + const totalBookedSek = receivables.reduce((s, r) => s + r.bookedSekAmount, 0) + const totalCurrentSek = receivables.reduce((s, r) => s + r.currentSekAmount, 0) + const totalUnrealized = Math.round((totalCurrentSek - totalBookedSek) * 100) / 100 + + return { + referenceDate: today, + exchangeRates: exposureByCurrency.map(e => ({ currency: e.currency, rate: e.currentRate, date: today })), + exposureByCurrency, + receivables, + realizedGainLoss: { year: new Date().getFullYear(), gains: 0, losses: 0, net: 0 }, + monthlyTrend: [], + revalPreview: { + totalUnrealizedGainLoss: totalUnrealized, + gains: Math.max(0, totalUnrealized), + losses: Math.abs(Math.min(0, totalUnrealized)), + }, + totals: { + bookedSekValue: totalBookedSek, + currentSekValue: totalCurrentSek, + totalUnrealizedGainLoss: totalUnrealized, + receivableCount: receivables.length, + currencyCount: exposureByCurrency.length, + }, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.receivables) && !Array.isArray(obj.exposureByCurrency)) { + return { valid: false, error: 'Fältet "receivables" eller "exposureByCurrency" saknas' } + } + if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } + return { valid: true } +} + +// ── Component ───────────────────────────────────────────────── + +export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'currency-receivables') + const [importDialogOpen, setImportDialogOpen] = useState(false) + + 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 () => { + if (isMockActive && mockReport) { + setReport(mockReport) + setIsLoading(false) + setRefreshing(false) + return + } + + 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, isMockActive, mockReport]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { + await saveMockData(data, meta) + setReport(data) + }, [saveMockData]) + + const handleMockClear = useCallback(async () => { + await clearMockData() + setReport(null) + setIsLoading(true) + 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() + setReport(json.data) + } + } catch { /* ignore */ } + setIsLoading(false) + }, [clearMockData, year]) + + 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 || mockLoading) && !report) { + return + } + + return ( +
+ {/* ── Header ─────────────────────────────────────── */} +
+
+ + +
+
+ + +
+
+ + {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + + {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. +

+
+
+
+
+ )} + + )} + + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="currency-receivables-template.csv" + onImport={handleMockImport} + /> +
+ ) +} + +// ── 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..507d98ea --- /dev/null +++ b/components/extensions/export/EuSalesListWorkspace.tsx @@ -0,0 +1,777 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import { useMockData } from '@/lib/extensions/use-mock-data' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import MockDataBanner from '@/components/extensions/shared/MockDataBanner' +import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' +import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' +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, + FlaskConical, +} 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) +} + +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'customerVatNumber', label: 'VAT-nummer', required: true }, + { key: 'customerName', label: 'Kundnamn', required: true }, + { key: 'customerCountry', label: 'Land', required: true }, + { key: 'goodsAmount', label: 'Varor (SEK)' }, + { key: 'servicesAmount', label: 'Tjänster (SEK)' }, + { key: 'triangulationAmount', label: 'Trepartshandel (SEK)' }, + { key: 'invoiceCount', label: 'Antal fakturor' }, +] + +const MOCK_CSV_TEMPLATE = `customerVatNumber;customerName;customerCountry;goodsAmount;servicesAmount;triangulationAmount;invoiceCount +DE123456789;Beispiel GmbH;DE;150000;25000;0;3 +FR987654321;Exemple SARL;FR;0;80000;0;2 +NL456789012;Voorbeeld BV;NL;45000;0;12000;1` + +function parseMockCsvRows(rows: Record[]): ReportData { + const lines: ECSalesListLine[] = rows.map(r => ({ + customerVatNumber: r.customerVatNumber || '', + customerName: r.customerName || '', + customerCountry: r.customerCountry || '', + customerId: r.customerVatNumber || '', + goodsAmount: parseFloat(r.goodsAmount || '0') || 0, + servicesAmount: parseFloat(r.servicesAmount || '0') || 0, + triangulationAmount: parseFloat(r.triangulationAmount || '0') || 0, + invoiceCount: parseInt(r.invoiceCount || '1', 10) || 1, + })) + + const goods = lines.reduce((s, l) => s + l.goodsAmount, 0) + const services = lines.reduce((s, l) => s + l.servicesAmount, 0) + const triangulation = lines.reduce((s, l) => s + l.triangulationAmount, 0) + const invoiceCount = lines.reduce((s, l) => s + l.invoiceCount, 0) + + return { + period: { year: new Date().getFullYear(), quarter: Math.ceil((new Date().getMonth() + 1) / 3) }, + filingType: 'quarterly', + reporterVatNumber: 'SE000000000001', + reporterName: 'Testdata', + lines, + totals: { goods, services, triangulation, total: goods + services + triangulation }, + warnings: [], + crossCheck: null, + invoiceCount, + customerCount: lines.length, + deadline: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), + daysUntilDeadline: 30, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' } + if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } + return { valid: true } +} + +// ── Component ───────────────────────────────────────────────── + +export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'eu-sales-list') + const [importDialogOpen, setImportDialogOpen] = useState(false) + + // 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 () => { + if (isMockActive && mockReport) { + setReport(mockReport) + setIsLoading(false) + return + } + + 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, isMockActive, mockReport]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { + await saveMockData(data, meta) + setReport(data) + }, [saveMockData]) + + const handleMockClear = useCallback(async () => { + await clearMockData() + setReport(null) + // Re-fetch from API + 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() + setReport(json.data) + } + } catch { /* ignore */ } + setIsLoading(false) + }, [clearMockData, year, month, quarter, periodType]) + + // 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 || mockLoading) && !report) { + return + } + + return ( +
+ {/* ── Period Selector ─────────────────────────────────── */} +
+
+ + +
+ +
+ + +
+ +
+ + {periodType === 'monthly' ? ( + + ) : ( + + )} +
+ + {/* Download + Import buttons */} +
+ + + +
+
+ + {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + + {/* ── 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]}`} + +
+ + )} + + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="eu-sales-list-template.csv" + onImport={handleMockImport} + /> +
+ ) +} + +// ── 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..22bda841 --- /dev/null +++ b/components/extensions/export/IntrastatWorkspace.tsx @@ -0,0 +1,747 @@ +'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 { useMockData } from '@/lib/extensions/use-mock-data' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import MockDataBanner from '@/components/extensions/shared/MockDataBanner' +import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' +import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' +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, FlaskConical, +} 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', +} + +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'cnCode', label: 'CN-kod', required: true }, + { key: 'partnerCountry', label: 'Partnerland', required: true }, + { key: 'countryOfOrigin', label: 'Ursprungsland' }, + { key: 'transactionNature', label: 'Transaktionstyp' }, + { key: 'deliveryTerms', label: 'Leveransvillkor' }, + { key: 'invoicedValue', label: 'Fakturerat värde (SEK)', required: true }, + { key: 'netMass', label: 'Nettovikt (kg)' }, + { key: 'partnerVatId', label: 'Partner VAT-ID' }, +] + +const MOCK_CSV_TEMPLATE = `cnCode;partnerCountry;countryOfOrigin;transactionNature;deliveryTerms;invoicedValue;netMass;partnerVatId +72163100;DE;SE;11;DAP;245000;4500;DE123456789 +84713000;FR;CN;11;EXW;128000;85;FR987654321 +39269090;NL;SE;11;FCA;67000;320;NL456789012` + +function parseMockCsvRows(rows: Record[]): ReportData { + const lines: IntrastatLine[] = rows.map(r => ({ + cnCode: r.cnCode || '00000000', + partnerCountry: r.partnerCountry || '', + countryOfOrigin: r.countryOfOrigin || 'SE', + transactionNature: r.transactionNature || '11', + deliveryTerms: r.deliveryTerms || 'DAP', + invoicedValue: parseFloat(r.invoicedValue || '0') || 0, + netMass: parseFloat(r.netMass || '0') || 0, + supplementaryUnit: null, + supplementaryUnitType: null, + partnerVatId: r.partnerVatId || '', + })) + + const invoicedValue = lines.reduce((s, l) => s + l.invoicedValue, 0) + const netMass = lines.reduce((s, l) => s + l.netMass, 0) + + return { + period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 }, + reporterVatNumber: 'SE000000000001', + reporterName: 'Testdata', + lines, + totals: { invoicedValue, netMass, lineCount: lines.length }, + thresholdStatus: { + cumulativeValue: invoicedValue, + threshold: 9000000, + isObligated: invoicedValue >= 9000000, + percentageUsed: Math.round(invoicedValue / 9000000 * 100), + }, + warnings: [], + invoiceCount: lines.length, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' } + if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } + return { valid: true } +} + +// ── Component ───────────────────────────────────────────────── + +export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'intrastat') + const [importDialogOpen, setImportDialogOpen] = useState(false) + + 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 () => { + if (isMockActive && mockReport) { + setReport(mockReport) + setIsLoading(false) + return + } + + 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, isMockActive, mockReport]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { + await saveMockData(data, meta) + setReport(data) + }, [saveMockData]) + + const handleMockClear = useCallback(async () => { + await clearMockData() + setReport(null) + setIsLoading(true) + 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() + setReport(json.data) + } + } catch { /* ignore */ } + setIsLoading(false) + }, [clearMockData, year, month]) + + // 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 || mockLoading) && !report) { + return + } + + return ( +
+ {/* ── Period Selector ─────────────────────────────────── */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + + {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 + +
+ + )} + + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="intrastat-template.csv" + onImport={handleMockImport} + /> + + {/* ── 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..2cccc7e3 --- /dev/null +++ b/components/extensions/export/VatMonitorWorkspace.tsx @@ -0,0 +1,656 @@ +'use client' + +import { useState, useEffect, useMemo, useCallback } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import { useMockData } from '@/lib/extensions/use-mock-data' +import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' +import MockDataBanner from '@/components/extensions/shared/MockDataBanner' +import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' +import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' +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, FlaskConical, +} 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'] + +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'boxNumber', label: 'Ruta', required: true }, + { key: 'label', label: 'Beskrivning', required: true }, + { key: 'amount', label: 'Belopp (SEK)', required: true }, + { key: 'accounts', label: 'Konton (kommaseparerade)' }, +] + +const MOCK_CSV_TEMPLATE = `boxNumber;label;amount;accounts +05;Momspliktiga intäkter;500000;3001,3002,3003 +10;Utgående moms 25%;100000;2611 +11;Utgående moms 12%;6000;2621 +12;Utgående moms 6%;3000;2631 +35;Varuförsäljning EU;75000;3305 +36;Tjänsteförsäljning EU;45000;3308 +38;Exportförsäljning;30000;3305 +39;Omvänd skattskyldighet;20000; +40;Inköp varor EU;60000; +48;Ingående moms;65000;2641 +49;Moms att betala;44000;` + +function parseMockCsvRows(rows: Record[]): ReportData { + const boxes: VatBoxData[] = rows.map(r => ({ + boxNumber: r.boxNumber || '', + label: r.label || '', + amount: parseFloat(r.amount || '0') || 0, + accounts: r.accounts ? r.accounts.split(',').map(a => a.trim()) : [], + })) + + // Derive revenue breakdown from box values + const getBox = (num: string) => boxes.find(b => b.boxNumber === num)?.amount || 0 + const domestic = getBox('05') + const euGoods = getBox('35') + const euServices = getBox('36') + const exportGoods = getBox('38') + const exportServices = 0 + const triangular = getBox('39') + const totalRevenue = domestic + euGoods + euServices + exportGoods + exportServices + triangular + + const revenueBreakdown: RevenueBreakdown = { + domestic: { amount: domestic, percentage: totalRevenue > 0 ? Math.round(domestic / totalRevenue * 100) : 0 }, + euGoods: { amount: euGoods, percentage: totalRevenue > 0 ? Math.round(euGoods / totalRevenue * 100) : 0 }, + euServices: { amount: euServices, percentage: totalRevenue > 0 ? Math.round(euServices / totalRevenue * 100) : 0 }, + exportGoods: { amount: exportGoods, percentage: totalRevenue > 0 ? Math.round(exportGoods / totalRevenue * 100) : 0 }, + exportServices: { amount: exportServices, percentage: 0 }, + triangular: { amount: triangular, percentage: totalRevenue > 0 ? Math.round(triangular / totalRevenue * 100) : 0 }, + totalRevenue, + } + + const outputVat25 = getBox('10') + const outputVat12 = getBox('11') + const outputVat6 = getBox('12') + const inputVat = getBox('48') + const netVat = getBox('49') + + return { + period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 }, + boxes, + revenueBreakdown, + vatSummary: { + outputVat25, + outputVat12, + outputVat6, + totalOutputVat: outputVat25 + outputVat12 + outputVat6, + inputVat, + netVat, + isRefund: netVat < 0, + }, + warnings: [], + comparison: null, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.boxes)) return { valid: false, error: 'Fältet "boxes" saknas eller är inte en array' } + if (!obj.revenueBreakdown || typeof obj.revenueBreakdown !== 'object') { + return { valid: false, error: 'Fältet "revenueBreakdown" saknas' } + } + if (!obj.vatSummary || typeof obj.vatSummary !== 'object') { + return { valid: false, error: 'Fältet "vatSummary" saknas' } + } + return { valid: true } +} + +// ── Component ───────────────────────────────────────────────── + +export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) { + void userId + + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'vat-monitor') + const [importDialogOpen, setImportDialogOpen] = useState(false) + + 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 () => { + if (isMockActive && mockReport) { + setReport(mockReport) + setIsLoading(false) + return + } + + 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, isMockActive, mockReport]) + + useEffect(() => { + fetchReport() + }, [fetchReport]) + + const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { + await saveMockData(data, meta) + setReport(data) + }, [saveMockData]) + + const handleMockClear = useCallback(async () => { + await clearMockData() + setReport(null) + 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() + setReport(json.data) + } + } catch { /* ignore */ } + setIsLoading(false) + }, [clearMockData, year, month, quarter, periodType, compareEnabled]) + + 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 || mockLoading) && !report) { + return + } + + return ( +
+ {/* ── Period Selector ─────────────────────────────────── */} +
+
+ + +
+ +
+ + +
+ +
+ + {periodType === 'monthly' ? ( + + ) : ( + + )} +
+ +
+ + +
+
+ + {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + + {/* ── 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]}`} + +
+ + )} + + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="vat-monitor-template.csv" + onImport={handleMockImport} + /> +
+ ) +} + +// ── 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/components/extensions/shared/MockDataBanner.tsx b/components/extensions/shared/MockDataBanner.tsx new file mode 100644 index 00000000..17470f1d --- /dev/null +++ b/components/extensions/shared/MockDataBanner.tsx @@ -0,0 +1,49 @@ +'use client' + +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { FlaskConical, X, Replace } from 'lucide-react' + +interface MockDataBannerProps { + importedAt: string | null + onClear: () => void + onReplace: () => void +} + +export default function MockDataBanner({ importedAt, onClear, onReplace }: MockDataBannerProps) { + const formatted = importedAt + ? new Date(importedAt).toLocaleString('sv-SE', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + : null + + return ( + + +
+ +
+

+ Testdata aktivt +

+

+ Rapporten visar importerad testdata istället för bokföringsdata. + {formatted && <> Importerat {formatted}.} +

+
+
+ + +
+
+
+
+ ) +} diff --git a/components/extensions/shared/MockDataImportDialog.tsx b/components/extensions/shared/MockDataImportDialog.tsx new file mode 100644 index 00000000..b5acbc6e --- /dev/null +++ b/components/extensions/shared/MockDataImportDialog.tsx @@ -0,0 +1,404 @@ +'use client' + +import { useState, useCallback, useRef } from 'react' +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { + Upload, FileJson, FileSpreadsheet, Download, AlertCircle, Check, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +// ── Types ───────────────────────────────────────────────────── + +export interface CsvFieldDef { + key: string + label: string + required?: boolean +} + +interface MockDataImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + csvFields: CsvFieldDef[] + defaultMappings?: Record + parseCsvRows: (rows: Record[]) => T + validateReport: (data: unknown) => { valid: boolean; error?: string } + templateCsvContent: string + templateFileName: string + onImport: (report: T, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => Promise +} + +function parseCsv(text: string): { headers: string[]; rows: string[][] } { + const lines = text.split(/\r?\n/).filter(line => line.trim()) + if (lines.length === 0) return { headers: [], rows: [] } + + const separator = lines[0].includes(';') ? ';' : ',' + const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1')) + const rows = lines.slice(1).map(line => + line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1')) + ) + return { headers, rows } +} + +// ── Component ───────────────────────────────────────────────── + +type Step = 'upload' | 'map-csv' | 'preview-json' | 'importing' + +export default function MockDataImportDialog({ + open, + onOpenChange, + csvFields, + defaultMappings, + parseCsvRows, + validateReport, + templateCsvContent, + templateFileName, + onImport, +}: MockDataImportDialogProps) { + const [step, setStep] = useState('upload') + const [isDragging, setIsDragging] = useState(false) + const [error, setError] = useState(null) + const fileInputRef = useRef(null) + + // CSV state + const [csvHeaders, setCsvHeaders] = useState([]) + const [csvRows, setCsvRows] = useState([]) + const [mappings, setMappings] = useState>({}) + const [fileName, setFileName] = useState('') + + // JSON state + const [jsonReport, setJsonReport] = useState(null) + const [jsonSummary, setJsonSummary] = useState('') + + const reset = useCallback(() => { + setStep('upload') + setError(null) + setCsvHeaders([]) + setCsvRows([]) + setMappings({}) + setFileName('') + setJsonReport(null) + setJsonSummary('') + setIsDragging(false) + }, []) + + const handleOpenChange = useCallback((open: boolean) => { + if (!open) reset() + onOpenChange(open) + }, [onOpenChange, reset]) + + const processFile = useCallback((file: File) => { + setError(null) + setFileName(file.name) + + const reader = new FileReader() + reader.onload = (ev) => { + const text = ev.target?.result as string + + if (file.name.endsWith('.json')) { + // JSON path + try { + const parsed = JSON.parse(text) + const validation = validateReport(parsed) + if (!validation.valid) { + setError(validation.error || 'Ogiltig JSON-struktur') + return + } + setJsonReport(parsed as T) + + // Build summary + const keys = Object.keys(parsed) + const lines = Array.isArray(parsed.lines) ? parsed.lines.length + : Array.isArray(parsed.receivables) ? parsed.receivables.length + : Array.isArray(parsed.boxes) ? parsed.boxes.length + : null + setJsonSummary( + `${keys.length} fält` + (lines !== null ? `, ${lines} rader` : '') + ) + setStep('preview-json') + } catch { + setError('Kunde inte tolka JSON-filen. Kontrollera formatet.') + } + } else { + // CSV path + const parsed = parseCsv(text) + if (parsed.headers.length === 0 || parsed.rows.length === 0) { + setError('Ingen data hittades i CSV-filen.') + return + } + + setCsvHeaders(parsed.headers) + setCsvRows(parsed.rows) + + // Auto-map columns + const autoMappings: Record = {} + for (const field of csvFields) { + const defaultCol = defaultMappings?.[field.key] + if (defaultCol && parsed.headers.includes(defaultCol)) { + autoMappings[field.key] = defaultCol + } else { + const match = parsed.headers.find( + h => h.toLowerCase() === field.key.toLowerCase() || + h.toLowerCase() === field.label.toLowerCase() + ) + if (match) autoMappings[field.key] = match + } + } + setMappings(autoMappings) + setStep('map-csv') + } + } + reader.readAsText(file) + }, [csvFields, defaultMappings, validateReport]) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) processFile(file) + }, [processFile]) + + const handleFileInput = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) processFile(file) + }, [processFile]) + + const handleCsvImport = useCallback(async () => { + setStep('importing') + setError(null) + + try { + const mappedRows = csvRows.map(row => { + const obj: Record = {} + for (const [fieldKey, csvCol] of Object.entries(mappings)) { + const colIdx = csvHeaders.indexOf(csvCol) + if (colIdx >= 0 && row[colIdx]) { + obj[fieldKey] = row[colIdx] + } + } + return obj + }).filter(row => Object.keys(row).length > 0) + + const report = parseCsvRows(mappedRows) + await onImport(report, { source: 'csv', fileName, rowCount: mappedRows.length }) + handleOpenChange(false) + } catch (e) { + setError(e instanceof Error ? e.message : 'Import misslyckades') + setStep('map-csv') + } + }, [csvRows, csvHeaders, mappings, parseCsvRows, onImport, fileName, handleOpenChange]) + + const handleJsonImport = useCallback(async () => { + if (!jsonReport) return + setStep('importing') + setError(null) + + try { + await onImport(jsonReport, { source: 'json', fileName, rowCount: 0 }) + handleOpenChange(false) + } catch (e) { + setError(e instanceof Error ? e.message : 'Import misslyckades') + setStep('preview-json') + } + }, [jsonReport, onImport, fileName, handleOpenChange]) + + const downloadTemplate = useCallback(() => { + const blob = new Blob([templateCsvContent], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = templateFileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + }, [templateCsvContent, templateFileName]) + + const requiredFieldsMapped = csvFields + .filter(f => f.required) + .every(f => mappings[f.key]) + + return ( + + + + + {step === 'upload' && 'Importera testdata'} + {step === 'map-csv' && 'Kolumnmappning'} + {step === 'preview-json' && 'Förhandsgranska JSON'} + {step === 'importing' && 'Importerar...'} + + + + {/* ── Error ─────────────────────────────────────── */} + {error && ( +
+ + {error} +
+ )} + + {/* ── Step: Upload ──────────────────────────────── */} + {step === 'upload' && ( +
+
{ e.preventDefault(); setIsDragging(true) }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + +

+ Dra och släpp en fil här +

+

+ CSV (.csv) eller JSON (.json) +

+ + +
+ +
+ +
+
+ )} + + {/* ── Step: Map CSV ─────────────────────────────── */} + {step === 'map-csv' && ( +
+
+ + {fileName} — {csvRows.length} rader +
+ +
+ {csvFields.map(field => ( +
+ + +
+ ))} +
+ + {/* Preview first 5 rows */} + {csvRows.length > 0 && ( +
+ + + + {csvHeaders.map(h => ( + {h} + ))} + + + + {csvRows.slice(0, 5).map((row, i) => ( + + {row.map((cell, j) => ( + {cell} + ))} + + ))} + +
+
+ )} + + + + + +
+ )} + + {/* ── Step: Preview JSON ────────────────────────── */} + {step === 'preview-json' && ( +
+
+ + {fileName} +
+ +
+ + Giltig JSON — {jsonSummary} +
+ + + + + +
+ )} + + {/* ── Step: Importing ───────────────────────────── */} + {step === 'importing' && ( +
+
+

Importerar testdata...

+
+ )} + +
+ ) +} diff --git a/export-biz-extension.md b/export-biz-extension.md new file mode 100644 index 00000000..3164d848 --- /dev/null +++ b/export-biz-extension.md @@ -0,0 +1,1153 @@ +# Export Business Extensions — Complete Idea Registry + +> **Sector slug**: `export` +> **Target user**: Small Swedish businesses (enskild firma & aktiebolag) that physically export goods from Sweden to EU and non-EU markets. +> **Constraint note**: Extensions primarily read core bookkeeping data. Some ideas below involve journal entry creation (revaluation, FX gain/loss) — these would require extending the extension permission model or routing through the core engine. + +--- + +## Research Summary + +### Key pain points for Swedish exporters +1. **VAT classification complexity** — Domestic (25/12/6%), EU B2B reverse charge (0%), EU B2C (OSS), non-EU export (0%) — each with different momsdeklaration boxes, documentation requirements, and penalty exposure. +2. **Currency management** — Three accounting moments per foreign invoice (invoice date, payment date, period-end revaluation). Most small exporters get this wrong. +3. **Multiple reporting obligations** — Momsdeklaration + Periodisk sammanställning (EC Sales List) + Intrastat (if >SEK 12M dispatches) + Tullverket customs declarations — all with different deadlines and formats. +4. **No market-level profitability visibility** — Same product shipped to Norway vs. USA has wildly different true margins after freight, customs, insurance, and FX costs. +5. **Missing export documentation = retroactive tax** — Non-EU zero-rating denied without proof of export (Tullverket EAD, CMR, bill of lading). 25% VAT + 20% penalty. + +### Relevant BAS accounts +| Account | Description | Momsdeklaration box | +|---------|-------------|-------------------| +| `3001` | Revenue goods 25% (domestic) | Box 05 | +| `3002` | Revenue goods 12% (domestic) | Box 05 | +| `3003` | Revenue goods 6% (domestic) | Box 05 | +| `3105` | Goods export outside EU | Box 36 | +| `3108` | Goods to EU B2B (reverse charge) | Box 35 | +| `3305` | Services outside EU | Box 40 | +| `3308` | Services to EU B2B (reverse charge) | Box 39 | +| `3109` | Triangular trade sales (trepartshandel) | Box 38 | +| `3521` | Invoiced freight, EU | Follows goods | +| `3522` | Invoiced freight, export | Box 36 | +| `3960` | FX gains (operating) | — | +| `7960` | FX losses (operating) | — | +| `3969` | Unrealized FX gains | — | +| `7969` | Unrealized FX losses | — | +| `2614` | Output VAT reverse charge 25% | — | +| `2641` | Deductible input VAT | Box 48 | +| `2645` | Calculated input VAT (EU acquisitions) | Box 48 | +| `5710` | Freight, transport, insurance | — | +| `5720` | Customs and forwarding costs | — | +| `6320` | Insurance costs | — | + +### Regulatory references +- **Mervärdesskattelagen (ML)** — Swedish VAT Act +- **Bokföringslagen (BFL)** — Swedish Bookkeeping Act (7-year retention) +- **VIES** — EU VAT Information Exchange System (free API for VAT number validation) +- **Riksbanken** — Daily exchange rates (REST API) +- **SCB Intrastat** — Monthly EU trade statistics (IDEP.web, transitioning to new platform 2026) +- **Skatteverket** — Momsdeklaration, Periodisk sammanställning (e-filing) +- **Tullverket** — Export declarations, EAD (Export Accompanying Document) + +--- + +## All Extension Ideas + +### IDEA 1: Export VAT Autopilot + +**Problem**: Every time an exporter creates an invoice to a customer in another EU country, they must manually determine the correct VAT treatment. Get it wrong and Skatteverket can deny the 0% rate or the customer can't deduct VAT. + +**What it does**: +- When creating an invoice, the user enters or selects the customer's country and VAT number (momsregistreringsnummer). +- The system automatically validates the VAT number via the EU VIES database (free API: https://ec.europa.eu/taxation_customs/vies/). +- Based on validated status + destination + goods vs. services, the system auto-applies the correct VAT treatment: + - **Intra-community supply (B2B, goods to EU)**: 0% VAT, auto-adds text "Omvänd skattskyldighet" and the legal reference. + - **Export outside EU**: 0% VAT, auto-adds "Export" and prompts for customs documentation. + - **B2C to EU (distance sale)**: Checks if OSS threshold (EUR 10,000) is exceeded, alerts if VAT registration in destination country may be needed. + - **Domestic**: Standard 25% / 12% / 6% as normal. +- Auto-books to the correct BAS account (e.g., 3108 for EU goods sales at 0%). +- Stores proof of transport (CMR, bill of lading reference) linked to the invoice — critical for defending the 0% rate in an audit. + +**Data pattern**: `both` (reads core invoice/customer data + stores VIES validation results and document references) + +**Why it wins**: Fortnox and Visma leave this entirely to the user. This extension eliminates the #1 compliance risk for exporters. + +**Note**: This idea touches core invoice creation flow. May require hooks into the invoice form rather than being a standalone workspace. Could also be implemented as a validation/enrichment layer that runs when invoices are created. + +--- + +### IDEA 2: Intrastat Generator + +**Problem**: Swedish companies dispatching goods worth >SEK 12M/year (threshold raised from 4.5M in 2025) to other EU countries must file monthly Intrastat reports to SCB. Currently done manually in Excel or IDEP.web. + +**What it does**: +- Each product in the system can be tagged with: CN commodity code (8-digit), net weight (kg), country of origin, and supplementary unit (pieces, liters, etc.). +- When an invoice is booked for an intra-community dispatch, the system automatically captures: commodity code, invoice value (in SEK), net weight, destination EU country, transaction nature code, delivery terms. +- At month end, generates a complete Intrastat dispatch declaration in the format accepted by SCB's IDEP.WEB (CSV/XML upload). +- Tracks cumulative dispatch value against the SEK 12M threshold and alerts when the company becomes obligated. +- Handles corrections: if a credit note is issued, generates a correction entry for the relevant month. + +**Data pattern**: `both` (reads core invoice data + stores product metadata: CN codes, weights, origin) + +**Required data fields per Intrastat line**: +| Field | Source | +|-------|--------| +| CN commodity code (8-digit) | Manual entry per product | +| Partner country (2-letter ISO) | From customer/invoice | +| Transaction nature code | From invoice type | +| Net mass (kg) | Manual entry per product | +| Supplementary unit | Manual entry per product (if required by CN code) | +| Invoiced value (SEK) | From invoice | +| Country of origin | Manual entry per product | +| Partner VAT ID | From customer | +| Delivery terms (Incoterms) | Manual entry per order | + +**Why it wins**: Pure pain for every exporting SME above the threshold. No Swedish bookkeeping system below ERP-level (SAP, Dynamics) does this well. Compelling reason to switch to erp-base. + +--- + +### IDEA 3: Multi-Currency Receivables Manager + +**Problem**: An exporter invoicing in EUR has open receivables whose SEK value fluctuates daily. At period end, these must be revalued. When payment arrives, there's an FX gain or loss to book. This is messy in current systems. + +**What it does**: +- Invoices can be created in any currency (EUR, USD, NOK, DKK, GBP, etc.) with the exchange rate auto-fetched from Riksbanken's daily rates. +- Open receivables dashboard showing: original amount, original SEK value, current SEK value, unrealized FX gain/loss — per customer and per currency. +- Period-end revaluation button: recalculates all open foreign-currency receivables at the closing rate and generates the required journal entries (BAS 3960 Valutakursvinster / 7960 Valutakursförluster / 3969 / 7969). +- Payment matching in foreign currency: When a EUR payment arrives, matches to EUR invoices and auto-calculates realized FX gain/loss, booking it to the correct BAS accounts. +- FX exposure summary: Shows total outstanding per currency — useful for deciding whether to hedge. + +**Data pattern**: `both` (reads core invoices/transactions + may create journal entries for revaluation) + +**Note**: The revaluation and payment-matching features involve journal entry creation, which currently isn't within extension permissions. Options: (a) route through core engine API, (b) generate draft entries for user approval, (c) expand extension capabilities. + +**Why it wins**: Fortnox handles basic multi-currency but the revaluation and FX gain/loss workflow is manual. This makes it automated and audit-ready. + +--- + +### IDEA 4: EU Sales List / Periodisk Sammanställning Auto-Reporter + +**Problem**: Every Swedish company making intra-community B2B supplies must file a quarterly (or monthly) EU sales list (periodisk sammanställning) to Skatteverket, listing each EU customer's VAT number and total value of supplies. + +**What it does**: +- Automatically compiles all 0%-rated intra-community invoices for the period. +- Groups by customer VAT number and destination country. +- Separates goods (momsdeklaration box 35) from services (box 39). +- Generates the report in Skatteverket's required format (XML for e-filing). +- Cross-references with VIES validation to catch invalid VAT numbers before filing. +- Handles credit notes (reduces the reported value for that customer). +- Alerts if any intra-community invoice is missing a validated VAT number. +- Cross-validates: total in this report should match box 35 + box 39 on the momsdeklaration — flags discrepancies. +- Filing deadline countdown with alerts. + +**Data pattern**: `core` (reads invoices + customer data, no manual data entry needed) + +**Filing frequency**: +| Type | Default | Reduced (if **Decision**: VAT Autopilot deferred (requires core invoice flow changes). 4 extensions is enough for a strong v1. +> **Language**: Swedish UI with English for international trade terms (Incoterms, VIES, CN codes, FOB/CIF). +> **Data**: All 4 extensions work with existing core schema — no database migrations needed for v1. +> **Product metadata**: Stored in `extension_data` table (isolated to Intrastat extension). + +### 1. EU Sales List / Periodisk Sammanställning (Idea 4) — Score: 9.00 +- Mandatory reporting, saves real hours, pure read-only, high standalone value +- Generates downloadable CSV/XML file for upload to Skatteverket +- Immediately useful to every exporter with EU B2B sales + +### 2. Export VAT Monitor / Exportmoms-monitor (Idea 7) — Score: 8.55 +- Post-hoc VAT analysis dashboard, 100% feasible within current architecture +- Maps revenue to momsdeklaration boxes, catches errors before filing + +### 3. Intrastat Generator (Idea 2) — Score: 8.30 +- No competing tool in the SME segment, generates SCB-compatible files +- Requires manual product metadata (stored in extension_data) but delivers massive time savings + +### 4. Multi-Currency Receivables Manager / Valutafordringar (Idea 3) — Score: 7.75 +- Dashboard portion (exposure by currency + unrealized gain/loss) is pure read-only +- Core schema already has full multi-currency support (currency, exchange_rate, total_sek on invoices) +- Journal entry generation for revaluation deferred to future phase + +### Deferred +- **Export VAT Autopilot** (Idea 1) — Deferred. Requires modifying core invoice creation flow, which violates the "extensions don't modify core data" constraint. Keep in this document for future consideration. + +### Future Phase +- **Export Document Center** (Idea 5) — Proforma invoices, packing lists, document archive +- **Freight Cost Allocator** (Idea 6) + **Market Profitability** (Idea 8) — Could merge into "Export Profitability" +- **Compliance Tracker** (Idea 9) — Partially covered by the 4 selected extensions combined + +--- + +## Core Schema Findings + +The existing schema already supports everything we need: + +**Invoice type** (`types/index.ts`): +- `currency: Currency` — EUR, USD, GBP, NOK, DKK, SEK +- `exchange_rate: number | null` — rate at invoice date +- `subtotal` / `subtotal_sek` — original and SEK amounts +- `total` / `total_sek` — original and SEK amounts +- `vat_treatment: VatTreatment` — includes `reverse_charge`, `export`, `exempt` +- `moms_ruta: string | null` — momsdeklaration box (05, 35, 36, 39, 40) + +**Customer type** (`types/index.ts`): +- `country: string` — ISO country code +- `vat_number: string | null` +- `vat_number_validated: boolean` +- `vat_number_validated_at: string | null` +- `customer_type: CustomerType` + +**JournalEntryLine type** (`types/index.ts`): +- `currency: string` +- `amount_in_currency: number | null` +- `exchange_rate: number | null` +- `account_number: string` — BAS account (3105, 3108, 3305, 3308, etc.) + +**Transaction type** (`types/index.ts`): +- `currency: Currency` +- `amount_sek: number | null` +- `exchange_rate: number | null` + +--- + +## Implementation Plan + +### Phase 0: Sector Registration & Shared Infrastructure + +**Goal**: Register the export sector, create shared components, set up the extension folder structure. + +#### 0.1 Create folder structure +``` +extensions/ + export/ + eu-sales-list/ + index.ts # Extension definition + lib/ + eu-sales-list-engine.ts # Core logic: aggregate, validate, generate file + vies-validator.ts # VIES API VAT number validation (shared utility) + skv-xml-generator.ts # Skatteverket XML format generation + vat-monitor/ + index.ts + lib/ + vat-monitor-engine.ts # GL account reading, box mapping, validation + intrastat/ + index.ts + lib/ + intrastat-engine.ts # Data aggregation, CN code management + scb-file-generator.ts # SCB IDEP.web compatible CSV/XML + currency-receivables/ + index.ts + lib/ + receivables-engine.ts # Exposure calc, unrealized gain/loss + riksbanken-rates.ts # Daily rate fetching (extend existing lib/currency/) +``` + +#### 0.2 Register sector in `lib/extensions/sectors.ts` +```typescript +{ + slug: 'export', + name: 'Export & Utrikeshandel', + icon: 'Ship', + description: 'Verktyg för svenska företag som exporterar varor till EU och övriga världen', + extensions: [ + { + slug: 'eu-sales-list', + name: 'Periodisk sammanställning', + sector: 'export', + category: 'accounting', + icon: 'FileText', + dataPattern: 'core', + readsCoreTables: ['invoices', 'customers'], + hasOwnData: false, + description: 'Generera periodisk sammanställning (EC Sales List) för Skatteverket', + longDescription: 'Sammanställer automatiskt alla momsfria EU-försäljningar grupperat per kund och momsregistreringsnummer. Genererar nedladdningsbar fil för uppladdning till Skatteverket. Validerar kundernas VAT-nummer via VIES och flaggar saknade uppgifter.' + }, + { + slug: 'vat-monitor', + name: 'Exportmoms-monitor', + sector: 'export', + category: 'reports', + icon: 'Shield', + dataPattern: 'core', + readsCoreTables: ['journal_entry_lines', 'journal_entries', 'invoices'], + hasOwnData: false, + description: 'Övervaka momsbehandling för export och EU-handel', + longDescription: 'Visar intäkter uppdelat på inhemsk försäljning, EU B2B (reverse charge) och export utanför EU. Mappar automatiskt till rätt rutor i momsdeklarationen (ruta 05, 35, 36, 39, 40). Flaggar potentiella fel som saknat momsregistreringsnummer på EU-kunder eller felaktig momsbehandling.' + }, + { + slug: 'intrastat', + name: 'Intrastat-generator', + sector: 'export', + category: 'accounting', + icon: 'BarChart3', + dataPattern: 'both', + readsCoreTables: ['invoices', 'customers'], + hasOwnData: true, + description: 'Generera Intrastat-deklarationer för rapportering till SCB', + longDescription: 'Tagga produkter med CN-koder (Combined Nomenclature), vikt och ursprungsland. Genererar kompletta Intrastat-deklarationer i CSV-format för uppladdning till SCB:s IDEP.web. Övervakar tröskelvärdet på 12 MSEK för utförsel och varnar när rapporteringsskyldighet uppstår.' + }, + { + slug: 'currency-receivables', + name: 'Valutafordringar', + sector: 'export', + category: 'reports', + icon: 'TrendingUp', + dataPattern: 'core', + readsCoreTables: ['invoices', 'journal_entry_lines', 'transactions'], + hasOwnData: false, + description: 'Övervaka valutaexponering och orealiserade kursvinster/-förluster', + longDescription: 'Visar öppna kundfordringar per valuta med aktuellt SEK-värde baserat på Riksbankens dagskurser. Beräknar orealiserade valutakursvinster och -förluster. Visar realiserade kursdifferenser per period (konto 3960/7960). Ger en samlad bild av företagets valutarisk.' + } + ] +} +``` + +#### 0.3 Shared components to build +All placed in `components/extensions/export/shared/`: + +| Component | Purpose | Used by | +|-----------|---------|---------| +| `PeriodSelector` | Month/quarter picker for reporting periods | All 4 | +| `CurrencyDisplay` | Shows amount in original currency + SEK | 3, 4 | +| `DeadlineCard` | Countdown to next filing deadline | 1, 2 | +| `ComplianceStatusBadge` | Filed / Pending / Overdue indicator | 1, 2 | +| `MomsrutaLabel` | Styled label for momsdeklaration box numbers | 1, 2 | +| `CountryFlag` | Small flag icon + country name for EU countries | 1, 3, 4 | +| `ExportKPICard` | Extension of existing KPICard with currency formatting | All 4 | +| `DownloadButton` | Trigger file download (CSV/XML) with loading state | 1, 3 | + +#### 0.4 Shared utilities +Placed in `extensions/export/shared/`: + +| Utility | Purpose | Used by | +|---------|---------|---------| +| `vies-client.ts` | VIES SOAP/REST API client for VAT number validation | 1 | +| `riksbanken-client.ts` | Extend existing `lib/currency/` to fetch daily rates | 4 | +| `eu-countries.ts` | EU member state list with ISO codes, currency, VAT prefixes | 1, 2, 3 | +| `moms-box-mapping.ts` | Maps BAS accounts + vat_treatment to momsdeklaration boxes | 1, 2 | +| `file-generators.ts` | CSV and XML file generation utilities | 1, 3 | + +--- + +### Phase 1: EU Sales List / Periodisk Sammanställning + +**Extension slug**: `eu-sales-list` +**Data pattern**: `core` (read-only from invoices + customers) +**Output**: Downloadable CSV/XML file + +#### 1.1 Engine (`eu-sales-list-engine.ts`) + +**Input**: User ID, period (year + month or quarter), filing type (goods/services/both) + +**Logic**: +1. Fetch all invoices for the period where: + - `vat_treatment = 'reverse_charge'` (EU B2B) + - `status` is `sent` or `paid` (not draft) + - Customer `country` is an EU member state (not Sweden) +2. Join with customers to get `vat_number`, `country`, `name` +3. Group by customer `vat_number` +4. For each customer, separate goods invoices (revenue accounts 3108) from services (3308) +5. Sum `total_sek` for goods and services separately +6. Handle credit notes: subtract from the customer's total (can result in negative amounts) +7. Validate: + - Flag customers with missing or unvalidated VAT numbers + - Flag invoices without `moms_ruta` set to '35' or '39' + - Cross-check: sum of goods should equal journal entries on account 3108 for the period + - Cross-check: sum of services should equal journal entries on account 3308 for the period + +**Output structure**: +```typescript +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 } + warnings: ECSalesListWarning[] + crossCheck: { boxMatch: boolean; box35Total: number; box39Total: number } +} + +interface ECSalesListLine { + customerVatNumber: string + customerName: string + customerCountry: string // ISO 2-letter + goodsAmount: number // SEK, rounded to whole number + servicesAmount: number // SEK, rounded to whole number + triangulationAmount: number // SEK, for trepartshandel +} + +interface ECSalesListWarning { + type: 'missing_vat_number' | 'unvalidated_vat_number' | 'missing_moms_ruta' | 'cross_check_mismatch' + invoiceId?: string + customerId?: string + message: string +} +``` + +#### 1.2 File generators + +**Skatteverket XML format** (`skv-xml-generator.ts`): +- Generate XML matching SKV 5740 schema +- Include header: reporter VAT number, period, contact info +- Include lines: customer VAT number, goods amount, services amount +- Encoding: UTF-8 + +**CSV fallback** (`csv-generator.ts`): +- Simple CSV with columns: Customer VAT Number, Country, Goods (SEK), Services (SEK) +- BOM for Excel compatibility + +#### 1.3 VIES integration (`vies-client.ts`) +- Call EU VIES API to validate customer VAT numbers +- Cache validation results (valid for 24 hours) +- Show validation status indicator (valid / invalid / pending / error) +- Used in the warnings system to flag invalid numbers before filing + +#### 1.4 Workspace UI (`EuSalesListWorkspace.tsx`) + +**Layout**: +``` +┌─────────────────────────────────────────────────┐ +│ Periodisk sammanställning │ +│ │ +│ [Period selector: 2026 / Kvartal 1 ▼] │ +│ Filing type: ○ Varor (monthly) ○ Tjänster (quarterly) ○ Båda │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ Total varor│ │Total tjänst│ │ Kunder │ │ +│ │ 1 250 000 │ │ 340 000 │ │ 12 │ │ +│ │ SEK │ │ SEK │ │ │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ⚠ 2 varningar [Ladda ner ▼] │ +│ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ VAT-nummer │ Land │ Varor │ Tjänster│ │ +│ │ DE123456789 │ 🇩🇪 │ 450 000│ 0 │ │ +│ │ FR87654321 │ 🇫🇷 │ 320 000│ 120 000 │ │ +│ │ FI11223344 │ 🇫🇮 │ 280 000│ 80 000 │ │ +│ │ ⚠ NL(saknas) │ 🇳🇱 │ 200 000│ 0 │ │ +│ │ ... │ │ │ │ │ +│ └───────────────────────────────────────────┘ │ +│ │ +│ Korsvalidering mot momsdeklaration: │ +│ Ruta 35 (varor): 1 250 000 ✓ │ +│ Ruta 39 (tjänster): 340 000 ✓ │ +│ Nästa deadline: 25 april 2026 (31 dagar kvar) │ +└─────────────────────────────────────────────────┘ +``` + +**Features**: +- Period selector (month or quarter) +- KPI cards: total goods, total services, customer count +- Warning banner with expandable details +- Sortable table of customers with VAT numbers, country flags, amounts +- Download button: CSV or XML format +- Cross-validation section: compares with momsdeklaration box totals +- Deadline countdown + +#### 1.5 Tests (`__tests__/eu-sales-list-engine.test.ts`) + +Test cases: +- Aggregation by customer VAT number (multiple invoices to same customer) +- Goods vs services separation (based on revenue account) +- Credit note handling (reduces customer total, can go negative) +- Missing VAT number warning +- Unvalidated VAT number warning +- Cross-check with GL account totals +- Empty period (no EU sales) +- Mixed period (some EU, some non-EU, some domestic) +- Currency conversion (all amounts in SEK regardless of invoice currency) + +--- + +### Phase 2: Export VAT Monitor / Exportmoms-monitor + +**Extension slug**: `vat-monitor` +**Data pattern**: `core` (read-only from journal entries + invoices) +**Output**: Dashboard with momsdeklaration box mapping + +#### 2.1 Engine (`vat-monitor-engine.ts`) + +**Input**: User ID, period (year + month or quarter) + +**Logic**: +1. Fetch all journal entry lines for the period on revenue accounts: + - Domestic: `3001` (25%), `3002` (12%), `3003` (6%) + - EU goods: `3108` (reverse charge, 0%) + - EU services: `3308` (reverse charge, 0%) + - Non-EU goods: `3105` (export, 0%) + - Non-EU services: `3305` (export, 0%) + - Triangular: `3109` (trepartshandel) + - Invoiced freight: `3521` (EU), `3522` (export) +2. Map each account to the correct momsdeklaration box: + - `3001/3002/3003` → Box 05 (standard taxable sales) + - `3108` → Box 35 (EU goods) + - `3305` → Box 40 (other services abroad) + - `3308` → Box 39 (EU services, main rule) + - `3105` → Box 36 (goods export outside EU) + - `3109` → Box 38 (triangular trade sales) + - `3521` → follows goods treatment (Box 35) + - `3522` → Box 36 +3. Sum credit amounts per box (revenue is credit-side) +4. Also fetch VAT account totals: + - `2611` (output VAT 25%), `2621` (12%), `2631` (6%) → Boxes 10, 11, 12 + - `2641` (input VAT) → Box 48 +5. Calculate net VAT (output - input) → Box 49 +6. Validate: + - Box 35 + Box 39 should match EU Sales List totals (if extension 1 is enabled) + - Invoices with `vat_treatment = 'reverse_charge'` should be on accounts 3108/3308 + - Invoices with `vat_treatment = 'export'` should be on accounts 3105/3305 + - Flag any invoice where the `moms_ruta` doesn't match the expected box for its account + +**Output structure**: +```typescript +interface VatMonitorReport { + period: { year: number; month?: number; quarter?: number } + boxes: Record // '05', '10', '11', '12', '35', '36', '39', '40', '48', '49' + 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 } + } + warnings: VatMonitorWarning[] + previousPeriod?: VatMonitorReport // For comparison +} + +interface VatBoxData { + boxNumber: string + label: string // Swedish label + amount: number // SEK + accounts: string[] // Contributing BAS accounts +} + +interface VatMonitorWarning { + type: 'wrong_account' | 'missing_moms_ruta' | 'vat_treatment_mismatch' | 'missing_vat_number' | 'cross_check_mismatch' + severity: 'error' | 'warning' + invoiceId?: string + message: string +} +``` + +#### 2.2 Box mapping reference (`moms-box-mapping.ts`) + +```typescript +// Shared between EU Sales List and VAT Monitor +const ACCOUNT_TO_BOX: Record = { + '3001': '05', '3002': '05', '3003': '05', // Domestic revenue + '3108': '35', // EU goods (reverse charge) + '3308': '39', // EU services (reverse charge) + '3105': '36', // Export goods (non-EU) + '3305': '40', // Export services (non-EU) + '3109': '38', // Triangular trade + '3521': '35', // Invoiced freight EU + '3522': '36', // Invoiced freight export + '2611': '10', '2621': '11', '2631': '12', // Output VAT + '2641': '48', // Input VAT +} + +const BOX_LABELS: Record = { + '05': 'Momspliktig försäljning', + '10': 'Utgående moms 25%', + '11': 'Utgående moms 12%', + '12': 'Utgående moms 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', +} +``` + +#### 2.3 Workspace UI (`VatMonitorWorkspace.tsx`) + +**Layout**: +``` +┌─────────────────────────────────────────────────────────┐ +│ Exportmoms-monitor │ +│ │ +│ [Period: 2026-03 ▼] [Jämför med: 2026-02 ▼] │ +│ │ +│ ── Intäktsfördelning ────────────────────────────── │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Inrikes │ │ EU varor │ │EU tjänst │ │ Export │ │ +│ │2 100 000 │ │1 250 000 │ │ 340 000 │ │ 890 000 │ │ +│ │ 46% │ │ 27% │ │ 7% │ │ 20% │ │ +│ │ ↑ +5% │ │ ↓ -3% │ │ ↑ +12% │ │ ↑ +8% │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ── Momsdeklaration (förhandsvisning) ────────────── │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Ruta │ Beskrivning │ Belopp │ │ +│ │ 05 │ Momspliktig försäljning │2 100 000│ │ +│ │ 10 │ Utgående moms 25% │ 525 000│ │ +│ │ 35 │ Varuförsäljning EU │1 250 000│ │ +│ │ 36 │ Export utanför EU │ 890 000│ │ +│ │ 39 │ Tjänsteförsäljning EU │ 340 000│ │ +│ │ 48 │ Ingående moms │ 380 000│ │ +│ │ 49 │ Moms att betala │ 145 000│ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ⚠ 1 varning: Faktura #2026-042 till DE-kund saknar │ +│ validerat VAT-nummer men är bokförd på konto 3108. │ +│ │ +│ ── Trend (senaste 6 månader) ────────────────────── │ +│ [Bar chart: domestic vs EU vs export per month] │ +└─────────────────────────────────────────────────────────┘ +``` + +**Features**: +- Period selector with comparison period +- KPI cards: revenue by destination type with % share and delta +- Momsdeklaration preview table with all relevant boxes pre-filled +- Warning panel with actionable messages +- 6-month trend chart showing revenue mix over time +- Drill-down: click a box number to see contributing invoices + +#### 2.4 Tests (`__tests__/vat-monitor-engine.test.ts`) + +Test cases: +- Correct box mapping for each revenue account +- Mixed domestic + EU + export revenue +- Period comparison (delta calculation) +- Warning: invoice on 3108 without validated VAT number +- Warning: invoice `vat_treatment` doesn't match account +- Warning: `moms_ruta` doesn't match expected box for account +- Empty period +- Only domestic sales (no export boxes populated) +- Freight accounts follow goods treatment +- VAT calculation: output minus input = box 49 + +--- + +### Phase 3: Intrastat Generator + +**Extension slug**: `intrastat` +**Data pattern**: `both` (reads invoices + stores product metadata in extension_data) +**Output**: Downloadable CSV file for SCB IDEP.web + +#### 3.1 Product metadata storage + +Uses `extension_data` table with these key patterns: +- `product:{productId}` → `{ cn_code, description, net_weight_kg, country_of_origin, supplementary_unit, supplementary_unit_type }` +- `settings` → `{ default_transaction_nature: '11', default_delivery_terms: 'FCA', threshold_alert_enabled: true }` + +The `productId` is a user-defined identifier (e.g., SKU or product name) since there's no core products table. + +#### 3.2 Engine (`intrastat-engine.ts`) + +**Input**: User ID, period (year + month) + +**Logic**: +1. Fetch all invoices for the period where: + - Customer `country` is an EU member state (not Sweden) + - `vat_treatment = 'reverse_charge'` (B2B goods) + - Revenue account is `3108` (goods to EU) + - `status` is `sent` or `paid` +2. For each invoice line, look up product metadata from extension_data +3. Aggregate by: CN code + partner country + country of origin + transaction nature + delivery terms +4. For each aggregated line, calculate: + - Total invoiced value in SEK (using `total_sek` from invoice) + - Total net mass (kg) from product metadata × quantity + - Supplementary units (if required by CN code) +5. Handle credit notes: generate correction lines for the original period +6. Calculate cumulative dispatch value (rolling 12 months) for threshold monitoring + +**Output structure**: +```typescript +interface IntrastatReport { + period: { year: number; month: number } + reporterVatNumber: string + reporterName: string + flowType: 'dispatch' // We focus on exports + lines: IntrastatLine[] + totals: { invoicedValue: number; netMass: number; lineCount: number } + thresholdStatus: { + cumulativeValue: number // Rolling 12 months + threshold: 12_000_000 // SEK + isObligated: boolean + percentageUsed: number + } + warnings: IntrastatWarning[] +} + +interface IntrastatLine { + cnCode: string // 8-digit CN commodity code + partnerCountry: string // 2-letter ISO (destination) + countryOfOrigin: string // 2-letter ISO + transactionNature: string // 2-digit code (e.g., '11' for outright sale) + deliveryTerms: string // Incoterms code + invoicedValue: number // SEK, rounded to whole + netMass: number // kg, up to 3 decimals + supplementaryUnit?: number + supplementaryUnitType?: string + partnerVatId: string // Customer VAT number +} + +interface IntrastatWarning { + type: 'missing_cn_code' | 'missing_weight' | 'missing_origin' | 'unmatched_invoice_line' | 'threshold_approaching' + invoiceId?: string + productId?: string + message: string +} +``` + +#### 3.3 SCB file generator (`scb-file-generator.ts`) + +Generates CSV compatible with IDEP.web upload: +- Header row with field names +- One row per aggregated line +- Encoding: UTF-8 with BOM +- Semicolon-separated (IDEP.web standard) +- Fields: CN code, partner country, country of origin, transaction nature, delivery terms, invoiced value, net mass, supplementary unit, partner VAT ID + +#### 3.4 Workspace UI (`IntrastatWorkspace.tsx`) + +**Layout**: +``` +┌──────────────────────────────────────────────────────────┐ +│ Intrastat-generator │ +│ │ +│ [Period: 2026-03 ▼] │ +│ │ +│ ── Tröskelvärde ──────────────────────────────────── │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ Ackumulerad utförsel (12 mån): 8 450 000 SEK │ │ +│ │ ████████████████░░░░░░░░ 70% av 12 000 000 │ │ +│ │ Status: Under tröskelvärdet (frivillig) │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ── Produktregister ─────────────────── [+ Lägg till] │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Produkt │ CN-kod │ Vikt(kg) │ Ursprung │ │ +│ │ Stålbalk M8 │ 72163100 │ 45.5 │ SE │ │ +│ │ Ventil DN50 │ 84818019 │ 2.3 │ DE │ │ +│ │ ⚠ Pump XL │ (saknas) │ 12.0 │ SE │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ── Deklaration mars 2026 ──────────── [Ladda ner CSV] │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ CN-kod │ Land │ Urspr │ Värde SEK│ Vikt kg │ │ +│ │ 72163100 │ DE │ SE │ 450 000 │ 4 550 │ │ +│ │ 72163100 │ FI │ SE │ 120 000 │ 1 200 │ │ +│ │ 84818019 │ DE │ DE │ 230 000 │ 46 │ │ +│ │ Total │ │ │ 800 000 │ 5 796 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ⚠ 1 varning: Produkt "Pump XL" saknar CN-kod. │ +│ Deadline: 14 april 2026 (10:e arbetsdagen) │ +└──────────────────────────────────────────────────────────┘ +``` + +**Features**: +- Threshold progress bar (cumulative 12-month dispatches vs SEK 12M) +- Product registry: CRUD for product metadata (CN codes, weights, origin) +- Auto-generated declaration table from period's EU goods invoices +- Warning panel for missing metadata +- Download button for SCB-compatible CSV +- Deadline display (10th business day of following month) + +#### 3.5 Tests (`__tests__/intrastat-engine.test.ts`) + +Test cases: +- Aggregation by CN code + country + origin +- Multiple invoices to same country with same CN code (should aggregate) +- Credit note correction (negative line for original period) +- Missing CN code warning +- Missing weight warning +- Threshold calculation (rolling 12 months) +- Threshold crossing alert +- Empty period (no EU goods dispatches) +- Non-EU invoices excluded +- Services excluded (only goods on account 3108) + +--- + +### Phase 4: Multi-Currency Receivables Manager / Valutafordringar + +**Extension slug**: `currency-receivables` +**Data pattern**: `core` (read-only from invoices + journal entries + transactions) +**Output**: Dashboard showing FX exposure and unrealized gains/losses + +#### 4.1 Engine (`receivables-engine.ts`) + +**Input**: User ID, reference date (default: today) + +**Logic**: +1. Fetch all unpaid invoices (`status = 'sent'` or `'overdue'`) where `currency != 'SEK'` +2. For each invoice, calculate: + - Original amount in foreign currency (`total`) + - Booked SEK value (`total_sek` or `total × exchange_rate`) + - Current SEK value using today's Riksbanken rate + - Unrealized gain/loss = current SEK value - booked SEK value +3. Group by currency for exposure summary +4. Fetch realized gains/losses from journal entry lines: + - Account `3960` (gains) credit amounts for the period + - Account `7960` (losses) debit amounts for the period +5. Fetch historical realized FX per month for trend analysis +6. Calculate totals: + - Total foreign receivables (SEK equivalent at current rate) + - Total unrealized gain/loss + - Total realized gain/loss for current period + +**Output structure**: +```typescript +interface CurrencyReceivablesReport { + referenceDate: string + exchangeRates: Record // From Riksbanken + + // Exposure by currency + exposureByCurrency: CurrencyExposure[] + + // Individual receivables + receivables: ForeignReceivable[] + + // Realized FX for period + realizedGainLoss: { + period: { year: number; month: number } + gains: number // Account 3960 credit total + losses: number // Account 7960 debit total + net: number + } + + // Monthly trend (last 12 months) + monthlyTrend: MonthlyFXTrend[] +} + +interface CurrencyExposure { + currency: string + totalForeignAmount: number // In original currency + bookedSekValue: number // At invoice-date rates + currentSekValue: number // At today's Riksbanken rate + unrealizedGainLoss: number // currentSek - bookedSek + 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 // 'YYYY-MM' + realizedGains: number + realizedLosses: number + netRealized: number + unrealizedAtMonthEnd: number +} +``` + +#### 4.2 Riksbanken rate integration + +Extend `lib/currency/` or create `riksbanken-client.ts`: +- Fetch daily mid-rates from Riksbanken's REST API +- Cache rates for the current day +- Support historical rate lookup (for trend calculations) +- Fallback: use the most recent available rate if today's isn't published yet (rates published at 16:15 on business days) + +**Riksbanken API**: `https://api.riksbank.se/swea/v1/CrossRates` + +#### 4.3 Workspace UI (`CurrencyReceivablesWorkspace.tsx`) + +**Layout**: +``` +┌──────────────────────────────────────────────────────────┐ +│ Valutafordringar │ +│ │ +│ Växelkurser per 2026-03-15 (Riksbanken) │ +│ EUR: 11.42 USD: 10.85 GBP: 13.72 NOK: 1.02 │ +│ │ +│ ── Valutaexponering ──────────────────────────────── │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ EUR │ │ USD │ │ GBP │ │ Totalt │ │ +│ │€ 125 000 │ │$ 45 000 │ │£ 12 000 │ │ │ │ +│ │1 427 500 │ │ 488 250 │ │ 164 640 │ │2 080 390 │ │ +│ │ SEK │ │ SEK │ │ SEK │ │ SEK │ │ +│ │ +32 500 │ │ -8 200 │ │ +1 440 │ │ +25 740 │ │ +│ │ orealis. │ │ orealis. │ │ orealis. │ │ orealis. │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ── Öppna fordringar ───────────────────────────────── │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Faktura │ Kund │ Valuta│ Belopp │Orealis.│ │ +│ │ 2026-031 │ Müller GmbH│ EUR │ 50 000 │+12 500 │ │ +│ │ 2026-035 │ Smith Inc │ USD │ 25 000 │ -5 200 │ │ +│ │ 2026-038 │ Dupont SA │ EUR │ 75 000 │+20 000 │ │ +│ │ 2026-041 │ Jones Ltd │ GBP │ 12 000 │ +1 440 │ │ +│ │ 2026-044 │ Brown Corp │ USD │ 20 000 │ -3 000 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ── Realiserade kursdifferenser 2026 ───────────────── │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Månad │ Vinst(3960)│ Förlust(7960)│ Netto │ │ +│ │ Jan │ +8 500 │ -3 200 │ +5 300 │ │ +│ │ Feb │ +12 300 │ -7 800 │ +4 500 │ │ +│ │ Mar │ +4 200 │ -1 100 │ +3 100 │ │ +│ │ Totalt │ +25 000 │ -12 100 │ +12 900 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ── Period-end revaluation preview ─────────────────── │ +│ Om bokslut görs idag: netto orealiserad vinst +25 740 │ +│ (Konto 3969: +33 940 / Konto 7969: -8 200) │ +│ ℹ Bokföringsposterna skapas inte av detta tillägg. │ +│ Använd värdena ovan som underlag vid periodbokslut. │ +└──────────────────────────────────────────────────────────┘ +``` + +**Features**: +- Live Riksbanken exchange rates display +- Exposure KPI cards per currency showing foreign amount, SEK value, unrealized gain/loss +- Sortable receivables table with per-invoice unrealized gain/loss +- Color coding: green for gains, red for losses +- Realized FX trend table (monthly, from accounts 3960/7960) +- Period-end revaluation preview (informational — tells user what entries to make, doesn't create them) +- Note clarifying that this extension doesn't create journal entries + +#### 4.4 Tests (`__tests__/receivables-engine.test.ts`) + +Test cases: +- Unrealized gain calculation (rate increased since invoice date) +- Unrealized loss calculation (rate decreased) +- Multiple currencies aggregation +- Paid invoices excluded from exposure +- Realized gains from account 3960 +- Realized losses from account 7960 +- Monthly trend calculation +- Empty state (no foreign receivables) +- SEK-only invoices excluded +- Exchange rate not available (use most recent) + +--- + +### Phase 5: Integration & Polish + +#### 5.1 Register all extensions in loader +Add to `FIRST_PARTY_EXTENSIONS` in `lib/extensions/loader.ts`: +```typescript +import { euSalesListExtension } from '@/extensions/export/eu-sales-list' +import { vatMonitorExtension } from '@/extensions/export/vat-monitor' +import { intrastatExtension } from '@/extensions/export/intrastat' +import { currencyReceivablesExtension } from '@/extensions/export/currency-receivables' +``` + +#### 5.2 Register workspace components +Add to `lib/extensions/workspace-registry.tsx`: +```typescript +'export/eu-sales-list': dynamic(() => import('@/components/extensions/export/EuSalesListWorkspace')), +'export/vat-monitor': dynamic(() => import('@/components/extensions/export/VatMonitorWorkspace')), +'export/intrastat': dynamic(() => import('@/components/extensions/export/IntrastatWorkspace')), +'export/currency-receivables': dynamic(() => import('@/components/extensions/export/CurrencyReceivablesWorkspace')), +``` + +#### 5.3 Add icon imports +Update `lib/extensions/icon-resolver.tsx` with new icons: +- `Ship` — sector icon +- `FileText` — EU Sales List +- `Shield` — VAT Monitor +- `BarChart3` — Intrastat (already exists) +- `TrendingUp` — Currency Receivables (already exists) + +#### 5.4 Cross-extension validation +When multiple export extensions are enabled: +- VAT Monitor can cross-reference with EU Sales List totals +- Intrastat threshold data validates against VAT Monitor's box 35 total +- Currency Receivables exposure aligns with invoices visible in EU Sales List + +#### 5.5 API routes +Each extension needs data-fetching API routes: + +| Route | Method | Purpose | +|-------|--------|---------| +| `/api/extensions/export/eu-sales-list/report` | GET | Generate report for period | +| `/api/extensions/export/eu-sales-list/download` | GET | Download CSV/XML file | +| `/api/extensions/export/eu-sales-list/validate-vat` | POST | VIES VAT number validation | +| `/api/extensions/export/vat-monitor/report` | GET | Generate VAT box report | +| `/api/extensions/export/intrastat/report` | GET | Generate Intrastat declaration | +| `/api/extensions/export/intrastat/download` | GET | Download SCB CSV file | +| `/api/extensions/export/intrastat/products` | GET/POST/DELETE | Product metadata CRUD | +| `/api/extensions/export/currency-receivables/report` | GET | Exposure + unrealized report | +| `/api/extensions/export/currency-receivables/rates` | GET | Current Riksbanken rates | + +--- + +## Build Order Summary + +| Phase | Extension | Key deliverables | Depends on | +|-------|-----------|-----------------|------------| +| 0 | Infrastructure | Sector registration, shared components, shared utilities | — | +| 1 | EU Sales List | Engine, VIES client, XML generator, workspace, tests | Phase 0 | +| 2 | VAT Monitor | Engine, box mapping, workspace, tests | Phase 0 | +| 3 | Intrastat | Engine, product CRUD, SCB CSV generator, workspace, tests | Phase 0 | +| 4 | Currency Receivables | Engine, Riksbanken client, workspace, tests | Phase 0 | +| 5 | Integration | Loader registration, workspace registry, icon imports, cross-validation | Phases 1-4 | 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..6cfa91e1 --- /dev/null +++ b/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts @@ -0,0 +1,453 @@ +/** + * 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, toCountryCode } 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) + + // Normalize country to ISO code for consistent output + const countryCode = toCountryCode(customer.country) + + // 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: countryCode, + 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..81938624 --- /dev/null +++ b/extensions/export/intrastat/lib/intrastat-engine.ts @@ -0,0 +1,466 @@ +/** + * Intrastat Generator Engine + * + * Aggregates EU B2B goods sales into Intrastat declaration lines + * for reporting to SCB (Statistiska Centralbyrån) via IDEP.web. + * + * Pure functions — no Supabase, no React, no side effects. + * + * Only covers dispatches (utförsel) — goods sent from Sweden to + * other EU member states. Services are excluded from Intrastat. + * + * Threshold: SEK 12,000,000 cumulative dispatches over 12 months + * determines mandatory reporting obligation. + * + * Reference: SCB Intrastat guidelines, Combined Nomenclature (CN) + */ + +import { isEUCountry, toCountryCode } from '@/extensions/export/shared/eu-countries' + +// ── Types ──────────────────────────────────────────────────── + +/** Invoice data needed for Intrastat */ +export interface IntrastatInvoice { + id: string + invoice_number: string + invoice_date: string + status: string + vat_treatment: string + moms_ruta: string | null + currency: string + total_sek: number | null + subtotal_sek: number | null + subtotal: number + document_type: string + credited_invoice_id: string | null + customer_id: string +} + +/** Customer data for Intrastat */ +export interface IntrastatCustomer { + id: string + name: string + country: string + vat_number: string | null +} + +/** Product metadata stored in extension_data */ +export interface ProductMetadata { + productId: string + cnCode: string | null + description: string + netWeightKg: number | null + countryOfOrigin: string + supplementaryUnit: number | null + supplementaryUnitType: string | null +} + +/** Invoice line item for matching to products */ +export interface IntrastatInvoiceItem { + id: string + invoice_id: string + description: string + quantity: number + unit_price: number + total: number + total_sek: number | null +} + +/** Aggregated Intrastat declaration line */ +export interface IntrastatLine { + cnCode: string + partnerCountry: string + countryOfOrigin: string + transactionNature: string + deliveryTerms: string + invoicedValue: number + netMass: number + supplementaryUnit: number | null + supplementaryUnitType: string | null + partnerVatId: string +} + +/** Warning about data quality */ +export interface IntrastatWarning { + type: + | 'missing_cn_code' + | 'missing_weight' + | 'missing_origin' + | 'threshold_approaching' + | 'threshold_exceeded' + severity: 'error' | 'warning' + invoiceId?: string + invoiceNumber?: string + productId?: string + message: string +} + +/** Threshold monitoring status */ +export interface ThresholdStatus { + cumulativeValue: number + threshold: number + isObligated: boolean + percentageUsed: number +} + +/** Complete Intrastat report */ +export interface IntrastatReport { + period: { year: number; month: number } + reporterVatNumber: string + reporterName: string + flowType: 'dispatch' + lines: IntrastatLine[] + totals: { + invoicedValue: number + netMass: number + lineCount: number + } + thresholdStatus: ThresholdStatus + warnings: IntrastatWarning[] + invoiceCount: number +} + +/** Options for generating the Intrastat report */ +export interface IntrastatOptions { + invoices: IntrastatInvoice[] + invoiceItems: IntrastatInvoiceItem[] + customers: IntrastatCustomer[] + products: ProductMetadata[] + reporterVatNumber: string + reporterName: string + year: number + month: number + defaultTransactionNature?: string + defaultDeliveryTerms?: string + /** Cumulative dispatch value from prior months in the rolling 12-month window */ + priorCumulativeValue?: number +} + +// ── Constants ─────────────────────────────────────────────── + +/** SCB Intrastat reporting threshold in SEK (12 million) */ +const INTRASTAT_THRESHOLD = 12_000_000 + +/** Goods revenue accounts that trigger Intrastat reporting */ +const GOODS_ACCOUNTS_RUTA = ['35'] + +// ── Core engine ────────────────────────────────────────────── + +export function generateIntrastatReport(options: IntrastatOptions): IntrastatReport { + const { + invoices, + invoiceItems, + customers, + products, + reporterVatNumber, + reporterName, + year, + month, + defaultTransactionNature = '11', + defaultDeliveryTerms = 'FCA', + priorCumulativeValue = 0, + } = options + + const warnings: IntrastatWarning[] = [] + const customerMap = new Map(customers.map(c => [c.id, c])) + const productMap = buildProductMap(products) + + // Step 1: Filter to EU B2B goods invoices + const relevantInvoices = invoices.filter(inv => { + if (!['sent', 'paid', 'overdue'].includes(inv.status)) return false + if (inv.document_type !== 'invoice' && inv.credited_invoice_id === null) return false + if (inv.vat_treatment !== 'reverse_charge') return false + // Only goods (moms_ruta 35) — services are excluded from Intrastat + if (inv.moms_ruta && !GOODS_ACCOUNTS_RUTA.includes(inv.moms_ruta)) return false + // Verify customer is in EU (not Sweden) + const customer = customerMap.get(inv.customer_id) + if (!customer || !isEUCountry(customer.country)) return false + return true + }) + + // Step 2: Build items index by invoice_id + const itemsByInvoice = new Map() + for (const item of invoiceItems) { + const list = itemsByInvoice.get(item.invoice_id) ?? [] + list.push(item) + itemsByInvoice.set(item.invoice_id, list) + } + + // Step 3: Build aggregation map + // Key: cnCode|partnerCountry|countryOfOrigin|transactionNature|deliveryTerms + const aggregation = new Map() + + for (const invoice of relevantInvoices) { + const customer = customerMap.get(invoice.customer_id)! + const partnerCountryCode = toCountryCode(customer.country) + const isCreditNote = invoice.credited_invoice_id !== null + const items = itemsByInvoice.get(invoice.id) ?? [] + + if (items.length === 0) { + // No line items — use invoice-level amount with unknown product + const amountSek = getInvoiceAmountSek(invoice) + const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek + + warnings.push({ + type: 'missing_cn_code', + severity: 'error', + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + message: `Faktura ${invoice.invoice_number} saknar fakturarader — kan inte tilldela CN-kod.`, + }) + + const key = buildAggKey('00000000', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms) + addToAggregation(aggregation, key, { + cnCode: '00000000', + partnerCountry: partnerCountryCode, + countryOfOrigin: 'SE', + transactionNature: defaultTransactionNature, + deliveryTerms: defaultDeliveryTerms, + invoicedValue: effectiveAmount, + netMass: 0, + supplementaryUnit: null, + supplementaryUnitType: null, + partnerVatId: customer.vat_number ?? '', + }) + continue + } + + // Process each line item + for (const item of items) { + const product = matchProduct(item.description, productMap) + const amountSek = item.total_sek ?? item.total + const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek + + if (!product) { + // No product metadata found — use defaults and warn + warnings.push({ + type: 'missing_cn_code', + severity: 'error', + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + message: `Faktura ${invoice.invoice_number}, rad "${item.description}" — ingen matchande produkt med CN-kod hittad.`, + }) + + const key = buildAggKey('00000000', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms) + addToAggregation(aggregation, key, { + cnCode: '00000000', + partnerCountry: partnerCountryCode, + countryOfOrigin: 'SE', + transactionNature: defaultTransactionNature, + deliveryTerms: defaultDeliveryTerms, + invoicedValue: effectiveAmount, + netMass: 0, + supplementaryUnit: null, + supplementaryUnitType: null, + partnerVatId: customer.vat_number ?? '', + }) + continue + } + + // Validate product metadata + if (!product.cnCode) { + warnings.push({ + type: 'missing_cn_code', + severity: 'error', + productId: product.productId, + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + message: `Produkt "${product.description}" saknar CN-kod.`, + }) + } + + if (product.netWeightKg === null) { + warnings.push({ + type: 'missing_weight', + severity: 'warning', + productId: product.productId, + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + message: `Produkt "${product.description}" saknar nettovikt.`, + }) + } + + const cnCode = product.cnCode ?? '00000000' + const origin = product.countryOfOrigin || 'SE' + const mass = (product.netWeightKg ?? 0) * item.quantity + const suppUnit = product.supplementaryUnit !== null + ? product.supplementaryUnit * item.quantity + : null + + const key = buildAggKey(cnCode, partnerCountryCode, origin, defaultTransactionNature, defaultDeliveryTerms) + addToAggregation(aggregation, key, { + cnCode, + partnerCountry: partnerCountryCode, + countryOfOrigin: origin, + transactionNature: defaultTransactionNature, + deliveryTerms: defaultDeliveryTerms, + invoicedValue: effectiveAmount, + netMass: isCreditNote ? -Math.abs(mass) : mass, + supplementaryUnit: suppUnit !== null ? (isCreditNote ? -Math.abs(suppUnit) : suppUnit) : null, + supplementaryUnitType: product.supplementaryUnitType, + partnerVatId: customer.vat_number ?? '', + }) + } + } + + // Step 4: Build sorted output + const lines = Array.from(aggregation.values()) + .map(line => ({ + ...line, + invoicedValue: round2(line.invoicedValue), + netMass: round3(line.netMass), + })) + .sort((a, b) => + a.cnCode.localeCompare(b.cnCode) || + a.partnerCountry.localeCompare(b.partnerCountry) + ) + + // Step 5: Calculate totals + const totals = { + invoicedValue: round2(lines.reduce((sum, l) => sum + l.invoicedValue, 0)), + netMass: round3(lines.reduce((sum, l) => sum + l.netMass, 0)), + lineCount: lines.length, + } + + // Step 6: Threshold status + const cumulativeValue = round2(priorCumulativeValue + totals.invoicedValue) + const percentageUsed = INTRASTAT_THRESHOLD > 0 + ? round2((cumulativeValue / INTRASTAT_THRESHOLD) * 100) + : 0 + + const thresholdStatus: ThresholdStatus = { + cumulativeValue, + threshold: INTRASTAT_THRESHOLD, + isObligated: cumulativeValue >= INTRASTAT_THRESHOLD, + percentageUsed, + } + + if (percentageUsed >= 100) { + warnings.push({ + type: 'threshold_exceeded', + severity: 'error', + message: `Tröskelvärdet för Intrastat (${formatSEK(INTRASTAT_THRESHOLD)} SEK) har överskridits. Rapportering till SCB är obligatorisk.`, + }) + } else if (percentageUsed >= 80) { + warnings.push({ + type: 'threshold_approaching', + severity: 'warning', + message: `Ackumulerad utförsel är ${formatSEK(cumulativeValue)} SEK (${percentageUsed}% av tröskelvärdet). Rapporteringsskyldighet kan uppstå snart.`, + }) + } + + return { + period: { year, month }, + reporterVatNumber, + reporterName, + flowType: 'dispatch', + lines, + totals, + thresholdStatus, + warnings, + invoiceCount: relevantInvoices.length, + } +} + +// ── Product matching ──────────────────────────────────────── + +/** + * Build a map for fast product lookup by description (case-insensitive). + * Products are matched by exact description or by productId. + */ +function buildProductMap(products: ProductMetadata[]): Map { + const map = new Map() + for (const product of products) { + map.set(product.productId.toLowerCase(), product) + if (product.description) { + map.set(product.description.toLowerCase(), product) + } + } + return map +} + +/** + * Match an invoice line description to a product. + * Tries exact match first, then checks if description starts with a product key. + */ +function matchProduct(description: string, productMap: Map): ProductMetadata | null { + const lower = description.toLowerCase().trim() + + // Exact match + if (productMap.has(lower)) return productMap.get(lower)! + + // Prefix match (invoice description may contain extra details) + for (const [key, product] of productMap) { + if (lower.startsWith(key) || key.startsWith(lower)) { + return product + } + } + + return null +} + +// ── Aggregation helpers ───────────────────────────────────── + +function buildAggKey( + cnCode: string, + partnerCountry: string, + origin: string, + transactionNature: string, + deliveryTerms: string, +): string { + return `${cnCode}|${partnerCountry}|${origin}|${transactionNature}|${deliveryTerms}` +} + +function addToAggregation( + map: Map, + key: string, + line: IntrastatLine, +): void { + const existing = map.get(key) + if (existing) { + existing.invoicedValue += line.invoicedValue + existing.netMass += line.netMass + if (existing.supplementaryUnit !== null && line.supplementaryUnit !== null) { + existing.supplementaryUnit += line.supplementaryUnit + } + // Keep the first partner VAT ID encountered + } else { + map.set(key, { ...line }) + } +} + +// ── Amount helpers ────────────────────────────────────────── + +function getInvoiceAmountSek(invoice: IntrastatInvoice): number { + if (invoice.subtotal_sek !== null) return round2(invoice.subtotal_sek) + return round2(invoice.subtotal) +} + +function round2(value: number): number { + return Math.round(value * 100) / 100 +} + +function round3(value: number): number { + return Math.round(value * 1000) / 1000 +} + +function formatSEK(amount: number): string { + return Math.round(amount).toLocaleString('sv-SE') +} + +// ── Period helpers ─────────────────────────────────────────── + +/** + * Get the Intrastat filing deadline for a month. + * The deadline is the 10th business day of the following month. + * For simplicity, we approximate as the 14th of the following month. + */ +export function getIntrastatDeadline(year: number, month: number): string { + let deadlineMonth = month + 1 + let deadlineYear = year + if (deadlineMonth > 12) { + deadlineMonth = 1 + deadlineYear++ + } + return `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-14` +} 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..98fce648 --- /dev/null +++ b/extensions/export/shared/eu-countries.ts @@ -0,0 +1,113 @@ +/** + * 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) + +/** + * Build a lookup set of all known names/codes for EU countries (excluding Sweden). + * Handles ISO codes, English names, and Swedish names — all uppercased for matching. + */ +const EU_LOOKUP_EXCL_SE = new Set( + EU_COUNTRIES + .filter(c => c.code !== 'SE') + .flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase())) +) + +const EU_LOOKUP_INCL_SE = new Set( + EU_COUNTRIES + .flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase())) +) + +/** + * Check if a country value is an EU member state (excluding Sweden). + * Accepts ISO codes ("DE"), English names ("Germany"), or Swedish names ("Tyskland"). + */ +export function isEUCountry(country: string): boolean { + return EU_LOOKUP_EXCL_SE.has(country.trim().toUpperCase()) +} + +/** + * Check if a country value is an EU member state (including Sweden). + * Accepts ISO codes, English names, or Swedish names. + */ +export function isEUCountryIncludingSE(country: string): boolean { + return EU_LOOKUP_INCL_SE.has(country.trim().toUpperCase()) +} + +/** Get EU country data by ISO code, English name, or Swedish name */ +export function getEUCountry(country: string): EUCountry | undefined { + const upper = country.trim().toUpperCase() + return EU_COUNTRIES.find( + c => c.code === upper || c.name.toUpperCase() === upper || c.nameEn.toUpperCase() === upper + ) +} + +/** Get the VIES VAT prefix for a country (note: Greece uses 'EL' not 'GR') */ +export function getVatPrefix(countryCode: string): string | undefined { + return getEUCountry(countryCode)?.vatPrefix +} + +/** + * Normalize a country value to its ISO 3166-1 alpha-2 code. + * Accepts ISO codes, English names, or Swedish names. + * Returns the input uppercased if no match is found. + */ +export function toCountryCode(country: string): string { + const found = getEUCountry(country) + return found ? found.code : country.trim().toUpperCase() +} 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 diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 0e9e22a4..4ab32a2c 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -506,6 +506,15 @@ export const ReportPeriodQuerySchema = z.object({ month: z.coerce.number().int().min(1).max(12).optional(), }) +// ============================================================ +// VAT validation schemas +// ============================================================ + +export const ValidateVatNumberSchema = z.object({ + vat_number: z.string().min(4, 'VAT number must be at least 4 characters'), + customer_id: uuid.optional(), +}) + // ============================================================ // Pagination schemas // ============================================================ diff --git a/lib/bookkeeping/bas-reference.ts b/lib/bookkeeping/bas-reference.ts index 7bbdba94..698a6c39 100644 --- a/lib/bookkeeping/bas-reference.ts +++ b/lib/bookkeeping/bas-reference.ts @@ -1015,6 +1015,38 @@ export const BAS_REFERENCE: BASReferenceAccount[] = [ sru_code: '7311', }, + // 31 - Forsaljning varor utanfor Sverige + { + account_number: '3105', + account_name: 'Forsaljning varor export utanfor EU', + account_class: 3, + account_group: '31', + account_type: 'revenue', + normal_balance: 'credit', + description: 'Intakter fran forsaljning av varor till kunder utanfor EU. Momsfritt (momsdeklaration ruta 36).', + sru_code: '7310', + }, + { + account_number: '3108', + account_name: 'Forsaljning varor till annat EU-land', + account_class: 3, + account_group: '31', + account_type: 'revenue', + normal_balance: 'credit', + description: 'Intakter fran forsaljning av varor till momsregistrerade foretag i andra EU-lander. Omvand skattskyldighet (momsdeklaration ruta 35).', + sru_code: '7310', + }, + { + account_number: '3109', + account_name: 'Forsaljning vid trepartshandel', + account_class: 3, + account_group: '31', + account_type: 'revenue', + normal_balance: 'credit', + description: 'Mellanmans forsaljning av varor vid trepartshandel inom EU (momsdeklaration ruta 38).', + sru_code: '7310', + }, + // 33 - Forsaljning tjanster utanfor Sverige { account_number: '3305', @@ -1037,6 +1069,28 @@ export const BAS_REFERENCE: BASReferenceAccount[] = [ sru_code: '7310', }, + // 35 - Fakturerade kostnader och frakter + { + account_number: '3521', + account_name: 'Fakturerade frakter, EU-land', + account_class: 3, + account_group: '35', + account_type: 'revenue', + normal_balance: 'credit', + description: 'Fraktkostnader som vidarefaktureras till kunder i andra EU-lander. Foljer varans momsbehandling.', + sru_code: '7310', + }, + { + account_number: '3522', + account_name: 'Fakturerade frakter, export', + account_class: 3, + account_group: '35', + account_type: 'revenue', + normal_balance: 'credit', + description: 'Fraktkostnader som vidarefaktureras till kunder utanfor EU. Momsfritt (momsdeklaration ruta 36).', + sru_code: '7310', + }, + // 35 - Fakturerade kostnader { account_number: '3510', diff --git a/lib/currency/__tests__/riksbanken.test.ts b/lib/currency/__tests__/riksbanken.test.ts new file mode 100644 index 00000000..b6f9dba2 --- /dev/null +++ b/lib/currency/__tests__/riksbanken.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + fetchExchangeRate, + fetchMultipleRates, + fetchRateRange, + fetchLatestRate, + convertToSEK, + formatCurrencyAmount, +} from '../riksbanken' + +// Mock logger to suppress output +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +describe('fetchExchangeRate', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns rate 1 for SEK without fetching', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await fetchExchangeRate('SEK') + + expect(result).toEqual({ + currency: 'SEK', + rate: 1, + date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('parses EUR rate from API response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + + const result = await fetchExchangeRate('EUR', new Date('2025-01-15')) + + expect(result).toEqual({ + currency: 'EUR', + rate: 11.42, + date: '2025-01-15', + }) + }) + + it('returns fallback rate on fetch error', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchExchangeRate('EUR') + + expect(result).not.toBeNull() + expect(result!.currency).toBe('EUR') + expect(result!.rate).toBeGreaterThan(0) + }) + + it('tries fallback URL when primary returns non-200', async () => { + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response('Not Found', { status: 404 })) + .mockResolvedValueOnce( + new Response(JSON.stringify([ + { value: '10.80', date: '2025-01-13' }, + { value: '10.85', date: '2025-01-14' }, + ]), { status: 200 }) + ) + + const result = await fetchExchangeRate('USD', new Date('2025-01-15')) + + expect(result).toEqual({ + currency: 'USD', + rate: 10.85, + date: '2025-01-14', + }) + }) +}) + +describe('fetchMultipleRates', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns Map with all requested currencies', async () => { + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '10.50', date: '2025-01-15' }]), { status: 200 }) + ) + + const result = await fetchMultipleRates(['EUR', 'USD']) + + expect(result.size).toBe(3) // EUR, USD, + always SEK + expect(result.get('SEK')!.rate).toBe(1) + expect(result.get('EUR')!.rate).toBe(11.42) + expect(result.get('USD')!.rate).toBe(10.50) + }) + + it('handles partial failure — returns fallback for failed currencies', async () => { + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + .mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchMultipleRates(['EUR', 'GBP']) + + expect(result.size).toBe(3) + expect(result.get('EUR')!.rate).toBe(11.42) + // GBP gets fallback rate (from the catch in fetchExchangeRate) + expect(result.get('GBP')).toBeDefined() + expect(result.get('GBP')!.rate).toBeGreaterThan(0) + }) + + it('returns only SEK when given empty array', async () => { + const result = await fetchMultipleRates([]) + expect(result.size).toBe(1) + expect(result.get('SEK')!.rate).toBe(1) + }) + + it('handles SEK in the input array without duplicate fetch', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + + const result = await fetchMultipleRates(['SEK', 'EUR']) + + expect(result.size).toBe(2) + expect(result.get('SEK')!.rate).toBe(1) + expect(result.get('EUR')!.rate).toBe(11.42) + }) +}) + +describe('fetchRateRange', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns sorted array of rates', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([ + { value: '11.40', date: '2025-01-13' }, + { value: '11.45', date: '2025-01-15' }, + { value: '11.42', date: '2025-01-14' }, + ]), { status: 200 }) + ) + + const result = await fetchRateRange( + 'EUR', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toHaveLength(3) + expect(result[0].date).toBe('2025-01-13') + expect(result[1].date).toBe('2025-01-14') + expect(result[2].date).toBe('2025-01-15') + }) + + it('returns [rate:1] for SEK', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await fetchRateRange( + 'SEK', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toHaveLength(1) + expect(result[0].rate).toBe(1) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('returns empty array on error', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchRateRange( + 'EUR', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toEqual([]) + }) + + it('returns empty array on non-200 response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response('Not Found', { status: 404 }) + ) + + const result = await fetchRateRange( + 'EUR', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toEqual([]) + }) +}) + +describe('fetchLatestRate', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns the last item from API response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([ + { value: '11.40', date: '2025-01-13' }, + { value: '11.42', date: '2025-01-14' }, + { value: '11.45', date: '2025-01-15' }, + ]), { status: 200 }) + ) + + const result = await fetchLatestRate('EUR') + + expect(result).toEqual({ + currency: 'EUR', + rate: 11.45, + date: '2025-01-15', + }) + }) + + it('returns rate 1 for SEK', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await fetchLatestRate('SEK') + + expect(result).toEqual({ + currency: 'SEK', + rate: 1, + date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('returns fallback on error', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchLatestRate('EUR') + + expect(result).not.toBeNull() + expect(result!.currency).toBe('EUR') + expect(result!.rate).toBeGreaterThan(0) + }) + + it('returns null on empty API response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([]), { status: 200 }) + ) + + const result = await fetchLatestRate('EUR') + + expect(result).toBeNull() + }) +}) + +describe('convertToSEK', () => { + it('converts amount correctly', () => { + expect(convertToSEK(100, 11.42)).toBe(1142) + }) + + it('handles zero amount', () => { + expect(convertToSEK(0, 11.42)).toBe(0) + }) +}) + +describe('formatCurrencyAmount', () => { + it('formats EUR with symbol prefix', () => { + const result = formatCurrencyAmount(1234.56, 'EUR') + // sv-SE uses non-breaking space as thousands separator + expect(result).toContain('€') + expect(result).toContain('1') + expect(result).toContain('234') + }) + + it('formats SEK with currency suffix', () => { + const result = formatCurrencyAmount(1234.56, 'SEK') + expect(result).toContain('SEK') + }) + + it('formats NOK with currency suffix', () => { + const result = formatCurrencyAmount(100, 'NOK') + expect(result).toContain('NOK') + }) +}) diff --git a/lib/currency/riksbanken.ts b/lib/currency/riksbanken.ts index 8e6d1376..ec922367 100644 --- a/lib/currency/riksbanken.ts +++ b/lib/currency/riksbanken.ts @@ -3,6 +3,16 @@ import type { Currency, ExchangeRate } from '@/types' const log = createLogger('riksbanken') +/** Riksbanken series IDs for each currency */ +const SERIES_IDS: Record = { + SEK: '', + EUR: 'SEKEURPMI', + USD: 'SEKUSDPMI', + GBP: 'SEKGBPPMI', + NOK: 'SEKNOKPMI', + DKK: 'SEKDKKPMI', +} + /** * Fetch exchange rates from Riksbanken API * Uses their public API for daily exchange rates @@ -22,17 +32,7 @@ export async function fetchExchangeRate( const targetDate = date || new Date() const formattedDate = targetDate.toISOString().split('T')[0] - // Riksbanken uses specific series IDs for each currency - const seriesIds: Record = { - SEK: '', - EUR: 'SEKEURPMI', - USD: 'SEKUSDPMI', - GBP: 'SEKGBPPMI', - NOK: 'SEKNOKPMI', - DKK: 'SEKDKKPMI', - } - - const seriesId = seriesIds[currency] + const seriesId = SERIES_IDS[currency] if (!seriesId) { log.error(`Unknown currency: ${currency}`) return null @@ -49,9 +49,16 @@ export async function fetchExchangeRate( next: { revalidate: 3600 }, // Cache for 1 hour }) - if (!response.ok) { - // If no rate for the specific date, try getting the latest available - const fallbackUrl = `https://api.riksbank.se/swea/v1/Observations/${seriesId}` + // 204 = no data for this date (e.g. rate not published yet today) + // Also handle non-ok responses by falling back to a recent date range + if (!response.ok || response.status === 204) { + // Fetch the last 7 days to find the most recent available rate + const to = formattedDate + const fromDate = new Date(targetDate) + fromDate.setDate(fromDate.getDate() - 7) + const from = fromDate.toISOString().split('T')[0] + + const fallbackUrl = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${from}/${to}` const fallbackResponse = await fetch(fallbackUrl, { headers: { Accept: 'application/json', @@ -59,7 +66,7 @@ export async function fetchExchangeRate( next: { revalidate: 3600 }, }) - if (!fallbackResponse.ok) { + if (!fallbackResponse.ok || fallbackResponse.status === 204) { throw new Error(`Failed to fetch exchange rate: ${fallbackResponse.status}`) } @@ -151,3 +158,145 @@ export function formatCurrencyAmount( return `${formatted} ${currency}` } + +/** + * Fetch exchange rates for multiple currencies in parallel. + * Returns a Map with all requested currencies. Individual failures + * use fallback rates so the Map is always fully populated. + * SEK is always included with rate 1. + */ +export async function fetchMultipleRates( + currencies: Currency[], + date?: Date +): Promise> { + const results = new Map() + + // Always include SEK + results.set('SEK', { + currency: 'SEK', + rate: 1, + date: (date || new Date()).toISOString().split('T')[0], + }) + + const nonSek = currencies.filter(c => c !== 'SEK') + if (nonSek.length === 0) return results + + const settled = await Promise.allSettled( + nonSek.map(currency => fetchExchangeRate(currency, date)) + ) + + for (let i = 0; i < nonSek.length; i++) { + const currency = nonSek[i] + const outcome = settled[i] + + if (outcome.status === 'fulfilled' && outcome.value) { + results.set(currency, outcome.value) + } else { + // fetchExchangeRate already returns fallback on error, + // but if it returned null or the promise rejected, use fallback + results.set(currency, getFallbackRate(currency)) + } + } + + return results +} + +/** + * Fetch exchange rates for a currency over a date range. + * Uses the Riksbanken date-range endpoint. Returns a sorted array. + */ +export async function fetchRateRange( + currency: Currency, + fromDate: Date, + toDate: Date +): Promise { + if (currency === 'SEK') { + return [{ + currency: 'SEK', + rate: 1, + date: fromDate.toISOString().split('T')[0], + }] + } + + const seriesId = SERIES_IDS[currency] + if (!seriesId) { + log.error(`Unknown currency: ${currency}`) + return [] + } + + const from = fromDate.toISOString().split('T')[0] + const to = toDate.toISOString().split('T')[0] + + try { + const url = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${from}/${to}` + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + log.error(`Failed to fetch rate range: ${response.status}`) + return [] + } + + const data = await response.json() + if (!Array.isArray(data)) return [] + + return data + .map((item: { date: string; value: string }) => ({ + currency, + rate: parseFloat(item.value), + date: item.date, + })) + .sort((a: ExchangeRate, b: ExchangeRate) => a.date.localeCompare(b.date)) + } catch (error) { + log.error('Error fetching rate range:', error) + return [] + } +} + +/** + * Fetch the latest available exchange rate for a currency. + * Useful when today's rate hasn't been published yet. + */ +export async function fetchLatestRate( + currency: Currency +): Promise { + if (currency === 'SEK') { + return { + currency: 'SEK', + rate: 1, + date: new Date().toISOString().split('T')[0], + } + } + + const seriesId = SERIES_IDS[currency] + if (!seriesId) { + log.error(`Unknown currency: ${currency}`) + return null + } + + try { + const url = `https://api.riksbank.se/swea/v1/Observations/${seriesId}` + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + log.error(`Failed to fetch latest rate: ${response.status}`) + return null + } + + const data = await response.json() + if (!Array.isArray(data) || data.length === 0) return null + + const latest = data[data.length - 1] + return { + currency, + rate: parseFloat(latest.value), + date: latest.date, + } + } catch (error) { + log.error('Error fetching latest rate:', error) + return getFallbackRate(currency) + } +} diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index c36bbfbb..88fa4a18 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -7,12 +7,12 @@ import { } from '../sectors' describe('sectors registry', () => { - it('should have 6 sectors', () => { - expect(SECTORS.length).toBe(6) + it('should have 7 sectors', () => { + expect(SECTORS.length).toBe(7) }) - it('should have 20 total extensions', () => { - expect(getAllExtensions().length).toBe(20) + it('should have 22 total extensions', () => { + expect(getAllExtensions().length).toBe(22) }) it('should have unique slugs within each sector', () => { diff --git a/lib/extensions/icon-resolver.tsx b/lib/extensions/icon-resolver.tsx index 8aaf1268..c2e47b8d 100644 --- a/lib/extensions/icon-resolver.tsx +++ b/lib/extensions/icon-resolver.tsx @@ -25,6 +25,9 @@ import { Layers, Puzzle, TextSearch, + Ship, + FileText, + Shield, type LucideIcon, } from 'lucide-react' @@ -55,6 +58,9 @@ const ICON_MAP: Record = { Layers, Puzzle, TextSearch, + Ship, + FileText, + Shield, } export function resolveIcon(name: string): LucideIcon { diff --git a/lib/extensions/loader.ts b/lib/extensions/loader.ts index dcf5ae57..0160a685 100644 --- a/lib/extensions/loader.ts +++ b/lib/extensions/loader.ts @@ -8,6 +8,10 @@ import { aiChatExtension } from '@/extensions/general/ai-chat' import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' import { calendarExtension } from '@/extensions/general/calendar' import { userDescriptionMatchExtension } from '@/extensions/general/user-description-match' +import { euSalesListExtension } from '@/extensions/export/eu-sales-list' +import { vatMonitorExtension } from '@/extensions/export/vat-monitor' +import { intrastatExtension } from '@/extensions/export/intrastat' +import { currencyReceivablesExtension } from '@/extensions/export/currency-receivables' import type { Extension } from './types' // ── Enable Banking (PSD2) — opt-in extension ─────────────────────────── @@ -33,6 +37,12 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [ calendarExtension, userDescriptionMatchExtension, // enableBankingExtension, // Uncomment to activate PSD2 bank sync + + // ── Export sector ────────────────────────────────────────── + euSalesListExtension, + vatMonitorExtension, + intrastatExtension, + currencyReceivablesExtension, ] let loaded = false diff --git a/lib/extensions/sectors.ts b/lib/extensions/sectors.ts index a4de9599..60b457fb 100644 --- a/lib/extensions/sectors.ts +++ b/lib/extensions/sectors.ts @@ -322,6 +322,68 @@ export const SECTORS: Sector[] = [ }, ], }, + + // ── Export ────────────────────────────────────────────── + { + slug: 'export', + name: 'Export & Utrikeshandel', + icon: 'Ship', + description: 'Branschverktyg för export och utrikeshandel', + extensions: [ + { + slug: 'eu-sales-list', + name: 'Periodisk sammanställning', + sector: 'export', + category: 'accounting', + icon: 'FileText', + dataPattern: 'core', + readsCoreTables: ['invoices', 'customers'], + hasOwnData: false, + description: 'Generera periodisk sammanställning (EC Sales List) för Skatteverket', + longDescription: + 'Sammanställer automatiskt alla momsfria EU-försäljningar grupperat per kund och momsregistreringsnummer. Genererar nedladdningsbar fil (CSV/XML) för uppladdning till Skatteverket. Validerar kundernas VAT-nummer via VIES och flaggar saknade uppgifter. Korsvaliderar mot momsdeklarationens ruta 35 och 39.', + }, + { + slug: 'vat-monitor', + name: 'Exportmoms-monitor', + sector: 'export', + category: 'reports', + icon: 'Shield', + dataPattern: 'core', + readsCoreTables: ['journal_entry_lines', 'journal_entries', 'invoices'], + hasOwnData: false, + description: 'Övervaka momsbehandling för export och EU-handel', + longDescription: + 'Visar intäkter uppdelat på inhemsk försäljning, EU B2B (reverse charge) och export utanför EU. Mappar automatiskt till rätt rutor i momsdeklarationen (ruta 05, 35, 36, 39, 40). Flaggar potentiella fel som saknat momsregistreringsnummer på EU-kunder eller felaktig momsbehandling.', + }, + { + slug: 'intrastat', + name: 'Intrastat-generator', + sector: 'export', + category: 'accounting', + icon: 'BarChart3', + dataPattern: 'both', + readsCoreTables: ['invoices', 'customers'], + hasOwnData: true, + description: 'Generera Intrastat-deklarationer för rapportering till SCB', + longDescription: + 'Tagga produkter med CN-koder (Combined Nomenclature), vikt och ursprungsland. Genererar kompletta Intrastat-deklarationer i CSV-format för uppladdning till SCB:s IDEP.web. Övervakar tröskelvärdet på 12 MSEK för utförsel och varnar när rapporteringsskyldighet uppstår.', + }, + { + slug: 'currency-receivables', + name: 'Valutafordringar', + sector: 'export', + category: 'reports', + icon: 'TrendingUp', + dataPattern: 'core', + readsCoreTables: ['invoices', 'journal_entry_lines', 'transactions'], + hasOwnData: false, + description: 'Övervaka valutaexponering och orealiserade kursvinster/-förluster', + longDescription: + 'Visar öppna kundfordringar per valuta med aktuellt SEK-värde baserat på Riksbankens dagskurser. Beräknar orealiserade valutakursvinster och -förluster. Visar realiserade kursdifferenser per period (konto 3960/7960). Ger en samlad bild av företagets valutarisk.', + }, + ], + }, ] // ============================================================ diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index fe70d047..4e38fdb9 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -10,7 +10,7 @@ import type { EntityType, RawTransaction, IngestResult } from '@/types' export type ExtensionCategory = 'accounting' | 'reports' | 'import' | 'operations' /** Sector slugs for extension organization */ -export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce' +export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce' | 'export' /** How an extension gets its data */ export type ExtensionDataPattern = 'core' | 'manual' | 'both' diff --git a/lib/extensions/use-mock-data.ts b/lib/extensions/use-mock-data.ts new file mode 100644 index 00000000..db01cd0e --- /dev/null +++ b/lib/extensions/use-mock-data.ts @@ -0,0 +1,85 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useExtensionData } from './use-extension-data' + +interface MockMeta { + importedAt: string + source: 'csv' | 'json' + fileName: string + rowCount: number +} + +interface UseMockDataResult { + mockReport: T | null + isMockActive: boolean + isLoading: boolean + importedAt: string | null + meta: MockMeta | null + saveMockData: (report: T, meta: Omit) => Promise + clearMockData: () => Promise +} + +export function useMockData(sector: string, slug: string): UseMockDataResult { + const { getByKey, save, remove, isLoading } = useExtensionData(sector, slug) + + const [mockReport, setMockReport] = useState(null) + const [isMockActive, setIsMockActive] = useState(false) + const [meta, setMeta] = useState(null) + + // Read mock state from extension data on load + useEffect(() => { + if (isLoading) return + + const enabledRecord = getByKey('mock:enabled') + const reportRecord = getByKey('mock:report') + const metaRecord = getByKey('mock:meta') + + if (enabledRecord && (enabledRecord.value as { enabled?: boolean }).enabled && reportRecord) { + setIsMockActive(true) + setMockReport(reportRecord.value as T) + if (metaRecord) { + setMeta(metaRecord.value as unknown as MockMeta) + } + } else { + setIsMockActive(false) + setMockReport(null) + setMeta(null) + } + }, [isLoading, getByKey]) + + const saveMockData = useCallback(async (report: T, metaInput: Omit) => { + const fullMeta: MockMeta = { + ...metaInput, + importedAt: new Date().toISOString(), + } + + await save('mock:enabled', { enabled: true }) + await save('mock:report', report as unknown as Record) + await save('mock:meta', fullMeta as unknown as Record) + + setIsMockActive(true) + setMockReport(report) + setMeta(fullMeta) + }, [save]) + + const clearMockData = useCallback(async () => { + await remove('mock:enabled') + await remove('mock:report') + await remove('mock:meta') + + setIsMockActive(false) + setMockReport(null) + setMeta(null) + }, [remove]) + + return { + mockReport, + isMockActive, + isLoading, + importedAt: meta?.importedAt ?? null, + meta, + saveMockData, + clearMockData, + } +} diff --git a/lib/extensions/workspace-registry.tsx b/lib/extensions/workspace-registry.tsx index c5f97517..2f34b2a4 100644 --- a/lib/extensions/workspace-registry.tsx +++ b/lib/extensions/workspace-registry.tsx @@ -34,6 +34,11 @@ const WORKSPACES: Record> = // E-commerce 'ecommerce/shopify-import': dynamic(() => import('@/components/extensions/ecommerce/ShopifyImportWorkspace')), 'ecommerce/multichannel-revenue': dynamic(() => import('@/components/extensions/ecommerce/MultichannelRevenueWorkspace')), + // Export + 'export/eu-sales-list': dynamic(() => import('@/components/extensions/export/EuSalesListWorkspace')), + 'export/vat-monitor': dynamic(() => import('@/components/extensions/export/VatMonitorWorkspace')), + 'export/intrastat': dynamic(() => import('@/components/extensions/export/IntrastatWorkspace')), + 'export/currency-receivables': dynamic(() => import('@/components/extensions/export/CurrencyReceivablesWorkspace')), } export function getWorkspaceComponent( diff --git a/lib/vat/__tests__/vies-client.test.ts b/lib/vat/__tests__/vies-client.test.ts new file mode 100644 index 00000000..bab2e42f --- /dev/null +++ b/lib/vat/__tests__/vies-client.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { parseVatNumber, validateVatFormat, validateVatNumber } from '../vies-client' + +// Mock logger to suppress output +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +describe('parseVatNumber', () => { + it('parses a DE VAT number', () => { + const result = parseVatNumber('DE123456789') + expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' }) + }) + + it('parses a SE VAT number', () => { + const result = parseVatNumber('SE123456789012') + expect(result).toEqual({ viesPrefix: 'SE', vatNumber: '123456789012' }) + }) + + it('maps GR to EL for Greece', () => { + const result = parseVatNumber('GR123456789') + expect(result).toEqual({ viesPrefix: 'EL', vatNumber: '123456789' }) + }) + + it('accepts EL prefix directly', () => { + const result = parseVatNumber('EL123456789') + expect(result).toEqual({ viesPrefix: 'EL', vatNumber: '123456789' }) + }) + + it('strips whitespace', () => { + const result = parseVatNumber('DE 123 456 789') + expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' }) + }) + + it('converts to uppercase', () => { + const result = parseVatNumber('de123456789') + expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' }) + }) + + it('rejects non-EU country prefix', () => { + expect(parseVatNumber('US123456789')).toBeNull() + }) + + it('rejects too-short input', () => { + expect(parseVatNumber('DE')).toBeNull() + }) + + it('parses FR VAT number with letters', () => { + const result = parseVatNumber('FRXX999999999') + expect(result).toEqual({ viesPrefix: 'FR', vatNumber: 'XX999999999' }) + }) +}) + +describe('validateVatFormat', () => { + it('validates DE format (9 digits)', () => { + expect(validateVatFormat('DE', '123456789')).toBe(true) + expect(validateVatFormat('DE', '12345678')).toBe(false) + expect(validateVatFormat('DE', '1234567890')).toBe(false) + }) + + it('validates SE format (12 digits)', () => { + expect(validateVatFormat('SE', '123456789012')).toBe(true) + expect(validateVatFormat('SE', '12345678901')).toBe(false) + }) + + it('validates EL (Greece) format (9 digits)', () => { + expect(validateVatFormat('EL', '123456789')).toBe(true) + expect(validateVatFormat('EL', '12345678')).toBe(false) + }) + + it('validates AT format (U + 8 digits)', () => { + expect(validateVatFormat('AT', 'U12345678')).toBe(true) + expect(validateVatFormat('AT', '12345678')).toBe(false) + }) + + it('validates NL format (9 digits + B + 2 digits)', () => { + expect(validateVatFormat('NL', '123456789B12')).toBe(true) + expect(validateVatFormat('NL', '123456789A12')).toBe(false) + }) + + it('validates FR format (2 alphanums + 9 digits)', () => { + expect(validateVatFormat('FR', 'XX999999999')).toBe(true) + expect(validateVatFormat('FR', '9999999999')).toBe(false) // only 10 chars + }) + + it('returns false for unknown prefix', () => { + expect(validateVatFormat('XX', '123456789')).toBe(false) + }) +}) + +describe('validateVatNumber', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns error for non-EU prefix', async () => { + const result = await validateVatNumber('US123456789') + expect(result.valid).toBe(false) + expect(result.error).toContain('non-EU') + }) + + it('returns error for invalid format without calling VIES', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await validateVatNumber('DE12345') // too short for DE + expect(result.valid).toBe(false) + expect(result.error).toContain('format') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('returns valid result from VIES API', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ + isValid: true, + name: 'Test Company GmbH', + address: 'Berlin, Germany', + }), { status: 200 }) + ) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(true) + expect(result.name).toBe('Test Company GmbH') + expect(result.address).toBe('Berlin, Germany') + expect(result.country_code).toBe('DE') + expect(result.vat_number).toBe('DE123456789') + }) + + it('returns invalid result from VIES API', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ isValid: false }), { status: 200 }) + ) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(false) + expect(result.country_code).toBe('DE') + }) + + it('handles VIES service unavailable (non-200)', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response('Service Unavailable', { status: 503 }) + ) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(false) + expect(result.error).toContain('unavailable') + }) + + it('handles network error gracefully', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(false) + expect(result.error).toContain('unavailable') + }) + + it('handles GR→EL mapping in API call', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ isValid: true }), { status: 200 }) + ) + + await validateVatNumber('GR123456789') + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/ms/EL/vat/'), + expect.any(Object) + ) + }) +}) diff --git a/lib/vat/vies-client.ts b/lib/vat/vies-client.ts new file mode 100644 index 00000000..eabf74ef --- /dev/null +++ b/lib/vat/vies-client.ts @@ -0,0 +1,147 @@ +import { createLogger } from '@/lib/logger' +import { EU_COUNTRIES } from '@/extensions/export/shared/eu-countries' +import type { VatValidationResult } from '@/types' + +const log = createLogger('vies-client') + +const VIES_TIMEOUT_MS = 10_000 + +/** + * VAT format patterns per VIES country prefix. + * Greece uses 'EL' as its VIES prefix (not 'GR'). + */ +const VAT_FORMAT_PATTERNS: Record = { + AT: /^U\d{8}$/, + BE: /^0\d{9}$/, + BG: /^\d{9,10}$/, + CY: /^\d{8}[A-Z]$/, + CZ: /^\d{8,10}$/, + DE: /^\d{9}$/, + DK: /^\d{8}$/, + EE: /^\d{9}$/, + EL: /^\d{9}$/, + ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/, + FI: /^\d{8}$/, + FR: /^[A-Z0-9]{2}\d{9}$/, + HR: /^\d{11}$/, + HU: /^\d{8}$/, + IE: /^[0-9A-Z]{8,9}$/, + IT: /^\d{11}$/, + LT: /^\d{9,12}$/, + LU: /^\d{8}$/, + LV: /^\d{11}$/, + MT: /^\d{8}$/, + NL: /^\d{9}B\d{2}$/, + PL: /^\d{10}$/, + PT: /^\d{9}$/, + RO: /^\d{2,10}$/, + SE: /^\d{12}$/, + SI: /^\d{8}$/, + SK: /^\d{10}$/, +} + +/** Valid VIES prefixes (derived from EU_COUNTRIES vatPrefix values) */ +const VALID_VIES_PREFIXES = new Set(EU_COUNTRIES.map(c => c.vatPrefix)) + +/** + * Parse a raw VAT number into its VIES prefix and numeric part. + * Handles the GR → EL mapping automatically. + * + * @returns `{ viesPrefix, vatNumber }` or `null` if the prefix is not a valid EU country + */ +export function parseVatNumber(raw: string): { viesPrefix: string; vatNumber: string } | null { + const cleaned = raw.replace(/\s/g, '').toUpperCase() + + if (cleaned.length < 3) return null + + const countryPrefix = cleaned.substring(0, 2) + const vatNumber = cleaned.substring(2) + + // Map GR → EL for Greece (VIES uses EL, not GR) + let viesPrefix = countryPrefix + if (countryPrefix === 'GR') { + viesPrefix = 'EL' + } + + if (!VALID_VIES_PREFIXES.has(viesPrefix)) { + return null + } + + return { viesPrefix, vatNumber } +} + +/** + * Validate the format of a VAT number against country-specific patterns. + */ +export function validateVatFormat(viesPrefix: string, vatNumber: string): boolean { + const pattern = VAT_FORMAT_PATTERNS[viesPrefix] + if (!pattern) return false + return pattern.test(vatNumber) +} + +/** + * Validate a VAT number against the EU VIES REST API. + * + * 1. Parses the prefix and number + * 2. Checks format locally + * 3. Calls the VIES REST API with a 10s timeout + * 4. Returns a VatValidationResult + */ +export async function validateVatNumber(rawVatNumber: string): Promise { + const parsed = parseVatNumber(rawVatNumber) + + if (!parsed) { + return { valid: false, error: 'Invalid or non-EU country prefix' } + } + + const { viesPrefix, vatNumber } = parsed + + if (!validateVatFormat(viesPrefix, vatNumber)) { + return { + valid: false, + country_code: viesPrefix, + vat_number: `${viesPrefix}${vatNumber}`, + error: 'Invalid VAT number format', + } + } + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), VIES_TIMEOUT_MS) + + const response = await fetch( + `https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${viesPrefix}/vat/${vatNumber}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + } + ) + + clearTimeout(timeout) + + if (!response.ok) { + return { + valid: false, + error: 'VAT validation service unavailable. Please try again later.', + } + } + + const data = await response.json() + const isValid = data.isValid === true + + return { + valid: isValid, + name: data.name || undefined, + address: data.address || undefined, + country_code: viesPrefix, + vat_number: `${viesPrefix}${vatNumber}`, + } + } catch (error) { + log.error('VIES API error:', error) + return { + valid: false, + error: 'Could not verify VAT number. Service temporarily unavailable.', + } + } +} diff --git a/mock_data/exportmoms-monitor.json b/mock_data/exportmoms-monitor.json new file mode 100644 index 00000000..b12c85b3 --- /dev/null +++ b/mock_data/exportmoms-monitor.json @@ -0,0 +1,56 @@ +{ + "period": { "year": 2025, "month": 12 }, + "boxes": [ + { "boxNumber": "05", "label": "Momspliktiga intakter", "amount": 1850000, "accounts": ["3001", "3002", "3003"] }, + { "boxNumber": "10", "label": "Utgaende moms 25%", "amount": 375000, "accounts": ["2611"] }, + { "boxNumber": "11", "label": "Utgaende moms 12%", "amount": 18000, "accounts": ["2621"] }, + { "boxNumber": "12", "label": "Utgaende moms 6%", "amount": 4500, "accounts": ["2631"] }, + { "boxNumber": "35", "label": "Varuforsal jning till annat EU-land", "amount": 711500, "accounts": ["3305"] }, + { "boxNumber": "36", "label": "Tjansteforsal jning till annat EU-land", "amount": 405000, "accounts": ["3308"] }, + { "boxNumber": "38", "label": "Exportforsal jning utanfor EU", "amount": 230000, "accounts": ["3305"] }, + { "boxNumber": "39", "label": "Omvand skattskyldighet — inkop", "amount": 60000, "accounts": [] }, + { "boxNumber": "40", "label": "Inkop varor fran EU", "amount": 185000, "accounts": ["4515"] }, + { "boxNumber": "48", "label": "Ingaende moms", "amount": 289000, "accounts": ["2641", "2645"] }, + { "boxNumber": "49", "label": "Moms att betala", "amount": 108500, "accounts": [] } + ], + "revenueBreakdown": { + "domestic": { "amount": 1850000, "percentage": 57 }, + "euGoods": { "amount": 711500, "percentage": 22 }, + "euServices": { "amount": 405000, "percentage": 12 }, + "exportGoods": { "amount": 230000, "percentage": 7 }, + "exportServices": { "amount": 0, "percentage": 0 }, + "triangular": { "amount": 54000, "percentage": 2 }, + "totalRevenue": 3250500 + }, + "vatSummary": { + "outputVat25": 375000, + "outputVat12": 18000, + "outputVat6": 4500, + "totalOutputVat": 397500, + "inputVat": 289000, + "netVat": 108500, + "isRefund": false + }, + "warnings": [ + { + "type": "box_mismatch", + "severity": "warning", + "message": "Ruta 39 (omvand skattskyldighet) har 60 000 SEK men inga matchande kontoposter hittades. Kontrollera bokforingen." + }, + { + "type": "high_input_vat_ratio", + "severity": "warning", + "message": "Ingaende moms (289 000 SEK) utgor 73% av utgaende moms. Kontrollera att alla avdrag ar korrekta." + } + ], + "comparison": { + "domestic": { "current": 1850000, "previous": 1620000, "change": 230000, "changePercent": 14 }, + "euGoods": { "current": 711500, "previous": 580000, "change": 131500, "changePercent": 23 }, + "euServices": { "current": 405000, "previous": 390000, "change": 15000, "changePercent": 4 }, + "exportGoods": { "current": 230000, "previous": 310000, "change": -80000, "changePercent": -26 }, + "exportServices": { "current": 0, "previous": 0, "change": 0, "changePercent": null }, + "triangular": { "current": 54000, "previous": 0, "change": 54000, "changePercent": null }, + "totalRevenue": { "current": 3250500, "previous": 2900000, "change": 350500, "changePercent": 12 }, + "netVat": { "current": 108500, "previous": 95200, "change": 13300, "changePercent": 14 } + } +} diff --git a/mock_data/intrastat-generator.json b/mock_data/intrastat-generator.json new file mode 100644 index 00000000..31e115b7 --- /dev/null +++ b/mock_data/intrastat-generator.json @@ -0,0 +1,116 @@ +{ + "period": { "year": 2025, "month": 12 }, + "reporterVatNumber": "SE556677889901", + "reporterName": "Testbolaget AB", + "lines": [ + { + "cnCode": "72163100", + "partnerCountry": "DE", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 245000, + "netMass": 4500, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "DE123456789" + }, + { + "cnCode": "84713000", + "partnerCountry": "FR", + "countryOfOrigin": "CN", + "transactionNature": "11", + "deliveryTerms": "EXW", + "invoicedValue": 128000, + "netMass": 85, + "supplementaryUnit": 40, + "supplementaryUnitType": "st", + "partnerVatId": "FR98765432101" + }, + { + "cnCode": "39269090", + "partnerCountry": "NL", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "FCA", + "invoicedValue": 78000, + "netMass": 620, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "NL456789012B01" + }, + { + "cnCode": "85176200", + "partnerCountry": "FI", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 56000, + "netMass": 12, + "supplementaryUnit": 200, + "supplementaryUnitType": "st", + "partnerVatId": "FI12345678" + }, + { + "cnCode": "72163100", + "partnerCountry": "ES", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "CIF", + "invoicedValue": 132000, + "netMass": 2800, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "ES87654321A" + }, + { + "cnCode": "73064090", + "partnerCountry": "IT", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 67500, + "netMass": 1450, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "IT01234567890" + }, + { + "cnCode": "44079910", + "partnerCountry": "PL", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "FCA", + "invoicedValue": 189000, + "netMass": 18600, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "PL5678901234" + }, + { + "cnCode": "84713000", + "partnerCountry": "DE", + "countryOfOrigin": "TW", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 94000, + "netMass": 62, + "supplementaryUnit": 30, + "supplementaryUnitType": "st", + "partnerVatId": "DE123456789" + } + ], + "totals": { + "invoicedValue": 989500, + "netMass": 28129, + "lineCount": 8 + }, + "thresholdStatus": { + "cumulativeValue": 7850000, + "threshold": 9000000, + "isObligated": false, + "percentageUsed": 87 + }, + "warnings": [], + "invoiceCount": 14 +} diff --git a/mock_data/periodisk-sammanstallning.json b/mock_data/periodisk-sammanstallning.json new file mode 100644 index 00000000..152a4b15 --- /dev/null +++ b/mock_data/periodisk-sammanstallning.json @@ -0,0 +1,115 @@ +{ + "period": { "year": 2025, "quarter": 4 }, + "filingType": "quarterly", + "reporterVatNumber": "SE556677889901", + "reporterName": "Testbolaget AB", + "lines": [ + { + "customerVatNumber": "DE123456789", + "customerName": "Berliner Maschinenbau GmbH", + "customerCountry": "DE", + "customerId": "cust-001", + "goodsAmount": 245000, + "servicesAmount": 0, + "triangulationAmount": 0, + "invoiceCount": 4 + }, + { + "customerVatNumber": "FR98765432101", + "customerName": "Lyon Digital SARL", + "customerCountry": "FR", + "customerId": "cust-002", + "goodsAmount": 0, + "servicesAmount": 185000, + "triangulationAmount": 0, + "invoiceCount": 3 + }, + { + "customerVatNumber": "NL456789012B01", + "customerName": "Amsterdam Trading BV", + "customerCountry": "NL", + "customerId": "cust-003", + "goodsAmount": 78000, + "servicesAmount": 42000, + "triangulationAmount": 0, + "invoiceCount": 2 + }, + { + "customerVatNumber": "FI12345678", + "customerName": "Helsinki Solutions Oy", + "customerCountry": "FI", + "customerId": "cust-004", + "goodsAmount": 0, + "servicesAmount": 96000, + "triangulationAmount": 0, + "invoiceCount": 1 + }, + { + "customerVatNumber": "ES87654321A", + "customerName": "Barcelona Componentes SL", + "customerCountry": "ES", + "customerId": "cust-005", + "goodsAmount": 132000, + "servicesAmount": 0, + "triangulationAmount": 54000, + "invoiceCount": 3 + }, + { + "customerVatNumber": "IT01234567890", + "customerName": "Milano Engineering SpA", + "customerCountry": "IT", + "customerId": "cust-006", + "goodsAmount": 67500, + "servicesAmount": 28000, + "triangulationAmount": 0, + "invoiceCount": 2 + }, + { + "customerVatNumber": "PL5678901234", + "customerName": "Warszawa Logistik Sp. z o.o.", + "customerCountry": "PL", + "customerId": "cust-007", + "goodsAmount": 189000, + "servicesAmount": 0, + "triangulationAmount": 0, + "invoiceCount": 5 + }, + { + "customerVatNumber": "DK12345678", + "customerName": "Kobenhavn Konsult ApS", + "customerCountry": "DK", + "customerId": "cust-008", + "goodsAmount": 0, + "servicesAmount": 54000, + "triangulationAmount": 0, + "invoiceCount": 1 + } + ], + "totals": { + "goods": 711500, + "services": 405000, + "triangulation": 54000, + "total": 1170500 + }, + "warnings": [ + { + "type": "missing_vat_validation", + "severity": "warning", + "customerId": "cust-005", + "customerName": "Barcelona Componentes SL", + "message": "VAT-nummer ES87654321A har inte validerats mot VIES. Verifiera innan inlämning." + } + ], + "crossCheck": { + "box35Match": true, + "box35ReportTotal": 711500, + "box35GLTotal": 711500, + "box39Match": false, + "box39ReportTotal": 405000, + "box39GLTotal": 403800 + }, + "invoiceCount": 21, + "customerCount": 8, + "deadline": "2026-02-20", + "daysUntilDeadline": 14 +} diff --git a/mock_data/valutafordringar.json b/mock_data/valutafordringar.json new file mode 100644 index 00000000..6f8af6aa --- /dev/null +++ b/mock_data/valutafordringar.json @@ -0,0 +1,245 @@ +{ + "referenceDate": "2025-12-15", + "exchangeRates": [ + { "currency": "EUR", "rate": 11.4215, "date": "2025-12-15" }, + { "currency": "USD", "rate": 10.3870, "date": "2025-12-15" }, + { "currency": "GBP", "rate": 13.5420, "date": "2025-12-15" }, + { "currency": "NOK", "rate": 0.9845, "date": "2025-12-15" } + ], + "exposureByCurrency": [ + { + "currency": "EUR", + "totalForeignAmount": 48500, + "bookedSekValue": 541350, + "currentSekValue": 553943, + "unrealizedGainLoss": 12593, + "invoiceCount": 4, + "averageBookedRate": 11.1619, + "currentRate": 11.4215 + }, + { + "currency": "USD", + "totalForeignAmount": 72000, + "bookedSekValue": 741600, + "currentSekValue": 747864, + "unrealizedGainLoss": 6264, + "invoiceCount": 3, + "averageBookedRate": 10.3000, + "currentRate": 10.3870 + }, + { + "currency": "GBP", + "totalForeignAmount": 15000, + "bookedSekValue": 199500, + "currentSekValue": 203130, + "unrealizedGainLoss": 3630, + "invoiceCount": 1, + "averageBookedRate": 13.3000, + "currentRate": 13.5420 + }, + { + "currency": "NOK", + "totalForeignAmount": 320000, + "bookedSekValue": 316800, + "currentSekValue": 315040, + "unrealizedGainLoss": -1760, + "invoiceCount": 2, + "averageBookedRate": 0.9900, + "currentRate": 0.9845 + } + ], + "receivables": [ + { + "invoiceId": "inv-1001", + "invoiceNumber": "1001", + "customerName": "Berliner Maschinenbau GmbH", + "customerCountry": "DE", + "currency": "EUR", + "foreignAmount": 22000, + "bookedSekAmount": 245300, + "bookedRate": 11.15, + "currentSekAmount": 251273, + "currentRate": 11.4215, + "unrealizedGainLoss": 5973, + "invoiceDate": "2025-10-15", + "dueDate": "2025-12-15", + "daysOutstanding": 61 + }, + { + "invoiceId": "inv-1008", + "invoiceNumber": "1008", + "customerName": "Lyon Digital SARL", + "customerCountry": "FR", + "currency": "EUR", + "foreignAmount": 14500, + "bookedSekAmount": 163050, + "bookedRate": 11.245, + "currentSekAmount": 165612, + "currentRate": 11.4215, + "unrealizedGainLoss": 2562, + "invoiceDate": "2025-11-05", + "dueDate": "2026-01-05", + "daysOutstanding": 40 + }, + { + "invoiceId": "inv-1012", + "invoiceNumber": "1012", + "customerName": "Amsterdam Trading BV", + "customerCountry": "NL", + "currency": "EUR", + "foreignAmount": 8000, + "bookedSekAmount": 89600, + "bookedRate": 11.20, + "currentSekAmount": 91372, + "currentRate": 11.4215, + "unrealizedGainLoss": 1772, + "invoiceDate": "2025-11-20", + "dueDate": "2025-12-20", + "daysOutstanding": 25 + }, + { + "invoiceId": "inv-1015", + "invoiceNumber": "1015", + "customerName": "Helsinki Solutions Oy", + "customerCountry": "FI", + "currency": "EUR", + "foreignAmount": 4000, + "bookedSekAmount": 43400, + "bookedRate": 10.85, + "currentSekAmount": 45686, + "currentRate": 11.4215, + "unrealizedGainLoss": 2286, + "invoiceDate": "2025-12-01", + "dueDate": "2026-01-01", + "daysOutstanding": 14 + }, + { + "invoiceId": "inv-1003", + "invoiceNumber": "1003", + "customerName": "New York Consulting Inc", + "customerCountry": "US", + "currency": "USD", + "foreignAmount": 35000, + "bookedSekAmount": 360500, + "bookedRate": 10.30, + "currentSekAmount": 363545, + "currentRate": 10.3870, + "unrealizedGainLoss": 3045, + "invoiceDate": "2025-09-28", + "dueDate": "2025-11-28", + "daysOutstanding": 78 + }, + { + "invoiceId": "inv-1009", + "invoiceNumber": "1009", + "customerName": "Chicago Parts LLC", + "customerCountry": "US", + "currency": "USD", + "foreignAmount": 22000, + "bookedSekAmount": 224400, + "bookedRate": 10.20, + "currentSekAmount": 228514, + "currentRate": 10.3870, + "unrealizedGainLoss": 4114, + "invoiceDate": "2025-10-20", + "dueDate": "2025-12-20", + "daysOutstanding": 56 + }, + { + "invoiceId": "inv-1018", + "invoiceNumber": "1018", + "customerName": "San Francisco Tech Corp", + "customerCountry": "US", + "currency": "USD", + "foreignAmount": 15000, + "bookedSekAmount": 156700, + "bookedRate": 10.4467, + "currentSekAmount": 155805, + "currentRate": 10.3870, + "unrealizedGainLoss": -895, + "invoiceDate": "2025-12-05", + "dueDate": "2026-02-05", + "daysOutstanding": 10 + }, + { + "invoiceId": "inv-1010", + "invoiceNumber": "1010", + "customerName": "London Engineering Ltd", + "customerCountry": "GB", + "currency": "GBP", + "foreignAmount": 15000, + "bookedSekAmount": 199500, + "bookedRate": 13.30, + "currentSekAmount": 203130, + "currentRate": 13.5420, + "unrealizedGainLoss": 3630, + "invoiceDate": "2025-11-01", + "dueDate": "2026-01-01", + "daysOutstanding": 44 + }, + { + "invoiceId": "inv-1005", + "invoiceNumber": "1005", + "customerName": "Oslo Shipping AS", + "customerCountry": "NO", + "currency": "NOK", + "foreignAmount": 200000, + "bookedSekAmount": 198000, + "bookedRate": 0.99, + "currentSekAmount": 196900, + "currentRate": 0.9845, + "unrealizedGainLoss": -1100, + "invoiceDate": "2025-10-10", + "dueDate": "2025-12-10", + "daysOutstanding": 66 + }, + { + "invoiceId": "inv-1016", + "invoiceNumber": "1016", + "customerName": "Bergen Industri AS", + "customerCountry": "NO", + "currency": "NOK", + "foreignAmount": 120000, + "bookedSekAmount": 118800, + "bookedRate": 0.99, + "currentSekAmount": 118140, + "currentRate": 0.9845, + "unrealizedGainLoss": -660, + "invoiceDate": "2025-11-22", + "dueDate": "2026-01-22", + "daysOutstanding": 23 + } + ], + "realizedGainLoss": { + "year": 2025, + "gains": 28450, + "losses": 7820, + "net": 20630 + }, + "monthlyTrend": [ + { "month": "2025-01", "realizedGains": 1200, "realizedLosses": 0, "netRealized": 1200 }, + { "month": "2025-02", "realizedGains": 0, "realizedLosses": 890, "netRealized": -890 }, + { "month": "2025-03", "realizedGains": 3400, "realizedLosses": 0, "netRealized": 3400 }, + { "month": "2025-04", "realizedGains": 2100, "realizedLosses": 1250, "netRealized": 850 }, + { "month": "2025-05", "realizedGains": 0, "realizedLosses": 2300, "netRealized": -2300 }, + { "month": "2025-06", "realizedGains": 4500, "realizedLosses": 0, "netRealized": 4500 }, + { "month": "2025-07", "realizedGains": 1850, "realizedLosses": 680, "netRealized": 1170 }, + { "month": "2025-08", "realizedGains": 3200, "realizedLosses": 0, "netRealized": 3200 }, + { "month": "2025-09", "realizedGains": 5600, "realizedLosses": 1400, "netRealized": 4200 }, + { "month": "2025-10", "realizedGains": 2800, "realizedLosses": 0, "netRealized": 2800 }, + { "month": "2025-11", "realizedGains": 1500, "realizedLosses": 1300, "netRealized": 200 }, + { "month": "2025-12", "realizedGains": 2300, "realizedLosses": 0, "netRealized": 2300 } + ], + "revalPreview": { + "totalUnrealizedGainLoss": 20727, + "gains": 22487, + "losses": 1760 + }, + "totals": { + "bookedSekValue": 1799250, + "currentSekValue": 1819977, + "totalUnrealizedGainLoss": 20727, + "receivableCount": 10, + "currencyCount": 4 + } +} diff --git a/scripts/seed-export-data.mjs b/scripts/seed-export-data.mjs new file mode 100644 index 00000000..620e88aa --- /dev/null +++ b/scripts/seed-export-data.mjs @@ -0,0 +1,554 @@ +/** + * Seed script: populate data for export extensions + * + * Creates EU customers, foreign-currency invoices, and journal entries + * so that all 4 export extensions (EU Sales List, VAT Monitor, Intrastat, + * Currency Receivables) have data to display. + * + * Usage: node scripts/seed-export-data.mjs + */ + +import { createClient } from '@supabase/supabase-js' +import 'dotenv/config' + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +// ── Helpers ───────────────────────────────────────────────── + +function round2(n) { + return Math.round(n * 100) / 100 +} + +function randomId() { + return crypto.randomUUID() +} + +function today() { + return new Date().toISOString().split('T')[0] +} + +function daysAgo(n) { + const d = new Date() + d.setDate(d.getDate() - n) + return d.toISOString().split('T')[0] +} + +function daysFromNow(n) { + const d = new Date() + d.setDate(d.getDate() + n) + return d.toISOString().split('T')[0] +} + +// ── Main ──────────────────────────────────────────────────── + +async function main() { + // 1. Find the user + const { data: { users }, error: usersError } = await supabase.auth.admin.listUsers() + if (usersError) { + console.error('Failed to list users:', usersError.message) + process.exit(1) + } + + if (users.length === 0) { + console.error('No users found. Please sign up first.') + process.exit(1) + } + + const user = users[0] + const userId = user.id + console.log(`Using user: ${user.email} (${userId})`) + + // 2. Ensure company settings exist + const { data: company, error: companyError } = await supabase + .from('company_settings') + .select('*') + .eq('user_id', userId) + .single() + + if (companyError || !company) { + console.error('No company_settings found. Complete onboarding first.') + process.exit(1) + } + + console.log(`Company: ${company.company_name || '(unnamed)'}`) + + // 3. Ensure fiscal period exists for current year + const year = new Date().getFullYear() + const periodStart = `${year}-01-01` + const periodEnd = `${year}-12-31` + + let { data: fiscalPeriod } = await supabase + .from('fiscal_periods') + .select('*') + .eq('user_id', userId) + .lte('period_start', today()) + .gte('period_end', today()) + .limit(1) + .single() + + if (!fiscalPeriod) { + console.log(`Creating fiscal period for ${year}...`) + const { data: newPeriod, error: periodError } = await supabase + .from('fiscal_periods') + .insert({ + id: randomId(), + user_id: userId, + name: `Räkenskapsår ${year}`, + period_start: periodStart, + period_end: periodEnd, + is_closed: false, + }) + .select() + .single() + + if (periodError) { + console.error('Failed to create fiscal period:', periodError.message) + process.exit(1) + } + fiscalPeriod = newPeriod + } + + console.log(`Fiscal period: ${fiscalPeriod.name} (${fiscalPeriod.period_start} – ${fiscalPeriod.period_end})`) + + // 4. Create EU customers + const customers = [ + { + id: randomId(), + user_id: userId, + name: 'TechHaus GmbH', + customer_type: 'eu_business', + email: 'billing@techhaus.de', + country: 'Germany', + org_number: 'HRB 12345', + vat_number: 'DE123456789', + vat_number_validated: true, + default_payment_terms: 30, + address_line1: 'Friedrichstraße 42', + postal_code: '10117', + city: 'Berlin', + }, + { + id: randomId(), + user_id: userId, + name: 'Suomen Softworks Oy', + customer_type: 'eu_business', + email: 'invoices@suomensoftworks.fi', + country: 'Finland', + org_number: '1234567-8', + vat_number: 'FI12345678', + vat_number_validated: true, + default_payment_terms: 30, + address_line1: 'Mannerheimintie 10', + postal_code: '00100', + city: 'Helsinki', + }, + { + id: randomId(), + user_id: userId, + name: 'Oranje Logistics B.V.', + customer_type: 'eu_business', + email: 'finance@oranjelogistics.nl', + country: 'Netherlands', + org_number: 'KvK 87654321', + vat_number: 'NL123456789B01', + vat_number_validated: true, + default_payment_terms: 14, + address_line1: 'Keizersgracht 120', + postal_code: '1015 AA', + city: 'Amsterdam', + }, + ] + + console.log('\nCreating 3 EU customers...') + const { error: custError } = await supabase.from('customers').insert(customers) + if (custError) { + console.error('Failed to create customers:', custError.message) + process.exit(1) + } + for (const c of customers) { + console.log(` ✓ ${c.name} (${c.vat_number})`) + } + + // 5. Create invoices + // Mix of: SEK reverse_charge, EUR reverse_charge, USD + const nextNum = company.next_invoice_number || 1 + + const invoices = [ + // Invoice 1: SEK reverse_charge to German customer (for EU Sales List — goods) + { + id: randomId(), + user_id: userId, + customer_id: customers[0].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum).padStart(4, '0')}`, + invoice_date: daysAgo(20), + due_date: daysFromNow(10), + status: 'sent', + currency: 'SEK', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 85000, + subtotal_sek: 85000, + vat_amount: 0, + vat_amount_sek: 0, + total: 85000, + total_sek: 85000, + exchange_rate: null, + moms_ruta: '35', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 2: EUR reverse_charge to Finnish customer (for EU Sales List — services + Currency Receivables) + { + id: randomId(), + user_id: userId, + customer_id: customers[1].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 1).padStart(4, '0')}`, + invoice_date: daysAgo(15), + due_date: daysFromNow(15), + status: 'sent', + currency: 'EUR', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 5000, + subtotal_sek: 57500, + vat_amount: 0, + vat_amount_sek: 0, + total: 5000, + total_sek: 57500, + exchange_rate: 11.50, + exchange_rate_date: daysAgo(15), + moms_ruta: '39', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 3: EUR reverse_charge to Dutch customer — goods (for EU Sales List + Currency Receivables) + { + id: randomId(), + user_id: userId, + customer_id: customers[2].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 2).padStart(4, '0')}`, + invoice_date: daysAgo(10), + due_date: daysFromNow(20), + status: 'sent', + currency: 'EUR', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 12000, + subtotal_sek: 138000, + vat_amount: 0, + vat_amount_sek: 0, + total: 12000, + total_sek: 138000, + exchange_rate: 11.50, + exchange_rate_date: daysAgo(10), + moms_ruta: '35', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 4: USD to Dutch customer — overdue (for Currency Receivables) + { + id: randomId(), + user_id: userId, + customer_id: customers[2].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 3).padStart(4, '0')}`, + invoice_date: daysAgo(45), + due_date: daysAgo(15), + status: 'overdue', + currency: 'USD', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 8500, + subtotal_sek: 91800, + vat_amount: 0, + vat_amount_sek: 0, + total: 8500, + total_sek: 91800, + exchange_rate: 10.80, + exchange_rate_date: daysAgo(45), + moms_ruta: '35', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 5: Paid SEK reverse_charge to Finnish customer (for EU Sales List history) + { + id: randomId(), + user_id: userId, + customer_id: customers[1].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 4).padStart(4, '0')}`, + invoice_date: daysAgo(60), + due_date: daysAgo(30), + status: 'paid', + currency: 'SEK', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 42000, + subtotal_sek: 42000, + vat_amount: 0, + vat_amount_sek: 0, + total: 42000, + total_sek: 42000, + exchange_rate: null, + moms_ruta: '39', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + ] + + console.log('\nCreating 5 invoices...') + const { error: invError } = await supabase.from('invoices').insert(invoices) + if (invError) { + console.error('Failed to create invoices:', invError.message) + process.exit(1) + } + + for (const inv of invoices) { + const cust = customers.find(c => c.id === inv.customer_id) + console.log(` ✓ ${inv.invoice_number} — ${cust.name} — ${inv.currency} ${inv.total} (${inv.status})`) + } + + // 6. Create invoice items + const invoiceItems = [ + // Invoice 1 items (SEK goods to Germany) + { id: randomId(), invoice_id: invoices[0].id, description: 'Industrial sensors batch', quantity: 50, unit: 'st', unit_price: 1200, line_total: 60000, sort_order: 1 }, + { id: randomId(), invoice_id: invoices[0].id, description: 'Installation & calibration', quantity: 10, unit: 'tim', unit_price: 2500, line_total: 25000, sort_order: 2 }, + // Invoice 2 items (EUR services to Finland) + { id: randomId(), invoice_id: invoices[1].id, description: 'Software consulting', quantity: 40, unit: 'tim', unit_price: 125, line_total: 5000, sort_order: 1 }, + // Invoice 3 items (EUR goods to Netherlands) + { id: randomId(), invoice_id: invoices[2].id, description: 'Steel components CN:72163100', quantity: 200, unit: 'st', unit_price: 45, line_total: 9000, sort_order: 1 }, + { id: randomId(), invoice_id: invoices[2].id, description: 'Aluminium fittings CN:76169990', quantity: 100, unit: 'st', unit_price: 30, line_total: 3000, sort_order: 2 }, + // Invoice 4 items (USD goods to Netherlands) + { id: randomId(), invoice_id: invoices[3].id, description: 'Custom machine parts', quantity: 25, unit: 'st', unit_price: 340, line_total: 8500, sort_order: 1 }, + // Invoice 5 items (SEK services to Finland) + { id: randomId(), invoice_id: invoices[4].id, description: 'IT architecture review', quantity: 24, unit: 'tim', unit_price: 1750, line_total: 42000, sort_order: 1 }, + ] + + const { error: itemsError } = await supabase.from('invoice_items').insert(invoiceItems) + if (itemsError) { + console.error('Failed to create invoice items:', itemsError.message) + process.exit(1) + } + console.log(` ✓ ${invoiceItems.length} invoice items created`) + + // Update next_invoice_number + await supabase + .from('company_settings') + .update({ next_invoice_number: nextNum + 5 }) + .eq('user_id', userId) + + // 7. Create journal entries for the invoices (reverse charge: debit 1510, credit 3305/3308) + // These are needed for VAT Monitor and EU Sales List cross-check + const journalEntries = [] + const journalLines = [] + + // Get current max voucher number + const { data: maxVoucher } = await supabase + .from('journal_entries') + .select('voucher_number') + .eq('user_id', userId) + .order('voucher_number', { ascending: false }) + .limit(1) + .single() + + let voucherNum = (maxVoucher?.voucher_number || 0) + 1 + + for (const inv of invoices) { + const entryId = randomId() + const totalSEK = inv.total_sek + + // Determine revenue account: goods = 3305, services = 3308 + // moms_ruta 35 = goods, 39 = services + const revenueAccount = inv.moms_ruta === '35' ? '3305' : '3308' + + journalEntries.push({ + id: entryId, + user_id: userId, + fiscal_period_id: fiscalPeriod.id, + voucher_number: voucherNum++, + voucher_series: 'A', + entry_date: inv.invoice_date, + description: `Faktura ${inv.invoice_number} — ${customers.find(c => c.id === inv.customer_id).name}`, + source_type: 'invoice_created', + source_id: inv.id, + status: 'posted', + committed_at: new Date().toISOString(), + }) + + // Debit 1510 (accounts receivable) + journalLines.push({ + id: randomId(), + journal_entry_id: entryId, + account_number: '1510', + debit_amount: round2(totalSEK), + credit_amount: 0, + currency: inv.currency, + amount_in_currency: inv.currency !== 'SEK' ? inv.total : null, + exchange_rate: inv.exchange_rate, + line_description: `Kundfordran ${inv.invoice_number}`, + sort_order: 1, + }) + + // Credit revenue account (3305 export goods or 3308 EU services) + journalLines.push({ + id: randomId(), + journal_entry_id: entryId, + account_number: revenueAccount, + debit_amount: 0, + credit_amount: round2(totalSEK), + currency: 'SEK', + line_description: `Intäkt ${inv.invoice_number}`, + sort_order: 2, + }) + } + + // Add a payment entry for invoice 5 (paid) — debit 1930, credit 1510 + const paymentEntryId = randomId() + journalEntries.push({ + id: paymentEntryId, + user_id: userId, + fiscal_period_id: fiscalPeriod.id, + voucher_number: voucherNum++, + voucher_series: 'A', + entry_date: daysAgo(25), + description: `Betalning ${invoices[4].invoice_number} — Suomen Softworks Oy`, + source_type: 'invoice_paid', + source_id: invoices[4].id, + status: 'posted', + committed_at: new Date().toISOString(), + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: paymentEntryId, + account_number: '1930', + debit_amount: 42000, + credit_amount: 0, + currency: 'SEK', + line_description: `Inbetalning ${invoices[4].invoice_number}`, + sort_order: 1, + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: paymentEntryId, + account_number: '1510', + debit_amount: 0, + credit_amount: 42000, + currency: 'SEK', + line_description: `Reglering ${invoices[4].invoice_number}`, + sort_order: 2, + }) + + // Add a small FX gain entry (for Currency Receivables realized FX) + const fxEntryId = randomId() + journalEntries.push({ + id: fxEntryId, + user_id: userId, + fiscal_period_id: fiscalPeriod.id, + voucher_number: voucherNum++, + voucher_series: 'A', + entry_date: daysAgo(25), + description: 'Kursdifferens vid betalning', + source_type: 'invoice_paid', + status: 'posted', + committed_at: new Date().toISOString(), + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: fxEntryId, + account_number: '1930', + debit_amount: 450, + credit_amount: 0, + currency: 'SEK', + line_description: 'Valutavinst', + sort_order: 1, + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: fxEntryId, + account_number: '3960', + debit_amount: 0, + credit_amount: 450, + currency: 'SEK', + line_description: 'Valutakursvinst', + sort_order: 2, + }) + + console.log(`\nCreating ${journalEntries.length} journal entries...`) + const { error: jeError } = await supabase.from('journal_entries').insert(journalEntries) + if (jeError) { + console.error('Failed to create journal entries:', jeError.message) + process.exit(1) + } + + const { error: jlError } = await supabase.from('journal_entry_lines').insert(journalLines) + if (jlError) { + console.error('Failed to create journal entry lines:', jlError.message) + console.error('Cleaning up journal entries...') + await supabase.from('journal_entries').delete().in('id', journalEntries.map(e => e.id)) + process.exit(1) + } + + for (const je of journalEntries) { + console.log(` ✓ A${je.voucher_number} — ${je.description}`) + } + + // 8. Add Intrastat product metadata via extension_data + console.log('\nCreating Intrastat product registry...') + const extensionId = 'export/intrastat' + const extensionData = [ + { + user_id: userId, + extension_id: extensionId, + key: 'product:STEEL-COMP', + value: { description: 'Steel components', cn_code: '72163100', net_weight_kg: 2.4, country_of_origin: 'SE' }, + }, + { + user_id: userId, + extension_id: extensionId, + key: 'product:ALU-FIT', + value: { description: 'Aluminium fittings', cn_code: '76169990', net_weight_kg: 0.8, country_of_origin: 'SE' }, + }, + { + user_id: userId, + extension_id: extensionId, + key: 'product:IND-SENSOR', + value: { description: 'Industrial sensors', cn_code: '90318080', net_weight_kg: 0.35, country_of_origin: 'SE' }, + }, + ] + + const { error: extError } = await supabase.from('extension_data').insert(extensionData) + if (extError) { + console.error('Warning: Failed to create extension_data (Intrastat products):', extError.message) + console.log(' (Export extensions will still work, just Intrastat product registry will be empty)') + } else { + for (const ed of extensionData) { + console.log(` ✓ ${ed.key} — ${ed.value.description} (CN: ${ed.value.cn_code})`) + } + } + + // Done + console.log('\n════════════════════════════════════════════════════') + console.log(' Seed data created successfully!') + console.log('════════════════════════════════════════════════════') + console.log('\nYou should now see data in:') + console.log(' • Periodisk sammanställning (EU Sales List) — 3 EU customers, 5 invoices') + console.log(' • Exportmoms-monitor (VAT Monitor) — journal entries on 3305/3308') + console.log(' • Intrastat — goods invoices + product registry') + console.log(' • Valutafordringar (Currency Receivables) — 3 open EUR/USD invoices') + console.log('\nSelect the current month/quarter to see the data.') +} + +main().catch(err => { + console.error('Unexpected error:', err) + process.exit(1) +})