@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<ReceivableInvoice>(({ 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<GLLine[]> {
|
||||
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<string, string>()
|
||||
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
|
||||
}
|
||||
@@ -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<ECSalesListInvoice>(({ 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ECSalesListInvoice>(({ 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<GLAccountTotal[]> {
|
||||
// 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<string, number>()
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -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<IntrastatInvoice>(({ 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<string, unknown> }) => ({
|
||||
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<string, unknown> | 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<IntrastatInvoice>(({ 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<string, unknown> }) => ({
|
||||
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<string, unknown> | 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<number> {
|
||||
// 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
|
||||
}
|
||||
@@ -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<VatMonitorInvoice>(({ 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<GLLine[]> {
|
||||
// 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)
|
||||
}
|
||||
@@ -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') })
|
||||
})
|
||||
})
|
||||
+19
-111
@@ -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<string, RegExp> = {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ export default function SectorCard({ sector }: { sector: Sector }) {
|
||||
const Icon = resolveIcon(sector.icon)
|
||||
|
||||
return (
|
||||
<Link href={`/extensions/${sector.slug}`}>
|
||||
<Card className="group hover:border-primary/30 transition-colors cursor-pointer">
|
||||
<Link href={`/extensions/${sector.slug}`} className="h-full">
|
||||
<Card className="group hover:border-primary/30 transition-colors cursor-pointer h-full">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
@@ -18,7 +18,7 @@ export default function SectorCard({ sector }: { sector: Sector }) {
|
||||
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
|
||||
{sector.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{sector.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{sector.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{sector.extensions.length} tillägg
|
||||
</p>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string>[]): 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<string, CurrencyExposure>()
|
||||
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<string, unknown>
|
||||
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<ReportData>('export', 'currency-receivables')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [sortField, setSortField] = useState<SortField>('unrealizedGainLoss')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Header ─────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År (realiserade)</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => setImportDialogOpen(true)}>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-1.5', refreshing && 'animate-spin')} />
|
||||
{refreshing ? 'Uppdaterar...' : 'Uppdatera kurser'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Exchange Rates ──────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium">Växelkurser</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{report.referenceDate}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{report.exchangeRates.map(r => (
|
||||
<div key={r.currency} className="flex items-baseline gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{r.currency}:</span>
|
||||
<span className="text-sm font-mono tabular-nums">{formatAmount(r.rate, 4)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Exposure Cards ─────────────────────────── */}
|
||||
{report.exposureByCurrency.length > 0 ? (
|
||||
<div className={cn(
|
||||
'grid gap-4',
|
||||
report.exposureByCurrency.length === 1 ? 'grid-cols-1 sm:grid-cols-2' :
|
||||
report.exposureByCurrency.length === 2 ? 'grid-cols-1 sm:grid-cols-2' :
|
||||
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
||||
)}>
|
||||
{report.exposureByCurrency.map(exp => (
|
||||
<ExposureCard key={exp.currency} exposure={exp} />
|
||||
))}
|
||||
|
||||
{/* Total card */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Totalt</span>
|
||||
<Badge variant="outline">{report.totals.receivableCount} fakturor</Badge>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{formatSEK(report.totals.currentSekValue)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">SEK (aktuell kurs)</p>
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<FXIndicator label="Orealiserat" amount={report.totals.totalUnrealizedGainLoss} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Inga öppna fordringar i utländsk valuta.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Receivables Table ──────────────────────── */}
|
||||
{sortedReceivables.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Öppna fordringar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Faktura</TableHead>
|
||||
<SortableHead field="customerName" label="Kund" current={sortField} dir={sortDir} onSort={toggleSort} />
|
||||
<TableHead>Valuta</TableHead>
|
||||
<SortableHead field="foreignAmount" label="Belopp" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
<TableHead className="text-right">Bokfört (SEK)</TableHead>
|
||||
<TableHead className="text-right">Aktuellt (SEK)</TableHead>
|
||||
<SortableHead field="unrealizedGainLoss" label="Orealiserat" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
<SortableHead field="daysOutstanding" label="Dagar" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedReceivables.map(r => (
|
||||
<TableRow key={r.invoiceId}>
|
||||
<TableCell className="font-mono text-sm">{r.invoiceNumber}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div>
|
||||
<span>{r.customerName}</span>
|
||||
{r.customerCountry && (
|
||||
<Badge variant="outline" className="ml-1.5 text-xs">{r.customerCountry}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{r.currency}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{currencySymbol(r.currency)}{formatAmount(r.foreignAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(r.bookedSekAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(r.currentSekAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<FXBadge amount={r.unrealizedGainLoss} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
<span className={cn(
|
||||
r.daysOutstanding > 30 ? 'text-destructive font-medium' :
|
||||
r.daysOutstanding > 14 ? 'text-warning-foreground' : ''
|
||||
)}>
|
||||
{r.daysOutstanding}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Realized FX Trend ──────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
Realiserade kursdifferenser {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeTrend.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Inga realiserade kursdifferenser för {year}.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Månad</TableHead>
|
||||
<TableHead className="text-right">Vinst (3960)</TableHead>
|
||||
<TableHead className="text-right">Förlust (7960)</TableHead>
|
||||
<TableHead className="text-right">Netto</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeTrend.map(t => (
|
||||
<TableRow key={t.month}>
|
||||
<TableCell className="text-sm">{monthLabel(t.month)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
|
||||
{t.realizedGains > 0 ? `+${formatSEK(t.realizedGains)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
|
||||
{t.realizedLosses > 0 ? `-${formatSEK(t.realizedLosses)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
<FXBadge amount={t.netRealized} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell className="text-sm">Totalt {year}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
|
||||
+{formatSEK(report.realizedGainLoss.gains)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
|
||||
-{formatSEK(report.realizedGainLoss.losses)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<FXBadge amount={report.realizedGainLoss.net} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Revaluation Preview ────────────────────── */}
|
||||
{report.receivables.length > 0 && (
|
||||
<Card className="border-l-4 border-l-blue-500/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Omvärdering vid periodbokslut</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Om bokslut görs idag: netto orealiserad{' '}
|
||||
<span className={cn(
|
||||
'font-medium',
|
||||
report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'vinst' : 'förlust'}{' '}
|
||||
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? '+' : ''}
|
||||
{formatSEK(report.revalPreview.totalUnrealizedGainLoss)} SEK
|
||||
</span>
|
||||
</p>
|
||||
{report.revalPreview.gains > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Konto 3969 (orealiserad kursvinst): {formatSEK(report.revalPreview.gains)} kr
|
||||
</p>
|
||||
)}
|
||||
{report.revalPreview.losses > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Konto 7969 (orealiserad kursförlust): {formatSEK(report.revalPreview.losses)} kr
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Bokföringsposterna skapas inte av detta tillägg. Använd värdena ovan som underlag vid periodbokslut.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="currency-receivables-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────
|
||||
|
||||
function ExposureCard({ exposure }: { exposure: CurrencyExposure }) {
|
||||
const sym = currencySymbol(exposure.currency)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Badge variant="outline" className="text-sm font-medium">{exposure.currency}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{exposure.invoiceCount} fakturor</span>
|
||||
</div>
|
||||
<p className="text-lg font-mono tabular-nums">
|
||||
{sym}{formatAmount(exposure.totalForeignAmount)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{formatSEK(exposure.currentSekValue)} SEK
|
||||
</p>
|
||||
<div className="mt-3 pt-3 border-t space-y-1">
|
||||
<FXIndicator label="Orealiserat" amount={exposure.unrealizedGainLoss} />
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Bokförd kurs: {formatAmount(exposure.averageBookedRate, 4)}</span>
|
||||
<span>Aktuell: {formatAmount(exposure.currentRate, 4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FXIndicator({ label, amount }: { label: string; amount: number }) {
|
||||
const isGain = amount >= 0
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className={cn(
|
||||
'flex items-center gap-1 text-sm font-medium tabular-nums',
|
||||
isGain ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isGain ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
|
||||
<span>{isGain ? '+' : ''}{formatSEK(amount)} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FXBadge({ amount }: { amount: number }) {
|
||||
if (amount === 0) return <span className="text-sm text-muted-foreground">—</span>
|
||||
const isGain = amount > 0
|
||||
return (
|
||||
<span className={cn(
|
||||
'text-sm font-mono tabular-nums font-medium',
|
||||
isGain ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isGain ? '+' : ''}{formatSEK(amount)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{label}
|
||||
<ArrowUpDown className={cn('h-3 w-3', isActive ? 'text-foreground' : 'text-muted-foreground/50')} />
|
||||
{isActive && <span className="text-xs">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
</button>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>[]): 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<string, unknown>
|
||||
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<ReportData>('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<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Table sort
|
||||
const [sortField, setSortField] = useState<SortField>('country')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Periodtyp</label>
|
||||
<Select value={periodType} onValueChange={v => setPeriodType(v as 'monthly' | 'quarterly')}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månad</SelectItem>
|
||||
<SelectItem value="quarterly">Kvartal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{periodType === 'monthly' ? 'Månad' : 'Kvartal'}
|
||||
</label>
|
||||
{periodType === 'monthly' ? (
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select value={String(quarter)} onValueChange={v => setQuarter(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUARTERS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download + Import buttons */}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload('csv')}
|
||||
disabled={downloading !== null || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
|
||||
{downloading === 'csv' ? 'Laddar...' : 'CSV'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload('xml')}
|
||||
disabled={downloading !== null || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileCode className="h-4 w-4 mr-1.5" />
|
||||
{downloading === 'xml' ? 'Laddar...' : 'SKV XML'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Error state ────────────────────────────────────── */}
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── KPI Cards ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Varuförsäljning EU"
|
||||
value={formatSEK(report.totals.goods)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Tjänsteförsäljning EU"
|
||||
value={formatSEK(report.totals.services)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Trepartshandel"
|
||||
value={formatSEK(report.totals.triangulation)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Kunder"
|
||||
value={report.customerCount}
|
||||
suffix={`(${report.invoiceCount} fakturor)`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Deadline + Cross-Check Row ────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Deadline */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Inlämningsdeadline</p>
|
||||
<p className="text-lg font-semibold mt-0.5">
|
||||
{formatDeadlineDate(report.deadline)}
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-sm mt-1',
|
||||
report.daysUntilDeadline <= 7 ? 'text-destructive font-medium' :
|
||||
report.daysUntilDeadline <= 14 ? 'text-warning-foreground' :
|
||||
'text-muted-foreground'
|
||||
)}>
|
||||
{report.daysUntilDeadline > 0
|
||||
? `${report.daysUntilDeadline} dagar kvar`
|
||||
: report.daysUntilDeadline === 0
|
||||
? 'Deadline idag!'
|
||||
: `${Math.abs(report.daysUntilDeadline)} dagar försenad`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cross-check */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm font-medium mb-3">Avstämning mot huvudbok</p>
|
||||
{report.crossCheck ? (
|
||||
<div className="space-y-2">
|
||||
<CrossCheckRow
|
||||
label="Ruta 35 — varor"
|
||||
reportTotal={report.crossCheck.box35ReportTotal}
|
||||
glTotal={report.crossCheck.box35GLTotal}
|
||||
match={report.crossCheck.box35Match}
|
||||
/>
|
||||
<CrossCheckRow
|
||||
label="Ruta 39 — tjänster"
|
||||
reportTotal={report.crossCheck.box39ReportTotal}
|
||||
glTotal={report.crossCheck.box39GLTotal}
|
||||
match={report.crossCheck.box39Match}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingen bokföringsdata tillgänglig för perioden.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn(
|
||||
'border-l-4',
|
||||
errorCount > 0 ? 'border-l-destructive' : 'border-l-warning'
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
onClick={() => setWarningsExpanded(!warningsExpanded)}
|
||||
>
|
||||
<AlertTriangle className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
errorCount > 0 ? 'text-destructive' : 'text-warning-foreground'
|
||||
)} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">{errorCount} fel</span>
|
||||
)}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && (
|
||||
<span className="text-warning-foreground">{warningCount} varningar</span>
|
||||
)}
|
||||
</span>
|
||||
{warningsExpanded
|
||||
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm py-2 px-3 rounded-md',
|
||||
w.severity === 'error'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-warning/10 text-warning-foreground'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Customer Table ────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Kunder per land
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedLines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Inga EU-försäljningar hittades för vald period.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead field="country" current={sortField} dir={sortDir} onSort={toggleSort}>
|
||||
Land
|
||||
</SortableHead>
|
||||
<SortableHead field="vatNumber" current={sortField} dir={sortDir} onSort={toggleSort}>
|
||||
VAT-nummer
|
||||
</SortableHead>
|
||||
<TableHead className="text-left">Kund</TableHead>
|
||||
<SortableHead field="goods" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
Varor (ruta 35)
|
||||
</span>
|
||||
</SortableHead>
|
||||
<SortableHead field="services" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Briefcase className="h-3.5 w-3.5" />
|
||||
Tjänster (ruta 39)
|
||||
</span>
|
||||
</SortableHead>
|
||||
<SortableHead field="invoices" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
Fakturor
|
||||
</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedLines.map(line => (
|
||||
<TableRow key={line.customerVatNumber}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{line.customerCountry}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{line.customerVatNumber}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{line.customerName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.goodsAmount !== 0 ? formatSEK(line.goodsAmount) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.servicesAmount !== 0 ? formatSEK(line.servicesAmount) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{line.invoiceCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell colSpan={3} className="text-sm">
|
||||
Summa ({sortedLines.length} kunder)
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.goods)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.services)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{report.invoiceCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Filing Info Footer ────────────────────────────── */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>
|
||||
Uppgiftslämnare: {report.reporterName} ({report.reporterVatNumber})
|
||||
</span>
|
||||
<span>
|
||||
Redovisningsperiod: {report.period.year}
|
||||
{report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`}
|
||||
{report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="eu-sales-list-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{match ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive shrink-0" />
|
||||
)}
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{formatSEK(reportTotal)} SEK
|
||||
</span>
|
||||
{!match && (
|
||||
<span className="font-mono tabular-nums text-destructive text-xs">
|
||||
(diff: {diff > 0 ? '+' : ''}{formatSEK(diff)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableHead className={cn('cursor-pointer select-none', className)} onClick={() => onSort(field)}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{children}
|
||||
{isActive && (
|
||||
dir === 'asc'
|
||||
? <ChevronUp className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>[]): 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<string, unknown>
|
||||
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<ReportData>('export', 'intrastat')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<string | null>(null)
|
||||
const [productForm, setProductForm] = useState<ProductForm>(EMPTY_PRODUCT)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Månad</label>
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => <SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
|
||||
{downloading ? 'Laddar...' : 'IDEP.web CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Threshold Progress ───────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">Tröskelvärde Intrastat (utförsel)</p>
|
||||
<Badge variant={report.thresholdStatus.isObligated ? 'destructive' : 'outline'}>
|
||||
{report.thresholdStatus.isObligated ? 'Obligatorisk rapportering' : 'Frivillig rapportering'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(report.thresholdStatus.percentageUsed, 100)}
|
||||
className="h-3"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Ackumulerat (12 mån): {formatSEK(report.thresholdStatus.cumulativeValue)} SEK
|
||||
</span>
|
||||
<span>
|
||||
{report.thresholdStatus.percentageUsed}% av {formatSEK(report.thresholdStatus.threshold)} SEK
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── KPI Row ──────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Fakturerat värde</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{formatSEK(report.totals.invoicedValue)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">SEK</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Nettovikt</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.netMass.toLocaleString('sv-SE')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">kg</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Deklarationsrader</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.lineCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{report.invoiceCount} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Product Registry ─────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Produktregister
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={openNewProduct}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Inga produkter registrerade. Lägg till produkter med CN-kod och vikt för att generera Intrastat-deklarationer.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Produkt</TableHead>
|
||||
<TableHead>CN-kod</TableHead>
|
||||
<TableHead className="text-right">Vikt (kg)</TableHead>
|
||||
<TableHead>Ursprung</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map(p => (
|
||||
<TableRow key={p.productId}>
|
||||
<TableCell className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{p.description || p.productId}</span>
|
||||
{p.productId !== p.description && (
|
||||
<span className="text-xs text-muted-foreground ml-1">({p.productId})</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.cn_code ? (
|
||||
<Badge variant="outline" className="font-mono text-xs">{p.cn_code}</Badge>
|
||||
) : (
|
||||
<span className="text-destructive text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Saknas
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{p.net_weight_kg !== null
|
||||
? String(p.net_weight_kg)
|
||||
: <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{p.country_of_origin}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditProduct(p.productId)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(p.productId)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Declaration Table ─────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
Deklaration {MONTHS[month - 1]} {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{report.lines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Inga EU-varuförsäljningar hittades för perioden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>CN-kod</TableHead>
|
||||
<TableHead>Land</TableHead>
|
||||
<TableHead>Urspr.</TableHead>
|
||||
<TableHead className="text-right">Värde (SEK)</TableHead>
|
||||
<TableHead className="text-right">Vikt (kg)</TableHead>
|
||||
<TableHead>Partner-VAT</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{report.lines.map((line, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={line.cnCode === '00000000' ? 'destructive' : 'outline'}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{line.cnCode}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{line.partnerCountry}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{line.countryOfOrigin}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(line.invoicedValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.netMass > 0 ? line.netMass.toLocaleString('sv-SE') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{line.partnerVatId || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell colSpan={3} className="text-sm">Summa</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.invoicedValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{report.totals.netMass.toLocaleString('sv-SE')}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn('border-l-4', errorCount > 0 ? 'border-l-destructive' : 'border-l-warning')}>
|
||||
<CardContent className="pt-6">
|
||||
<button className="flex items-center gap-2 w-full text-left" onClick={() => setWarningsExpanded(!warningsExpanded)}>
|
||||
<AlertTriangle className={cn('h-4 w-4 shrink-0', errorCount > 0 ? 'text-destructive' : 'text-warning-foreground')} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && <span className="text-destructive">{errorCount} fel</span>}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && <span className="text-warning-foreground">{warningCount} varningar</span>}
|
||||
</span>
|
||||
{warningsExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div key={i} className={cn('flex items-start gap-2 text-sm py-2 px-3 rounded-md', w.severity === 'error' ? 'bg-destructive/5 text-destructive' : 'bg-warning/10 text-warning-foreground')}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Deadline Footer ───────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Deadline: 10:e arbetsdagen efter redovisningsperiodens slut
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="intrastat-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
|
||||
{/* ── Product Dialog ────────────────────────────────────── */}
|
||||
<Dialog open={productDialogOpen} onOpenChange={setProductDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProduct ? 'Redigera produkt' : 'Lägg till produkt'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{!editingProduct && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="productId">Produkt-ID (SKU)</Label>
|
||||
<Input
|
||||
id="productId"
|
||||
value={productForm.productId}
|
||||
onChange={e => setProductForm(f => ({ ...f, productId: e.target.value }))}
|
||||
placeholder="T.ex. STALBALK-M8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={productForm.description}
|
||||
onChange={e => setProductForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="T.ex. Stålbalk M8 200mm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cnCode">CN-kod (8 siffror)</Label>
|
||||
<Input
|
||||
id="cnCode"
|
||||
value={productForm.cnCode}
|
||||
onChange={e => setProductForm(f => ({ ...f, cnCode: e.target.value.replace(/\D/g, '').slice(0, 8) }))}
|
||||
placeholder="T.ex. 72163100"
|
||||
maxLength={8}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="weight">Nettovikt per enhet (kg)</Label>
|
||||
<Input
|
||||
id="weight"
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={productForm.netWeightKg}
|
||||
onChange={e => setProductForm(f => ({ ...f, netWeightKg: e.target.value }))}
|
||||
placeholder="45.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="origin">Ursprungsland</Label>
|
||||
<Input
|
||||
id="origin"
|
||||
value={productForm.countryOfOrigin}
|
||||
onChange={e => setProductForm(f => ({ ...f, countryOfOrigin: e.target.value.toUpperCase().slice(0, 2) }))}
|
||||
placeholder="SE"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setProductDialogOpen(false)}>Avbryt</Button>
|
||||
<Button
|
||||
onClick={saveProduct}
|
||||
disabled={!editingProduct && !productForm.productId.trim()}
|
||||
>
|
||||
{editingProduct ? 'Spara' : 'Lägg till'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Confirmation ───────────────────────────────── */}
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ta bort produkt?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Är du säker på att du vill ta bort produkten “{deleteConfirm}”? Denna åtgärd kan inte ångras.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Avbryt</Button>
|
||||
<Button variant="destructive" onClick={() => deleteConfirm && deleteProduct(deleteConfirm)}>
|
||||
Ta bort
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<RevenueBreakdown, 'totalRevenue'>; 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<string, string>[]): 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<string, unknown>
|
||||
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<ReportData>('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<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Periodtyp</label>
|
||||
<Select value={periodType} onValueChange={v => setPeriodType(v as 'monthly' | 'quarterly')}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månad</SelectItem>
|
||||
<SelectItem value="quarterly">Kvartal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{periodType === 'monthly' ? 'Månad' : 'Kvartal'}
|
||||
</label>
|
||||
{periodType === 'monthly' ? (
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select value={String(quarter)} onValueChange={v => setQuarter(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUARTERS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant={compareEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setCompareEnabled(!compareEnabled)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-1.5" />
|
||||
{compareEnabled ? 'Jämförelse på' : 'Jämför perioder'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Error state ────────────────────────────────────── */}
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Revenue Breakdown Cards ──────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">Intäktsfördelning</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{REVENUE_CARDS.map(({ key, label, compKey }) => {
|
||||
const data = report.revenueBreakdown[key]
|
||||
const delta = report.comparison?.[compKey]
|
||||
return (
|
||||
<Card key={key} className={cn(data.amount === 0 && 'opacity-50')}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground truncate">{label}</p>
|
||||
<p className="text-lg font-semibold tabular-nums mt-0.5">
|
||||
{formatSEK(data.amount)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.percentage}%
|
||||
</span>
|
||||
{delta && <DeltaIndicator delta={delta} />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── VAT Summary + Moms Box Table ─────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* VAT Summary cards */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Moms</h3>
|
||||
<KPICard
|
||||
label="Utgående moms"
|
||||
value={formatSEK(report.vatSummary.totalOutputVat)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Ingående moms"
|
||||
value={formatSEK(report.vatSummary.inputVat)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<Card className={cn(
|
||||
report.vatSummary.isRefund ? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/30' : ''
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{report.vatSummary.isRefund ? 'Moms att få tillbaka' : 'Moms att betala'}
|
||||
</p>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className={cn(
|
||||
'text-2xl font-semibold tracking-tight',
|
||||
report.vatSummary.isRefund && 'text-green-700 dark:text-green-400'
|
||||
)}>
|
||||
{formatSEK(Math.abs(report.vatSummary.netVat))}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">SEK</span>
|
||||
</div>
|
||||
{report.comparison && (
|
||||
<div className="mt-1">
|
||||
<DeltaIndicator delta={report.comparison.netVat} invert />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Momsdeklaration preview table */}
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Momsdeklaration (förhandsvisning)
|
||||
</h3>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Ruta</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right w-36">Belopp (SEK)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayBoxes.map(box => {
|
||||
const isNetVat = box.boxNumber === '49'
|
||||
const isInputVat = box.boxNumber === '48'
|
||||
return (
|
||||
<TableRow
|
||||
key={box.boxNumber}
|
||||
className={cn(isNetVat && 'font-medium border-t-2')}
|
||||
>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={isNetVat ? 'default' : isInputVat ? 'secondary' : 'outline'}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{box.boxNumber}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{box.label}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(box.amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{displayBoxes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground py-8">
|
||||
Ingen bokföringsdata för perioden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn(
|
||||
'border-l-4',
|
||||
errorCount > 0 ? 'border-l-destructive' : 'border-l-warning'
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
onClick={() => setWarningsExpanded(!warningsExpanded)}
|
||||
>
|
||||
<AlertTriangle className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
errorCount > 0 ? 'text-destructive' : 'text-warning-foreground'
|
||||
)} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">{errorCount} fel</span>
|
||||
)}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && (
|
||||
<span className="text-warning-foreground">{warningCount} varningar</span>
|
||||
)}
|
||||
</span>
|
||||
{warningsExpanded
|
||||
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm py-2 px-3 rounded-md',
|
||||
w.severity === 'error'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-warning/10 text-warning-foreground'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Total Revenue Footer ─────────────────────────── */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>
|
||||
Total omsättning: {formatSEK(report.revenueBreakdown.totalRevenue)} SEK
|
||||
</span>
|
||||
<span>
|
||||
{report.period.year}
|
||||
{report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`}
|
||||
{report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="vat-monitor-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────
|
||||
|
||||
function DeltaIndicator({ delta, invert = false }: { delta: PeriodDelta; invert?: boolean }) {
|
||||
if (delta.changePercent === null || delta.change === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-0.5 text-xs',
|
||||
isGood ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isPositive
|
||||
? <ArrowUp className="h-3 w-3" />
|
||||
: <ArrowDown className="h-3 w-3" />
|
||||
}
|
||||
<span>{delta.changePercent > 0 ? '+' : ''}{delta.changePercent}%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="border-l-4 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20 dark:border-l-amber-400">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||
Testdata aktivt
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
Rapporten visar importerad testdata istället för bokföringsdata.
|
||||
{formatted && <> Importerat {formatted}.</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={onReplace} className="h-7 text-xs">
|
||||
<Replace className="h-3.5 w-3.5 mr-1" />
|
||||
Ersätt
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClear} className="h-7 text-xs">
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
Rensa
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<T> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
csvFields: CsvFieldDef[]
|
||||
defaultMappings?: Record<string, string>
|
||||
parseCsvRows: (rows: Record<string, string>[]) => T
|
||||
validateReport: (data: unknown) => { valid: boolean; error?: string }
|
||||
templateCsvContent: string
|
||||
templateFileName: string
|
||||
onImport: (report: T, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => Promise<void>
|
||||
}
|
||||
|
||||
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<T>({
|
||||
open,
|
||||
onOpenChange,
|
||||
csvFields,
|
||||
defaultMappings,
|
||||
parseCsvRows,
|
||||
validateReport,
|
||||
templateCsvContent,
|
||||
templateFileName,
|
||||
onImport,
|
||||
}: MockDataImportDialogProps<T>) {
|
||||
const [step, setStep] = useState<Step>('upload')
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// CSV state
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
|
||||
const [csvRows, setCsvRows] = useState<string[][]>([])
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({})
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
// JSON state
|
||||
const [jsonReport, setJsonReport] = useState<T | null>(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<string, string> = {}
|
||||
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<HTMLInputElement>) => {
|
||||
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<string, string> = {}
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'upload' && 'Importera testdata'}
|
||||
{step === 'map-csv' && 'Kolumnmappning'}
|
||||
{step === 'preview-json' && 'Förhandsgranska JSON'}
|
||||
{step === 'importing' && 'Importerar...'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Error ─────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/5 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Upload ──────────────────────────────── */}
|
||||
{step === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-10 text-center transition-colors',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm font-medium mb-1">
|
||||
Dra och släpp en fil här
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
CSV (.csv) eller JSON (.json)
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Välj fil
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Ladda ner CSV-mall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Map CSV ─────────────────────────────── */}
|
||||
{step === 'map-csv' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
<span>{fileName} — {csvRows.length} rader</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{csvFields.map(field => (
|
||||
<div key={field.key} className="flex items-center gap-3">
|
||||
<Label className="w-40 text-sm shrink-0">
|
||||
{field.label}{field.required && ' *'}
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings[field.key] ?? '___none___'}
|
||||
onValueChange={(val) => setMappings(prev => ({
|
||||
...prev,
|
||||
[field.key]: val === '___none___' ? '' : val,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Välj kolumn..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="___none___">— Välj kolumn —</SelectItem>
|
||||
{csvHeaders.map(h => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview first 5 rows */}
|
||||
{csvRows.length > 0 && (
|
||||
<div className="rounded-lg border overflow-auto max-h-48">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{csvHeaders.map(h => (
|
||||
<TableHead key={h} className="text-xs whitespace-nowrap">{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{csvRows.slice(0, 5).map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={reset}>Tillbaka</Button>
|
||||
<Button
|
||||
onClick={handleCsvImport}
|
||||
disabled={!requiredFieldsMapped}
|
||||
>
|
||||
Importera {csvRows.length} rader
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Preview JSON ────────────────────────── */}
|
||||
{step === 'preview-json' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileJson className="h-4 w-4" />
|
||||
<span>{fileName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
<span>Giltig JSON — {jsonSummary}</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={reset}>Tillbaka</Button>
|
||||
<Button onClick={handleJsonImport}>
|
||||
Importera testdata
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Importing ───────────────────────────── */}
|
||||
{step === 'importing' && (
|
||||
<div className="py-8 text-center">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Importerar testdata...</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* Multi-Currency Receivables Manager / Valutafordringar Extension
|
||||
*
|
||||
* Dashboard showing foreign currency exposure from open receivables.
|
||||
* Calculates unrealized FX gains/losses using current Riksbanken rates
|
||||
* compared to booking rates.
|
||||
*
|
||||
* Shows realized FX gains (account 3960) and losses (account 7960) per
|
||||
* period, with monthly trend analysis. Provides period-end revaluation
|
||||
* preview for informational purposes (does not create journal entries).
|
||||
*/
|
||||
export const currencyReceivablesExtension: Extension = {
|
||||
id: 'currency-receivables',
|
||||
name: 'Valutafordringar',
|
||||
version: '1.0.0',
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
generateReceivablesReport,
|
||||
type ReceivableInvoice,
|
||||
type ReceivableCustomer,
|
||||
type GLLine,
|
||||
type ExchangeRateInfo,
|
||||
type ReceivablesOptions,
|
||||
} from '../receivables-engine'
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────
|
||||
|
||||
function makeInvoice(overrides: Partial<ReceivableInvoice> = {}): ReceivableInvoice {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F2026-001',
|
||||
invoice_date: '2026-01-15',
|
||||
due_date: '2026-02-15',
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
total: 10000,
|
||||
total_sek: 114200,
|
||||
exchange_rate: 11.42,
|
||||
customer_id: 'cust-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCustomer(overrides: Partial<ReceivableCustomer> = {}): ReceivableCustomer {
|
||||
return {
|
||||
id: 'cust-1',
|
||||
name: 'Müller GmbH',
|
||||
country: 'DE',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRate(overrides: Partial<ExchangeRateInfo> = {}): ExchangeRateInfo {
|
||||
return {
|
||||
currency: 'EUR',
|
||||
rate: 11.50,
|
||||
date: '2026-03-15',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeGLLine(overrides: Partial<GLLine> = {}): GLLine {
|
||||
return {
|
||||
account_number: '3960',
|
||||
debit: 0,
|
||||
credit: 0,
|
||||
entry_date: '2026-01-31',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_OPTIONS: ReceivablesOptions = {
|
||||
invoices: [],
|
||||
customers: [],
|
||||
currentRates: [makeRate()],
|
||||
realizedFXLines: [],
|
||||
referenceDate: '2026-03-15',
|
||||
year: 2026,
|
||||
}
|
||||
|
||||
// ── Basic report tests ──────────────────────────────────────
|
||||
|
||||
describe('generateReceivablesReport', () => {
|
||||
it('generates empty report when no invoices', () => {
|
||||
const report = generateReceivablesReport(BASE_OPTIONS)
|
||||
|
||||
expect(report.receivables).toHaveLength(0)
|
||||
expect(report.exposureByCurrency).toHaveLength(0)
|
||||
expect(report.totals.receivableCount).toBe(0)
|
||||
expect(report.totals.currencyCount).toBe(0)
|
||||
expect(report.totals.totalUnrealizedGainLoss).toBe(0)
|
||||
expect(report.referenceDate).toBe('2026-03-15')
|
||||
})
|
||||
|
||||
it('calculates unrealized gain when rate increases', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({
|
||||
total: 10000,
|
||||
total_sek: 114200, // Booked at 11.42
|
||||
exchange_rate: 11.42,
|
||||
})],
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.50 })], // Current rate higher
|
||||
})
|
||||
|
||||
expect(report.receivables).toHaveLength(1)
|
||||
const r = report.receivables[0]
|
||||
expect(r.bookedSekAmount).toBe(114200)
|
||||
expect(r.currentSekAmount).toBe(115000) // 10000 × 11.50
|
||||
expect(r.unrealizedGainLoss).toBe(800) // 115000 - 114200
|
||||
expect(r.bookedRate).toBe(11.42)
|
||||
expect(r.currentRate).toBe(11.50)
|
||||
})
|
||||
|
||||
it('calculates unrealized loss when rate decreases', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({
|
||||
total: 10000,
|
||||
total_sek: 114200,
|
||||
exchange_rate: 11.42,
|
||||
})],
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.00 })], // Current rate lower
|
||||
})
|
||||
|
||||
const r = report.receivables[0]
|
||||
expect(r.currentSekAmount).toBe(110000)
|
||||
expect(r.unrealizedGainLoss).toBe(-4200) // 110000 - 114200
|
||||
})
|
||||
|
||||
it('aggregates exposure by currency', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', total: 5000, total_sek: 57100, exchange_rate: 11.42 }),
|
||||
makeInvoice({ id: 'inv-2', total: 3000, total_sek: 34260, exchange_rate: 11.42 }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.50 })],
|
||||
})
|
||||
|
||||
expect(report.exposureByCurrency).toHaveLength(1)
|
||||
const exp = report.exposureByCurrency[0]
|
||||
expect(exp.currency).toBe('EUR')
|
||||
expect(exp.totalForeignAmount).toBe(8000)
|
||||
expect(exp.bookedSekValue).toBe(91360) // 57100 + 34260
|
||||
expect(exp.currentSekValue).toBe(92000) // 8000 × 11.50
|
||||
expect(exp.invoiceCount).toBe(2)
|
||||
expect(exp.averageBookedRate).toBe(11.42) // 91360 / 8000
|
||||
expect(exp.currentRate).toBe(11.50)
|
||||
})
|
||||
|
||||
it('handles multiple currencies', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', currency: 'EUR', total: 5000, total_sek: 57100 }),
|
||||
makeInvoice({ id: 'inv-2', currency: 'USD', total: 8000, total_sek: 84000, customer_id: 'cust-2' }),
|
||||
]
|
||||
const customers = [
|
||||
makeCustomer({ id: 'cust-1' }),
|
||||
makeCustomer({ id: 'cust-2', name: 'Smith Inc', country: 'US' }),
|
||||
]
|
||||
const rates = [
|
||||
makeRate({ currency: 'EUR', rate: 11.50 }),
|
||||
makeRate({ currency: 'USD', rate: 10.60 }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers,
|
||||
currentRates: rates,
|
||||
})
|
||||
|
||||
expect(report.exposureByCurrency).toHaveLength(2)
|
||||
expect(report.totals.currencyCount).toBe(2)
|
||||
expect(report.totals.receivableCount).toBe(2)
|
||||
|
||||
const eurExp = report.exposureByCurrency.find(e => e.currency === 'EUR')!
|
||||
const usdExp = report.exposureByCurrency.find(e => e.currency === 'USD')!
|
||||
expect(eurExp.currentSekValue).toBe(57500) // 5000 × 11.50
|
||||
expect(usdExp.currentSekValue).toBe(84800) // 8000 × 10.60
|
||||
})
|
||||
|
||||
// ── Filtering ─────────────────────────────────────────────
|
||||
|
||||
it('excludes paid invoices', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ status: 'paid' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.receivables).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes SEK invoices', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ currency: 'SEK', total: 10000, total_sek: 10000 })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.receivables).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes zero-amount invoices', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ total: 0 })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.receivables).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes overdue invoices', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ status: 'overdue' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.receivables).toHaveLength(1)
|
||||
})
|
||||
|
||||
// ── Booked value fallback ─────────────────────────────────
|
||||
|
||||
it('falls back to total × exchange_rate when total_sek is null', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ total: 5000, total_sek: null, exchange_rate: 11.42 })],
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.50 })],
|
||||
})
|
||||
|
||||
const r = report.receivables[0]
|
||||
expect(r.bookedSekAmount).toBe(57100) // 5000 × 11.42
|
||||
expect(r.currentSekAmount).toBe(57500) // 5000 × 11.50
|
||||
expect(r.unrealizedGainLoss).toBe(400)
|
||||
})
|
||||
|
||||
it('uses 0 booked value when no exchange rate info', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ total: 5000, total_sek: null, exchange_rate: null })],
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.50 })],
|
||||
})
|
||||
|
||||
const r = report.receivables[0]
|
||||
expect(r.bookedSekAmount).toBe(0)
|
||||
expect(r.currentSekAmount).toBe(57500)
|
||||
})
|
||||
|
||||
// ── Days outstanding ──────────────────────────────────────
|
||||
|
||||
it('calculates days outstanding correctly', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ invoice_date: '2026-03-01' })],
|
||||
customers: [makeCustomer()],
|
||||
referenceDate: '2026-03-15',
|
||||
})
|
||||
|
||||
expect(report.receivables[0].daysOutstanding).toBe(14)
|
||||
})
|
||||
|
||||
// ── Realized FX from GL ───────────────────────────────────
|
||||
|
||||
it('calculates realized FX gains from account 3960 credits', () => {
|
||||
const lines: GLLine[] = [
|
||||
makeGLLine({ account_number: '3960', credit: 5000, debit: 0, entry_date: '2026-01-31' }),
|
||||
makeGLLine({ account_number: '3960', credit: 3000, debit: 0, entry_date: '2026-02-28' }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
realizedFXLines: lines,
|
||||
})
|
||||
|
||||
expect(report.realizedGainLoss.gains).toBe(8000)
|
||||
expect(report.realizedGainLoss.losses).toBe(0)
|
||||
expect(report.realizedGainLoss.net).toBe(8000)
|
||||
})
|
||||
|
||||
it('calculates realized FX losses from account 7960 debits', () => {
|
||||
const lines: GLLine[] = [
|
||||
makeGLLine({ account_number: '7960', debit: 3200, credit: 0, entry_date: '2026-01-31' }),
|
||||
makeGLLine({ account_number: '7960', debit: 1800, credit: 0, entry_date: '2026-02-28' }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
realizedFXLines: lines,
|
||||
})
|
||||
|
||||
expect(report.realizedGainLoss.gains).toBe(0)
|
||||
expect(report.realizedGainLoss.losses).toBe(5000)
|
||||
expect(report.realizedGainLoss.net).toBe(-5000)
|
||||
})
|
||||
|
||||
it('calculates net realized FX with both gains and losses', () => {
|
||||
const lines: GLLine[] = [
|
||||
makeGLLine({ account_number: '3960', credit: 8500, debit: 0, entry_date: '2026-01-31' }),
|
||||
makeGLLine({ account_number: '7960', debit: 3200, credit: 0, entry_date: '2026-01-31' }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
realizedFXLines: lines,
|
||||
})
|
||||
|
||||
expect(report.realizedGainLoss.gains).toBe(8500)
|
||||
expect(report.realizedGainLoss.losses).toBe(3200)
|
||||
expect(report.realizedGainLoss.net).toBe(5300)
|
||||
})
|
||||
|
||||
// ── Monthly trend ─────────────────────────────────────────
|
||||
|
||||
it('generates monthly trend with all 12 months', () => {
|
||||
const report = generateReceivablesReport(BASE_OPTIONS)
|
||||
|
||||
expect(report.monthlyTrend).toHaveLength(12)
|
||||
expect(report.monthlyTrend[0].month).toBe('2026-01')
|
||||
expect(report.monthlyTrend[11].month).toBe('2026-12')
|
||||
})
|
||||
|
||||
it('distributes realized FX to correct months', () => {
|
||||
const lines: GLLine[] = [
|
||||
makeGLLine({ account_number: '3960', credit: 5000, entry_date: '2026-01-15' }),
|
||||
makeGLLine({ account_number: '7960', debit: 2000, entry_date: '2026-01-20' }),
|
||||
makeGLLine({ account_number: '3960', credit: 8000, entry_date: '2026-03-10' }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
realizedFXLines: lines,
|
||||
})
|
||||
|
||||
const jan = report.monthlyTrend.find(t => t.month === '2026-01')!
|
||||
expect(jan.realizedGains).toBe(5000)
|
||||
expect(jan.realizedLosses).toBe(2000)
|
||||
expect(jan.netRealized).toBe(3000)
|
||||
|
||||
const feb = report.monthlyTrend.find(t => t.month === '2026-02')!
|
||||
expect(feb.realizedGains).toBe(0)
|
||||
expect(feb.realizedLosses).toBe(0)
|
||||
expect(feb.netRealized).toBe(0)
|
||||
|
||||
const mar = report.monthlyTrend.find(t => t.month === '2026-03')!
|
||||
expect(mar.realizedGains).toBe(8000)
|
||||
expect(mar.realizedLosses).toBe(0)
|
||||
expect(mar.netRealized).toBe(8000)
|
||||
})
|
||||
|
||||
// ── Revaluation preview ───────────────────────────────────
|
||||
|
||||
it('shows revaluation preview with net gain', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ total: 10000, total_sek: 114200 })],
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.50 })],
|
||||
})
|
||||
|
||||
expect(report.revalPreview.totalUnrealizedGainLoss).toBe(800)
|
||||
expect(report.revalPreview.gains).toBe(800)
|
||||
expect(report.revalPreview.losses).toBe(0)
|
||||
})
|
||||
|
||||
it('shows revaluation preview with net loss', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ total: 10000, total_sek: 114200 })],
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.00 })],
|
||||
})
|
||||
|
||||
expect(report.revalPreview.totalUnrealizedGainLoss).toBe(-4200)
|
||||
expect(report.revalPreview.gains).toBe(0)
|
||||
expect(report.revalPreview.losses).toBe(4200)
|
||||
})
|
||||
|
||||
// ── Customer data ─────────────────────────────────────────
|
||||
|
||||
it('uses customer name and country from customer lookup', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
customers: [makeCustomer({ name: 'Acme Corp', country: 'FI' })],
|
||||
})
|
||||
|
||||
expect(report.receivables[0].customerName).toBe('Acme Corp')
|
||||
expect(report.receivables[0].customerCountry).toBe('FI')
|
||||
})
|
||||
|
||||
it('falls back to unknown customer when not found', () => {
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ customer_id: 'unknown' })],
|
||||
customers: [makeCustomer()], // cust-1, not 'unknown'
|
||||
})
|
||||
|
||||
expect(report.receivables[0].customerName).toBe('Okänd kund')
|
||||
})
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────
|
||||
|
||||
it('calculates correct totals across all receivables', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', currency: 'EUR', total: 5000, total_sek: 57100 }),
|
||||
makeInvoice({ id: 'inv-2', currency: 'USD', total: 3000, total_sek: 31500, customer_id: 'cust-2' }),
|
||||
]
|
||||
const customers = [
|
||||
makeCustomer({ id: 'cust-1' }),
|
||||
makeCustomer({ id: 'cust-2', name: 'Smith Inc', country: 'US' }),
|
||||
]
|
||||
const rates = [
|
||||
makeRate({ currency: 'EUR', rate: 11.50 }),
|
||||
makeRate({ currency: 'USD', rate: 10.60 }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers,
|
||||
currentRates: rates,
|
||||
})
|
||||
|
||||
// EUR: 5000 × 11.50 = 57500, USD: 3000 × 10.60 = 31800
|
||||
expect(report.totals.bookedSekValue).toBe(88600) // 57100 + 31500
|
||||
expect(report.totals.currentSekValue).toBe(89300) // 57500 + 31800
|
||||
expect(report.totals.totalUnrealizedGainLoss).toBe(700) // 89300 - 88600
|
||||
expect(report.totals.receivableCount).toBe(2)
|
||||
expect(report.totals.currencyCount).toBe(2)
|
||||
})
|
||||
|
||||
// ── Period info ───────────────────────────────────────────
|
||||
|
||||
it('includes year and exchange rate info', () => {
|
||||
const rates = [
|
||||
makeRate({ currency: 'EUR', rate: 11.50 }),
|
||||
makeRate({ currency: 'USD', rate: 10.60 }),
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
currentRates: rates,
|
||||
})
|
||||
|
||||
expect(report.realizedGainLoss.year).toBe(2026)
|
||||
expect(report.exchangeRates).toHaveLength(2)
|
||||
expect(report.exchangeRates.find(r => r.currency === 'EUR')?.rate).toBe(11.50)
|
||||
})
|
||||
|
||||
// ── Sorting ───────────────────────────────────────────────
|
||||
|
||||
it('sorts receivables by absolute unrealized gain/loss descending', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', total: 1000, total_sek: 11420 }), // gain: 80
|
||||
makeInvoice({ id: 'inv-2', total: 5000, total_sek: 57100, customer_id: 'cust-1' }), // gain: 400
|
||||
makeInvoice({ id: 'inv-3', total: 2000, total_sek: 22840, customer_id: 'cust-1' }), // gain: 160
|
||||
]
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers: [makeCustomer()],
|
||||
currentRates: [makeRate({ rate: 11.50 })],
|
||||
})
|
||||
|
||||
expect(report.receivables[0].foreignAmount).toBe(5000) // largest gain
|
||||
expect(report.receivables[1].foreignAmount).toBe(2000)
|
||||
expect(report.receivables[2].foreignAmount).toBe(1000) // smallest gain
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Multi-Currency Receivables Engine
|
||||
*
|
||||
* Pure-function engine that calculates foreign currency exposure from
|
||||
* open receivables, unrealized FX gains/losses vs. current Riksbanken
|
||||
* rates, and realized FX gains/losses from GL accounts 3960/7960.
|
||||
*
|
||||
* This extension is READ-ONLY — it does not create journal entries.
|
||||
* The revaluation preview is informational only.
|
||||
*/
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal invoice data needed by the engine */
|
||||
export interface ReceivableInvoice {
|
||||
id: string
|
||||
invoice_number: string
|
||||
invoice_date: string
|
||||
due_date: string
|
||||
status: string
|
||||
currency: string
|
||||
total: number
|
||||
total_sek: number | null
|
||||
exchange_rate: number | null
|
||||
customer_id: string
|
||||
}
|
||||
|
||||
/** Minimal customer data */
|
||||
export interface ReceivableCustomer {
|
||||
id: string
|
||||
name: string
|
||||
country: string
|
||||
}
|
||||
|
||||
/** GL line for realized FX */
|
||||
export interface GLLine {
|
||||
account_number: string
|
||||
debit: number
|
||||
credit: number
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
/** Current exchange rate */
|
||||
export interface ExchangeRateInfo {
|
||||
currency: string
|
||||
rate: number
|
||||
date: string
|
||||
}
|
||||
|
||||
/** Engine options */
|
||||
export interface ReceivablesOptions {
|
||||
/** Open (unpaid) foreign-currency invoices */
|
||||
invoices: ReceivableInvoice[]
|
||||
/** Customers for those invoices */
|
||||
customers: ReceivableCustomer[]
|
||||
/** Current exchange rates from Riksbanken */
|
||||
currentRates: ExchangeRateInfo[]
|
||||
/** GL lines on accounts 3960 and 7960 for the selected year */
|
||||
realizedFXLines: GLLine[]
|
||||
/** Reference date (for days outstanding calculation) */
|
||||
referenceDate: string
|
||||
/** Selected year for realized FX */
|
||||
year: number
|
||||
}
|
||||
|
||||
// ── Output types ───────────────────────────────────────────────
|
||||
|
||||
export interface CurrencyExposure {
|
||||
currency: string
|
||||
totalForeignAmount: number
|
||||
bookedSekValue: number
|
||||
currentSekValue: number
|
||||
unrealizedGainLoss: number
|
||||
invoiceCount: number
|
||||
averageBookedRate: number
|
||||
currentRate: number
|
||||
}
|
||||
|
||||
export interface ForeignReceivable {
|
||||
invoiceId: string
|
||||
invoiceNumber: string
|
||||
customerName: string
|
||||
customerCountry: string
|
||||
currency: string
|
||||
foreignAmount: number
|
||||
bookedSekAmount: number
|
||||
bookedRate: number
|
||||
currentSekAmount: number
|
||||
currentRate: number
|
||||
unrealizedGainLoss: number
|
||||
invoiceDate: string
|
||||
dueDate: string
|
||||
daysOutstanding: number
|
||||
}
|
||||
|
||||
export interface MonthlyFXTrend {
|
||||
month: string // 'YYYY-MM'
|
||||
realizedGains: number
|
||||
realizedLosses: number
|
||||
netRealized: number
|
||||
}
|
||||
|
||||
export interface RevalPreview {
|
||||
totalUnrealizedGainLoss: number
|
||||
gains: number // Positive: amount for account 3969
|
||||
losses: number // Positive: amount for account 7969
|
||||
}
|
||||
|
||||
export interface ReceivablesReport {
|
||||
referenceDate: string
|
||||
exchangeRates: ExchangeRateInfo[]
|
||||
|
||||
exposureByCurrency: CurrencyExposure[]
|
||||
receivables: ForeignReceivable[]
|
||||
|
||||
realizedGainLoss: {
|
||||
year: number
|
||||
gains: number
|
||||
losses: number
|
||||
net: number
|
||||
}
|
||||
|
||||
monthlyTrend: MonthlyFXTrend[]
|
||||
revalPreview: RevalPreview
|
||||
|
||||
totals: {
|
||||
bookedSekValue: number
|
||||
currentSekValue: number
|
||||
totalUnrealizedGainLoss: number
|
||||
receivableCount: number
|
||||
currencyCount: number
|
||||
}
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────
|
||||
|
||||
const ACCOUNT_FX_GAINS = '3960'
|
||||
const ACCOUNT_FX_LOSSES = '7960'
|
||||
|
||||
const OPEN_STATUSES = ['sent', 'overdue']
|
||||
|
||||
// ── Engine ─────────────────────────────────────────────────────
|
||||
|
||||
export function generateReceivablesReport(options: ReceivablesOptions): ReceivablesReport {
|
||||
const { invoices, customers, currentRates, realizedFXLines, referenceDate, year } = options
|
||||
|
||||
const rateMap = new Map<string, ExchangeRateInfo>()
|
||||
for (const r of currentRates) {
|
||||
rateMap.set(r.currency.toUpperCase(), r)
|
||||
}
|
||||
|
||||
const customerMap = new Map<string, ReceivableCustomer>()
|
||||
for (const c of customers) {
|
||||
customerMap.set(c.id, c)
|
||||
}
|
||||
|
||||
// Filter to open foreign-currency invoices
|
||||
const foreignInvoices = invoices.filter(inv =>
|
||||
OPEN_STATUSES.includes(inv.status) &&
|
||||
inv.currency !== 'SEK' &&
|
||||
inv.total > 0
|
||||
)
|
||||
|
||||
// Build receivable details
|
||||
const receivables: ForeignReceivable[] = []
|
||||
for (const inv of foreignInvoices) {
|
||||
const customer = customerMap.get(inv.customer_id)
|
||||
const rateInfo = rateMap.get(inv.currency.toUpperCase())
|
||||
const currentRate = rateInfo?.rate ?? 0
|
||||
|
||||
const bookedSekAmount = getBookedSekAmount(inv)
|
||||
const bookedRate = inv.total > 0 ? bookedSekAmount / inv.total : 0
|
||||
const currentSekAmount = round2(inv.total * currentRate)
|
||||
const unrealizedGainLoss = round2(currentSekAmount - bookedSekAmount)
|
||||
const daysOutstanding = daysBetween(inv.invoice_date, referenceDate)
|
||||
|
||||
receivables.push({
|
||||
invoiceId: inv.id,
|
||||
invoiceNumber: inv.invoice_number,
|
||||
customerName: customer?.name ?? 'Okänd kund',
|
||||
customerCountry: customer?.country ?? '',
|
||||
currency: inv.currency,
|
||||
foreignAmount: inv.total,
|
||||
bookedSekAmount,
|
||||
bookedRate: round4(bookedRate),
|
||||
currentSekAmount,
|
||||
currentRate: round4(currentRate),
|
||||
unrealizedGainLoss,
|
||||
invoiceDate: inv.invoice_date,
|
||||
dueDate: inv.due_date,
|
||||
daysOutstanding,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by unrealized gain/loss (largest absolute first)
|
||||
receivables.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
|
||||
|
||||
// Aggregate by currency
|
||||
const exposureMap = new Map<string, {
|
||||
totalForeign: number
|
||||
bookedSek: number
|
||||
currentSek: number
|
||||
count: number
|
||||
}>()
|
||||
|
||||
for (const r of receivables) {
|
||||
const existing = exposureMap.get(r.currency) || { totalForeign: 0, bookedSek: 0, currentSek: 0, count: 0 }
|
||||
existing.totalForeign += r.foreignAmount
|
||||
existing.bookedSek += r.bookedSekAmount
|
||||
existing.currentSek += r.currentSekAmount
|
||||
existing.count += 1
|
||||
exposureMap.set(r.currency, existing)
|
||||
}
|
||||
|
||||
const exposureByCurrency: CurrencyExposure[] = []
|
||||
for (const [currency, agg] of exposureMap.entries()) {
|
||||
const rateInfo = rateMap.get(currency.toUpperCase())
|
||||
exposureByCurrency.push({
|
||||
currency,
|
||||
totalForeignAmount: round2(agg.totalForeign),
|
||||
bookedSekValue: round2(agg.bookedSek),
|
||||
currentSekValue: round2(agg.currentSek),
|
||||
unrealizedGainLoss: round2(agg.currentSek - agg.bookedSek),
|
||||
invoiceCount: agg.count,
|
||||
averageBookedRate: agg.totalForeign > 0 ? round4(agg.bookedSek / agg.totalForeign) : 0,
|
||||
currentRate: rateInfo?.rate ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort exposures by absolute unrealized gain/loss descending
|
||||
exposureByCurrency.sort((a, b) => Math.abs(b.unrealizedGainLoss) - Math.abs(a.unrealizedGainLoss))
|
||||
|
||||
// Calculate realized FX from GL
|
||||
const { gains, losses } = calculateRealizedFX(realizedFXLines)
|
||||
|
||||
// Monthly trend
|
||||
const monthlyTrend = calculateMonthlyTrend(realizedFXLines, year)
|
||||
|
||||
// Revaluation preview
|
||||
const totalBooked = receivables.reduce((sum, r) => sum + r.bookedSekAmount, 0)
|
||||
const totalCurrent = receivables.reduce((sum, r) => sum + r.currentSekAmount, 0)
|
||||
const totalUnrealized = round2(totalCurrent - totalBooked)
|
||||
|
||||
const revalPreview: RevalPreview = {
|
||||
totalUnrealizedGainLoss: totalUnrealized,
|
||||
gains: round2(Math.max(0, totalUnrealized)),
|
||||
losses: round2(Math.abs(Math.min(0, totalUnrealized))),
|
||||
}
|
||||
|
||||
const currencies = new Set(receivables.map(r => r.currency))
|
||||
|
||||
return {
|
||||
referenceDate,
|
||||
exchangeRates: currentRates,
|
||||
exposureByCurrency,
|
||||
receivables,
|
||||
realizedGainLoss: {
|
||||
year,
|
||||
gains: round2(gains),
|
||||
losses: round2(losses),
|
||||
net: round2(gains - losses),
|
||||
},
|
||||
monthlyTrend,
|
||||
revalPreview,
|
||||
totals: {
|
||||
bookedSekValue: round2(totalBooked),
|
||||
currentSekValue: round2(totalCurrent),
|
||||
totalUnrealizedGainLoss: totalUnrealized,
|
||||
receivableCount: receivables.length,
|
||||
currencyCount: currencies.size,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function getBookedSekAmount(inv: ReceivableInvoice): number {
|
||||
// Prefer the explicitly stored SEK amount
|
||||
if (inv.total_sek !== null && inv.total_sek !== undefined) {
|
||||
return round2(inv.total_sek)
|
||||
}
|
||||
// Fallback: total × exchange_rate at invoice date
|
||||
if (inv.exchange_rate !== null && inv.exchange_rate !== undefined) {
|
||||
return round2(inv.total * inv.exchange_rate)
|
||||
}
|
||||
// No rate info — return 0 (will show as warning in UI)
|
||||
return 0
|
||||
}
|
||||
|
||||
function calculateRealizedFX(lines: GLLine[]): { gains: number; losses: number } {
|
||||
let gains = 0
|
||||
let losses = 0
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.account_number === ACCOUNT_FX_GAINS) {
|
||||
// FX gains are booked as credits on 3960
|
||||
gains += line.credit - line.debit
|
||||
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
|
||||
// FX losses are booked as debits on 7960
|
||||
losses += line.debit - line.credit
|
||||
}
|
||||
}
|
||||
|
||||
return { gains: Math.max(0, gains), losses: Math.max(0, losses) }
|
||||
}
|
||||
|
||||
function calculateMonthlyTrend(lines: GLLine[], year: number): MonthlyFXTrend[] {
|
||||
const monthData = new Map<string, { gains: number; losses: number }>()
|
||||
|
||||
// Initialize all 12 months
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const key = `${year}-${String(m).padStart(2, '0')}`
|
||||
monthData.set(key, { gains: 0, losses: 0 })
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const monthKey = line.entry_date.substring(0, 7) // 'YYYY-MM'
|
||||
const data = monthData.get(monthKey)
|
||||
if (!data) continue
|
||||
|
||||
if (line.account_number === ACCOUNT_FX_GAINS) {
|
||||
data.gains += line.credit - line.debit
|
||||
} else if (line.account_number === ACCOUNT_FX_LOSSES) {
|
||||
data.losses += line.debit - line.credit
|
||||
}
|
||||
}
|
||||
|
||||
const trend: MonthlyFXTrend[] = []
|
||||
for (const [month, data] of monthData.entries()) {
|
||||
const gains = Math.max(0, round2(data.gains))
|
||||
const losses = Math.max(0, round2(data.losses))
|
||||
trend.push({
|
||||
month,
|
||||
realizedGains: gains,
|
||||
realizedLosses: losses,
|
||||
netRealized: round2(gains - losses),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort chronologically
|
||||
trend.sort((a, b) => a.month.localeCompare(b.month))
|
||||
|
||||
return trend
|
||||
}
|
||||
|
||||
function daysBetween(dateStr: string, refDateStr: string): number {
|
||||
const d1 = new Date(dateStr)
|
||||
const d2 = new Date(refDateStr)
|
||||
const diffMs = d2.getTime() - d1.getTime()
|
||||
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)))
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function round4(n: number): number {
|
||||
return Math.round(n * 10000) / 10000
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* EU Sales List / Periodisk Sammanställning Extension
|
||||
*
|
||||
* Generates the mandatory EC Sales List (periodisk sammanställning) report
|
||||
* for Skatteverket. Aggregates intra-community B2B sales by customer VAT
|
||||
* number, separating goods (box 35) from services (box 39).
|
||||
*
|
||||
* Outputs downloadable CSV/XML files for upload to Skatteverket's e-service.
|
||||
* Validates customer VAT numbers via VIES and cross-checks against
|
||||
* momsdeklaration box totals.
|
||||
*/
|
||||
export const euSalesListExtension: Extension = {
|
||||
id: 'eu-sales-list',
|
||||
name: 'Periodisk sammanställning',
|
||||
version: '1.0.0',
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateCSV, generateCSVFilename } from '../csv-generator'
|
||||
import type { ECSalesListReport } from '../eu-sales-list-engine'
|
||||
|
||||
function makeReport(overrides: Partial<ECSalesListReport> = {}): ECSalesListReport {
|
||||
return {
|
||||
period: { year: 2026, quarter: 1 },
|
||||
filingType: 'quarterly',
|
||||
reporterVatNumber: 'SE556677889901',
|
||||
reporterName: 'Test AB',
|
||||
lines: [
|
||||
{
|
||||
customerVatNumber: 'DE123456789',
|
||||
customerName: 'Acme GmbH',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'cust-1',
|
||||
goodsAmount: 50000,
|
||||
servicesAmount: 30000,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 3,
|
||||
},
|
||||
],
|
||||
totals: { goods: 50000, services: 30000, triangulation: 0, total: 80000 },
|
||||
warnings: [],
|
||||
crossCheck: null,
|
||||
invoiceCount: 3,
|
||||
customerCount: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateCSV', () => {
|
||||
it('starts with UTF-8 BOM', () => {
|
||||
const csv = generateCSV(makeReport())
|
||||
expect(csv.charCodeAt(0)).toBe(0xFEFF)
|
||||
})
|
||||
|
||||
it('uses semicolons as delimiter', () => {
|
||||
const csv = generateCSV(makeReport())
|
||||
const headerLine = csv.split('\r\n')[0].replace('\uFEFF', '')
|
||||
expect(headerLine).toContain(';')
|
||||
expect(headerLine).not.toContain(',')
|
||||
})
|
||||
|
||||
it('has correct header columns', () => {
|
||||
const csv = generateCSV(makeReport())
|
||||
const header = csv.split('\r\n')[0].replace('\uFEFF', '')
|
||||
expect(header).toBe('Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)')
|
||||
})
|
||||
|
||||
it('includes customer data rows', () => {
|
||||
const csv = generateCSV(makeReport())
|
||||
const lines = csv.split('\r\n')
|
||||
expect(lines[1]).toBe('DE;DE123456789;50000;30000;0')
|
||||
})
|
||||
|
||||
it('rounds amounts to whole SEK', () => {
|
||||
const report = makeReport({
|
||||
lines: [{
|
||||
customerVatNumber: 'DE123',
|
||||
customerName: 'Test',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'c-1',
|
||||
goodsAmount: 12345.67,
|
||||
servicesAmount: 89012.34,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 1,
|
||||
}],
|
||||
totals: { goods: 12345.67, services: 89012.34, triangulation: 0, total: 101358.01 },
|
||||
})
|
||||
|
||||
const csv = generateCSV(report)
|
||||
const dataLine = csv.split('\r\n')[1]
|
||||
expect(dataLine).toBe('DE;DE123;12346;89012;0')
|
||||
})
|
||||
|
||||
it('includes summary row', () => {
|
||||
const csv = generateCSV(makeReport())
|
||||
const lines = csv.split('\r\n')
|
||||
const summaryIndex = lines.findIndex(l => l.startsWith('Summa'))
|
||||
expect(summaryIndex).toBeGreaterThan(0)
|
||||
expect(lines[summaryIndex]).toBe('Summa;;50000;30000;0')
|
||||
})
|
||||
|
||||
it('uses CRLF line endings', () => {
|
||||
const csv = generateCSV(makeReport())
|
||||
expect(csv).toContain('\r\n')
|
||||
})
|
||||
|
||||
it('handles multiple customers', () => {
|
||||
const report = makeReport({
|
||||
lines: [
|
||||
{
|
||||
customerVatNumber: 'DE111',
|
||||
customerName: 'A',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'c-1',
|
||||
goodsAmount: 10000,
|
||||
servicesAmount: 0,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 1,
|
||||
},
|
||||
{
|
||||
customerVatNumber: 'FR222',
|
||||
customerName: 'B',
|
||||
customerCountry: 'FR',
|
||||
customerId: 'c-2',
|
||||
goodsAmount: 0,
|
||||
servicesAmount: 20000,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 2,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const csv = generateCSV(report)
|
||||
const lines = csv.split('\r\n')
|
||||
expect(lines[1]).toBe('DE;DE111;10000;0;0')
|
||||
expect(lines[2]).toBe('FR;FR222;0;20000;0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateCSVFilename', () => {
|
||||
it('generates quarterly filename', () => {
|
||||
const filename = generateCSVFilename(makeReport())
|
||||
expect(filename).toBe('PS_SE556677889901_2026-Q1.csv')
|
||||
})
|
||||
|
||||
it('generates monthly filename', () => {
|
||||
const report = makeReport({ period: { year: 2026, month: 3 }, filingType: 'monthly' })
|
||||
const filename = generateCSVFilename(report)
|
||||
expect(filename).toBe('PS_SE556677889901_2026-03.csv')
|
||||
})
|
||||
|
||||
it('strips spaces from VAT number', () => {
|
||||
const report = makeReport({ reporterVatNumber: 'SE 5566 7788 9901' })
|
||||
const filename = generateCSVFilename(report)
|
||||
expect(filename).toBe('PS_SE556677889901_2026-Q1.csv')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,559 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
generateECSalesListReport,
|
||||
getMonthPeriod,
|
||||
getQuarterPeriod,
|
||||
getFilingDeadline,
|
||||
daysUntilDeadline,
|
||||
type ECSalesListInvoice,
|
||||
type ECSalesListCustomer,
|
||||
type GLAccountTotal,
|
||||
} from '../eu-sales-list-engine'
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────
|
||||
|
||||
function makeInvoice(overrides: Partial<ECSalesListInvoice> = {}): ECSalesListInvoice {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F2026-001',
|
||||
invoice_date: '2026-01-15',
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
total: 10000,
|
||||
total_sek: 112000,
|
||||
subtotal: 10000,
|
||||
subtotal_sek: 112000,
|
||||
vat_treatment: 'reverse_charge',
|
||||
moms_ruta: '35',
|
||||
document_type: 'invoice',
|
||||
credited_invoice_id: null,
|
||||
customer_id: 'cust-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCustomer(overrides: Partial<ECSalesListCustomer> = {}): ECSalesListCustomer {
|
||||
return {
|
||||
id: 'cust-1',
|
||||
name: 'Acme GmbH',
|
||||
country: 'DE',
|
||||
customer_type: 'eu_business',
|
||||
vat_number: 'DE123456789',
|
||||
vat_number_validated: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_OPTIONS = {
|
||||
reporterVatNumber: 'SE556677889901',
|
||||
reporterName: 'Test AB',
|
||||
year: 2026,
|
||||
month: 1 as number | undefined,
|
||||
quarter: undefined as number | undefined,
|
||||
}
|
||||
|
||||
// ── Report generation tests ───────────────────────────────────
|
||||
|
||||
describe('generateECSalesListReport', () => {
|
||||
it('generates empty report when no invoices', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [],
|
||||
customers: [],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
expect(report.totals.goods).toBe(0)
|
||||
expect(report.totals.services).toBe(0)
|
||||
expect(report.totals.total).toBe(0)
|
||||
expect(report.invoiceCount).toBe(0)
|
||||
expect(report.customerCount).toBe(0)
|
||||
expect(report.warnings).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('aggregates goods invoice correctly (moms_ruta 35)', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ moms_ruta: '35', subtotal_sek: 50000 })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(1)
|
||||
expect(report.lines[0].goodsAmount).toBe(50000)
|
||||
expect(report.lines[0].servicesAmount).toBe(0)
|
||||
expect(report.totals.goods).toBe(50000)
|
||||
expect(report.invoiceCount).toBe(1)
|
||||
})
|
||||
|
||||
it('aggregates service invoice correctly (moms_ruta 39)', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ moms_ruta: '39', subtotal_sek: 30000 })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].servicesAmount).toBe(30000)
|
||||
expect(report.lines[0].goodsAmount).toBe(0)
|
||||
expect(report.totals.services).toBe(30000)
|
||||
})
|
||||
|
||||
it('aggregates triangulation invoice correctly (moms_ruta 38)', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ moms_ruta: '38', subtotal_sek: 20000 })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].triangulationAmount).toBe(20000)
|
||||
expect(report.totals.triangulation).toBe(20000)
|
||||
})
|
||||
|
||||
it('defaults to services when moms_ruta is null', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ moms_ruta: null, subtotal_sek: 15000 })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].servicesAmount).toBe(15000)
|
||||
expect(report.lines[0].goodsAmount).toBe(0)
|
||||
})
|
||||
|
||||
it('groups multiple invoices by customer VAT number', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', subtotal_sek: 10000, moms_ruta: '35' }),
|
||||
makeInvoice({ id: 'inv-2', subtotal_sek: 20000, moms_ruta: '35' }),
|
||||
makeInvoice({ id: 'inv-3', subtotal_sek: 5000, moms_ruta: '39' }),
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(1)
|
||||
expect(report.lines[0].goodsAmount).toBe(30000)
|
||||
expect(report.lines[0].servicesAmount).toBe(5000)
|
||||
expect(report.lines[0].invoiceCount).toBe(3)
|
||||
})
|
||||
|
||||
it('separates different customers into different lines', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', customer_id: 'cust-1', subtotal_sek: 10000 }),
|
||||
makeInvoice({ id: 'inv-2', customer_id: 'cust-2', subtotal_sek: 20000 }),
|
||||
]
|
||||
const customers = [
|
||||
makeCustomer({ id: 'cust-1', vat_number: 'DE111111111', country: 'DE' }),
|
||||
makeCustomer({ id: 'cust-2', vat_number: 'FR222222222', country: 'FR', name: 'Fromage SARL' }),
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers,
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(2)
|
||||
expect(report.customerCount).toBe(2)
|
||||
// Sorted by country, so DE comes before FR
|
||||
expect(report.lines[0].customerCountry).toBe('DE')
|
||||
expect(report.lines[1].customerCountry).toBe('FR')
|
||||
})
|
||||
|
||||
it('handles credit notes (subtracts from customer total)', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, moms_ruta: '35' }),
|
||||
makeInvoice({
|
||||
id: 'inv-2',
|
||||
subtotal_sek: 10000,
|
||||
moms_ruta: '35',
|
||||
credited_invoice_id: 'inv-1',
|
||||
}),
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].goodsAmount).toBe(40000) // 50000 - 10000
|
||||
expect(report.invoiceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('uses subtotal (not total) for amounts', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 80000, total_sek: 100000, moms_ruta: '35' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].goodsAmount).toBe(80000)
|
||||
})
|
||||
|
||||
it('falls back to subtotal when subtotal_sek is null (SEK invoices)', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: null, subtotal: 45000, moms_ruta: '35' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].goodsAmount).toBe(45000)
|
||||
})
|
||||
|
||||
// ── Filtering tests ──────────────────────────────────────
|
||||
|
||||
it('excludes draft invoices', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ status: 'draft' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
expect(report.invoiceCount).toBe(0)
|
||||
})
|
||||
|
||||
it('excludes non-reverse-charge invoices', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ vat_treatment: 'standard_25' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes paid and overdue invoices', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', status: 'paid', subtotal_sek: 10000 }),
|
||||
makeInvoice({ id: 'inv-2', status: 'overdue', subtotal_sek: 20000 }),
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.invoiceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('excludes proforma documents without credit note link', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ document_type: 'proforma', credited_invoice_id: null })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes credit notes even with non-invoice document type', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({
|
||||
document_type: 'credit_note',
|
||||
credited_invoice_id: 'inv-original',
|
||||
subtotal_sek: 5000,
|
||||
moms_ruta: '35',
|
||||
})],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
// Credit note creates a line with negative amount
|
||||
expect(report.lines).toHaveLength(1)
|
||||
expect(report.lines[0].goodsAmount).toBe(-5000)
|
||||
})
|
||||
|
||||
// ── Warning tests ────────────────────────────────────────
|
||||
|
||||
it('warns on missing VAT number (error severity)', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
customers: [makeCustomer({ vat_number: null })],
|
||||
})
|
||||
|
||||
expect(report.warnings).toHaveLength(1)
|
||||
expect(report.warnings[0].type).toBe('missing_vat_number')
|
||||
expect(report.warnings[0].severity).toBe('error')
|
||||
// Invoice is excluded from lines when VAT number missing
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('warns on unvalidated VAT number (warning severity)', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
customers: [makeCustomer({ vat_number_validated: false })],
|
||||
})
|
||||
|
||||
const unvalidatedWarnings = report.warnings.filter(w => w.type === 'unvalidated_vat_number')
|
||||
expect(unvalidatedWarnings).toHaveLength(1)
|
||||
expect(unvalidatedWarnings[0].severity).toBe('warning')
|
||||
// Invoice is still included in report
|
||||
expect(report.lines).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('warns on non-EU country', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
customers: [makeCustomer({ country: 'US' })],
|
||||
})
|
||||
|
||||
expect(report.warnings).toHaveLength(1)
|
||||
expect(report.warnings[0].type).toBe('non_eu_country')
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes Sweden (SE) as non-intra-community', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
customers: [makeCustomer({ country: 'SE' })],
|
||||
})
|
||||
|
||||
expect(report.warnings).toHaveLength(1)
|
||||
expect(report.warnings[0].type).toBe('non_eu_country')
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
// ── Cross-check tests ────────────────────────────────────
|
||||
|
||||
it('cross-check passes when GL matches report totals', () => {
|
||||
const glTotals: GLAccountTotal[] = [
|
||||
{ account_number: '3108', credit: 50000 },
|
||||
{ account_number: '3308', credit: 30000 },
|
||||
]
|
||||
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, moms_ruta: '35' }),
|
||||
makeInvoice({ id: 'inv-2', subtotal_sek: 30000, moms_ruta: '39' }),
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
customers: [makeCustomer()],
|
||||
glTotals,
|
||||
})
|
||||
|
||||
expect(report.crossCheck).not.toBeNull()
|
||||
expect(report.crossCheck!.box35Match).toBe(true)
|
||||
expect(report.crossCheck!.box39Match).toBe(true)
|
||||
})
|
||||
|
||||
it('cross-check fails when GL does not match', () => {
|
||||
const glTotals: GLAccountTotal[] = [
|
||||
{ account_number: '3108', credit: 99999 },
|
||||
{ account_number: '3308', credit: 30000 },
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
|
||||
customers: [makeCustomer()],
|
||||
glTotals,
|
||||
})
|
||||
|
||||
expect(report.crossCheck!.box35Match).toBe(false)
|
||||
const mismatchWarnings = report.warnings.filter(w => w.type === 'cross_check_mismatch')
|
||||
expect(mismatchWarnings.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('cross-check allows 1 SEK rounding tolerance', () => {
|
||||
const glTotals: GLAccountTotal[] = [
|
||||
{ account_number: '3108', credit: 50000.50 },
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
|
||||
customers: [makeCustomer()],
|
||||
glTotals,
|
||||
})
|
||||
|
||||
expect(report.crossCheck!.box35Match).toBe(true)
|
||||
})
|
||||
|
||||
it('cross-check sums multiple accounts for box 35 (3108 + 3521)', () => {
|
||||
const glTotals: GLAccountTotal[] = [
|
||||
{ account_number: '3108', credit: 40000 },
|
||||
{ account_number: '3521', credit: 10000 },
|
||||
]
|
||||
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 50000, moms_ruta: '35' })],
|
||||
customers: [makeCustomer()],
|
||||
glTotals,
|
||||
})
|
||||
|
||||
expect(report.crossCheck!.box35Match).toBe(true)
|
||||
expect(report.crossCheck!.box35GLTotal).toBe(50000)
|
||||
})
|
||||
|
||||
it('skips cross-check when no GL data provided', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.crossCheck).toBeNull()
|
||||
})
|
||||
|
||||
// ── Period and metadata tests ────────────────────────────
|
||||
|
||||
it('sets filingType to monthly when month is provided', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [],
|
||||
customers: [],
|
||||
month: 3,
|
||||
quarter: undefined,
|
||||
})
|
||||
|
||||
expect(report.filingType).toBe('monthly')
|
||||
expect(report.period.month).toBe(3)
|
||||
})
|
||||
|
||||
it('sets filingType to quarterly when quarter is provided', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [],
|
||||
customers: [],
|
||||
month: undefined,
|
||||
quarter: 2,
|
||||
})
|
||||
|
||||
expect(report.filingType).toBe('quarterly')
|
||||
expect(report.period.quarter).toBe(2)
|
||||
})
|
||||
|
||||
it('includes reporter info in report', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [],
|
||||
customers: [],
|
||||
})
|
||||
|
||||
expect(report.reporterVatNumber).toBe('SE556677889901')
|
||||
expect(report.reporterName).toBe('Test AB')
|
||||
})
|
||||
|
||||
// ── Monetary precision tests ─────────────────────────────
|
||||
|
||||
it('rounds amounts to 2 decimal places', () => {
|
||||
const report = generateECSalesListReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 33333.335, moms_ruta: '35' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].goodsAmount).toBe(33333.34)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Period helper tests ───────────────────────────────────────
|
||||
|
||||
describe('getMonthPeriod', () => {
|
||||
it('returns correct start and end for January', () => {
|
||||
const { start, end } = getMonthPeriod(2026, 1)
|
||||
expect(start).toBe('2026-01-01')
|
||||
expect(end).toBe('2026-01-31')
|
||||
})
|
||||
|
||||
it('handles February in a non-leap year', () => {
|
||||
const { start, end } = getMonthPeriod(2027, 2)
|
||||
expect(start).toBe('2027-02-01')
|
||||
expect(end).toBe('2027-02-28')
|
||||
})
|
||||
|
||||
it('handles February in a leap year', () => {
|
||||
const { start, end } = getMonthPeriod(2028, 2)
|
||||
expect(start).toBe('2028-02-01')
|
||||
expect(end).toBe('2028-02-29')
|
||||
})
|
||||
|
||||
it('returns correct dates for December', () => {
|
||||
const { start, end } = getMonthPeriod(2026, 12)
|
||||
expect(start).toBe('2026-12-01')
|
||||
expect(end).toBe('2026-12-31')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getQuarterPeriod', () => {
|
||||
it('returns Q1 dates', () => {
|
||||
const { start, end } = getQuarterPeriod(2026, 1)
|
||||
expect(start).toBe('2026-01-01')
|
||||
expect(end).toBe('2026-03-31')
|
||||
})
|
||||
|
||||
it('returns Q2 dates', () => {
|
||||
const { start, end } = getQuarterPeriod(2026, 2)
|
||||
expect(start).toBe('2026-04-01')
|
||||
expect(end).toBe('2026-06-30')
|
||||
})
|
||||
|
||||
it('returns Q3 dates', () => {
|
||||
const { start, end } = getQuarterPeriod(2026, 3)
|
||||
expect(start).toBe('2026-07-01')
|
||||
expect(end).toBe('2026-09-30')
|
||||
})
|
||||
|
||||
it('returns Q4 dates', () => {
|
||||
const { start, end } = getQuarterPeriod(2026, 4)
|
||||
expect(start).toBe('2026-10-01')
|
||||
expect(end).toBe('2026-12-31')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilingDeadline', () => {
|
||||
it('returns 25th of following month for monthly', () => {
|
||||
expect(getFilingDeadline(2026, 1)).toBe('2026-02-25')
|
||||
expect(getFilingDeadline(2026, 6)).toBe('2026-07-25')
|
||||
})
|
||||
|
||||
it('rolls over to next year for December', () => {
|
||||
expect(getFilingDeadline(2026, 12)).toBe('2027-01-25')
|
||||
})
|
||||
|
||||
it('returns correct deadline for quarterly', () => {
|
||||
expect(getFilingDeadline(2026, undefined, 1)).toBe('2026-04-25')
|
||||
expect(getFilingDeadline(2026, undefined, 2)).toBe('2026-07-25')
|
||||
expect(getFilingDeadline(2026, undefined, 3)).toBe('2026-10-25')
|
||||
})
|
||||
|
||||
it('rolls over to next year for Q4', () => {
|
||||
expect(getFilingDeadline(2026, undefined, 4)).toBe('2027-01-25')
|
||||
})
|
||||
|
||||
it('throws when neither month nor quarter provided', () => {
|
||||
expect(() => getFilingDeadline(2026)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('daysUntilDeadline', () => {
|
||||
it('returns positive number for future deadlines', () => {
|
||||
const future = new Date()
|
||||
future.setDate(future.getDate() + 10)
|
||||
const dateStr = future.toISOString().slice(0, 10)
|
||||
expect(daysUntilDeadline(dateStr)).toBe(10)
|
||||
})
|
||||
|
||||
it('returns 0 for today', () => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
expect(daysUntilDeadline(today)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns negative number for past deadlines', () => {
|
||||
const past = new Date()
|
||||
past.setDate(past.getDate() - 5)
|
||||
const dateStr = past.toISOString().slice(0, 10)
|
||||
expect(daysUntilDeadline(dateStr)).toBe(-5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateSKVXml, generateXMLFilename } from '../skv-xml-generator'
|
||||
import type { ECSalesListReport } from '../eu-sales-list-engine'
|
||||
|
||||
function makeReport(overrides: Partial<ECSalesListReport> = {}): ECSalesListReport {
|
||||
return {
|
||||
period: { year: 2026, quarter: 1 },
|
||||
filingType: 'quarterly',
|
||||
reporterVatNumber: 'SE556677889901',
|
||||
reporterName: 'Test AB',
|
||||
lines: [
|
||||
{
|
||||
customerVatNumber: 'DE123456789',
|
||||
customerName: 'Acme GmbH',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'cust-1',
|
||||
goodsAmount: 50000,
|
||||
servicesAmount: 30000,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 3,
|
||||
},
|
||||
],
|
||||
totals: { goods: 50000, services: 30000, triangulation: 0, total: 80000 },
|
||||
warnings: [],
|
||||
crossCheck: null,
|
||||
invoiceCount: 3,
|
||||
customerCount: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateSKVXml', () => {
|
||||
it('starts with XML declaration', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
expect(xml).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/)
|
||||
})
|
||||
|
||||
it('wraps content in KVPS root element', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
expect(xml).toContain('<KVPS>')
|
||||
expect(xml).toContain('</KVPS>')
|
||||
})
|
||||
|
||||
it('includes reporter VAT number', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
expect(xml).toContain('<Momsregistreringsnummer>SE556677889901</Momsregistreringsnummer>')
|
||||
})
|
||||
|
||||
it('includes reporter name', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
expect(xml).toContain('<Namn>Test AB</Namn>')
|
||||
})
|
||||
|
||||
it('includes quarterly period info', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
expect(xml).toContain('<Ar>2026</Ar>')
|
||||
expect(xml).toContain('<Kvartal>1</Kvartal>')
|
||||
expect(xml).toContain('<Redovisningstyp>Kvartal</Redovisningstyp>')
|
||||
})
|
||||
|
||||
it('includes monthly period info', () => {
|
||||
const report = makeReport({ period: { year: 2026, month: 3 }, filingType: 'monthly' })
|
||||
const xml = generateSKVXml(report)
|
||||
expect(xml).toContain('<Manad>03</Manad>')
|
||||
expect(xml).toContain('<Redovisningstyp>Manad</Redovisningstyp>')
|
||||
})
|
||||
|
||||
it('includes customer line with goods and services', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
expect(xml).toContain('<KopareVATnr>DE123456789</KopareVATnr>')
|
||||
expect(xml).toContain('<KopareLand>DE</KopareLand>')
|
||||
expect(xml).toContain('<VarorBeloppSEK>50000</VarorBeloppSEK>')
|
||||
expect(xml).toContain('<TjansterBeloppSEK>30000</TjansterBeloppSEK>')
|
||||
})
|
||||
|
||||
it('omits zero amount elements', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
// Triangulation is 0, should not appear in line
|
||||
expect(xml).not.toContain('<TriangelhandelBeloppSEK>0</TriangelhandelBeloppSEK>')
|
||||
})
|
||||
|
||||
it('includes triangulation when non-zero', () => {
|
||||
const report = makeReport({
|
||||
lines: [{
|
||||
customerVatNumber: 'DE123',
|
||||
customerName: 'Test',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'c-1',
|
||||
goodsAmount: 0,
|
||||
servicesAmount: 0,
|
||||
triangulationAmount: 15000,
|
||||
invoiceCount: 1,
|
||||
}],
|
||||
})
|
||||
|
||||
const xml = generateSKVXml(report)
|
||||
expect(xml).toContain('<TriangelhandelBeloppSEK>15000</TriangelhandelBeloppSEK>')
|
||||
})
|
||||
|
||||
it('skips lines with all zero amounts', () => {
|
||||
const report = makeReport({
|
||||
lines: [
|
||||
{
|
||||
customerVatNumber: 'DE111',
|
||||
customerName: 'Zero',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'c-1',
|
||||
goodsAmount: 0,
|
||||
servicesAmount: 0,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 0,
|
||||
},
|
||||
{
|
||||
customerVatNumber: 'FR222',
|
||||
customerName: 'NonZero',
|
||||
customerCountry: 'FR',
|
||||
customerId: 'c-2',
|
||||
goodsAmount: 10000,
|
||||
servicesAmount: 0,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const xml = generateSKVXml(report)
|
||||
expect(xml).not.toContain('DE111')
|
||||
expect(xml).toContain('FR222')
|
||||
})
|
||||
|
||||
it('includes totals section', () => {
|
||||
const xml = generateSKVXml(makeReport())
|
||||
expect(xml).toContain('<VarorTotaltSEK>50000</VarorTotaltSEK>')
|
||||
expect(xml).toContain('<TjansterTotaltSEK>30000</TjansterTotaltSEK>')
|
||||
expect(xml).toContain('<TotaltSEK>80000</TotaltSEK>')
|
||||
})
|
||||
|
||||
it('rounds amounts to whole SEK', () => {
|
||||
const report = makeReport({
|
||||
lines: [{
|
||||
customerVatNumber: 'DE123',
|
||||
customerName: 'Test',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'c-1',
|
||||
goodsAmount: 12345.67,
|
||||
servicesAmount: 0,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 1,
|
||||
}],
|
||||
totals: { goods: 12345.67, services: 0, triangulation: 0, total: 12345.67 },
|
||||
})
|
||||
|
||||
const xml = generateSKVXml(report)
|
||||
expect(xml).toContain('<VarorBeloppSEK>12346</VarorBeloppSEK>')
|
||||
})
|
||||
|
||||
it('escapes XML special characters in names', () => {
|
||||
const report = makeReport({
|
||||
reporterName: 'Foo & Bar <AB>',
|
||||
lines: [{
|
||||
customerVatNumber: 'DE123',
|
||||
customerName: 'Test',
|
||||
customerCountry: 'DE',
|
||||
customerId: 'c-1',
|
||||
goodsAmount: 1000,
|
||||
servicesAmount: 0,
|
||||
triangulationAmount: 0,
|
||||
invoiceCount: 1,
|
||||
}],
|
||||
})
|
||||
|
||||
const xml = generateSKVXml(report)
|
||||
expect(xml).toContain('Foo & Bar <AB>')
|
||||
expect(xml).not.toContain('Foo & Bar <AB>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateXMLFilename', () => {
|
||||
it('generates quarterly filename', () => {
|
||||
const filename = generateXMLFilename(makeReport())
|
||||
expect(filename).toBe('KVPS_SE556677889901_2026-Q1.xml')
|
||||
})
|
||||
|
||||
it('generates monthly filename', () => {
|
||||
const report = makeReport({ period: { year: 2026, month: 11 }, filingType: 'monthly' })
|
||||
const filename = generateXMLFilename(report)
|
||||
expect(filename).toBe('KVPS_SE556677889901_2026-11.xml')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* EU Sales List CSV Generator
|
||||
*
|
||||
* Generates a semicolon-separated CSV file (UTF-8 with BOM) for the
|
||||
* periodisk sammanställning report. Compatible with Excel and Skatteverket's
|
||||
* import tools.
|
||||
*
|
||||
* Format: Semicolon-delimited, UTF-8 BOM, whole SEK amounts.
|
||||
*/
|
||||
|
||||
import type { ECSalesListReport } from './eu-sales-list-engine'
|
||||
|
||||
/** UTF-8 BOM for Excel compatibility */
|
||||
const UTF8_BOM = '\uFEFF'
|
||||
|
||||
/**
|
||||
* Generate a CSV string for the EC Sales List report.
|
||||
*
|
||||
* Columns:
|
||||
* Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)
|
||||
*
|
||||
* Amounts are rounded to whole SEK (öre removed) as required by Skatteverket.
|
||||
*/
|
||||
export function generateCSV(report: ECSalesListReport): string {
|
||||
const header = 'Land;VAT-nummer;Varuförsäljning (SEK);Tjänsteförsäljning (SEK);Trepartshandel (SEK)'
|
||||
|
||||
const rows = report.lines.map(line => {
|
||||
const goods = Math.round(line.goodsAmount)
|
||||
const services = Math.round(line.servicesAmount)
|
||||
const triangulation = Math.round(line.triangulationAmount)
|
||||
return `${line.customerCountry};${line.customerVatNumber};${goods};${services};${triangulation}`
|
||||
})
|
||||
|
||||
// Summary row
|
||||
const totalGoods = Math.round(report.totals.goods)
|
||||
const totalServices = Math.round(report.totals.services)
|
||||
const totalTriangulation = Math.round(report.totals.triangulation)
|
||||
rows.push('')
|
||||
rows.push(`Summa;;${totalGoods};${totalServices};${totalTriangulation}`)
|
||||
|
||||
return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filename for the CSV download.
|
||||
*
|
||||
* Format: PS_<VAT>_<period>.csv
|
||||
* Example: PS_SE556677889901_2026-Q1.csv or PS_SE556677889901_2026-03.csv
|
||||
*/
|
||||
export function generateCSVFilename(report: ECSalesListReport): string {
|
||||
const vat = report.reporterVatNumber.replace(/\s/g, '')
|
||||
const period = formatPeriod(report)
|
||||
return `PS_${vat}_${period}.csv`
|
||||
}
|
||||
|
||||
function formatPeriod(report: ECSalesListReport): string {
|
||||
const { year, month, quarter } = report.period
|
||||
if (month !== undefined) {
|
||||
return `${year}-${String(month).padStart(2, '0')}`
|
||||
}
|
||||
if (quarter !== undefined) {
|
||||
return `${year}-Q${quarter}`
|
||||
}
|
||||
return `${year}`
|
||||
}
|
||||
@@ -0,0 +1,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<string, ECSalesListLine>()
|
||||
|
||||
for (const invoice of relevantInvoices) {
|
||||
const customer = customerMap.get(invoice.customer_id)
|
||||
if (!customer) continue
|
||||
|
||||
// Validate: customer should be in an EU country (not Sweden)
|
||||
if (!isEUCountry(customer.country)) {
|
||||
warnings.push({
|
||||
type: 'non_eu_country',
|
||||
severity: 'warning',
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
message: `Faktura ${invoice.invoice_number} till ${customer.name} har omvänd skattskyldighet men kunden är i ${customer.country} (ej EU).`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate: VAT number must exist
|
||||
if (!customer.vat_number) {
|
||||
warnings.push({
|
||||
type: 'missing_vat_number',
|
||||
severity: 'error',
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
message: `Faktura ${invoice.invoice_number} till ${customer.name} saknar momsregistreringsnummer (VAT-nummer). Krävs för periodisk sammanställning.`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate: VAT number should be validated via VIES
|
||||
if (!customer.vat_number_validated) {
|
||||
warnings.push({
|
||||
type: 'unvalidated_vat_number',
|
||||
severity: 'warning',
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
message: `VAT-nummer ${customer.vat_number} för ${customer.name} har inte validerats via VIES.`,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine amount in SEK (use total_sek if available, else total for SEK invoices)
|
||||
const amountSek = getAmountSek(invoice)
|
||||
|
||||
// Determine if this is a credit note (negative amount)
|
||||
const isCreditNote = invoice.credited_invoice_id !== null
|
||||
const effectiveAmount = isCreditNote ? -Math.abs(amountSek) : amountSek
|
||||
|
||||
// Classify: goods (box 35) or services (box 39)
|
||||
const classification = classifyInvoice(invoice)
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* EU Sales List XML Generator (SKV 5740 format)
|
||||
*
|
||||
* Generates XML compatible with Skatteverket's e-filing system for
|
||||
* periodisk sammanställning (EC Sales List / recapitulative statement).
|
||||
*
|
||||
* Reference: Skatteverket SKV 5740, KVPS XML schema
|
||||
* Filing: Monthly for goods, quarterly for services
|
||||
*
|
||||
* The XML structure follows Skatteverket's KVPS (Kvartalsvis Periodisk
|
||||
* Sammanställning) format with elements for reporter info, period, and
|
||||
* per-customer goods/services/triangulation amounts in whole SEK.
|
||||
*/
|
||||
|
||||
import type { ECSalesListReport } from './eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* Generate SKV-compatible XML for the EC Sales List report.
|
||||
*
|
||||
* Structure:
|
||||
* <KVPS>
|
||||
* <Avsandare> — reporter/sender information
|
||||
* <Period> — reporting period
|
||||
* <Rad> — one per customer VAT number
|
||||
* <KopareVATnr> — buyer VAT number
|
||||
* <KopareLand> — buyer country code
|
||||
* <VarorBeloppSEK> — goods amount (box 35)
|
||||
* <TjansterBeloppSEK> — services amount (box 39)
|
||||
* <TriangelhandelBeloppSEK> — triangulation (box 38)
|
||||
* </Rad>
|
||||
* </KVPS>
|
||||
*
|
||||
* All amounts are rounded to whole SEK (no decimals).
|
||||
*/
|
||||
export function generateSKVXml(report: ECSalesListReport): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
lines.push('<KVPS>')
|
||||
|
||||
// Reporter/sender info
|
||||
lines.push(' <Avsandare>')
|
||||
lines.push(` <Momsregistreringsnummer>${escapeXml(report.reporterVatNumber)}</Momsregistreringsnummer>`)
|
||||
lines.push(` <Namn>${escapeXml(report.reporterName)}</Namn>`)
|
||||
lines.push(' </Avsandare>')
|
||||
|
||||
// Period info
|
||||
lines.push(' <Period>')
|
||||
lines.push(` <Ar>${report.period.year}</Ar>`)
|
||||
if (report.period.month !== undefined) {
|
||||
lines.push(` <Manad>${String(report.period.month).padStart(2, '0')}</Manad>`)
|
||||
}
|
||||
if (report.period.quarter !== undefined) {
|
||||
lines.push(` <Kvartal>${report.period.quarter}</Kvartal>`)
|
||||
}
|
||||
lines.push(` <Redovisningstyp>${report.filingType === 'monthly' ? 'Manad' : 'Kvartal'}</Redovisningstyp>`)
|
||||
lines.push(' </Period>')
|
||||
|
||||
// Customer lines
|
||||
for (const line of report.lines) {
|
||||
const goods = Math.round(line.goodsAmount)
|
||||
const services = Math.round(line.servicesAmount)
|
||||
const triangulation = Math.round(line.triangulationAmount)
|
||||
|
||||
// Skip lines with all zero amounts
|
||||
if (goods === 0 && services === 0 && triangulation === 0) continue
|
||||
|
||||
lines.push(' <Rad>')
|
||||
lines.push(` <KopareVATnr>${escapeXml(line.customerVatNumber)}</KopareVATnr>`)
|
||||
lines.push(` <KopareLand>${escapeXml(line.customerCountry)}</KopareLand>`)
|
||||
if (goods !== 0) {
|
||||
lines.push(` <VarorBeloppSEK>${goods}</VarorBeloppSEK>`)
|
||||
}
|
||||
if (services !== 0) {
|
||||
lines.push(` <TjansterBeloppSEK>${services}</TjansterBeloppSEK>`)
|
||||
}
|
||||
if (triangulation !== 0) {
|
||||
lines.push(` <TriangelhandelBeloppSEK>${triangulation}</TriangelhandelBeloppSEK>`)
|
||||
}
|
||||
lines.push(' </Rad>')
|
||||
}
|
||||
|
||||
// Totals
|
||||
lines.push(' <Summa>')
|
||||
lines.push(` <VarorTotaltSEK>${Math.round(report.totals.goods)}</VarorTotaltSEK>`)
|
||||
lines.push(` <TjansterTotaltSEK>${Math.round(report.totals.services)}</TjansterTotaltSEK>`)
|
||||
lines.push(` <TriangelhandelTotaltSEK>${Math.round(report.totals.triangulation)}</TriangelhandelTotaltSEK>`)
|
||||
lines.push(` <TotaltSEK>${Math.round(report.totals.total)}</TotaltSEK>`)
|
||||
lines.push(' </Summa>')
|
||||
|
||||
lines.push('</KVPS>')
|
||||
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filename for the XML download.
|
||||
*
|
||||
* Format: KVPS_<VAT>_<period>.xml
|
||||
* Example: KVPS_SE556677889901_2026-Q1.xml
|
||||
*/
|
||||
export function generateXMLFilename(report: ECSalesListReport): string {
|
||||
const vat = report.reporterVatNumber.replace(/\s/g, '')
|
||||
const period = formatPeriod(report)
|
||||
return `KVPS_${vat}_${period}.xml`
|
||||
}
|
||||
|
||||
function formatPeriod(report: ECSalesListReport): string {
|
||||
const { year, month, quarter } = report.period
|
||||
if (month !== undefined) {
|
||||
return `${year}-${String(month).padStart(2, '0')}`
|
||||
}
|
||||
if (quarter !== undefined) {
|
||||
return `${year}-Q${quarter}`
|
||||
}
|
||||
return `${year}`
|
||||
}
|
||||
|
||||
/** Escape special XML characters to prevent injection */
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* Intrastat Generator Extension
|
||||
*
|
||||
* Generates monthly Intrastat dispatch declarations for reporting to SCB
|
||||
* (Statistics Sweden). Manages product metadata (CN commodity codes, weights,
|
||||
* country of origin) and aggregates EU goods dispatches.
|
||||
*
|
||||
* Outputs SCB IDEP.web compatible CSV files. Monitors the SEK 12M dispatch
|
||||
* threshold and alerts when the reporting obligation is triggered.
|
||||
*/
|
||||
export const intrastatExtension: Extension = {
|
||||
id: 'intrastat',
|
||||
name: 'Intrastat-generator',
|
||||
version: '1.0.0',
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
generateIntrastatReport,
|
||||
getIntrastatDeadline,
|
||||
type IntrastatInvoice,
|
||||
type IntrastatCustomer,
|
||||
type IntrastatInvoiceItem,
|
||||
type ProductMetadata,
|
||||
type IntrastatOptions,
|
||||
} from '../intrastat-engine'
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────
|
||||
|
||||
function makeInvoice(overrides: Partial<IntrastatInvoice> = {}): IntrastatInvoice {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F2026-001',
|
||||
invoice_date: '2026-01-15',
|
||||
status: 'sent',
|
||||
vat_treatment: 'reverse_charge',
|
||||
moms_ruta: '35',
|
||||
currency: 'EUR',
|
||||
total_sek: 100000,
|
||||
subtotal_sek: 100000,
|
||||
subtotal: 9000,
|
||||
document_type: 'invoice',
|
||||
credited_invoice_id: null,
|
||||
customer_id: 'cust-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCustomer(overrides: Partial<IntrastatCustomer> = {}): IntrastatCustomer {
|
||||
return {
|
||||
id: 'cust-1',
|
||||
name: 'Acme GmbH',
|
||||
country: 'DE',
|
||||
vat_number: 'DE123456789',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeItem(overrides: Partial<IntrastatInvoiceItem> = {}): IntrastatInvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
description: 'Stålbalk M8',
|
||||
quantity: 100,
|
||||
unit_price: 90,
|
||||
total: 9000,
|
||||
total_sek: 100000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeProduct(overrides: Partial<ProductMetadata> = {}): ProductMetadata {
|
||||
return {
|
||||
productId: 'stålbalk m8',
|
||||
cnCode: '72163100',
|
||||
description: 'Stålbalk M8',
|
||||
netWeightKg: 45.5,
|
||||
countryOfOrigin: 'SE',
|
||||
supplementaryUnit: null,
|
||||
supplementaryUnitType: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_OPTIONS: IntrastatOptions = {
|
||||
invoices: [],
|
||||
invoiceItems: [],
|
||||
customers: [],
|
||||
products: [],
|
||||
reporterVatNumber: 'SE556677889901',
|
||||
reporterName: 'Test AB',
|
||||
year: 2026,
|
||||
month: 1,
|
||||
}
|
||||
|
||||
// ── Basic report tests ──────────────────────────────────────
|
||||
|
||||
describe('generateIntrastatReport', () => {
|
||||
it('generates empty report when no invoices', () => {
|
||||
const report = generateIntrastatReport(BASE_OPTIONS)
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
expect(report.totals.invoicedValue).toBe(0)
|
||||
expect(report.totals.netMass).toBe(0)
|
||||
expect(report.invoiceCount).toBe(0)
|
||||
expect(report.flowType).toBe('dispatch')
|
||||
})
|
||||
|
||||
it('generates line from matched invoice + product', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(1)
|
||||
expect(report.lines[0].cnCode).toBe('72163100')
|
||||
expect(report.lines[0].partnerCountry).toBe('DE')
|
||||
expect(report.lines[0].countryOfOrigin).toBe('SE')
|
||||
expect(report.lines[0].invoicedValue).toBe(100000)
|
||||
expect(report.lines[0].netMass).toBe(4550) // 45.5 kg × 100 units
|
||||
expect(report.lines[0].partnerVatId).toBe('DE123456789')
|
||||
})
|
||||
|
||||
it('aggregates multiple invoices with same CN code + country', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', subtotal_sek: 50000, total_sek: 50000 }),
|
||||
makeInvoice({ id: 'inv-2', subtotal_sek: 30000, total_sek: 30000 }),
|
||||
]
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', invoice_id: 'inv-1', total_sek: 50000, quantity: 50 }),
|
||||
makeItem({ id: 'item-2', invoice_id: 'inv-2', total_sek: 30000, quantity: 30 }),
|
||||
]
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
invoiceItems: items,
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(1)
|
||||
expect(report.lines[0].invoicedValue).toBe(80000)
|
||||
expect(report.lines[0].netMass).toBe(3640) // 45.5 × (50 + 30)
|
||||
expect(report.invoiceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('separates lines by different partner countries', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', customer_id: 'cust-1' }),
|
||||
makeInvoice({ id: 'inv-2', customer_id: 'cust-2' }),
|
||||
]
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', invoice_id: 'inv-1', quantity: 10 }),
|
||||
makeItem({ id: 'item-2', invoice_id: 'inv-2', quantity: 20 }),
|
||||
]
|
||||
const customers = [
|
||||
makeCustomer({ id: 'cust-1', country: 'DE' }),
|
||||
makeCustomer({ id: 'cust-2', country: 'FI', vat_number: 'FI12345678', name: 'Finnish Co' }),
|
||||
]
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
invoiceItems: items,
|
||||
customers,
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(2)
|
||||
expect(report.lines[0].partnerCountry).toBe('DE')
|
||||
expect(report.lines[1].partnerCountry).toBe('FI')
|
||||
})
|
||||
|
||||
it('separates lines by different CN codes', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', invoice_id: 'inv-1', description: 'Stålbalk M8', quantity: 10, total_sek: 50000 }),
|
||||
makeItem({ id: 'item-2', invoice_id: 'inv-1', description: 'Ventil DN50', quantity: 5, total_sek: 50000 }),
|
||||
]
|
||||
const products = [
|
||||
makeProduct({ productId: 'stålbalk m8', cnCode: '72163100', description: 'Stålbalk M8' }),
|
||||
makeProduct({ productId: 'ventil dn50', cnCode: '84818019', description: 'Ventil DN50', netWeightKg: 2.3 }),
|
||||
]
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: items,
|
||||
customers: [makeCustomer()],
|
||||
products,
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(2)
|
||||
const codes = report.lines.map(l => l.cnCode).sort()
|
||||
expect(codes).toEqual(['72163100', '84818019'])
|
||||
})
|
||||
|
||||
// ── Credit notes ──────────────────────────────────────────
|
||||
|
||||
it('handles credit notes (subtracts from totals)', () => {
|
||||
const invoices = [
|
||||
makeInvoice({ id: 'inv-1', subtotal_sek: 100000, total_sek: 100000 }),
|
||||
makeInvoice({
|
||||
id: 'inv-2',
|
||||
subtotal_sek: 20000,
|
||||
total_sek: 20000,
|
||||
credited_invoice_id: 'inv-1',
|
||||
}),
|
||||
]
|
||||
const items = [
|
||||
makeItem({ id: 'item-1', invoice_id: 'inv-1', total_sek: 100000, quantity: 100 }),
|
||||
makeItem({ id: 'item-2', invoice_id: 'inv-2', total_sek: 20000, quantity: 20 }),
|
||||
]
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices,
|
||||
invoiceItems: items,
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].invoicedValue).toBe(80000)
|
||||
expect(report.lines[0].netMass).toBe(3640) // 45.5 × (100 - 20)
|
||||
})
|
||||
|
||||
// ── Filtering ─────────────────────────────────────────────
|
||||
|
||||
it('excludes non-reverse-charge invoices', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ vat_treatment: 'standard_25' })],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes services (moms_ruta 39)', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ moms_ruta: '39' })],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes non-EU countries', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer({ country: 'US' })],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes Sweden', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer({ country: 'SE' })],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes draft invoices', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ status: 'draft' })],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
// ── Warnings ──────────────────────────────────────────────
|
||||
|
||||
it('warns when product has no CN code', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct({ cnCode: null })],
|
||||
})
|
||||
|
||||
const cnWarnings = report.warnings.filter(w => w.type === 'missing_cn_code')
|
||||
expect(cnWarnings.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('warns when product has no weight', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct({ netWeightKg: null })],
|
||||
})
|
||||
|
||||
const weightWarnings = report.warnings.filter(w => w.type === 'missing_weight')
|
||||
expect(weightWarnings.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('warns when no product match found for invoice line', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem({ description: 'Unknown Product XYZ' })],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()], // won't match 'Unknown Product XYZ'
|
||||
})
|
||||
|
||||
const cnWarnings = report.warnings.filter(w => w.type === 'missing_cn_code')
|
||||
expect(cnWarnings.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── Threshold ─────────────────────────────────────────────
|
||||
|
||||
it('calculates threshold status with prior cumulative', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 500000 })],
|
||||
invoiceItems: [makeItem({ total_sek: 500000 })],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
priorCumulativeValue: 11_000_000,
|
||||
})
|
||||
|
||||
expect(report.thresholdStatus.cumulativeValue).toBe(11_500_000)
|
||||
expect(report.thresholdStatus.isObligated).toBe(false)
|
||||
expect(report.thresholdStatus.percentageUsed).toBeCloseTo(95.83, 1)
|
||||
})
|
||||
|
||||
it('flags when threshold is exceeded', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 2_000_000 })],
|
||||
invoiceItems: [makeItem({ total_sek: 2_000_000 })],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
priorCumulativeValue: 11_000_000,
|
||||
})
|
||||
|
||||
expect(report.thresholdStatus.isObligated).toBe(true)
|
||||
const thresholdWarnings = report.warnings.filter(w => w.type === 'threshold_exceeded')
|
||||
expect(thresholdWarnings).toHaveLength(1)
|
||||
expect(thresholdWarnings[0].severity).toBe('error')
|
||||
})
|
||||
|
||||
it('warns when threshold is approaching (80%+)', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice({ subtotal_sek: 100000 })],
|
||||
invoiceItems: [makeItem({ total_sek: 100000 })],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
priorCumulativeValue: 10_000_000,
|
||||
})
|
||||
|
||||
const approaching = report.warnings.filter(w => w.type === 'threshold_approaching')
|
||||
expect(approaching).toHaveLength(1)
|
||||
expect(approaching[0].severity).toBe('warning')
|
||||
})
|
||||
|
||||
// ── Default values ────────────────────────────────────────
|
||||
|
||||
it('uses custom default transaction nature and delivery terms', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
defaultTransactionNature: '31',
|
||||
defaultDeliveryTerms: 'DAP',
|
||||
})
|
||||
|
||||
expect(report.lines[0].transactionNature).toBe('31')
|
||||
expect(report.lines[0].deliveryTerms).toBe('DAP')
|
||||
})
|
||||
|
||||
it('defaults to transaction nature 11 and FCA', () => {
|
||||
const report = generateIntrastatReport({
|
||||
...BASE_OPTIONS,
|
||||
invoices: [makeInvoice()],
|
||||
invoiceItems: [makeItem()],
|
||||
customers: [makeCustomer()],
|
||||
products: [makeProduct()],
|
||||
})
|
||||
|
||||
expect(report.lines[0].transactionNature).toBe('11')
|
||||
expect(report.lines[0].deliveryTerms).toBe('FCA')
|
||||
})
|
||||
|
||||
// ── Period info ───────────────────────────────────────────
|
||||
|
||||
it('includes period and reporter info', () => {
|
||||
const report = generateIntrastatReport(BASE_OPTIONS)
|
||||
|
||||
expect(report.period).toEqual({ year: 2026, month: 1 })
|
||||
expect(report.reporterVatNumber).toBe('SE556677889901')
|
||||
expect(report.reporterName).toBe('Test AB')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Deadline tests ──────────────────────────────────────────
|
||||
|
||||
describe('getIntrastatDeadline', () => {
|
||||
it('returns 14th of following month', () => {
|
||||
expect(getIntrastatDeadline(2026, 1)).toBe('2026-02-14')
|
||||
expect(getIntrastatDeadline(2026, 6)).toBe('2026-07-14')
|
||||
})
|
||||
|
||||
it('rolls over to next year for December', () => {
|
||||
expect(getIntrastatDeadline(2026, 12)).toBe('2027-01-14')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateSCBCsv, generateSCBFilename } from '../scb-csv-generator'
|
||||
import type { IntrastatReport, IntrastatLine } from '../intrastat-engine'
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────
|
||||
|
||||
function makeLine(overrides: Partial<IntrastatLine> = {}): IntrastatLine {
|
||||
return {
|
||||
cnCode: '72163100',
|
||||
partnerCountry: 'DE',
|
||||
countryOfOrigin: 'SE',
|
||||
transactionNature: '11',
|
||||
deliveryTerms: 'FCA',
|
||||
invoicedValue: 100000,
|
||||
netMass: 4550,
|
||||
supplementaryUnit: null,
|
||||
supplementaryUnitType: null,
|
||||
partnerVatId: 'DE123456789',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeReport(overrides: Partial<IntrastatReport> = {}): IntrastatReport {
|
||||
return {
|
||||
period: { year: 2026, month: 1 },
|
||||
reporterVatNumber: 'SE556677889901',
|
||||
reporterName: 'Test AB',
|
||||
flowType: 'dispatch',
|
||||
lines: [makeLine()],
|
||||
totals: { invoicedValue: 100000, netMass: 4550, lineCount: 1 },
|
||||
thresholdStatus: {
|
||||
cumulativeValue: 100000,
|
||||
threshold: 12000000,
|
||||
isObligated: false,
|
||||
percentageUsed: 0.83,
|
||||
},
|
||||
warnings: [],
|
||||
invoiceCount: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSV generation ──────────────────────────────────────────
|
||||
|
||||
describe('generateSCBCsv', () => {
|
||||
it('starts with UTF-8 BOM', () => {
|
||||
const csv = generateSCBCsv(makeReport())
|
||||
expect(csv.charCodeAt(0)).toBe(0xFEFF)
|
||||
})
|
||||
|
||||
it('has correct header row', () => {
|
||||
const csv = generateSCBCsv(makeReport())
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
expect(lines[0]).toBe(
|
||||
'CN-kod;Partnerland;Ursprungsland;Transaktionstyp;Leveransvillkor;Fakturerat värde (SEK);Nettovikt (kg);Kompletterande enhet;Partner-VAT'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses semicolons as delimiter', () => {
|
||||
const csv = generateSCBCsv(makeReport())
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
// Header has 9 columns = 8 semicolons
|
||||
expect(lines[0].split(';')).toHaveLength(9)
|
||||
// Data row also has 9 columns
|
||||
expect(lines[1].split(';')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('uses CRLF line endings', () => {
|
||||
const csv = generateSCBCsv(makeReport())
|
||||
expect(csv).toContain('\r\n')
|
||||
// Should not contain lone LF without preceding CR
|
||||
const withoutCRLF = csv.replace(/\r\n/g, '')
|
||||
expect(withoutCRLF).not.toContain('\n')
|
||||
})
|
||||
|
||||
it('ends with CRLF', () => {
|
||||
const csv = generateSCBCsv(makeReport())
|
||||
expect(csv.endsWith('\r\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders data row with correct values', () => {
|
||||
const csv = generateSCBCsv(makeReport())
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const cols = lines[1].split(';')
|
||||
expect(cols[0]).toBe('72163100') // CN-kod
|
||||
expect(cols[1]).toBe('DE') // Partnerland
|
||||
expect(cols[2]).toBe('SE') // Ursprungsland
|
||||
expect(cols[3]).toBe('11') // Transaktionstyp
|
||||
expect(cols[4]).toBe('FCA') // Leveransvillkor
|
||||
expect(cols[5]).toBe('100000') // Fakturerat värde
|
||||
expect(cols[6]).toBe('4550') // Nettovikt
|
||||
expect(cols[7]).toBe('') // Kompletterande enhet (null)
|
||||
expect(cols[8]).toBe('DE123456789') // Partner-VAT
|
||||
})
|
||||
|
||||
it('rounds invoiced value to whole SEK', () => {
|
||||
const csv = generateSCBCsv(makeReport({
|
||||
lines: [makeLine({ invoicedValue: 123456.78 })],
|
||||
}))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const value = lines[1].split(';')[5]
|
||||
expect(value).toBe('123457') // Rounded up
|
||||
})
|
||||
|
||||
it('formats integer mass without decimals', () => {
|
||||
const csv = generateSCBCsv(makeReport({
|
||||
lines: [makeLine({ netMass: 4550 })],
|
||||
}))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const mass = lines[1].split(';')[6]
|
||||
expect(mass).toBe('4550')
|
||||
})
|
||||
|
||||
it('formats fractional mass with up to 3 decimal places', () => {
|
||||
const csv = generateSCBCsv(makeReport({
|
||||
lines: [makeLine({ netMass: 45.123 })],
|
||||
}))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const mass = lines[1].split(';')[6]
|
||||
expect(mass).toBe('45.123')
|
||||
})
|
||||
|
||||
it('removes trailing zeros from mass', () => {
|
||||
const csv = generateSCBCsv(makeReport({
|
||||
lines: [makeLine({ netMass: 45.1 })],
|
||||
}))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const mass = lines[1].split(';')[6]
|
||||
expect(mass).toBe('45.1')
|
||||
})
|
||||
|
||||
it('rounds mass to max 3 decimal places', () => {
|
||||
const csv = generateSCBCsv(makeReport({
|
||||
lines: [makeLine({ netMass: 45.12345 })],
|
||||
}))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const mass = lines[1].split(';')[6]
|
||||
expect(mass).toBe('45.123')
|
||||
})
|
||||
|
||||
it('includes supplementary unit when present', () => {
|
||||
const csv = generateSCBCsv(makeReport({
|
||||
lines: [makeLine({ supplementaryUnit: 150 })],
|
||||
}))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const suppUnit = lines[1].split(';')[7]
|
||||
expect(suppUnit).toBe('150')
|
||||
})
|
||||
|
||||
it('rounds supplementary unit to whole number', () => {
|
||||
const csv = generateSCBCsv(makeReport({
|
||||
lines: [makeLine({ supplementaryUnit: 150.7 })],
|
||||
}))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n')
|
||||
const suppUnit = lines[1].split(';')[7]
|
||||
expect(suppUnit).toBe('151')
|
||||
})
|
||||
|
||||
it('renders multiple data rows', () => {
|
||||
const report = makeReport({
|
||||
lines: [
|
||||
makeLine({ cnCode: '72163100', partnerCountry: 'DE' }),
|
||||
makeLine({ cnCode: '84818019', partnerCountry: 'FI', partnerVatId: 'FI12345678' }),
|
||||
],
|
||||
})
|
||||
const csv = generateSCBCsv(report)
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n').filter(l => l.length > 0)
|
||||
expect(lines).toHaveLength(3) // header + 2 data rows
|
||||
expect(lines[1].split(';')[0]).toBe('72163100')
|
||||
expect(lines[2].split(';')[0]).toBe('84818019')
|
||||
})
|
||||
|
||||
it('generates empty CSV with only header when no lines', () => {
|
||||
const csv = generateSCBCsv(makeReport({ lines: [] }))
|
||||
const lines = csv.replace('\uFEFF', '').split('\r\n').filter(l => l.length > 0)
|
||||
expect(lines).toHaveLength(1) // header only
|
||||
expect(lines[0]).toContain('CN-kod')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Filename generation ─────────────────────────────────────
|
||||
|
||||
describe('generateSCBFilename', () => {
|
||||
it('generates correct filename', () => {
|
||||
const filename = generateSCBFilename(makeReport())
|
||||
expect(filename).toBe('INTRASTAT_SE556677889901_2026-01.csv')
|
||||
})
|
||||
|
||||
it('pads single-digit month', () => {
|
||||
const filename = generateSCBFilename(makeReport({ period: { year: 2026, month: 3 } }))
|
||||
expect(filename).toBe('INTRASTAT_SE556677889901_2026-03.csv')
|
||||
})
|
||||
|
||||
it('does not pad double-digit month', () => {
|
||||
const filename = generateSCBFilename(makeReport({ period: { year: 2026, month: 12 } }))
|
||||
expect(filename).toBe('INTRASTAT_SE556677889901_2026-12.csv')
|
||||
})
|
||||
|
||||
it('strips whitespace from VAT number', () => {
|
||||
const filename = generateSCBFilename(makeReport({ reporterVatNumber: 'SE 5566 7788 9901' }))
|
||||
expect(filename).toBe('INTRASTAT_SE556677889901_2026-01.csv')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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<string, IntrastatInvoiceItem[]>()
|
||||
for (const item of invoiceItems) {
|
||||
const list = itemsByInvoice.get(item.invoice_id) ?? []
|
||||
list.push(item)
|
||||
itemsByInvoice.set(item.invoice_id, list)
|
||||
}
|
||||
|
||||
// Step 3: Build aggregation map
|
||||
// Key: cnCode|partnerCountry|countryOfOrigin|transactionNature|deliveryTerms
|
||||
const aggregation = new Map<string, IntrastatLine>()
|
||||
|
||||
for (const invoice of relevantInvoices) {
|
||||
const customer = customerMap.get(invoice.customer_id)!
|
||||
const 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<string, ProductMetadata> {
|
||||
const map = new Map<string, ProductMetadata>()
|
||||
for (const product of products) {
|
||||
map.set(product.productId.toLowerCase(), product)
|
||||
if (product.description) {
|
||||
map.set(product.description.toLowerCase(), product)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an invoice line description to a product.
|
||||
* Tries exact match first, then checks if description starts with a product key.
|
||||
*/
|
||||
function matchProduct(description: string, productMap: Map<string, ProductMetadata>): ProductMetadata | null {
|
||||
const lower = description.toLowerCase().trim()
|
||||
|
||||
// Exact match
|
||||
if (productMap.has(lower)) return productMap.get(lower)!
|
||||
|
||||
// Prefix match (invoice description may contain extra details)
|
||||
for (const [key, product] of productMap) {
|
||||
if (lower.startsWith(key) || key.startsWith(lower)) {
|
||||
return product
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Aggregation helpers ─────────────────────────────────────
|
||||
|
||||
function buildAggKey(
|
||||
cnCode: string,
|
||||
partnerCountry: string,
|
||||
origin: string,
|
||||
transactionNature: string,
|
||||
deliveryTerms: string,
|
||||
): string {
|
||||
return `${cnCode}|${partnerCountry}|${origin}|${transactionNature}|${deliveryTerms}`
|
||||
}
|
||||
|
||||
function addToAggregation(
|
||||
map: Map<string, IntrastatLine>,
|
||||
key: string,
|
||||
line: IntrastatLine,
|
||||
): void {
|
||||
const existing = map.get(key)
|
||||
if (existing) {
|
||||
existing.invoicedValue += line.invoicedValue
|
||||
existing.netMass += line.netMass
|
||||
if (existing.supplementaryUnit !== null && line.supplementaryUnit !== null) {
|
||||
existing.supplementaryUnit += line.supplementaryUnit
|
||||
}
|
||||
// Keep the first partner VAT ID encountered
|
||||
} else {
|
||||
map.set(key, { ...line })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Amount helpers ──────────────────────────────────────────
|
||||
|
||||
function getInvoiceAmountSek(invoice: IntrastatInvoice): number {
|
||||
if (invoice.subtotal_sek !== null) return round2(invoice.subtotal_sek)
|
||||
return round2(invoice.subtotal)
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function round3(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return Math.round(amount).toLocaleString('sv-SE')
|
||||
}
|
||||
|
||||
// ── Period helpers ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the Intrastat filing deadline for a month.
|
||||
* The deadline is the 10th business day of the following month.
|
||||
* For simplicity, we approximate as the 14th of the following month.
|
||||
*/
|
||||
export function getIntrastatDeadline(year: number, month: number): string {
|
||||
let deadlineMonth = month + 1
|
||||
let deadlineYear = year
|
||||
if (deadlineMonth > 12) {
|
||||
deadlineMonth = 1
|
||||
deadlineYear++
|
||||
}
|
||||
return `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-14`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Intrastat SCB CSV Generator
|
||||
*
|
||||
* Generates a semicolon-separated CSV file (UTF-8 with BOM)
|
||||
* compatible with SCB's IDEP.web upload for Intrastat declarations.
|
||||
*
|
||||
* Format: Semicolon-delimited, UTF-8 BOM, whole SEK amounts,
|
||||
* net mass in kg with up to 3 decimal places.
|
||||
*
|
||||
* Reference: SCB IDEP.web filformat, Intrastat utförsel
|
||||
*/
|
||||
|
||||
import type { IntrastatReport } from './intrastat-engine'
|
||||
|
||||
const UTF8_BOM = '\uFEFF'
|
||||
|
||||
/**
|
||||
* Generate an IDEP.web-compatible CSV for the Intrastat report.
|
||||
*
|
||||
* Columns (matching SCB IDEP.web format):
|
||||
* CN-kod;Partnerland;Ursprungsland;Transaktionstyp;Leveransvillkor;
|
||||
* Fakturerat värde (SEK);Nettovikt (kg);Kompletterande enhet;Partner-VAT
|
||||
*/
|
||||
export function generateSCBCsv(report: IntrastatReport): string {
|
||||
const header = [
|
||||
'CN-kod',
|
||||
'Partnerland',
|
||||
'Ursprungsland',
|
||||
'Transaktionstyp',
|
||||
'Leveransvillkor',
|
||||
'Fakturerat värde (SEK)',
|
||||
'Nettovikt (kg)',
|
||||
'Kompletterande enhet',
|
||||
'Partner-VAT',
|
||||
].join(';')
|
||||
|
||||
const rows = report.lines.map(line => {
|
||||
const value = Math.round(line.invoicedValue)
|
||||
const mass = roundMass(line.netMass)
|
||||
const suppUnit = line.supplementaryUnit !== null ? String(Math.round(line.supplementaryUnit)) : ''
|
||||
|
||||
return [
|
||||
line.cnCode,
|
||||
line.partnerCountry,
|
||||
line.countryOfOrigin,
|
||||
line.transactionNature,
|
||||
line.deliveryTerms,
|
||||
String(value),
|
||||
String(mass),
|
||||
suppUnit,
|
||||
line.partnerVatId,
|
||||
].join(';')
|
||||
})
|
||||
|
||||
return UTF8_BOM + [header, ...rows].join('\r\n') + '\r\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a filename for the IDEP.web CSV download.
|
||||
*
|
||||
* Format: INTRASTAT_<VAT>_<YYYY>-<MM>.csv
|
||||
*/
|
||||
export function generateSCBFilename(report: IntrastatReport): string {
|
||||
const vat = report.reporterVatNumber.replace(/\s/g, '')
|
||||
const period = `${report.period.year}-${String(report.period.month).padStart(2, '0')}`
|
||||
return `INTRASTAT_${vat}_${period}.csv`
|
||||
}
|
||||
|
||||
/** Round mass to max 3 decimal places, removing trailing zeros */
|
||||
function roundMass(kg: number): string {
|
||||
const rounded = Math.round(kg * 1000) / 1000
|
||||
if (rounded === Math.floor(rounded)) return String(rounded)
|
||||
return rounded.toFixed(3).replace(/0+$/, '')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Momsdeklaration box mapping for Swedish VAT returns.
|
||||
*
|
||||
* Maps BAS revenue accounts to the correct box (ruta) in the
|
||||
* momsdeklaration filed with Skatteverket. Used by:
|
||||
* - Export VAT Monitor (full box overview)
|
||||
* - EU Sales List (cross-validation against box 35/39)
|
||||
*
|
||||
* Reference: Skatteverket momsdeklaration (SKV 4700)
|
||||
* https://www.skatteverket.se/foretag/moms/deklareramoms/fyllaimomsdeklarationen
|
||||
*/
|
||||
|
||||
/** Momsdeklaration box number */
|
||||
export type MomsBox =
|
||||
| '05' // Momspliktig forsaljning (taxable sales)
|
||||
| '06' // Momspliktiga uttag (taxable withdrawals)
|
||||
| '07' // Vinstmarginalbeskattning (margin scheme)
|
||||
| '08' // Hyresinkomster frivillig beskattning (rental)
|
||||
| '10' // Utgaende moms 25%
|
||||
| '11' // Utgaende moms 12%
|
||||
| '12' // Utgaende moms 6%
|
||||
| '20' // Inkop varor fran EU
|
||||
| '21' // Inkop tjanster fran EU
|
||||
| '22' // Inkop tjanster utanfor EU
|
||||
| '23' // Inkop varor Sverige omvand skattskyldighet
|
||||
| '24' // Inkop tjanster Sverige omvand skattskyldighet
|
||||
| '30' // Utgaende moms inkop 25%
|
||||
| '31' // Utgaende moms inkop 12%
|
||||
| '32' // Utgaende moms inkop 6%
|
||||
| '35' // Varuforssaljning till annat EU-land
|
||||
| '36' // Varuforssaljning utanfor EU (export)
|
||||
| '37' // Mellanmans inkop trepartshandel
|
||||
| '38' // Mellanmans forsaljning trepartshandel
|
||||
| '39' // Tjansteforssaljning EU (huvudregeln)
|
||||
| '40' // Ovrig forsaljning av tjanster utomlands
|
||||
| '41' // Forsaljning omvand skattskyldighet Sverige
|
||||
| '42' // Ovrig forsaljning m.m.
|
||||
| '48' // Ingaende moms att dra av
|
||||
| '49' // Moms att betala eller fa tillbaka
|
||||
| '50' // Importbeskattningsunderlag
|
||||
| '60' // Importmoms 25%
|
||||
| '61' // Importmoms 12%
|
||||
| '62' // Importmoms 6%
|
||||
|
||||
/** Map BAS revenue account to momsdeklaration box */
|
||||
export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
|
||||
// Domestic revenue (taxable) → Box 05
|
||||
'3001': '05', // Forsaljning varor/tjanster 25%
|
||||
'3002': '05', // Forsaljning varor/tjanster 12%
|
||||
'3003': '05', // Forsaljning varor/tjanster 6%
|
||||
|
||||
// EU goods (reverse charge, VAT-free) → Box 35
|
||||
'3108': '35', // Forsaljning varor till annat EU-land
|
||||
'3521': '35', // Fakturerade frakter EU (follows goods treatment)
|
||||
|
||||
// Non-EU goods export (zero-rated) → Box 36
|
||||
'3105': '36', // Forsaljning varor export utanfor EU
|
||||
'3522': '36', // Fakturerade frakter export
|
||||
|
||||
// Triangular trade → Box 38
|
||||
'3109': '38', // Mellanmans forsaljning trepartshandel
|
||||
|
||||
// EU services (reverse charge, main rule) → Box 39
|
||||
'3308': '39', // Forsaljning tjanster EU
|
||||
|
||||
// Non-EU services → Box 40
|
||||
'3305': '40', // Forsaljning tjanster export utanfor EU
|
||||
|
||||
// Output VAT → Boxes 10, 11, 12
|
||||
'2611': '10', // Utgaende moms 25%
|
||||
'2621': '11', // Utgaende moms 12%
|
||||
'2631': '12', // Utgaende moms 6%
|
||||
|
||||
// Input VAT → Box 48
|
||||
'2641': '48', // Ingaende moms
|
||||
'2645': '48', // Beraknad ingaende moms (EU forvarv)
|
||||
}
|
||||
|
||||
/** Swedish labels for each momsdeklaration box */
|
||||
export const BOX_LABELS: Record<MomsBox, string> = {
|
||||
'05': 'Momspliktig försäljning',
|
||||
'06': 'Momspliktiga uttag',
|
||||
'07': 'Vinstmarginalbeskattning',
|
||||
'08': 'Hyresinkomster (frivillig beskattning)',
|
||||
'10': 'Utgående moms 25%',
|
||||
'11': 'Utgående moms 12%',
|
||||
'12': 'Utgående moms 6%',
|
||||
'20': 'Inköp varor från EU',
|
||||
'21': 'Inköp tjänster från EU',
|
||||
'22': 'Inköp tjänster utanför EU',
|
||||
'23': 'Inköp varor Sverige (omvänd skattskyldighet)',
|
||||
'24': 'Inköp tjänster Sverige (omvänd skattskyldighet)',
|
||||
'30': 'Utgående moms på inköp 25%',
|
||||
'31': 'Utgående moms på inköp 12%',
|
||||
'32': 'Utgående moms på inköp 6%',
|
||||
'35': 'Varuförsäljning till annat EU-land',
|
||||
'36': 'Varuförsäljning utanför EU (export)',
|
||||
'37': 'Mellanmans inköp vid trepartshandel',
|
||||
'38': 'Mellanmans försäljning vid trepartshandel',
|
||||
'39': 'Tjänsteförsäljning till EU (huvudregeln)',
|
||||
'40': 'Övrig försäljning av tjänster utomlands',
|
||||
'41': 'Försäljning med omvänd skattskyldighet (Sverige)',
|
||||
'42': 'Övrig försäljning m.m.',
|
||||
'48': 'Ingående moms att dra av',
|
||||
'49': 'Moms att betala eller få tillbaka',
|
||||
'50': 'Beskattningsunderlag vid import',
|
||||
'60': 'Importmoms 25%',
|
||||
'61': 'Importmoms 12%',
|
||||
'62': 'Importmoms 6%',
|
||||
}
|
||||
|
||||
/** Get the momsdeklaration box for a BAS account number */
|
||||
export function getBoxForAccount(accountNumber: string): MomsBox | undefined {
|
||||
return ACCOUNT_TO_BOX[accountNumber]
|
||||
}
|
||||
|
||||
/** Get the Swedish label for a momsdeklaration box */
|
||||
export function getBoxLabel(box: MomsBox): string {
|
||||
return BOX_LABELS[box]
|
||||
}
|
||||
|
||||
/** Boxes that represent VAT-exempt export/EU sales (no output VAT) */
|
||||
export const EXPORT_BOXES: MomsBox[] = ['35', '36', '38', '39', '40']
|
||||
|
||||
/** Boxes that represent taxable domestic sales (have output VAT) */
|
||||
export const DOMESTIC_BOXES: MomsBox[] = ['05', '06', '07', '08']
|
||||
|
||||
/** Boxes that represent output VAT */
|
||||
export const OUTPUT_VAT_BOXES: MomsBox[] = ['10', '11', '12']
|
||||
|
||||
/** Boxes that represent input VAT */
|
||||
export const INPUT_VAT_BOXES: MomsBox[] = ['48']
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* Export VAT Monitor / Exportmoms-monitor Extension
|
||||
*
|
||||
* Dashboard that maps GL revenue accounts to Swedish momsdeklaration boxes
|
||||
* (05, 35, 36, 39, 40). Shows revenue breakdown by destination type:
|
||||
* domestic, EU B2B (reverse charge), and non-EU export.
|
||||
*
|
||||
* Validates VAT treatment consistency and flags potential errors before
|
||||
* the user files their momsdeklaration.
|
||||
*/
|
||||
export const vatMonitorExtension: Extension = {
|
||||
id: 'vat-monitor',
|
||||
name: 'Exportmoms-monitor',
|
||||
version: '1.0.0',
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
generateVatMonitorReport,
|
||||
type GLLine,
|
||||
type VatMonitorInvoice,
|
||||
type VatMonitorCustomer,
|
||||
type VatMonitorOptions,
|
||||
} from '../vat-monitor-engine'
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Create a credit GL line (revenue is credit-side) */
|
||||
function creditLine(account: string, amount: number): GLLine {
|
||||
return { account_number: account, debit_amount: 0, credit_amount: amount }
|
||||
}
|
||||
|
||||
/** Create a debit GL line (input VAT is debit-side) */
|
||||
function debitLine(account: string, amount: number): GLLine {
|
||||
return { account_number: account, debit_amount: amount, credit_amount: 0 }
|
||||
}
|
||||
|
||||
function makeInvoice(overrides: Partial<VatMonitorInvoice> = {}): VatMonitorInvoice {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F2026-001',
|
||||
vat_treatment: 'reverse_charge',
|
||||
moms_ruta: '35',
|
||||
customer_id: 'cust-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCustomer(overrides: Partial<VatMonitorCustomer> = {}): VatMonitorCustomer {
|
||||
return {
|
||||
id: 'cust-1',
|
||||
name: 'Acme GmbH',
|
||||
country: 'DE',
|
||||
vat_number: 'DE123456789',
|
||||
vat_number_validated: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_OPTIONS: VatMonitorOptions = {
|
||||
glLines: [],
|
||||
year: 2026,
|
||||
month: 1,
|
||||
}
|
||||
|
||||
// ── Box mapping tests ───────────────────────────────────────
|
||||
|
||||
describe('box mapping', () => {
|
||||
it('maps domestic revenue (3001) to box 05', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3001', 100000)],
|
||||
})
|
||||
|
||||
const box05 = report.boxes.find(b => b.boxNumber === '05')
|
||||
expect(box05).toBeDefined()
|
||||
expect(box05!.amount).toBe(100000)
|
||||
})
|
||||
|
||||
it('maps EU goods (3108) to box 35', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3108', 50000)],
|
||||
})
|
||||
|
||||
const box35 = report.boxes.find(b => b.boxNumber === '35')
|
||||
expect(box35).toBeDefined()
|
||||
expect(box35!.amount).toBe(50000)
|
||||
})
|
||||
|
||||
it('maps EU freight (3521) to box 35 alongside 3108', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3108', 40000), creditLine('3521', 10000)],
|
||||
})
|
||||
|
||||
const box35 = report.boxes.find(b => b.boxNumber === '35')
|
||||
expect(box35!.amount).toBe(50000)
|
||||
expect(box35!.accounts).toContain('3108')
|
||||
expect(box35!.accounts).toContain('3521')
|
||||
})
|
||||
|
||||
it('maps export goods (3105) to box 36', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3105', 80000)],
|
||||
})
|
||||
|
||||
const box36 = report.boxes.find(b => b.boxNumber === '36')
|
||||
expect(box36!.amount).toBe(80000)
|
||||
})
|
||||
|
||||
it('maps export freight (3522) to box 36', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3522', 15000)],
|
||||
})
|
||||
|
||||
const box36 = report.boxes.find(b => b.boxNumber === '36')
|
||||
expect(box36!.amount).toBe(15000)
|
||||
})
|
||||
|
||||
it('maps triangulation (3109) to box 38', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3109', 25000)],
|
||||
})
|
||||
|
||||
const box38 = report.boxes.find(b => b.boxNumber === '38')
|
||||
expect(box38!.amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('maps EU services (3308) to box 39', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3308', 30000)],
|
||||
})
|
||||
|
||||
const box39 = report.boxes.find(b => b.boxNumber === '39')
|
||||
expect(box39!.amount).toBe(30000)
|
||||
})
|
||||
|
||||
it('maps export services (3305) to box 40', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3305', 20000)],
|
||||
})
|
||||
|
||||
const box40 = report.boxes.find(b => b.boxNumber === '40')
|
||||
expect(box40!.amount).toBe(20000)
|
||||
})
|
||||
|
||||
it('maps output VAT (2611) to box 10', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('2611', 25000)],
|
||||
})
|
||||
|
||||
const box10 = report.boxes.find(b => b.boxNumber === '10')
|
||||
expect(box10!.amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('maps input VAT (2641) to box 48', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [debitLine('2641', 15000)],
|
||||
})
|
||||
|
||||
const box48 = report.boxes.find(b => b.boxNumber === '48')
|
||||
expect(box48!.amount).toBe(15000)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Revenue breakdown tests ─────────────────────────────────
|
||||
|
||||
describe('revenue breakdown', () => {
|
||||
it('calculates correct breakdown for mixed revenue', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('3001', 200000), // domestic
|
||||
creditLine('3108', 100000), // EU goods
|
||||
creditLine('3308', 50000), // EU services
|
||||
creditLine('3105', 75000), // export goods
|
||||
creditLine('3305', 25000), // export services
|
||||
],
|
||||
})
|
||||
|
||||
const rb = report.revenueBreakdown
|
||||
expect(rb.totalRevenue).toBe(450000)
|
||||
expect(rb.domestic.amount).toBe(200000)
|
||||
expect(rb.euGoods.amount).toBe(100000)
|
||||
expect(rb.euServices.amount).toBe(50000)
|
||||
expect(rb.exportGoods.amount).toBe(75000)
|
||||
expect(rb.exportServices.amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('calculates correct percentages', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('3001', 50000),
|
||||
creditLine('3108', 50000),
|
||||
],
|
||||
})
|
||||
|
||||
expect(report.revenueBreakdown.domestic.percentage).toBe(50)
|
||||
expect(report.revenueBreakdown.euGoods.percentage).toBe(50)
|
||||
})
|
||||
|
||||
it('returns 0% when no revenue', () => {
|
||||
const report = generateVatMonitorReport(BASE_OPTIONS)
|
||||
|
||||
expect(report.revenueBreakdown.totalRevenue).toBe(0)
|
||||
expect(report.revenueBreakdown.domestic.percentage).toBe(0)
|
||||
})
|
||||
|
||||
it('only domestic sales — export categories are zero', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3001', 100000)],
|
||||
})
|
||||
|
||||
expect(report.revenueBreakdown.domestic.amount).toBe(100000)
|
||||
expect(report.revenueBreakdown.euGoods.amount).toBe(0)
|
||||
expect(report.revenueBreakdown.exportGoods.amount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── VAT summary tests ───────────────────────────────────────
|
||||
|
||||
describe('VAT summary', () => {
|
||||
it('calculates net VAT: output - input = box 49', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('2611', 50000), // output 25%
|
||||
creditLine('2621', 12000), // output 12%
|
||||
creditLine('2631', 3000), // output 6%
|
||||
debitLine('2641', 30000), // input VAT
|
||||
],
|
||||
})
|
||||
|
||||
expect(report.vatSummary.outputVat25).toBe(50000)
|
||||
expect(report.vatSummary.outputVat12).toBe(12000)
|
||||
expect(report.vatSummary.outputVat6).toBe(3000)
|
||||
expect(report.vatSummary.totalOutputVat).toBe(65000)
|
||||
expect(report.vatSummary.inputVat).toBe(30000)
|
||||
expect(report.vatSummary.netVat).toBe(35000)
|
||||
expect(report.vatSummary.isRefund).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies refund when input > output', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('2611', 10000),
|
||||
debitLine('2641', 50000),
|
||||
],
|
||||
})
|
||||
|
||||
expect(report.vatSummary.netVat).toBe(-40000)
|
||||
expect(report.vatSummary.isRefund).toBe(true)
|
||||
})
|
||||
|
||||
it('box 49 appears in boxes list', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('2611', 25000),
|
||||
debitLine('2641', 10000),
|
||||
],
|
||||
})
|
||||
|
||||
const box49 = report.boxes.find(b => b.boxNumber === '49')
|
||||
expect(box49).toBeDefined()
|
||||
expect(box49!.amount).toBe(15000)
|
||||
expect(box49!.label).toContain('betala')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Invoice validation tests ────────────────────────────────
|
||||
|
||||
describe('invoice validation', () => {
|
||||
it('warns when reverse_charge invoice has customer without VAT number', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3108', 10000)],
|
||||
invoices: [makeInvoice({ vat_treatment: 'reverse_charge' })],
|
||||
customers: [makeCustomer({ vat_number: null })],
|
||||
})
|
||||
|
||||
const missingVat = report.warnings.filter(w => w.type === 'missing_vat_number')
|
||||
expect(missingVat).toHaveLength(1)
|
||||
expect(missingVat[0].severity).toBe('error')
|
||||
})
|
||||
|
||||
it('warns when reverse_charge invoice has unvalidated VAT number', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3108', 10000)],
|
||||
invoices: [makeInvoice({ vat_treatment: 'reverse_charge' })],
|
||||
customers: [makeCustomer({ vat_number_validated: false })],
|
||||
})
|
||||
|
||||
const unvalidated = report.warnings.filter(w => w.type === 'unvalidated_vat_number')
|
||||
expect(unvalidated).toHaveLength(1)
|
||||
expect(unvalidated[0].severity).toBe('warning')
|
||||
})
|
||||
|
||||
it('warns when moms_ruta does not match vat_treatment', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [],
|
||||
invoices: [makeInvoice({ vat_treatment: 'standard_25', moms_ruta: '35' })],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
|
||||
expect(mismatch).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('no warning when moms_ruta matches reverse_charge (35, 38, or 39)', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [],
|
||||
invoices: [
|
||||
makeInvoice({ id: 'i1', vat_treatment: 'reverse_charge', moms_ruta: '35' }),
|
||||
makeInvoice({ id: 'i2', vat_treatment: 'reverse_charge', moms_ruta: '39' }),
|
||||
makeInvoice({ id: 'i3', vat_treatment: 'reverse_charge', moms_ruta: '38' }),
|
||||
],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
|
||||
expect(mismatch).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('no warning when moms_ruta matches export (36 or 40)', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [],
|
||||
invoices: [
|
||||
makeInvoice({ id: 'i1', vat_treatment: 'export', moms_ruta: '36' }),
|
||||
makeInvoice({ id: 'i2', vat_treatment: 'export', moms_ruta: '40' }),
|
||||
],
|
||||
customers: [makeCustomer()],
|
||||
})
|
||||
|
||||
const mismatch = report.warnings.filter(w => w.type === 'vat_treatment_mismatch')
|
||||
expect(mismatch).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('no validation warnings when no invoices provided', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3108', 10000)],
|
||||
})
|
||||
|
||||
expect(report.warnings).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Period comparison tests ─────────────────────────────────
|
||||
|
||||
describe('period comparison', () => {
|
||||
it('calculates delta between current and previous period', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3001', 200000)],
|
||||
previousGlLines: [creditLine('3001', 150000)],
|
||||
})
|
||||
|
||||
expect(report.comparison).not.toBeNull()
|
||||
expect(report.comparison!.domestic.current).toBe(200000)
|
||||
expect(report.comparison!.domestic.previous).toBe(150000)
|
||||
expect(report.comparison!.domestic.change).toBe(50000)
|
||||
expect(report.comparison!.domestic.changePercent).toBeCloseTo(33.33, 1)
|
||||
})
|
||||
|
||||
it('returns null changePercent when previous is zero', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3108', 50000)],
|
||||
previousGlLines: [],
|
||||
})
|
||||
|
||||
expect(report.comparison!.euGoods.changePercent).toBeNull()
|
||||
})
|
||||
|
||||
it('shows negative delta when revenue decreased', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3001', 80000)],
|
||||
previousGlLines: [creditLine('3001', 100000)],
|
||||
})
|
||||
|
||||
expect(report.comparison!.domestic.change).toBe(-20000)
|
||||
expect(report.comparison!.domestic.changePercent).toBe(-20)
|
||||
})
|
||||
|
||||
it('no comparison when previousGlLines not provided', () => {
|
||||
const report = generateVatMonitorReport(BASE_OPTIONS)
|
||||
expect(report.comparison).toBeNull()
|
||||
})
|
||||
|
||||
it('includes netVat comparison', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('2611', 50000), debitLine('2641', 20000)],
|
||||
previousGlLines: [creditLine('2611', 40000), debitLine('2641', 25000)],
|
||||
})
|
||||
|
||||
expect(report.comparison!.netVat.current).toBe(30000)
|
||||
expect(report.comparison!.netVat.previous).toBe(15000)
|
||||
expect(report.comparison!.netVat.change).toBe(15000)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Empty / edge case tests ─────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('generates empty report with no GL data', () => {
|
||||
const report = generateVatMonitorReport(BASE_OPTIONS)
|
||||
|
||||
expect(report.boxes).toHaveLength(1) // Only box 49 (net = 0)
|
||||
expect(report.revenueBreakdown.totalRevenue).toBe(0)
|
||||
expect(report.vatSummary.netVat).toBe(0)
|
||||
expect(report.warnings).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores irrelevant accounts', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('1510', 100000), // accounts receivable — not relevant
|
||||
creditLine('3001', 50000), // domestic revenue — relevant
|
||||
],
|
||||
})
|
||||
|
||||
expect(report.revenueBreakdown.totalRevenue).toBe(50000)
|
||||
})
|
||||
|
||||
it('handles mixed debit/credit on same account', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('3001', 100000),
|
||||
{ account_number: '3001', debit_amount: 10000, credit_amount: 0 }, // credit note reversal
|
||||
],
|
||||
})
|
||||
|
||||
expect(report.revenueBreakdown.domestic.amount).toBe(90000)
|
||||
})
|
||||
|
||||
it('rounds amounts to 2 decimal places', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [creditLine('3001', 33333.335)],
|
||||
})
|
||||
|
||||
expect(report.revenueBreakdown.domestic.amount).toBe(33333.34)
|
||||
})
|
||||
|
||||
it('boxes are sorted by box number', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
glLines: [
|
||||
creditLine('2641', 0), // We need debit line for 2641
|
||||
debitLine('2641', 10000),
|
||||
creditLine('2611', 25000),
|
||||
creditLine('3001', 100000),
|
||||
creditLine('3108', 50000),
|
||||
],
|
||||
})
|
||||
|
||||
const boxNumbers = report.boxes.map(b => b.boxNumber)
|
||||
const sorted = [...boxNumbers].sort()
|
||||
expect(boxNumbers).toEqual(sorted)
|
||||
})
|
||||
|
||||
it('includes period info in report', () => {
|
||||
const report = generateVatMonitorReport({
|
||||
...BASE_OPTIONS,
|
||||
year: 2026,
|
||||
month: 3,
|
||||
quarter: undefined,
|
||||
})
|
||||
|
||||
expect(report.period.year).toBe(2026)
|
||||
expect(report.period.month).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Export VAT Monitor Engine
|
||||
*
|
||||
* Analyses journal entry data to produce a momsdeklaration preview
|
||||
* focused on export and intra-community trade. Maps BAS accounts to
|
||||
* momsdeklaration boxes, breaks down revenue by destination type,
|
||||
* and validates invoice-to-account consistency.
|
||||
*
|
||||
* Pure functions — no Supabase, no React, no side effects.
|
||||
*
|
||||
* Complements the core VatDeclaration report by adding:
|
||||
* - Revenue breakdown by destination (domestic / EU / non-EU)
|
||||
* - Invoice-level validation (vat_treatment vs account)
|
||||
* - Period-over-period comparison
|
||||
*
|
||||
* Reference: Skatteverket momsdeklaration (SKV 4700)
|
||||
*/
|
||||
|
||||
import {
|
||||
ACCOUNT_TO_BOX,
|
||||
BOX_LABELS,
|
||||
type MomsBox,
|
||||
} from '@/extensions/export/shared/moms-box-mapping'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
/** Journal entry line data pre-fetched from GL */
|
||||
export interface GLLine {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}
|
||||
|
||||
/** Invoice data for validation (subset of core Invoice type) */
|
||||
export interface VatMonitorInvoice {
|
||||
id: string
|
||||
invoice_number: string
|
||||
vat_treatment: string
|
||||
moms_ruta: string | null
|
||||
customer_id: string
|
||||
}
|
||||
|
||||
/** Customer data for validation */
|
||||
export interface VatMonitorCustomer {
|
||||
id: string
|
||||
name: string
|
||||
country: string
|
||||
vat_number: string | null
|
||||
vat_number_validated: boolean
|
||||
}
|
||||
|
||||
/** A single momsdeklaration box result */
|
||||
export interface VatBoxData {
|
||||
boxNumber: MomsBox
|
||||
label: string
|
||||
amount: number
|
||||
accounts: string[]
|
||||
}
|
||||
|
||||
/** Revenue breakdown by destination type */
|
||||
export interface RevenueBreakdown {
|
||||
domestic: { amount: number; percentage: number }
|
||||
euGoods: { amount: number; percentage: number }
|
||||
euServices: { amount: number; percentage: number }
|
||||
exportGoods: { amount: number; percentage: number }
|
||||
exportServices: { amount: number; percentage: number }
|
||||
triangular: { amount: number; percentage: number }
|
||||
totalRevenue: number
|
||||
}
|
||||
|
||||
/** Warning about VAT data quality */
|
||||
export interface VatMonitorWarning {
|
||||
type:
|
||||
| 'vat_treatment_mismatch'
|
||||
| 'missing_vat_number'
|
||||
| 'unvalidated_vat_number'
|
||||
| 'wrong_account'
|
||||
severity: 'error' | 'warning'
|
||||
invoiceId?: string
|
||||
invoiceNumber?: string
|
||||
customerName?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Period comparison delta for a destination type */
|
||||
export interface PeriodDelta {
|
||||
current: number
|
||||
previous: number
|
||||
change: number
|
||||
changePercent: number | null
|
||||
}
|
||||
|
||||
/** Complete VAT Monitor report */
|
||||
export interface VatMonitorReport {
|
||||
period: { year: number; month?: number; quarter?: number }
|
||||
boxes: VatBoxData[]
|
||||
revenueBreakdown: RevenueBreakdown
|
||||
vatSummary: {
|
||||
outputVat25: number
|
||||
outputVat12: number
|
||||
outputVat6: number
|
||||
totalOutputVat: number
|
||||
inputVat: number
|
||||
netVat: number
|
||||
isRefund: boolean
|
||||
}
|
||||
warnings: VatMonitorWarning[]
|
||||
comparison: PeriodComparison | null
|
||||
}
|
||||
|
||||
/** Period-over-period comparison */
|
||||
export interface PeriodComparison {
|
||||
domestic: PeriodDelta
|
||||
euGoods: PeriodDelta
|
||||
euServices: PeriodDelta
|
||||
exportGoods: PeriodDelta
|
||||
exportServices: PeriodDelta
|
||||
triangular: PeriodDelta
|
||||
totalRevenue: PeriodDelta
|
||||
netVat: PeriodDelta
|
||||
}
|
||||
|
||||
/** Options for generating the VAT Monitor report */
|
||||
export interface VatMonitorOptions {
|
||||
glLines: GLLine[]
|
||||
invoices?: VatMonitorInvoice[]
|
||||
customers?: VatMonitorCustomer[]
|
||||
year: number
|
||||
month?: number
|
||||
quarter?: number
|
||||
previousGlLines?: GLLine[]
|
||||
}
|
||||
|
||||
// ── Revenue account groups ──────────────────────────────────
|
||||
|
||||
const DOMESTIC_ACCOUNTS = ['3001', '3002', '3003']
|
||||
const EU_GOODS_ACCOUNTS = ['3108', '3521']
|
||||
const EU_SERVICES_ACCOUNTS = ['3308']
|
||||
const EXPORT_GOODS_ACCOUNTS = ['3105', '3522']
|
||||
const EXPORT_SERVICES_ACCOUNTS = ['3305']
|
||||
const TRIANGULATION_ACCOUNTS = ['3109']
|
||||
|
||||
const OUTPUT_VAT_ACCOUNTS: Record<string, string> = {
|
||||
'2611': '25%',
|
||||
'2621': '12%',
|
||||
'2631': '6%',
|
||||
}
|
||||
|
||||
const INPUT_VAT_ACCOUNTS = ['2641', '2645']
|
||||
|
||||
// All accounts this engine cares about
|
||||
const ALL_ACCOUNTS = [
|
||||
...DOMESTIC_ACCOUNTS,
|
||||
...EU_GOODS_ACCOUNTS,
|
||||
...EU_SERVICES_ACCOUNTS,
|
||||
...EXPORT_GOODS_ACCOUNTS,
|
||||
...EXPORT_SERVICES_ACCOUNTS,
|
||||
...TRIANGULATION_ACCOUNTS,
|
||||
...Object.keys(OUTPUT_VAT_ACCOUNTS),
|
||||
...INPUT_VAT_ACCOUNTS,
|
||||
]
|
||||
|
||||
// ── Expected vat_treatment for revenue account groups ───────
|
||||
|
||||
const ACCOUNT_EXPECTED_VAT_TREATMENT: Record<string, string[]> = {
|
||||
'3001': ['standard_25'],
|
||||
'3002': ['reduced_12'],
|
||||
'3003': ['reduced_6'],
|
||||
'3108': ['reverse_charge'],
|
||||
'3521': ['reverse_charge'],
|
||||
'3109': ['reverse_charge'],
|
||||
'3308': ['reverse_charge'],
|
||||
'3105': ['export'],
|
||||
'3522': ['export'],
|
||||
'3305': ['export'],
|
||||
}
|
||||
|
||||
// ── Core engine ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a VAT Monitor report from pre-fetched GL data.
|
||||
*/
|
||||
export function generateVatMonitorReport(options: VatMonitorOptions): VatMonitorReport {
|
||||
const { glLines, invoices, customers, year, month, quarter, previousGlLines } = options
|
||||
|
||||
const warnings: VatMonitorWarning[] = []
|
||||
|
||||
// Step 1: Aggregate GL lines into account credit balances
|
||||
const accountBalances = aggregateGLLines(glLines)
|
||||
|
||||
// Step 2: Map accounts to boxes
|
||||
const boxes = buildBoxes(accountBalances)
|
||||
|
||||
// Step 3: Calculate revenue breakdown
|
||||
const revenueBreakdown = calculateRevenueBreakdown(accountBalances)
|
||||
|
||||
// Step 4: Calculate VAT summary
|
||||
const vatSummary = calculateVatSummary(accountBalances)
|
||||
|
||||
// Step 5: Validate invoices if provided
|
||||
if (invoices && customers) {
|
||||
validateInvoices(invoices, customers, warnings)
|
||||
}
|
||||
|
||||
// Step 6: Calculate period comparison if previous data provided
|
||||
let comparison: PeriodComparison | null = null
|
||||
if (previousGlLines) {
|
||||
const prevBalances = aggregateGLLines(previousGlLines)
|
||||
const prevBreakdown = calculateRevenueBreakdown(prevBalances)
|
||||
const prevVat = calculateVatSummary(prevBalances)
|
||||
comparison = buildComparison(revenueBreakdown, prevBreakdown, vatSummary.netVat, prevVat.netVat)
|
||||
}
|
||||
|
||||
return {
|
||||
period: { year, month, quarter },
|
||||
boxes,
|
||||
revenueBreakdown,
|
||||
vatSummary,
|
||||
warnings,
|
||||
comparison,
|
||||
}
|
||||
}
|
||||
|
||||
// ── GL aggregation ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate GL lines into net balances per account.
|
||||
* Revenue accounts: credit balance (credit - debit).
|
||||
* VAT accounts: see sign per account type.
|
||||
*/
|
||||
function aggregateGLLines(lines: GLLine[]): Map<string, number> {
|
||||
const balances = new Map<string, number>()
|
||||
|
||||
for (const line of lines) {
|
||||
if (!ALL_ACCOUNTS.includes(line.account_number)) continue
|
||||
|
||||
const credit = Number(line.credit_amount) || 0
|
||||
const debit = Number(line.debit_amount) || 0
|
||||
|
||||
// Input VAT is debit-side, all others are credit-side
|
||||
const isInputVat = INPUT_VAT_ACCOUNTS.includes(line.account_number)
|
||||
const balance = isInputVat ? (debit - credit) : (credit - debit)
|
||||
|
||||
balances.set(
|
||||
line.account_number,
|
||||
round2((balances.get(line.account_number) ?? 0) + balance),
|
||||
)
|
||||
}
|
||||
|
||||
return balances
|
||||
}
|
||||
|
||||
/** Sum balances for a set of accounts */
|
||||
function sumAccounts(balances: Map<string, number>, accounts: string[]): number {
|
||||
return round2(accounts.reduce((sum, acc) => sum + (balances.get(acc) ?? 0), 0))
|
||||
}
|
||||
|
||||
// ── Box building ────────────────────────────────────────────
|
||||
|
||||
function buildBoxes(balances: Map<string, number>): VatBoxData[] {
|
||||
// Group accounts by box
|
||||
const boxAccounts = new Map<MomsBox, string[]>()
|
||||
const boxAmounts = new Map<MomsBox, number>()
|
||||
|
||||
for (const [account, balance] of balances) {
|
||||
const box = ACCOUNT_TO_BOX[account]
|
||||
if (!box) continue
|
||||
|
||||
if (!boxAccounts.has(box)) boxAccounts.set(box, [])
|
||||
boxAccounts.get(box)!.push(account)
|
||||
|
||||
boxAmounts.set(box, round2((boxAmounts.get(box) ?? 0) + balance))
|
||||
}
|
||||
|
||||
// Build sorted box list
|
||||
const boxes: VatBoxData[] = []
|
||||
for (const [box, amount] of boxAmounts) {
|
||||
boxes.push({
|
||||
boxNumber: box,
|
||||
label: BOX_LABELS[box],
|
||||
amount: round2(amount),
|
||||
accounts: boxAccounts.get(box) ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
// Add box 49 (net VAT = output - input)
|
||||
const outputVat = sumAccounts(balances, Object.keys(OUTPUT_VAT_ACCOUNTS))
|
||||
const inputVat = sumAccounts(balances, INPUT_VAT_ACCOUNTS)
|
||||
const netVat = round2(outputVat - inputVat)
|
||||
|
||||
boxes.push({
|
||||
boxNumber: '49',
|
||||
label: BOX_LABELS['49'],
|
||||
amount: netVat,
|
||||
accounts: [...Object.keys(OUTPUT_VAT_ACCOUNTS), ...INPUT_VAT_ACCOUNTS],
|
||||
})
|
||||
|
||||
// Sort by box number
|
||||
boxes.sort((a, b) => a.boxNumber.localeCompare(b.boxNumber))
|
||||
|
||||
return boxes
|
||||
}
|
||||
|
||||
// ── Revenue breakdown ───────────────────────────────────────
|
||||
|
||||
function calculateRevenueBreakdown(balances: Map<string, number>): RevenueBreakdown {
|
||||
const domestic = sumAccounts(balances, DOMESTIC_ACCOUNTS)
|
||||
const euGoods = sumAccounts(balances, EU_GOODS_ACCOUNTS)
|
||||
const euServices = sumAccounts(balances, EU_SERVICES_ACCOUNTS)
|
||||
const exportGoods = sumAccounts(balances, EXPORT_GOODS_ACCOUNTS)
|
||||
const exportServices = sumAccounts(balances, EXPORT_SERVICES_ACCOUNTS)
|
||||
const triangular = sumAccounts(balances, TRIANGULATION_ACCOUNTS)
|
||||
|
||||
const totalRevenue = round2(domestic + euGoods + euServices + exportGoods + exportServices + triangular)
|
||||
|
||||
const pct = (amount: number) => totalRevenue > 0 ? round2((amount / totalRevenue) * 100) : 0
|
||||
|
||||
return {
|
||||
domestic: { amount: domestic, percentage: pct(domestic) },
|
||||
euGoods: { amount: euGoods, percentage: pct(euGoods) },
|
||||
euServices: { amount: euServices, percentage: pct(euServices) },
|
||||
exportGoods: { amount: exportGoods, percentage: pct(exportGoods) },
|
||||
exportServices: { amount: exportServices, percentage: pct(exportServices) },
|
||||
triangular: { amount: triangular, percentage: pct(triangular) },
|
||||
totalRevenue,
|
||||
}
|
||||
}
|
||||
|
||||
// ── VAT summary ─────────────────────────────────────────────
|
||||
|
||||
function calculateVatSummary(balances: Map<string, number>) {
|
||||
const outputVat25 = balances.get('2611') ?? 0
|
||||
const outputVat12 = balances.get('2621') ?? 0
|
||||
const outputVat6 = balances.get('2631') ?? 0
|
||||
const totalOutputVat = round2(outputVat25 + outputVat12 + outputVat6)
|
||||
const inputVat = sumAccounts(balances, INPUT_VAT_ACCOUNTS)
|
||||
const netVat = round2(totalOutputVat - inputVat)
|
||||
|
||||
return {
|
||||
outputVat25: round2(outputVat25),
|
||||
outputVat12: round2(outputVat12),
|
||||
outputVat6: round2(outputVat6),
|
||||
totalOutputVat,
|
||||
inputVat: round2(inputVat),
|
||||
netVat,
|
||||
isRefund: netVat < 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invoice validation ──────────────────────────────────────
|
||||
|
||||
function validateInvoices(
|
||||
invoices: VatMonitorInvoice[],
|
||||
customers: VatMonitorCustomer[],
|
||||
warnings: VatMonitorWarning[],
|
||||
) {
|
||||
const customerMap = new Map(customers.map(c => [c.id, c]))
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const customer = customerMap.get(invoice.customer_id)
|
||||
|
||||
// Check: reverse_charge invoices should have EU customer with VAT number
|
||||
if (invoice.vat_treatment === 'reverse_charge' && customer) {
|
||||
if (!customer.vat_number) {
|
||||
warnings.push({
|
||||
type: 'missing_vat_number',
|
||||
severity: 'error',
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerName: customer.name,
|
||||
message: `Faktura ${invoice.invoice_number} (${customer.name}) har omvänd skattskyldighet men kunden saknar VAT-nummer.`,
|
||||
})
|
||||
} else if (!customer.vat_number_validated) {
|
||||
warnings.push({
|
||||
type: 'unvalidated_vat_number',
|
||||
severity: 'warning',
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerName: customer.name,
|
||||
message: `Faktura ${invoice.invoice_number} (${customer.name}): VAT-nummer ${customer.vat_number} har inte validerats via VIES.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check: moms_ruta consistency with vat_treatment
|
||||
if (invoice.moms_ruta && invoice.vat_treatment) {
|
||||
const expectedBoxes = getExpectedBoxesForTreatment(invoice.vat_treatment)
|
||||
if (expectedBoxes.length > 0 && !expectedBoxes.includes(invoice.moms_ruta)) {
|
||||
warnings.push({
|
||||
type: 'vat_treatment_mismatch',
|
||||
severity: 'warning',
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
message: `Faktura ${invoice.invoice_number} har momsbehandling "${invoice.vat_treatment}" men moms_ruta "${invoice.moms_ruta}" (förväntat: ${expectedBoxes.join('/')}).`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get expected momsdeklaration boxes for a vat_treatment value */
|
||||
function getExpectedBoxesForTreatment(vatTreatment: string): string[] {
|
||||
switch (vatTreatment) {
|
||||
case 'standard_25':
|
||||
case 'reduced_12':
|
||||
case 'reduced_6':
|
||||
return ['05']
|
||||
case 'reverse_charge':
|
||||
return ['35', '38', '39']
|
||||
case 'export':
|
||||
return ['36', '40']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ── Period comparison ───────────────────────────────────────
|
||||
|
||||
function buildComparison(
|
||||
current: RevenueBreakdown,
|
||||
previous: RevenueBreakdown,
|
||||
currentNetVat: number,
|
||||
previousNetVat: number,
|
||||
): PeriodComparison {
|
||||
return {
|
||||
domestic: makeDelta(current.domestic.amount, previous.domestic.amount),
|
||||
euGoods: makeDelta(current.euGoods.amount, previous.euGoods.amount),
|
||||
euServices: makeDelta(current.euServices.amount, previous.euServices.amount),
|
||||
exportGoods: makeDelta(current.exportGoods.amount, previous.exportGoods.amount),
|
||||
exportServices: makeDelta(current.exportServices.amount, previous.exportServices.amount),
|
||||
triangular: makeDelta(current.triangular.amount, previous.triangular.amount),
|
||||
totalRevenue: makeDelta(current.totalRevenue, previous.totalRevenue),
|
||||
netVat: makeDelta(currentNetVat, previousNetVat),
|
||||
}
|
||||
}
|
||||
|
||||
function makeDelta(current: number, previous: number): PeriodDelta {
|
||||
const change = round2(current - previous)
|
||||
const changePercent = previous !== 0 ? round2((change / Math.abs(previous)) * 100) : null
|
||||
|
||||
return { current, previous, change, changePercent }
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
// ── Exported constants for API route ────────────────────────
|
||||
|
||||
/** All BAS accounts that the VAT Monitor needs from GL */
|
||||
export const VAT_MONITOR_ACCOUNTS = ALL_ACCOUNTS
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+164
-15
@@ -3,6 +3,16 @@ import type { Currency, ExchangeRate } from '@/types'
|
||||
|
||||
const log = createLogger('riksbanken')
|
||||
|
||||
/** Riksbanken series IDs for each currency */
|
||||
const SERIES_IDS: Record<Currency, string> = {
|
||||
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<Currency, string> = {
|
||||
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<Map<Currency, ExchangeRate>> {
|
||||
const results = new Map<Currency, ExchangeRate>()
|
||||
|
||||
// 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<ExchangeRate[]> {
|
||||
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<ExchangeRate | null> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
Layers,
|
||||
Puzzle,
|
||||
TextSearch,
|
||||
Ship,
|
||||
FileText,
|
||||
Shield,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -55,6 +58,9 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Layers,
|
||||
Puzzle,
|
||||
TextSearch,
|
||||
Ship,
|
||||
FileText,
|
||||
Shield,
|
||||
}
|
||||
|
||||
export function resolveIcon(name: string): LucideIcon {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<T> {
|
||||
mockReport: T | null
|
||||
isMockActive: boolean
|
||||
isLoading: boolean
|
||||
importedAt: string | null
|
||||
meta: MockMeta | null
|
||||
saveMockData: (report: T, meta: Omit<MockMeta, 'importedAt'>) => Promise<void>
|
||||
clearMockData: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useMockData<T>(sector: string, slug: string): UseMockDataResult<T> {
|
||||
const { getByKey, save, remove, isLoading } = useExtensionData(sector, slug)
|
||||
|
||||
const [mockReport, setMockReport] = useState<T | null>(null)
|
||||
const [isMockActive, setIsMockActive] = useState(false)
|
||||
const [meta, setMeta] = useState<MockMeta | null>(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<MockMeta, 'importedAt'>) => {
|
||||
const fullMeta: MockMeta = {
|
||||
...metaInput,
|
||||
importedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await save('mock:enabled', { enabled: true })
|
||||
await save('mock:report', report as unknown as Record<string, unknown>)
|
||||
await save('mock:meta', fullMeta as unknown as Record<string, unknown>)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,11 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
// 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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string, RegExp> = {
|
||||
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<VatValidationResult> {
|
||||
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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user