diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 41132c0c..7d45a202 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,18 @@ "Bash(git commit:*)", "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:support.fortnox.se)", - "Bash(npx vitest run:*)" + "Bash(npx vitest run:*)", + "WebFetch(domain:www.bjornlunden.se)", + "WebFetch(domain:www.scb.se)", + "WebFetch(domain:stripe.com)", + "WebFetch(domain:tullify.se)", + "WebFetch(domain:www.momsens.se)", + "WebFetch(domain:www.bokforingstips.se)", + "WebFetch(domain:rattsakuten.se)", + "WebFetch(domain:www.faronline.se)", + "WebFetch(domain:www.worldstopexports.com)", + "WebFetch(domain:www.riksbank.se)", + "WebFetch(domain:www.avalara.com)" ] } } diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 8dfc0977..04511729 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -2,6 +2,10 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBody } from '@/lib/api/validate' import { UpdateCustomerSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' +import { createLogger } from '@/lib/logger' + +const log = createLogger('api/customers/[id]') export async function GET( request: Request, @@ -95,6 +99,56 @@ export async function PATCH( return NextResponse.json({ error: error.message }, { status: 500 }) } + // Auto-validate VAT number when it changes on an EU business customer (non-blocking) + const isEuBusiness = (body.customer_type || data.customer_type) === 'eu_business' + if (body.vat_number !== undefined && isEuBusiness) { + try { + if (body.vat_number) { + const vatResult = await validateVatNumber(body.vat_number) + if (vatResult.valid) { + await supabase + .from('customers') + .update({ + vat_number_validated: true, + vat_number_validated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('user_id', user.id) + + data.vat_number_validated = true + data.vat_number_validated_at = new Date().toISOString() + } else { + await supabase + .from('customers') + .update({ + vat_number_validated: false, + vat_number_validated_at: null, + }) + .eq('id', id) + .eq('user_id', user.id) + + data.vat_number_validated = false + data.vat_number_validated_at = null + } + } else { + // VAT number cleared + await supabase + .from('customers') + .update({ + vat_number_validated: false, + vat_number_validated_at: null, + }) + .eq('id', id) + .eq('user_id', user.id) + + data.vat_number_validated = false + data.vat_number_validated_at = null + } + } catch (err) { + log.warn('Auto-VIES validation failed on customer update:', err) + } + } + return NextResponse.json({ data }) } diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index d22153d1..2eb12d97 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -4,8 +4,12 @@ import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateCustomerSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' +import { createLogger } from '@/lib/logger' import type { Customer } from '@/types' +const log = createLogger('api/customers') + ensureInitialized() export async function GET() { @@ -68,6 +72,28 @@ export async function POST(request: Request) { return NextResponse.json({ error: error.message }, { status: 500 }) } + // Auto-validate VAT number for EU business customers (non-blocking) + if (body.customer_type === 'eu_business' && body.vat_number) { + try { + const vatResult = await validateVatNumber(body.vat_number) + if (vatResult.valid) { + await supabase + .from('customers') + .update({ + vat_number_validated: true, + vat_number_validated_at: new Date().toISOString(), + }) + .eq('id', data.id) + .eq('user_id', user.id) + + data.vat_number_validated = true + data.vat_number_validated_at = new Date().toISOString() + } + } catch (err) { + log.warn('Auto-VIES validation failed on customer create:', err) + } + } + await eventBus.emit({ type: 'customer.created', payload: { customer: data as Customer, userId: user.id }, diff --git a/app/api/extensions/export/currency-receivables/report/route.ts b/app/api/extensions/export/currency-receivables/report/route.ts index 19c1028f..fa77eed9 100644 --- a/app/api/extensions/export/currency-receivables/report/route.ts +++ b/app/api/extensions/export/currency-receivables/report/route.ts @@ -8,7 +8,7 @@ import { type GLLine, type ExchangeRateInfo, } from '@/extensions/export/currency-receivables/lib/receivables-engine' -import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import { fetchMultipleRates } from '@/lib/currency/riksbanken' import type { Currency } from '@/types' const SUPPORTED_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK'] @@ -67,18 +67,17 @@ export async function GET(request: Request) { } // Fetch current Riksbanken rates for all supported currencies + const rateMap = await fetchMultipleRates(SUPPORTED_CURRENCIES) const currentRates: ExchangeRateInfo[] = [] - const ratePromises = SUPPORTED_CURRENCIES.map(async (currency) => { - const rate = await fetchExchangeRate(currency) - if (rate) { + for (const [, rate] of rateMap) { + if (rate.currency !== 'SEK') { currentRates.push({ currency: rate.currency, rate: rate.rate, date: rate.date, }) } - }) - await Promise.all(ratePromises) + } // Fetch realized FX GL lines for the year const realizedFXLines = await fetchFXLines(supabase, user.id, year) diff --git a/app/api/vat/validate/__tests__/route.test.ts b/app/api/vat/validate/__tests__/route.test.ts new file mode 100644 index 00000000..77bd2d2c --- /dev/null +++ b/app/api/vat/validate/__tests__/route.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +// Mock Supabase +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +// Mock VIES client +const mockValidateVatNumber = vi.fn() +vi.mock('@/lib/vat/vies-client', () => ({ + validateVatNumber: (...args: unknown[]) => mockValidateVatNumber(...args), +})) + +import { createClient } from '@/lib/supabase/server' +import { POST } from '../route' + +const mockCreateClient = vi.mocked(createClient) + +describe('POST /api/vat/validate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE123456789' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when vat_number is missing', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: {}, + }) + + const res = await POST(req) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(400) + }) + + it('returns 400 when vat_number is too short', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE' }, + }) + + const res = await POST(req) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(400) + }) + + it('returns valid result from VIES', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: true, + name: 'Test GmbH', + address: 'Berlin', + country_code: 'DE', + vat_number: 'DE123456789', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE123456789' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toEqual({ + valid: true, + name: 'Test GmbH', + address: 'Berlin', + country_code: 'DE', + vat_number: 'DE123456789', + }) + }) + + it('returns invalid result from VIES', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: false, + country_code: 'DE', + vat_number: 'DE000000000', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE000000000' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: false }) + }) + + it('updates customer when customer_id provided and valid', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: true, + name: 'Test GmbH', + country_code: 'DE', + vat_number: 'DE123456789', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { + vat_number: 'DE123456789', + customer_id: '550e8400-e29b-41d4-a716-446655440000', + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: true }) + }) + + it('does not update customer when validation fails', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: false, + error: 'Invalid VAT number format', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { + vat_number: 'DE12345', + customer_id: '550e8400-e29b-41d4-a716-446655440000', + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: false }) + }) + + it('handles VIES service error gracefully', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockValidateVatNumber.mockResolvedValueOnce({ + valid: false, + error: 'Could not verify VAT number. Service temporarily unavailable.', + }) + + const req = createMockRequest('/api/vat/validate', { + method: 'POST', + body: { vat_number: 'DE123456789' }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(200) + expect(body).toMatchObject({ valid: false, error: expect.stringContaining('unavailable') }) + }) +}) diff --git a/app/api/vat/validate/route.ts b/app/api/vat/validate/route.ts index 3e98ea4b..5c71d493 100644 --- a/app/api/vat/validate/route.ts +++ b/app/api/vat/validate/route.ts @@ -1,12 +1,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { validateBody } from '@/lib/api/validate' +import { ValidateVatNumberSchema } from '@/lib/api/schemas' +import { validateVatNumber } from '@/lib/vat/vies-client' -/** - * Validate EU VAT number using VIES (VAT Information Exchange System) - * - * The EU provides a SOAP-based API, but we'll use a REST wrapper - * In production, you might want to use the official SOAP API or a dedicated service - */ export async function POST(request: Request) { const supabase = await createClient() @@ -16,113 +13,24 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { vat_number, customer_id } = await request.json() + const result = await validateBody(request, ValidateVatNumberSchema) + if (!result.success) return result.response + const { vat_number, customer_id } = result.data - if (!vat_number) { - return NextResponse.json({ error: 'VAT number is required' }, { status: 400 }) - } + const validation = await validateVatNumber(vat_number) - // Extract country code and number - const countryCode = vat_number.substring(0, 2).toUpperCase() - const vatNumber = vat_number.substring(2).replace(/\s/g, '') - - try { - // Use the EU VIES validation API - // Note: In production, you should use the official SOAP API or a reliable service - const response = await fetch( - `https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${countryCode}/vat/${vatNumber}`, - { - method: 'GET', - headers: { - Accept: 'application/json', - }, - } - ) - - if (!response.ok) { - // If VIES is unavailable, return a soft error - return NextResponse.json({ - valid: false, - error: 'VAT validation service unavailable. Please try again later.', + // Update customer record if customer_id provided and VAT is valid + if (customer_id && validation.valid) { + await supabase + .from('customers') + .update({ + vat_number: validation.vat_number, + vat_number_validated: true, + vat_number_validated_at: new Date().toISOString(), }) - } - - const data = await response.json() - - const isValid = data.isValid === true - - // Update customer if customer_id provided - if (customer_id && isValid) { - await supabase - .from('customers') - .update({ - vat_number: vat_number.toUpperCase(), - vat_number_validated: true, - vat_number_validated_at: new Date().toISOString(), - }) - .eq('id', customer_id) - .eq('user_id', user.id) - } - - return NextResponse.json({ - valid: isValid, - name: data.name || null, - address: data.address || null, - country_code: countryCode, - vat_number: vat_number.toUpperCase(), - }) - } catch (error) { - console.error('VAT validation error:', error) - - // Fallback: basic format validation - const isValidFormat = validateVatNumberFormat(countryCode, vatNumber) - - return NextResponse.json({ - valid: false, - error: 'Could not verify VAT number. Service temporarily unavailable.', - format_valid: isValidFormat, - }) + .eq('id', customer_id) + .eq('user_id', user.id) } -} -/** - * Basic VAT number format validation by country - */ -function validateVatNumberFormat(countryCode: string, vatNumber: string): boolean { - const patterns: Record = { - AT: /^U\d{8}$/, - BE: /^0\d{9}$/, - BG: /^\d{9,10}$/, - CY: /^\d{8}[A-Z]$/, - CZ: /^\d{8,10}$/, - DE: /^\d{9}$/, - DK: /^\d{8}$/, - EE: /^\d{9}$/, - EL: /^\d{9}$/, // Greece - ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/, - FI: /^\d{8}$/, - FR: /^[A-Z0-9]{2}\d{9}$/, - HR: /^\d{11}$/, - HU: /^\d{8}$/, - IE: /^[0-9A-Z]{8,9}$/, - IT: /^\d{11}$/, - LT: /^\d{9,12}$/, - LU: /^\d{8}$/, - LV: /^\d{11}$/, - MT: /^\d{8}$/, - NL: /^\d{9}B\d{2}$/, - PL: /^\d{10}$/, - PT: /^\d{9}$/, - RO: /^\d{2,10}$/, - SE: /^\d{12}$/, - SI: /^\d{8}$/, - SK: /^\d{10}$/, - } - - const pattern = patterns[countryCode] - if (!pattern) { - return false - } - - return pattern.test(vatNumber) + return NextResponse.json(validation) } diff --git a/components/extensions/export/CurrencyReceivablesWorkspace.tsx b/components/extensions/export/CurrencyReceivablesWorkspace.tsx index 2242f84f..2cdd1e00 100644 --- a/components/extensions/export/CurrencyReceivablesWorkspace.tsx +++ b/components/extensions/export/CurrencyReceivablesWorkspace.tsx @@ -2,7 +2,11 @@ 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 { @@ -12,7 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { - TrendingUp, TrendingDown, RefreshCw, Info, ArrowUpDown, + TrendingUp, TrendingDown, RefreshCw, Info, ArrowUpDown, FlaskConical, } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -124,11 +128,126 @@ function currentYear(): number { return new Date().getFullYear() } type SortField = 'unrealizedGainLoss' | 'foreignAmount' | 'daysOutstanding' | 'customerName' type SortDir = 'asc' | 'desc' +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'invoiceNumber', label: 'Fakturanummer', required: true }, + { key: 'customerName', label: 'Kund', required: true }, + { key: 'currency', label: 'Valuta', required: true }, + { key: 'foreignAmount', label: 'Belopp (utl. valuta)', required: true }, + { key: 'bookedSekAmount', label: 'Bokfört (SEK)' }, + { key: 'bookedRate', label: 'Bokförd kurs' }, + { key: 'currentSekAmount', label: 'Aktuellt (SEK)' }, + { key: 'currentRate', label: 'Aktuell kurs' }, + { key: 'invoiceDate', label: 'Fakturadatum' }, + { key: 'dueDate', label: 'Förfallodatum' }, +] + +const MOCK_CSV_TEMPLATE = `invoiceNumber;customerName;currency;foreignAmount;bookedSekAmount;bookedRate;currentSekAmount;currentRate;invoiceDate;dueDate +1001;Beispiel GmbH;EUR;10000;112500;11.25;114200;11.42;2025-01-15;2025-02-15 +1002;Example Corp;USD;25000;262500;10.50;260000;10.40;2025-01-20;2025-02-20 +1003;London Ltd;GBP;8000;106400;13.30;108000;13.50;2025-02-01;2025-03-01` + +function parseMockCsvRows(rows: Record[]): ReportData { + const today = new Date().toISOString().slice(0, 10) + const receivables: ForeignReceivable[] = rows.map(r => { + const foreignAmount = parseFloat(r.foreignAmount || '0') || 0 + const bookedRate = parseFloat(r.bookedRate || '0') || 0 + const currentRate = parseFloat(r.currentRate || '0') || bookedRate + const bookedSek = parseFloat(r.bookedSekAmount || '0') || Math.round(foreignAmount * bookedRate * 100) / 100 + const currentSek = parseFloat(r.currentSekAmount || '0') || Math.round(foreignAmount * currentRate * 100) / 100 + const invoiceDate = r.invoiceDate || today + const dueDate = r.dueDate || today + const daysOutstanding = Math.max(0, Math.floor((Date.now() - new Date(invoiceDate).getTime()) / 86400000)) + + return { + invoiceId: r.invoiceNumber || '', + invoiceNumber: r.invoiceNumber || '', + customerName: r.customerName || '', + customerCountry: '', + currency: r.currency || 'EUR', + foreignAmount, + bookedSekAmount: bookedSek, + bookedRate, + currentSekAmount: currentSek, + currentRate, + unrealizedGainLoss: Math.round((currentSek - bookedSek) * 100) / 100, + invoiceDate, + dueDate, + daysOutstanding, + } + }) + + // Group by currency for exposure + const currencyMap = new Map() + for (const r of receivables) { + const existing = currencyMap.get(r.currency) + if (existing) { + existing.totalForeignAmount += r.foreignAmount + existing.bookedSekValue += r.bookedSekAmount + existing.currentSekValue += r.currentSekAmount + existing.unrealizedGainLoss += r.unrealizedGainLoss + existing.invoiceCount++ + } else { + currencyMap.set(r.currency, { + currency: r.currency, + totalForeignAmount: r.foreignAmount, + bookedSekValue: r.bookedSekAmount, + currentSekValue: r.currentSekAmount, + unrealizedGainLoss: r.unrealizedGainLoss, + invoiceCount: 1, + averageBookedRate: r.bookedRate, + currentRate: r.currentRate, + }) + } + } + + const exposureByCurrency = Array.from(currencyMap.values()) + const totalBookedSek = receivables.reduce((s, r) => s + r.bookedSekAmount, 0) + const totalCurrentSek = receivables.reduce((s, r) => s + r.currentSekAmount, 0) + const totalUnrealized = Math.round((totalCurrentSek - totalBookedSek) * 100) / 100 + + return { + referenceDate: today, + exchangeRates: exposureByCurrency.map(e => ({ currency: e.currency, rate: e.currentRate, date: today })), + exposureByCurrency, + receivables, + realizedGainLoss: { year: new Date().getFullYear(), gains: 0, losses: 0, net: 0 }, + monthlyTrend: [], + revalPreview: { + totalUnrealizedGainLoss: totalUnrealized, + gains: Math.max(0, totalUnrealized), + losses: Math.abs(Math.min(0, totalUnrealized)), + }, + totals: { + bookedSekValue: totalBookedSek, + currentSekValue: totalCurrentSek, + totalUnrealizedGainLoss: totalUnrealized, + receivableCount: receivables.length, + currencyCount: exposureByCurrency.length, + }, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.receivables) && !Array.isArray(obj.exposureByCurrency)) { + return { valid: false, error: 'Fältet "receivables" eller "exposureByCurrency" saknas' } + } + if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } + return { valid: true } +} + // ── Component ───────────────────────────────────────────────── export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) { void userId + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'currency-receivables') + const [importDialogOpen, setImportDialogOpen] = useState(false) + const [year, setYear] = useState(currentYear()) const [report, setReport] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -141,6 +260,13 @@ export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceCompon 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 { @@ -161,12 +287,32 @@ export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceCompon setIsLoading(false) setRefreshing(false) } - }, [year]) + }, [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() @@ -200,7 +346,7 @@ export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceCompon return m <= new Date().getMonth() + 1 }) || [] - if (isLoading && !report) { + if ((isLoading || mockLoading) && !report) { return } @@ -217,12 +363,27 @@ export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceCompon - +
+ + +
+ {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + {error && ( @@ -454,6 +615,18 @@ export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceCompon )} )} + + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="currency-receivables-template.csv" + onImport={handleMockImport} + /> ) } diff --git a/components/extensions/export/EuSalesListWorkspace.tsx b/components/extensions/export/EuSalesListWorkspace.tsx index 8d356d8c..507d98ea 100644 --- a/components/extensions/export/EuSalesListWorkspace.tsx +++ b/components/extensions/export/EuSalesListWorkspace.tsx @@ -2,7 +2,11 @@ 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' @@ -16,6 +20,7 @@ import { import { AlertTriangle, CheckCircle2, FileSpreadsheet, FileCode, Clock, ChevronDown, ChevronUp, Users, Package, Briefcase, + FlaskConical, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -99,11 +104,73 @@ function currentQuarter(): number { return Math.ceil(currentMonth() / 3) } +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'customerVatNumber', label: 'VAT-nummer', required: true }, + { key: 'customerName', label: 'Kundnamn', required: true }, + { key: 'customerCountry', label: 'Land', required: true }, + { key: 'goodsAmount', label: 'Varor (SEK)' }, + { key: 'servicesAmount', label: 'Tjänster (SEK)' }, + { key: 'triangulationAmount', label: 'Trepartshandel (SEK)' }, + { key: 'invoiceCount', label: 'Antal fakturor' }, +] + +const MOCK_CSV_TEMPLATE = `customerVatNumber;customerName;customerCountry;goodsAmount;servicesAmount;triangulationAmount;invoiceCount +DE123456789;Beispiel GmbH;DE;150000;25000;0;3 +FR987654321;Exemple SARL;FR;0;80000;0;2 +NL456789012;Voorbeeld BV;NL;45000;0;12000;1` + +function parseMockCsvRows(rows: Record[]): ReportData { + const lines: ECSalesListLine[] = rows.map(r => ({ + customerVatNumber: r.customerVatNumber || '', + customerName: r.customerName || '', + customerCountry: r.customerCountry || '', + customerId: r.customerVatNumber || '', + goodsAmount: parseFloat(r.goodsAmount || '0') || 0, + servicesAmount: parseFloat(r.servicesAmount || '0') || 0, + triangulationAmount: parseFloat(r.triangulationAmount || '0') || 0, + invoiceCount: parseInt(r.invoiceCount || '1', 10) || 1, + })) + + const goods = lines.reduce((s, l) => s + l.goodsAmount, 0) + const services = lines.reduce((s, l) => s + l.servicesAmount, 0) + const triangulation = lines.reduce((s, l) => s + l.triangulationAmount, 0) + const invoiceCount = lines.reduce((s, l) => s + l.invoiceCount, 0) + + return { + period: { year: new Date().getFullYear(), quarter: Math.ceil((new Date().getMonth() + 1) / 3) }, + filingType: 'quarterly', + reporterVatNumber: 'SE000000000001', + reporterName: 'Testdata', + lines, + totals: { goods, services, triangulation, total: goods + services + triangulation }, + warnings: [], + crossCheck: null, + invoiceCount, + customerCount: lines.length, + deadline: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), + daysUntilDeadline: 30, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' } + if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } + return { valid: true } +} + // ── Component ───────────────────────────────────────────────── export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) { void userId + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'eu-sales-list') + const [importDialogOpen, setImportDialogOpen] = useState(false) + // Period selection state const [year, setYear] = useState(currentYear()) const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly') @@ -133,6 +200,12 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps // Fetch report const fetchReport = useCallback(async () => { + if (isMockActive && mockReport) { + setReport(mockReport) + setIsLoading(false) + return + } + setIsLoading(true) setError(null) @@ -159,12 +232,39 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps } finally { setIsLoading(false) } - }, [year, month, quarter, periodType]) + }, [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 [] @@ -245,7 +345,7 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 - if (isLoading && !report) { + if ((isLoading || mockLoading) && !report) { return } @@ -309,8 +409,16 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps )} - {/* Download buttons */} + {/* Download + Import buttons */}
+
+ {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + {/* ── Error state ────────────────────────────────────── */} {error && ( @@ -578,6 +695,18 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps )} + + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="eu-sales-list-template.csv" + onImport={handleMockImport} + /> ) } diff --git a/components/extensions/export/IntrastatWorkspace.tsx b/components/extensions/export/IntrastatWorkspace.tsx index 2930125c..22bda841 100644 --- a/components/extensions/export/IntrastatWorkspace.tsx +++ b/components/extensions/export/IntrastatWorkspace.tsx @@ -3,7 +3,11 @@ 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' @@ -21,7 +25,7 @@ import { } from '@/components/ui/dialog' import { AlertTriangle, Plus, Pencil, Trash2, FileSpreadsheet, Clock, - ChevronDown, ChevronUp, Package, + ChevronDown, ChevronUp, Package, FlaskConical, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -102,11 +106,75 @@ const EMPTY_PRODUCT: ProductForm = { productId: '', description: '', cnCode: '', netWeightKg: '', countryOfOrigin: 'SE', } +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'cnCode', label: 'CN-kod', required: true }, + { key: 'partnerCountry', label: 'Partnerland', required: true }, + { key: 'countryOfOrigin', label: 'Ursprungsland' }, + { key: 'transactionNature', label: 'Transaktionstyp' }, + { key: 'deliveryTerms', label: 'Leveransvillkor' }, + { key: 'invoicedValue', label: 'Fakturerat värde (SEK)', required: true }, + { key: 'netMass', label: 'Nettovikt (kg)' }, + { key: 'partnerVatId', label: 'Partner VAT-ID' }, +] + +const MOCK_CSV_TEMPLATE = `cnCode;partnerCountry;countryOfOrigin;transactionNature;deliveryTerms;invoicedValue;netMass;partnerVatId +72163100;DE;SE;11;DAP;245000;4500;DE123456789 +84713000;FR;CN;11;EXW;128000;85;FR987654321 +39269090;NL;SE;11;FCA;67000;320;NL456789012` + +function parseMockCsvRows(rows: Record[]): ReportData { + const lines: IntrastatLine[] = rows.map(r => ({ + cnCode: r.cnCode || '00000000', + partnerCountry: r.partnerCountry || '', + countryOfOrigin: r.countryOfOrigin || 'SE', + transactionNature: r.transactionNature || '11', + deliveryTerms: r.deliveryTerms || 'DAP', + invoicedValue: parseFloat(r.invoicedValue || '0') || 0, + netMass: parseFloat(r.netMass || '0') || 0, + supplementaryUnit: null, + supplementaryUnitType: null, + partnerVatId: r.partnerVatId || '', + })) + + const invoicedValue = lines.reduce((s, l) => s + l.invoicedValue, 0) + const netMass = lines.reduce((s, l) => s + l.netMass, 0) + + return { + period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 }, + reporterVatNumber: 'SE000000000001', + reporterName: 'Testdata', + lines, + totals: { invoicedValue, netMass, lineCount: lines.length }, + thresholdStatus: { + cumulativeValue: invoicedValue, + threshold: 9000000, + isObligated: invoicedValue >= 9000000, + percentageUsed: Math.round(invoicedValue / 9000000 * 100), + }, + warnings: [], + invoiceCount: lines.length, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' } + if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } + return { valid: true } +} + // ── Component ───────────────────────────────────────────────── export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) { void userId + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'intrastat') + const [importDialogOpen, setImportDialogOpen] = useState(false) + const [year, setYear] = useState(currentYear()) const [month, setMonth] = useState(currentMonth()) @@ -146,6 +214,12 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) // Fetch report const fetchReport = useCallback(async () => { + if (isMockActive && mockReport) { + setReport(mockReport) + setIsLoading(false) + return + } + setIsLoading(true) setError(null) @@ -166,12 +240,32 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) } finally { setIsLoading(false) } - }, [year, month]) + }, [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) @@ -248,7 +342,7 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0 const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0 - if ((isLoading || productsLoading) && !report) { + if ((isLoading || productsLoading || mockLoading) && !report) { return } @@ -275,6 +369,13 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps)
+
+ {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + {error && ( @@ -529,6 +639,18 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) )} + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="intrastat-template.csv" + onImport={handleMockImport} + /> + {/* ── Product Dialog ────────────────────────────────────── */} diff --git a/components/extensions/export/VatMonitorWorkspace.tsx b/components/extensions/export/VatMonitorWorkspace.tsx index 11747bcc..2cccc7e3 100644 --- a/components/extensions/export/VatMonitorWorkspace.tsx +++ b/components/extensions/export/VatMonitorWorkspace.tsx @@ -2,7 +2,11 @@ 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' @@ -15,7 +19,7 @@ import { } from '@/components/ui/table' import { AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, - ArrowUp, ArrowDown, Minus, BarChart3, + ArrowUp, ArrowDown, Minus, BarChart3, FlaskConical, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -120,11 +124,102 @@ const REVENUE_CARDS: { key: keyof Omit; label: // Box display order (only show relevant ones) const DISPLAY_BOX_ORDER = ['05', '10', '11', '12', '35', '36', '38', '39', '40', '48', '49'] +// ── Mock Data Config ────────────────────────────────────────── + +const MOCK_CSV_FIELDS: CsvFieldDef[] = [ + { key: 'boxNumber', label: 'Ruta', required: true }, + { key: 'label', label: 'Beskrivning', required: true }, + { key: 'amount', label: 'Belopp (SEK)', required: true }, + { key: 'accounts', label: 'Konton (kommaseparerade)' }, +] + +const MOCK_CSV_TEMPLATE = `boxNumber;label;amount;accounts +05;Momspliktiga intäkter;500000;3001,3002,3003 +10;Utgående moms 25%;100000;2611 +11;Utgående moms 12%;6000;2621 +12;Utgående moms 6%;3000;2631 +35;Varuförsäljning EU;75000;3305 +36;Tjänsteförsäljning EU;45000;3308 +38;Exportförsäljning;30000;3305 +39;Omvänd skattskyldighet;20000; +40;Inköp varor EU;60000; +48;Ingående moms;65000;2641 +49;Moms att betala;44000;` + +function parseMockCsvRows(rows: Record[]): ReportData { + const boxes: VatBoxData[] = rows.map(r => ({ + boxNumber: r.boxNumber || '', + label: r.label || '', + amount: parseFloat(r.amount || '0') || 0, + accounts: r.accounts ? r.accounts.split(',').map(a => a.trim()) : [], + })) + + // Derive revenue breakdown from box values + const getBox = (num: string) => boxes.find(b => b.boxNumber === num)?.amount || 0 + const domestic = getBox('05') + const euGoods = getBox('35') + const euServices = getBox('36') + const exportGoods = getBox('38') + const exportServices = 0 + const triangular = getBox('39') + const totalRevenue = domestic + euGoods + euServices + exportGoods + exportServices + triangular + + const revenueBreakdown: RevenueBreakdown = { + domestic: { amount: domestic, percentage: totalRevenue > 0 ? Math.round(domestic / totalRevenue * 100) : 0 }, + euGoods: { amount: euGoods, percentage: totalRevenue > 0 ? Math.round(euGoods / totalRevenue * 100) : 0 }, + euServices: { amount: euServices, percentage: totalRevenue > 0 ? Math.round(euServices / totalRevenue * 100) : 0 }, + exportGoods: { amount: exportGoods, percentage: totalRevenue > 0 ? Math.round(exportGoods / totalRevenue * 100) : 0 }, + exportServices: { amount: exportServices, percentage: 0 }, + triangular: { amount: triangular, percentage: totalRevenue > 0 ? Math.round(triangular / totalRevenue * 100) : 0 }, + totalRevenue, + } + + const outputVat25 = getBox('10') + const outputVat12 = getBox('11') + const outputVat6 = getBox('12') + const inputVat = getBox('48') + const netVat = getBox('49') + + return { + period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 }, + boxes, + revenueBreakdown, + vatSummary: { + outputVat25, + outputVat12, + outputVat6, + totalOutputVat: outputVat25 + outputVat12 + outputVat6, + inputVat, + netVat, + isRefund: netVat < 0, + }, + warnings: [], + comparison: null, + } +} + +function validateMockReport(data: unknown): { valid: boolean; error?: string } { + if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } + const obj = data as Record + if (!Array.isArray(obj.boxes)) return { valid: false, error: 'Fältet "boxes" saknas eller är inte en array' } + if (!obj.revenueBreakdown || typeof obj.revenueBreakdown !== 'object') { + return { valid: false, error: 'Fältet "revenueBreakdown" saknas' } + } + if (!obj.vatSummary || typeof obj.vatSummary !== 'object') { + return { valid: false, error: 'Fältet "vatSummary" saknas' } + } + return { valid: true } +} + // ── Component ───────────────────────────────────────────────── export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) { void userId + // Mock data + const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'vat-monitor') + const [importDialogOpen, setImportDialogOpen] = useState(false) + const [year, setYear] = useState(currentYear()) const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('monthly') const [month, setMonth] = useState(currentMonth()) @@ -143,6 +238,12 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) }, []) const fetchReport = useCallback(async () => { + if (isMockActive && mockReport) { + setReport(mockReport) + setIsLoading(false) + return + } + setIsLoading(true) setError(null) @@ -172,12 +273,41 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) } finally { setIsLoading(false) } - }, [year, month, quarter, periodType, compareEnabled]) + }, [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 @@ -190,7 +320,7 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) .filter((b): b is VatBoxData => b !== undefined) }, [report]) - if (isLoading && !report) { + if ((isLoading || mockLoading) && !report) { return } @@ -254,7 +384,15 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) )} -
+
+
+ {/* ── Mock Data Banner ──────────────────────────────── */} + {isMockActive && ( + setImportDialogOpen(true)} + /> + )} + {/* ── Error state ────────────────────────────────────── */} {error && ( @@ -462,6 +609,18 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps)
)} + + {/* ── Mock Data Import Dialog ───────────────────────── */} + + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + csvFields={MOCK_CSV_FIELDS} + parseCsvRows={parseMockCsvRows} + validateReport={validateMockReport} + templateCsvContent={MOCK_CSV_TEMPLATE} + templateFileName="vat-monitor-template.csv" + onImport={handleMockImport} + /> ) } diff --git a/components/extensions/shared/MockDataBanner.tsx b/components/extensions/shared/MockDataBanner.tsx new file mode 100644 index 00000000..17470f1d --- /dev/null +++ b/components/extensions/shared/MockDataBanner.tsx @@ -0,0 +1,49 @@ +'use client' + +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { FlaskConical, X, Replace } from 'lucide-react' + +interface MockDataBannerProps { + importedAt: string | null + onClear: () => void + onReplace: () => void +} + +export default function MockDataBanner({ importedAt, onClear, onReplace }: MockDataBannerProps) { + const formatted = importedAt + ? new Date(importedAt).toLocaleString('sv-SE', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + : null + + return ( + + +
+ +
+

+ Testdata aktivt +

+

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

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

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

+

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

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

Importerar testdata...

+
+ )} + +
+ ) +} diff --git a/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts b/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts index 80f7294f..6cfa91e1 100644 --- a/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts +++ b/extensions/export/eu-sales-list/lib/eu-sales-list-engine.ts @@ -12,7 +12,7 @@ * Deadline: 25th of the month following the reporting period */ -import { isEUCountry } from '@/extensions/export/shared/eu-countries' +import { isEUCountry, toCountryCode } from '@/extensions/export/shared/eu-countries' // ── Types ──────────────────────────────────────────────────── @@ -231,6 +231,9 @@ export function generateECSalesListReport(options: GenerateReportOptions): ECSal // 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 @@ -238,7 +241,7 @@ export function generateECSalesListReport(options: GenerateReportOptions): ECSal aggregation.set(key, { customerVatNumber: vatNumber, customerName: customer.name, - customerCountry: customer.country, + customerCountry: countryCode, customerId: customer.id, goodsAmount: 0, servicesAmount: 0, diff --git a/extensions/export/intrastat/lib/intrastat-engine.ts b/extensions/export/intrastat/lib/intrastat-engine.ts index e5e9296d..81938624 100644 --- a/extensions/export/intrastat/lib/intrastat-engine.ts +++ b/extensions/export/intrastat/lib/intrastat-engine.ts @@ -15,7 +15,7 @@ * Reference: SCB Intrastat guidelines, Combined Nomenclature (CN) */ -import { isEUCountry } from '@/extensions/export/shared/eu-countries' +import { isEUCountry, toCountryCode } from '@/extensions/export/shared/eu-countries' // ── Types ──────────────────────────────────────────────────── @@ -192,6 +192,7 @@ export function generateIntrastatReport(options: IntrastatOptions): IntrastatRep 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) ?? [] @@ -208,10 +209,10 @@ export function generateIntrastatReport(options: IntrastatOptions): IntrastatRep message: `Faktura ${invoice.invoice_number} saknar fakturarader — kan inte tilldela CN-kod.`, }) - const key = buildAggKey('00000000', customer.country, 'SE', defaultTransactionNature, defaultDeliveryTerms) + const key = buildAggKey('00000000', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms) addToAggregation(aggregation, key, { cnCode: '00000000', - partnerCountry: customer.country, + partnerCountry: partnerCountryCode, countryOfOrigin: 'SE', transactionNature: defaultTransactionNature, deliveryTerms: defaultDeliveryTerms, @@ -240,10 +241,10 @@ export function generateIntrastatReport(options: IntrastatOptions): IntrastatRep message: `Faktura ${invoice.invoice_number}, rad "${item.description}" — ingen matchande produkt med CN-kod hittad.`, }) - const key = buildAggKey('00000000', customer.country, 'SE', defaultTransactionNature, defaultDeliveryTerms) + const key = buildAggKey('00000000', partnerCountryCode, 'SE', defaultTransactionNature, defaultDeliveryTerms) addToAggregation(aggregation, key, { cnCode: '00000000', - partnerCountry: customer.country, + partnerCountry: partnerCountryCode, countryOfOrigin: 'SE', transactionNature: defaultTransactionNature, deliveryTerms: defaultDeliveryTerms, @@ -286,10 +287,10 @@ export function generateIntrastatReport(options: IntrastatOptions): IntrastatRep ? product.supplementaryUnit * item.quantity : null - const key = buildAggKey(cnCode, customer.country, origin, defaultTransactionNature, defaultDeliveryTerms) + const key = buildAggKey(cnCode, partnerCountryCode, origin, defaultTransactionNature, defaultDeliveryTerms) addToAggregation(aggregation, key, { cnCode, - partnerCountry: customer.country, + partnerCountry: partnerCountryCode, countryOfOrigin: origin, transactionNature: defaultTransactionNature, deliveryTerms: defaultDeliveryTerms, diff --git a/extensions/export/shared/eu-countries.ts b/extensions/export/shared/eu-countries.ts index 00012531..98fce648 100644 --- a/extensions/export/shared/eu-countries.ts +++ b/extensions/export/shared/eu-countries.ts @@ -58,22 +58,56 @@ export const EU_COUNTRY_CODES_EXCL_SE = EU_COUNTRIES /** All EU country codes including Sweden */ export const EU_COUNTRY_CODES = EU_COUNTRIES.map(c => c.code) -/** Check if a country code is an EU member state (excluding Sweden) */ -export function isEUCountry(countryCode: string): boolean { - return EU_COUNTRY_CODES_EXCL_SE.includes(countryCode.toUpperCase()) +/** + * 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 code is an EU member state (including Sweden) */ -export function isEUCountryIncludingSE(countryCode: string): boolean { - return EU_COUNTRY_CODES.includes(countryCode.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 */ -export function getEUCountry(countryCode: string): EUCountry | undefined { - return EU_COUNTRIES.find(c => c.code === countryCode.toUpperCase()) +/** Get EU country data by ISO code, English name, or Swedish name */ +export function getEUCountry(country: string): EUCountry | undefined { + const upper = country.trim().toUpperCase() + return EU_COUNTRIES.find( + c => c.code === upper || c.name.toUpperCase() === upper || c.nameEn.toUpperCase() === upper + ) } /** Get the VIES VAT prefix for a country (note: Greece uses 'EL' not 'GR') */ export function getVatPrefix(countryCode: string): string | undefined { return getEUCountry(countryCode)?.vatPrefix } + +/** + * Normalize a country value to its ISO 3166-1 alpha-2 code. + * Accepts ISO codes, English names, or Swedish names. + * Returns the input uppercased if no match is found. + */ +export function toCountryCode(country: string): string { + const found = getEUCountry(country) + return found ? found.code : country.trim().toUpperCase() +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 1ad50617..3054b825 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -490,6 +490,15 @@ export const ReportPeriodQuerySchema = z.object({ month: z.coerce.number().int().min(1).max(12).optional(), }) +// ============================================================ +// VAT validation schemas +// ============================================================ + +export const ValidateVatNumberSchema = z.object({ + vat_number: z.string().min(4, 'VAT number must be at least 4 characters'), + customer_id: uuid.optional(), +}) + // ============================================================ // Pagination schemas // ============================================================ diff --git a/lib/currency/__tests__/riksbanken.test.ts b/lib/currency/__tests__/riksbanken.test.ts new file mode 100644 index 00000000..b6f9dba2 --- /dev/null +++ b/lib/currency/__tests__/riksbanken.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + fetchExchangeRate, + fetchMultipleRates, + fetchRateRange, + fetchLatestRate, + convertToSEK, + formatCurrencyAmount, +} from '../riksbanken' + +// Mock logger to suppress output +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +describe('fetchExchangeRate', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns rate 1 for SEK without fetching', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await fetchExchangeRate('SEK') + + expect(result).toEqual({ + currency: 'SEK', + rate: 1, + date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('parses EUR rate from API response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + + const result = await fetchExchangeRate('EUR', new Date('2025-01-15')) + + expect(result).toEqual({ + currency: 'EUR', + rate: 11.42, + date: '2025-01-15', + }) + }) + + it('returns fallback rate on fetch error', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchExchangeRate('EUR') + + expect(result).not.toBeNull() + expect(result!.currency).toBe('EUR') + expect(result!.rate).toBeGreaterThan(0) + }) + + it('tries fallback URL when primary returns non-200', async () => { + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce(new Response('Not Found', { status: 404 })) + .mockResolvedValueOnce( + new Response(JSON.stringify([ + { value: '10.80', date: '2025-01-13' }, + { value: '10.85', date: '2025-01-14' }, + ]), { status: 200 }) + ) + + const result = await fetchExchangeRate('USD', new Date('2025-01-15')) + + expect(result).toEqual({ + currency: 'USD', + rate: 10.85, + date: '2025-01-14', + }) + }) +}) + +describe('fetchMultipleRates', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns Map with all requested currencies', async () => { + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '10.50', date: '2025-01-15' }]), { status: 200 }) + ) + + const result = await fetchMultipleRates(['EUR', 'USD']) + + expect(result.size).toBe(3) // EUR, USD, + always SEK + expect(result.get('SEK')!.rate).toBe(1) + expect(result.get('EUR')!.rate).toBe(11.42) + expect(result.get('USD')!.rate).toBe(10.50) + }) + + it('handles partial failure — returns fallback for failed currencies', async () => { + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + .mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchMultipleRates(['EUR', 'GBP']) + + expect(result.size).toBe(3) + expect(result.get('EUR')!.rate).toBe(11.42) + // GBP gets fallback rate (from the catch in fetchExchangeRate) + expect(result.get('GBP')).toBeDefined() + expect(result.get('GBP')!.rate).toBeGreaterThan(0) + }) + + it('returns only SEK when given empty array', async () => { + const result = await fetchMultipleRates([]) + expect(result.size).toBe(1) + expect(result.get('SEK')!.rate).toBe(1) + }) + + it('handles SEK in the input array without duplicate fetch', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 }) + ) + + const result = await fetchMultipleRates(['SEK', 'EUR']) + + expect(result.size).toBe(2) + expect(result.get('SEK')!.rate).toBe(1) + expect(result.get('EUR')!.rate).toBe(11.42) + }) +}) + +describe('fetchRateRange', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns sorted array of rates', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([ + { value: '11.40', date: '2025-01-13' }, + { value: '11.45', date: '2025-01-15' }, + { value: '11.42', date: '2025-01-14' }, + ]), { status: 200 }) + ) + + const result = await fetchRateRange( + 'EUR', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toHaveLength(3) + expect(result[0].date).toBe('2025-01-13') + expect(result[1].date).toBe('2025-01-14') + expect(result[2].date).toBe('2025-01-15') + }) + + it('returns [rate:1] for SEK', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await fetchRateRange( + 'SEK', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toHaveLength(1) + expect(result[0].rate).toBe(1) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('returns empty array on error', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchRateRange( + 'EUR', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toEqual([]) + }) + + it('returns empty array on non-200 response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response('Not Found', { status: 404 }) + ) + + const result = await fetchRateRange( + 'EUR', + new Date('2025-01-13'), + new Date('2025-01-15') + ) + + expect(result).toEqual([]) + }) +}) + +describe('fetchLatestRate', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns the last item from API response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([ + { value: '11.40', date: '2025-01-13' }, + { value: '11.42', date: '2025-01-14' }, + { value: '11.45', date: '2025-01-15' }, + ]), { status: 200 }) + ) + + const result = await fetchLatestRate('EUR') + + expect(result).toEqual({ + currency: 'EUR', + rate: 11.45, + date: '2025-01-15', + }) + }) + + it('returns rate 1 for SEK', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await fetchLatestRate('SEK') + + expect(result).toEqual({ + currency: 'SEK', + rate: 1, + date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('returns fallback on error', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchLatestRate('EUR') + + expect(result).not.toBeNull() + expect(result!.currency).toBe('EUR') + expect(result!.rate).toBeGreaterThan(0) + }) + + it('returns null on empty API response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([]), { status: 200 }) + ) + + const result = await fetchLatestRate('EUR') + + expect(result).toBeNull() + }) +}) + +describe('convertToSEK', () => { + it('converts amount correctly', () => { + expect(convertToSEK(100, 11.42)).toBe(1142) + }) + + it('handles zero amount', () => { + expect(convertToSEK(0, 11.42)).toBe(0) + }) +}) + +describe('formatCurrencyAmount', () => { + it('formats EUR with symbol prefix', () => { + const result = formatCurrencyAmount(1234.56, 'EUR') + // sv-SE uses non-breaking space as thousands separator + expect(result).toContain('€') + expect(result).toContain('1') + expect(result).toContain('234') + }) + + it('formats SEK with currency suffix', () => { + const result = formatCurrencyAmount(1234.56, 'SEK') + expect(result).toContain('SEK') + }) + + it('formats NOK with currency suffix', () => { + const result = formatCurrencyAmount(100, 'NOK') + expect(result).toContain('NOK') + }) +}) diff --git a/lib/currency/riksbanken.ts b/lib/currency/riksbanken.ts index 8e6d1376..ec922367 100644 --- a/lib/currency/riksbanken.ts +++ b/lib/currency/riksbanken.ts @@ -3,6 +3,16 @@ import type { Currency, ExchangeRate } from '@/types' const log = createLogger('riksbanken') +/** Riksbanken series IDs for each currency */ +const SERIES_IDS: Record = { + SEK: '', + EUR: 'SEKEURPMI', + USD: 'SEKUSDPMI', + GBP: 'SEKGBPPMI', + NOK: 'SEKNOKPMI', + DKK: 'SEKDKKPMI', +} + /** * Fetch exchange rates from Riksbanken API * Uses their public API for daily exchange rates @@ -22,17 +32,7 @@ export async function fetchExchangeRate( const targetDate = date || new Date() const formattedDate = targetDate.toISOString().split('T')[0] - // Riksbanken uses specific series IDs for each currency - const seriesIds: Record = { - SEK: '', - EUR: 'SEKEURPMI', - USD: 'SEKUSDPMI', - GBP: 'SEKGBPPMI', - NOK: 'SEKNOKPMI', - DKK: 'SEKDKKPMI', - } - - const seriesId = seriesIds[currency] + const seriesId = SERIES_IDS[currency] if (!seriesId) { log.error(`Unknown currency: ${currency}`) return null @@ -49,9 +49,16 @@ export async function fetchExchangeRate( next: { revalidate: 3600 }, // Cache for 1 hour }) - if (!response.ok) { - // If no rate for the specific date, try getting the latest available - const fallbackUrl = `https://api.riksbank.se/swea/v1/Observations/${seriesId}` + // 204 = no data for this date (e.g. rate not published yet today) + // Also handle non-ok responses by falling back to a recent date range + if (!response.ok || response.status === 204) { + // Fetch the last 7 days to find the most recent available rate + const to = formattedDate + const fromDate = new Date(targetDate) + fromDate.setDate(fromDate.getDate() - 7) + const from = fromDate.toISOString().split('T')[0] + + const fallbackUrl = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${from}/${to}` const fallbackResponse = await fetch(fallbackUrl, { headers: { Accept: 'application/json', @@ -59,7 +66,7 @@ export async function fetchExchangeRate( next: { revalidate: 3600 }, }) - if (!fallbackResponse.ok) { + if (!fallbackResponse.ok || fallbackResponse.status === 204) { throw new Error(`Failed to fetch exchange rate: ${fallbackResponse.status}`) } @@ -151,3 +158,145 @@ export function formatCurrencyAmount( return `${formatted} ${currency}` } + +/** + * Fetch exchange rates for multiple currencies in parallel. + * Returns a Map with all requested currencies. Individual failures + * use fallback rates so the Map is always fully populated. + * SEK is always included with rate 1. + */ +export async function fetchMultipleRates( + currencies: Currency[], + date?: Date +): Promise> { + const results = new Map() + + // Always include SEK + results.set('SEK', { + currency: 'SEK', + rate: 1, + date: (date || new Date()).toISOString().split('T')[0], + }) + + const nonSek = currencies.filter(c => c !== 'SEK') + if (nonSek.length === 0) return results + + const settled = await Promise.allSettled( + nonSek.map(currency => fetchExchangeRate(currency, date)) + ) + + for (let i = 0; i < nonSek.length; i++) { + const currency = nonSek[i] + const outcome = settled[i] + + if (outcome.status === 'fulfilled' && outcome.value) { + results.set(currency, outcome.value) + } else { + // fetchExchangeRate already returns fallback on error, + // but if it returned null or the promise rejected, use fallback + results.set(currency, getFallbackRate(currency)) + } + } + + return results +} + +/** + * Fetch exchange rates for a currency over a date range. + * Uses the Riksbanken date-range endpoint. Returns a sorted array. + */ +export async function fetchRateRange( + currency: Currency, + fromDate: Date, + toDate: Date +): Promise { + if (currency === 'SEK') { + return [{ + currency: 'SEK', + rate: 1, + date: fromDate.toISOString().split('T')[0], + }] + } + + const seriesId = SERIES_IDS[currency] + if (!seriesId) { + log.error(`Unknown currency: ${currency}`) + return [] + } + + const from = fromDate.toISOString().split('T')[0] + const to = toDate.toISOString().split('T')[0] + + try { + const url = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${from}/${to}` + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + log.error(`Failed to fetch rate range: ${response.status}`) + return [] + } + + const data = await response.json() + if (!Array.isArray(data)) return [] + + return data + .map((item: { date: string; value: string }) => ({ + currency, + rate: parseFloat(item.value), + date: item.date, + })) + .sort((a: ExchangeRate, b: ExchangeRate) => a.date.localeCompare(b.date)) + } catch (error) { + log.error('Error fetching rate range:', error) + return [] + } +} + +/** + * Fetch the latest available exchange rate for a currency. + * Useful when today's rate hasn't been published yet. + */ +export async function fetchLatestRate( + currency: Currency +): Promise { + if (currency === 'SEK') { + return { + currency: 'SEK', + rate: 1, + date: new Date().toISOString().split('T')[0], + } + } + + const seriesId = SERIES_IDS[currency] + if (!seriesId) { + log.error(`Unknown currency: ${currency}`) + return null + } + + try { + const url = `https://api.riksbank.se/swea/v1/Observations/${seriesId}` + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + log.error(`Failed to fetch latest rate: ${response.status}`) + return null + } + + const data = await response.json() + if (!Array.isArray(data) || data.length === 0) return null + + const latest = data[data.length - 1] + return { + currency, + rate: parseFloat(latest.value), + date: latest.date, + } + } catch (error) { + log.error('Error fetching latest rate:', error) + return getFallbackRate(currency) + } +} diff --git a/lib/extensions/use-mock-data.ts b/lib/extensions/use-mock-data.ts new file mode 100644 index 00000000..db01cd0e --- /dev/null +++ b/lib/extensions/use-mock-data.ts @@ -0,0 +1,85 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useExtensionData } from './use-extension-data' + +interface MockMeta { + importedAt: string + source: 'csv' | 'json' + fileName: string + rowCount: number +} + +interface UseMockDataResult { + mockReport: T | null + isMockActive: boolean + isLoading: boolean + importedAt: string | null + meta: MockMeta | null + saveMockData: (report: T, meta: Omit) => Promise + clearMockData: () => Promise +} + +export function useMockData(sector: string, slug: string): UseMockDataResult { + const { getByKey, save, remove, isLoading } = useExtensionData(sector, slug) + + const [mockReport, setMockReport] = useState(null) + const [isMockActive, setIsMockActive] = useState(false) + const [meta, setMeta] = useState(null) + + // Read mock state from extension data on load + useEffect(() => { + if (isLoading) return + + const enabledRecord = getByKey('mock:enabled') + const reportRecord = getByKey('mock:report') + const metaRecord = getByKey('mock:meta') + + if (enabledRecord && (enabledRecord.value as { enabled?: boolean }).enabled && reportRecord) { + setIsMockActive(true) + setMockReport(reportRecord.value as T) + if (metaRecord) { + setMeta(metaRecord.value as unknown as MockMeta) + } + } else { + setIsMockActive(false) + setMockReport(null) + setMeta(null) + } + }, [isLoading, getByKey]) + + const saveMockData = useCallback(async (report: T, metaInput: Omit) => { + const fullMeta: MockMeta = { + ...metaInput, + importedAt: new Date().toISOString(), + } + + await save('mock:enabled', { enabled: true }) + await save('mock:report', report as unknown as Record) + await save('mock:meta', fullMeta as unknown as Record) + + setIsMockActive(true) + setMockReport(report) + setMeta(fullMeta) + }, [save]) + + const clearMockData = useCallback(async () => { + await remove('mock:enabled') + await remove('mock:report') + await remove('mock:meta') + + setIsMockActive(false) + setMockReport(null) + setMeta(null) + }, [remove]) + + return { + mockReport, + isMockActive, + isLoading, + importedAt: meta?.importedAt ?? null, + meta, + saveMockData, + clearMockData, + } +} diff --git a/lib/vat/__tests__/vies-client.test.ts b/lib/vat/__tests__/vies-client.test.ts new file mode 100644 index 00000000..bab2e42f --- /dev/null +++ b/lib/vat/__tests__/vies-client.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { parseVatNumber, validateVatFormat, validateVatNumber } from '../vies-client' + +// Mock logger to suppress output +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +describe('parseVatNumber', () => { + it('parses a DE VAT number', () => { + const result = parseVatNumber('DE123456789') + expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' }) + }) + + it('parses a SE VAT number', () => { + const result = parseVatNumber('SE123456789012') + expect(result).toEqual({ viesPrefix: 'SE', vatNumber: '123456789012' }) + }) + + it('maps GR to EL for Greece', () => { + const result = parseVatNumber('GR123456789') + expect(result).toEqual({ viesPrefix: 'EL', vatNumber: '123456789' }) + }) + + it('accepts EL prefix directly', () => { + const result = parseVatNumber('EL123456789') + expect(result).toEqual({ viesPrefix: 'EL', vatNumber: '123456789' }) + }) + + it('strips whitespace', () => { + const result = parseVatNumber('DE 123 456 789') + expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' }) + }) + + it('converts to uppercase', () => { + const result = parseVatNumber('de123456789') + expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' }) + }) + + it('rejects non-EU country prefix', () => { + expect(parseVatNumber('US123456789')).toBeNull() + }) + + it('rejects too-short input', () => { + expect(parseVatNumber('DE')).toBeNull() + }) + + it('parses FR VAT number with letters', () => { + const result = parseVatNumber('FRXX999999999') + expect(result).toEqual({ viesPrefix: 'FR', vatNumber: 'XX999999999' }) + }) +}) + +describe('validateVatFormat', () => { + it('validates DE format (9 digits)', () => { + expect(validateVatFormat('DE', '123456789')).toBe(true) + expect(validateVatFormat('DE', '12345678')).toBe(false) + expect(validateVatFormat('DE', '1234567890')).toBe(false) + }) + + it('validates SE format (12 digits)', () => { + expect(validateVatFormat('SE', '123456789012')).toBe(true) + expect(validateVatFormat('SE', '12345678901')).toBe(false) + }) + + it('validates EL (Greece) format (9 digits)', () => { + expect(validateVatFormat('EL', '123456789')).toBe(true) + expect(validateVatFormat('EL', '12345678')).toBe(false) + }) + + it('validates AT format (U + 8 digits)', () => { + expect(validateVatFormat('AT', 'U12345678')).toBe(true) + expect(validateVatFormat('AT', '12345678')).toBe(false) + }) + + it('validates NL format (9 digits + B + 2 digits)', () => { + expect(validateVatFormat('NL', '123456789B12')).toBe(true) + expect(validateVatFormat('NL', '123456789A12')).toBe(false) + }) + + it('validates FR format (2 alphanums + 9 digits)', () => { + expect(validateVatFormat('FR', 'XX999999999')).toBe(true) + expect(validateVatFormat('FR', '9999999999')).toBe(false) // only 10 chars + }) + + it('returns false for unknown prefix', () => { + expect(validateVatFormat('XX', '123456789')).toBe(false) + }) +}) + +describe('validateVatNumber', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns error for non-EU prefix', async () => { + const result = await validateVatNumber('US123456789') + expect(result.valid).toBe(false) + expect(result.error).toContain('non-EU') + }) + + it('returns error for invalid format without calling VIES', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + const result = await validateVatNumber('DE12345') // too short for DE + expect(result.valid).toBe(false) + expect(result.error).toContain('format') + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('returns valid result from VIES API', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ + isValid: true, + name: 'Test Company GmbH', + address: 'Berlin, Germany', + }), { status: 200 }) + ) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(true) + expect(result.name).toBe('Test Company GmbH') + expect(result.address).toBe('Berlin, Germany') + expect(result.country_code).toBe('DE') + expect(result.vat_number).toBe('DE123456789') + }) + + it('returns invalid result from VIES API', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ isValid: false }), { status: 200 }) + ) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(false) + expect(result.country_code).toBe('DE') + }) + + it('handles VIES service unavailable (non-200)', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response('Service Unavailable', { status: 503 }) + ) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(false) + expect(result.error).toContain('unavailable') + }) + + it('handles network error gracefully', async () => { + vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + const result = await validateVatNumber('DE123456789') + expect(result.valid).toBe(false) + expect(result.error).toContain('unavailable') + }) + + it('handles GR→EL mapping in API call', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ isValid: true }), { status: 200 }) + ) + + await validateVatNumber('GR123456789') + + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/ms/EL/vat/'), + expect.any(Object) + ) + }) +}) diff --git a/lib/vat/vies-client.ts b/lib/vat/vies-client.ts new file mode 100644 index 00000000..eabf74ef --- /dev/null +++ b/lib/vat/vies-client.ts @@ -0,0 +1,147 @@ +import { createLogger } from '@/lib/logger' +import { EU_COUNTRIES } from '@/extensions/export/shared/eu-countries' +import type { VatValidationResult } from '@/types' + +const log = createLogger('vies-client') + +const VIES_TIMEOUT_MS = 10_000 + +/** + * VAT format patterns per VIES country prefix. + * Greece uses 'EL' as its VIES prefix (not 'GR'). + */ +const VAT_FORMAT_PATTERNS: Record = { + AT: /^U\d{8}$/, + BE: /^0\d{9}$/, + BG: /^\d{9,10}$/, + CY: /^\d{8}[A-Z]$/, + CZ: /^\d{8,10}$/, + DE: /^\d{9}$/, + DK: /^\d{8}$/, + EE: /^\d{9}$/, + EL: /^\d{9}$/, + ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/, + FI: /^\d{8}$/, + FR: /^[A-Z0-9]{2}\d{9}$/, + HR: /^\d{11}$/, + HU: /^\d{8}$/, + IE: /^[0-9A-Z]{8,9}$/, + IT: /^\d{11}$/, + LT: /^\d{9,12}$/, + LU: /^\d{8}$/, + LV: /^\d{11}$/, + MT: /^\d{8}$/, + NL: /^\d{9}B\d{2}$/, + PL: /^\d{10}$/, + PT: /^\d{9}$/, + RO: /^\d{2,10}$/, + SE: /^\d{12}$/, + SI: /^\d{8}$/, + SK: /^\d{10}$/, +} + +/** Valid VIES prefixes (derived from EU_COUNTRIES vatPrefix values) */ +const VALID_VIES_PREFIXES = new Set(EU_COUNTRIES.map(c => c.vatPrefix)) + +/** + * Parse a raw VAT number into its VIES prefix and numeric part. + * Handles the GR → EL mapping automatically. + * + * @returns `{ viesPrefix, vatNumber }` or `null` if the prefix is not a valid EU country + */ +export function parseVatNumber(raw: string): { viesPrefix: string; vatNumber: string } | null { + const cleaned = raw.replace(/\s/g, '').toUpperCase() + + if (cleaned.length < 3) return null + + const countryPrefix = cleaned.substring(0, 2) + const vatNumber = cleaned.substring(2) + + // Map GR → EL for Greece (VIES uses EL, not GR) + let viesPrefix = countryPrefix + if (countryPrefix === 'GR') { + viesPrefix = 'EL' + } + + if (!VALID_VIES_PREFIXES.has(viesPrefix)) { + return null + } + + return { viesPrefix, vatNumber } +} + +/** + * Validate the format of a VAT number against country-specific patterns. + */ +export function validateVatFormat(viesPrefix: string, vatNumber: string): boolean { + const pattern = VAT_FORMAT_PATTERNS[viesPrefix] + if (!pattern) return false + return pattern.test(vatNumber) +} + +/** + * Validate a VAT number against the EU VIES REST API. + * + * 1. Parses the prefix and number + * 2. Checks format locally + * 3. Calls the VIES REST API with a 10s timeout + * 4. Returns a VatValidationResult + */ +export async function validateVatNumber(rawVatNumber: string): Promise { + const parsed = parseVatNumber(rawVatNumber) + + if (!parsed) { + return { valid: false, error: 'Invalid or non-EU country prefix' } + } + + const { viesPrefix, vatNumber } = parsed + + if (!validateVatFormat(viesPrefix, vatNumber)) { + return { + valid: false, + country_code: viesPrefix, + vat_number: `${viesPrefix}${vatNumber}`, + error: 'Invalid VAT number format', + } + } + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), VIES_TIMEOUT_MS) + + const response = await fetch( + `https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${viesPrefix}/vat/${vatNumber}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + } + ) + + clearTimeout(timeout) + + if (!response.ok) { + return { + valid: false, + error: 'VAT validation service unavailable. Please try again later.', + } + } + + const data = await response.json() + const isValid = data.isValid === true + + return { + valid: isValid, + name: data.name || undefined, + address: data.address || undefined, + country_code: viesPrefix, + vat_number: `${viesPrefix}${vatNumber}`, + } + } catch (error) { + log.error('VIES API error:', error) + return { + valid: false, + error: 'Could not verify VAT number. Service temporarily unavailable.', + } + } +} diff --git a/mock_data/exportmoms-monitor.json b/mock_data/exportmoms-monitor.json new file mode 100644 index 00000000..b12c85b3 --- /dev/null +++ b/mock_data/exportmoms-monitor.json @@ -0,0 +1,56 @@ +{ + "period": { "year": 2025, "month": 12 }, + "boxes": [ + { "boxNumber": "05", "label": "Momspliktiga intakter", "amount": 1850000, "accounts": ["3001", "3002", "3003"] }, + { "boxNumber": "10", "label": "Utgaende moms 25%", "amount": 375000, "accounts": ["2611"] }, + { "boxNumber": "11", "label": "Utgaende moms 12%", "amount": 18000, "accounts": ["2621"] }, + { "boxNumber": "12", "label": "Utgaende moms 6%", "amount": 4500, "accounts": ["2631"] }, + { "boxNumber": "35", "label": "Varuforsal jning till annat EU-land", "amount": 711500, "accounts": ["3305"] }, + { "boxNumber": "36", "label": "Tjansteforsal jning till annat EU-land", "amount": 405000, "accounts": ["3308"] }, + { "boxNumber": "38", "label": "Exportforsal jning utanfor EU", "amount": 230000, "accounts": ["3305"] }, + { "boxNumber": "39", "label": "Omvand skattskyldighet — inkop", "amount": 60000, "accounts": [] }, + { "boxNumber": "40", "label": "Inkop varor fran EU", "amount": 185000, "accounts": ["4515"] }, + { "boxNumber": "48", "label": "Ingaende moms", "amount": 289000, "accounts": ["2641", "2645"] }, + { "boxNumber": "49", "label": "Moms att betala", "amount": 108500, "accounts": [] } + ], + "revenueBreakdown": { + "domestic": { "amount": 1850000, "percentage": 57 }, + "euGoods": { "amount": 711500, "percentage": 22 }, + "euServices": { "amount": 405000, "percentage": 12 }, + "exportGoods": { "amount": 230000, "percentage": 7 }, + "exportServices": { "amount": 0, "percentage": 0 }, + "triangular": { "amount": 54000, "percentage": 2 }, + "totalRevenue": 3250500 + }, + "vatSummary": { + "outputVat25": 375000, + "outputVat12": 18000, + "outputVat6": 4500, + "totalOutputVat": 397500, + "inputVat": 289000, + "netVat": 108500, + "isRefund": false + }, + "warnings": [ + { + "type": "box_mismatch", + "severity": "warning", + "message": "Ruta 39 (omvand skattskyldighet) har 60 000 SEK men inga matchande kontoposter hittades. Kontrollera bokforingen." + }, + { + "type": "high_input_vat_ratio", + "severity": "warning", + "message": "Ingaende moms (289 000 SEK) utgor 73% av utgaende moms. Kontrollera att alla avdrag ar korrekta." + } + ], + "comparison": { + "domestic": { "current": 1850000, "previous": 1620000, "change": 230000, "changePercent": 14 }, + "euGoods": { "current": 711500, "previous": 580000, "change": 131500, "changePercent": 23 }, + "euServices": { "current": 405000, "previous": 390000, "change": 15000, "changePercent": 4 }, + "exportGoods": { "current": 230000, "previous": 310000, "change": -80000, "changePercent": -26 }, + "exportServices": { "current": 0, "previous": 0, "change": 0, "changePercent": null }, + "triangular": { "current": 54000, "previous": 0, "change": 54000, "changePercent": null }, + "totalRevenue": { "current": 3250500, "previous": 2900000, "change": 350500, "changePercent": 12 }, + "netVat": { "current": 108500, "previous": 95200, "change": 13300, "changePercent": 14 } + } +} diff --git a/mock_data/intrastat-generator.json b/mock_data/intrastat-generator.json new file mode 100644 index 00000000..31e115b7 --- /dev/null +++ b/mock_data/intrastat-generator.json @@ -0,0 +1,116 @@ +{ + "period": { "year": 2025, "month": 12 }, + "reporterVatNumber": "SE556677889901", + "reporterName": "Testbolaget AB", + "lines": [ + { + "cnCode": "72163100", + "partnerCountry": "DE", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 245000, + "netMass": 4500, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "DE123456789" + }, + { + "cnCode": "84713000", + "partnerCountry": "FR", + "countryOfOrigin": "CN", + "transactionNature": "11", + "deliveryTerms": "EXW", + "invoicedValue": 128000, + "netMass": 85, + "supplementaryUnit": 40, + "supplementaryUnitType": "st", + "partnerVatId": "FR98765432101" + }, + { + "cnCode": "39269090", + "partnerCountry": "NL", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "FCA", + "invoicedValue": 78000, + "netMass": 620, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "NL456789012B01" + }, + { + "cnCode": "85176200", + "partnerCountry": "FI", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 56000, + "netMass": 12, + "supplementaryUnit": 200, + "supplementaryUnitType": "st", + "partnerVatId": "FI12345678" + }, + { + "cnCode": "72163100", + "partnerCountry": "ES", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "CIF", + "invoicedValue": 132000, + "netMass": 2800, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "ES87654321A" + }, + { + "cnCode": "73064090", + "partnerCountry": "IT", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 67500, + "netMass": 1450, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "IT01234567890" + }, + { + "cnCode": "44079910", + "partnerCountry": "PL", + "countryOfOrigin": "SE", + "transactionNature": "11", + "deliveryTerms": "FCA", + "invoicedValue": 189000, + "netMass": 18600, + "supplementaryUnit": null, + "supplementaryUnitType": null, + "partnerVatId": "PL5678901234" + }, + { + "cnCode": "84713000", + "partnerCountry": "DE", + "countryOfOrigin": "TW", + "transactionNature": "11", + "deliveryTerms": "DAP", + "invoicedValue": 94000, + "netMass": 62, + "supplementaryUnit": 30, + "supplementaryUnitType": "st", + "partnerVatId": "DE123456789" + } + ], + "totals": { + "invoicedValue": 989500, + "netMass": 28129, + "lineCount": 8 + }, + "thresholdStatus": { + "cumulativeValue": 7850000, + "threshold": 9000000, + "isObligated": false, + "percentageUsed": 87 + }, + "warnings": [], + "invoiceCount": 14 +} diff --git a/mock_data/periodisk-sammanstallning.json b/mock_data/periodisk-sammanstallning.json new file mode 100644 index 00000000..152a4b15 --- /dev/null +++ b/mock_data/periodisk-sammanstallning.json @@ -0,0 +1,115 @@ +{ + "period": { "year": 2025, "quarter": 4 }, + "filingType": "quarterly", + "reporterVatNumber": "SE556677889901", + "reporterName": "Testbolaget AB", + "lines": [ + { + "customerVatNumber": "DE123456789", + "customerName": "Berliner Maschinenbau GmbH", + "customerCountry": "DE", + "customerId": "cust-001", + "goodsAmount": 245000, + "servicesAmount": 0, + "triangulationAmount": 0, + "invoiceCount": 4 + }, + { + "customerVatNumber": "FR98765432101", + "customerName": "Lyon Digital SARL", + "customerCountry": "FR", + "customerId": "cust-002", + "goodsAmount": 0, + "servicesAmount": 185000, + "triangulationAmount": 0, + "invoiceCount": 3 + }, + { + "customerVatNumber": "NL456789012B01", + "customerName": "Amsterdam Trading BV", + "customerCountry": "NL", + "customerId": "cust-003", + "goodsAmount": 78000, + "servicesAmount": 42000, + "triangulationAmount": 0, + "invoiceCount": 2 + }, + { + "customerVatNumber": "FI12345678", + "customerName": "Helsinki Solutions Oy", + "customerCountry": "FI", + "customerId": "cust-004", + "goodsAmount": 0, + "servicesAmount": 96000, + "triangulationAmount": 0, + "invoiceCount": 1 + }, + { + "customerVatNumber": "ES87654321A", + "customerName": "Barcelona Componentes SL", + "customerCountry": "ES", + "customerId": "cust-005", + "goodsAmount": 132000, + "servicesAmount": 0, + "triangulationAmount": 54000, + "invoiceCount": 3 + }, + { + "customerVatNumber": "IT01234567890", + "customerName": "Milano Engineering SpA", + "customerCountry": "IT", + "customerId": "cust-006", + "goodsAmount": 67500, + "servicesAmount": 28000, + "triangulationAmount": 0, + "invoiceCount": 2 + }, + { + "customerVatNumber": "PL5678901234", + "customerName": "Warszawa Logistik Sp. z o.o.", + "customerCountry": "PL", + "customerId": "cust-007", + "goodsAmount": 189000, + "servicesAmount": 0, + "triangulationAmount": 0, + "invoiceCount": 5 + }, + { + "customerVatNumber": "DK12345678", + "customerName": "Kobenhavn Konsult ApS", + "customerCountry": "DK", + "customerId": "cust-008", + "goodsAmount": 0, + "servicesAmount": 54000, + "triangulationAmount": 0, + "invoiceCount": 1 + } + ], + "totals": { + "goods": 711500, + "services": 405000, + "triangulation": 54000, + "total": 1170500 + }, + "warnings": [ + { + "type": "missing_vat_validation", + "severity": "warning", + "customerId": "cust-005", + "customerName": "Barcelona Componentes SL", + "message": "VAT-nummer ES87654321A har inte validerats mot VIES. Verifiera innan inlämning." + } + ], + "crossCheck": { + "box35Match": true, + "box35ReportTotal": 711500, + "box35GLTotal": 711500, + "box39Match": false, + "box39ReportTotal": 405000, + "box39GLTotal": 403800 + }, + "invoiceCount": 21, + "customerCount": 8, + "deadline": "2026-02-20", + "daysUntilDeadline": 14 +} diff --git a/mock_data/valutafordringar.json b/mock_data/valutafordringar.json new file mode 100644 index 00000000..6f8af6aa --- /dev/null +++ b/mock_data/valutafordringar.json @@ -0,0 +1,245 @@ +{ + "referenceDate": "2025-12-15", + "exchangeRates": [ + { "currency": "EUR", "rate": 11.4215, "date": "2025-12-15" }, + { "currency": "USD", "rate": 10.3870, "date": "2025-12-15" }, + { "currency": "GBP", "rate": 13.5420, "date": "2025-12-15" }, + { "currency": "NOK", "rate": 0.9845, "date": "2025-12-15" } + ], + "exposureByCurrency": [ + { + "currency": "EUR", + "totalForeignAmount": 48500, + "bookedSekValue": 541350, + "currentSekValue": 553943, + "unrealizedGainLoss": 12593, + "invoiceCount": 4, + "averageBookedRate": 11.1619, + "currentRate": 11.4215 + }, + { + "currency": "USD", + "totalForeignAmount": 72000, + "bookedSekValue": 741600, + "currentSekValue": 747864, + "unrealizedGainLoss": 6264, + "invoiceCount": 3, + "averageBookedRate": 10.3000, + "currentRate": 10.3870 + }, + { + "currency": "GBP", + "totalForeignAmount": 15000, + "bookedSekValue": 199500, + "currentSekValue": 203130, + "unrealizedGainLoss": 3630, + "invoiceCount": 1, + "averageBookedRate": 13.3000, + "currentRate": 13.5420 + }, + { + "currency": "NOK", + "totalForeignAmount": 320000, + "bookedSekValue": 316800, + "currentSekValue": 315040, + "unrealizedGainLoss": -1760, + "invoiceCount": 2, + "averageBookedRate": 0.9900, + "currentRate": 0.9845 + } + ], + "receivables": [ + { + "invoiceId": "inv-1001", + "invoiceNumber": "1001", + "customerName": "Berliner Maschinenbau GmbH", + "customerCountry": "DE", + "currency": "EUR", + "foreignAmount": 22000, + "bookedSekAmount": 245300, + "bookedRate": 11.15, + "currentSekAmount": 251273, + "currentRate": 11.4215, + "unrealizedGainLoss": 5973, + "invoiceDate": "2025-10-15", + "dueDate": "2025-12-15", + "daysOutstanding": 61 + }, + { + "invoiceId": "inv-1008", + "invoiceNumber": "1008", + "customerName": "Lyon Digital SARL", + "customerCountry": "FR", + "currency": "EUR", + "foreignAmount": 14500, + "bookedSekAmount": 163050, + "bookedRate": 11.245, + "currentSekAmount": 165612, + "currentRate": 11.4215, + "unrealizedGainLoss": 2562, + "invoiceDate": "2025-11-05", + "dueDate": "2026-01-05", + "daysOutstanding": 40 + }, + { + "invoiceId": "inv-1012", + "invoiceNumber": "1012", + "customerName": "Amsterdam Trading BV", + "customerCountry": "NL", + "currency": "EUR", + "foreignAmount": 8000, + "bookedSekAmount": 89600, + "bookedRate": 11.20, + "currentSekAmount": 91372, + "currentRate": 11.4215, + "unrealizedGainLoss": 1772, + "invoiceDate": "2025-11-20", + "dueDate": "2025-12-20", + "daysOutstanding": 25 + }, + { + "invoiceId": "inv-1015", + "invoiceNumber": "1015", + "customerName": "Helsinki Solutions Oy", + "customerCountry": "FI", + "currency": "EUR", + "foreignAmount": 4000, + "bookedSekAmount": 43400, + "bookedRate": 10.85, + "currentSekAmount": 45686, + "currentRate": 11.4215, + "unrealizedGainLoss": 2286, + "invoiceDate": "2025-12-01", + "dueDate": "2026-01-01", + "daysOutstanding": 14 + }, + { + "invoiceId": "inv-1003", + "invoiceNumber": "1003", + "customerName": "New York Consulting Inc", + "customerCountry": "US", + "currency": "USD", + "foreignAmount": 35000, + "bookedSekAmount": 360500, + "bookedRate": 10.30, + "currentSekAmount": 363545, + "currentRate": 10.3870, + "unrealizedGainLoss": 3045, + "invoiceDate": "2025-09-28", + "dueDate": "2025-11-28", + "daysOutstanding": 78 + }, + { + "invoiceId": "inv-1009", + "invoiceNumber": "1009", + "customerName": "Chicago Parts LLC", + "customerCountry": "US", + "currency": "USD", + "foreignAmount": 22000, + "bookedSekAmount": 224400, + "bookedRate": 10.20, + "currentSekAmount": 228514, + "currentRate": 10.3870, + "unrealizedGainLoss": 4114, + "invoiceDate": "2025-10-20", + "dueDate": "2025-12-20", + "daysOutstanding": 56 + }, + { + "invoiceId": "inv-1018", + "invoiceNumber": "1018", + "customerName": "San Francisco Tech Corp", + "customerCountry": "US", + "currency": "USD", + "foreignAmount": 15000, + "bookedSekAmount": 156700, + "bookedRate": 10.4467, + "currentSekAmount": 155805, + "currentRate": 10.3870, + "unrealizedGainLoss": -895, + "invoiceDate": "2025-12-05", + "dueDate": "2026-02-05", + "daysOutstanding": 10 + }, + { + "invoiceId": "inv-1010", + "invoiceNumber": "1010", + "customerName": "London Engineering Ltd", + "customerCountry": "GB", + "currency": "GBP", + "foreignAmount": 15000, + "bookedSekAmount": 199500, + "bookedRate": 13.30, + "currentSekAmount": 203130, + "currentRate": 13.5420, + "unrealizedGainLoss": 3630, + "invoiceDate": "2025-11-01", + "dueDate": "2026-01-01", + "daysOutstanding": 44 + }, + { + "invoiceId": "inv-1005", + "invoiceNumber": "1005", + "customerName": "Oslo Shipping AS", + "customerCountry": "NO", + "currency": "NOK", + "foreignAmount": 200000, + "bookedSekAmount": 198000, + "bookedRate": 0.99, + "currentSekAmount": 196900, + "currentRate": 0.9845, + "unrealizedGainLoss": -1100, + "invoiceDate": "2025-10-10", + "dueDate": "2025-12-10", + "daysOutstanding": 66 + }, + { + "invoiceId": "inv-1016", + "invoiceNumber": "1016", + "customerName": "Bergen Industri AS", + "customerCountry": "NO", + "currency": "NOK", + "foreignAmount": 120000, + "bookedSekAmount": 118800, + "bookedRate": 0.99, + "currentSekAmount": 118140, + "currentRate": 0.9845, + "unrealizedGainLoss": -660, + "invoiceDate": "2025-11-22", + "dueDate": "2026-01-22", + "daysOutstanding": 23 + } + ], + "realizedGainLoss": { + "year": 2025, + "gains": 28450, + "losses": 7820, + "net": 20630 + }, + "monthlyTrend": [ + { "month": "2025-01", "realizedGains": 1200, "realizedLosses": 0, "netRealized": 1200 }, + { "month": "2025-02", "realizedGains": 0, "realizedLosses": 890, "netRealized": -890 }, + { "month": "2025-03", "realizedGains": 3400, "realizedLosses": 0, "netRealized": 3400 }, + { "month": "2025-04", "realizedGains": 2100, "realizedLosses": 1250, "netRealized": 850 }, + { "month": "2025-05", "realizedGains": 0, "realizedLosses": 2300, "netRealized": -2300 }, + { "month": "2025-06", "realizedGains": 4500, "realizedLosses": 0, "netRealized": 4500 }, + { "month": "2025-07", "realizedGains": 1850, "realizedLosses": 680, "netRealized": 1170 }, + { "month": "2025-08", "realizedGains": 3200, "realizedLosses": 0, "netRealized": 3200 }, + { "month": "2025-09", "realizedGains": 5600, "realizedLosses": 1400, "netRealized": 4200 }, + { "month": "2025-10", "realizedGains": 2800, "realizedLosses": 0, "netRealized": 2800 }, + { "month": "2025-11", "realizedGains": 1500, "realizedLosses": 1300, "netRealized": 200 }, + { "month": "2025-12", "realizedGains": 2300, "realizedLosses": 0, "netRealized": 2300 } + ], + "revalPreview": { + "totalUnrealizedGainLoss": 20727, + "gains": 22487, + "losses": 1760 + }, + "totals": { + "bookedSekValue": 1799250, + "currentSekValue": 1819977, + "totalUnrealizedGainLoss": 20727, + "receivableCount": 10, + "currencyCount": 4 + } +} diff --git a/scripts/seed-export-data.mjs b/scripts/seed-export-data.mjs new file mode 100644 index 00000000..620e88aa --- /dev/null +++ b/scripts/seed-export-data.mjs @@ -0,0 +1,554 @@ +/** + * Seed script: populate data for export extensions + * + * Creates EU customers, foreign-currency invoices, and journal entries + * so that all 4 export extensions (EU Sales List, VAT Monitor, Intrastat, + * Currency Receivables) have data to display. + * + * Usage: node scripts/seed-export-data.mjs + */ + +import { createClient } from '@supabase/supabase-js' +import 'dotenv/config' + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +// ── Helpers ───────────────────────────────────────────────── + +function round2(n) { + return Math.round(n * 100) / 100 +} + +function randomId() { + return crypto.randomUUID() +} + +function today() { + return new Date().toISOString().split('T')[0] +} + +function daysAgo(n) { + const d = new Date() + d.setDate(d.getDate() - n) + return d.toISOString().split('T')[0] +} + +function daysFromNow(n) { + const d = new Date() + d.setDate(d.getDate() + n) + return d.toISOString().split('T')[0] +} + +// ── Main ──────────────────────────────────────────────────── + +async function main() { + // 1. Find the user + const { data: { users }, error: usersError } = await supabase.auth.admin.listUsers() + if (usersError) { + console.error('Failed to list users:', usersError.message) + process.exit(1) + } + + if (users.length === 0) { + console.error('No users found. Please sign up first.') + process.exit(1) + } + + const user = users[0] + const userId = user.id + console.log(`Using user: ${user.email} (${userId})`) + + // 2. Ensure company settings exist + const { data: company, error: companyError } = await supabase + .from('company_settings') + .select('*') + .eq('user_id', userId) + .single() + + if (companyError || !company) { + console.error('No company_settings found. Complete onboarding first.') + process.exit(1) + } + + console.log(`Company: ${company.company_name || '(unnamed)'}`) + + // 3. Ensure fiscal period exists for current year + const year = new Date().getFullYear() + const periodStart = `${year}-01-01` + const periodEnd = `${year}-12-31` + + let { data: fiscalPeriod } = await supabase + .from('fiscal_periods') + .select('*') + .eq('user_id', userId) + .lte('period_start', today()) + .gte('period_end', today()) + .limit(1) + .single() + + if (!fiscalPeriod) { + console.log(`Creating fiscal period for ${year}...`) + const { data: newPeriod, error: periodError } = await supabase + .from('fiscal_periods') + .insert({ + id: randomId(), + user_id: userId, + name: `Räkenskapsår ${year}`, + period_start: periodStart, + period_end: periodEnd, + is_closed: false, + }) + .select() + .single() + + if (periodError) { + console.error('Failed to create fiscal period:', periodError.message) + process.exit(1) + } + fiscalPeriod = newPeriod + } + + console.log(`Fiscal period: ${fiscalPeriod.name} (${fiscalPeriod.period_start} – ${fiscalPeriod.period_end})`) + + // 4. Create EU customers + const customers = [ + { + id: randomId(), + user_id: userId, + name: 'TechHaus GmbH', + customer_type: 'eu_business', + email: 'billing@techhaus.de', + country: 'Germany', + org_number: 'HRB 12345', + vat_number: 'DE123456789', + vat_number_validated: true, + default_payment_terms: 30, + address_line1: 'Friedrichstraße 42', + postal_code: '10117', + city: 'Berlin', + }, + { + id: randomId(), + user_id: userId, + name: 'Suomen Softworks Oy', + customer_type: 'eu_business', + email: 'invoices@suomensoftworks.fi', + country: 'Finland', + org_number: '1234567-8', + vat_number: 'FI12345678', + vat_number_validated: true, + default_payment_terms: 30, + address_line1: 'Mannerheimintie 10', + postal_code: '00100', + city: 'Helsinki', + }, + { + id: randomId(), + user_id: userId, + name: 'Oranje Logistics B.V.', + customer_type: 'eu_business', + email: 'finance@oranjelogistics.nl', + country: 'Netherlands', + org_number: 'KvK 87654321', + vat_number: 'NL123456789B01', + vat_number_validated: true, + default_payment_terms: 14, + address_line1: 'Keizersgracht 120', + postal_code: '1015 AA', + city: 'Amsterdam', + }, + ] + + console.log('\nCreating 3 EU customers...') + const { error: custError } = await supabase.from('customers').insert(customers) + if (custError) { + console.error('Failed to create customers:', custError.message) + process.exit(1) + } + for (const c of customers) { + console.log(` ✓ ${c.name} (${c.vat_number})`) + } + + // 5. Create invoices + // Mix of: SEK reverse_charge, EUR reverse_charge, USD + const nextNum = company.next_invoice_number || 1 + + const invoices = [ + // Invoice 1: SEK reverse_charge to German customer (for EU Sales List — goods) + { + id: randomId(), + user_id: userId, + customer_id: customers[0].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum).padStart(4, '0')}`, + invoice_date: daysAgo(20), + due_date: daysFromNow(10), + status: 'sent', + currency: 'SEK', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 85000, + subtotal_sek: 85000, + vat_amount: 0, + vat_amount_sek: 0, + total: 85000, + total_sek: 85000, + exchange_rate: null, + moms_ruta: '35', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 2: EUR reverse_charge to Finnish customer (for EU Sales List — services + Currency Receivables) + { + id: randomId(), + user_id: userId, + customer_id: customers[1].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 1).padStart(4, '0')}`, + invoice_date: daysAgo(15), + due_date: daysFromNow(15), + status: 'sent', + currency: 'EUR', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 5000, + subtotal_sek: 57500, + vat_amount: 0, + vat_amount_sek: 0, + total: 5000, + total_sek: 57500, + exchange_rate: 11.50, + exchange_rate_date: daysAgo(15), + moms_ruta: '39', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 3: EUR reverse_charge to Dutch customer — goods (for EU Sales List + Currency Receivables) + { + id: randomId(), + user_id: userId, + customer_id: customers[2].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 2).padStart(4, '0')}`, + invoice_date: daysAgo(10), + due_date: daysFromNow(20), + status: 'sent', + currency: 'EUR', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 12000, + subtotal_sek: 138000, + vat_amount: 0, + vat_amount_sek: 0, + total: 12000, + total_sek: 138000, + exchange_rate: 11.50, + exchange_rate_date: daysAgo(10), + moms_ruta: '35', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 4: USD to Dutch customer — overdue (for Currency Receivables) + { + id: randomId(), + user_id: userId, + customer_id: customers[2].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 3).padStart(4, '0')}`, + invoice_date: daysAgo(45), + due_date: daysAgo(15), + status: 'overdue', + currency: 'USD', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 8500, + subtotal_sek: 91800, + vat_amount: 0, + vat_amount_sek: 0, + total: 8500, + total_sek: 91800, + exchange_rate: 10.80, + exchange_rate_date: daysAgo(45), + moms_ruta: '35', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + // Invoice 5: Paid SEK reverse_charge to Finnish customer (for EU Sales List history) + { + id: randomId(), + user_id: userId, + customer_id: customers[1].id, + invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 4).padStart(4, '0')}`, + invoice_date: daysAgo(60), + due_date: daysAgo(30), + status: 'paid', + currency: 'SEK', + document_type: 'invoice', + vat_treatment: 'reverse_charge', + vat_rate: 0, + subtotal: 42000, + subtotal_sek: 42000, + vat_amount: 0, + vat_amount_sek: 0, + total: 42000, + total_sek: 42000, + exchange_rate: null, + moms_ruta: '39', + reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC', + }, + ] + + console.log('\nCreating 5 invoices...') + const { error: invError } = await supabase.from('invoices').insert(invoices) + if (invError) { + console.error('Failed to create invoices:', invError.message) + process.exit(1) + } + + for (const inv of invoices) { + const cust = customers.find(c => c.id === inv.customer_id) + console.log(` ✓ ${inv.invoice_number} — ${cust.name} — ${inv.currency} ${inv.total} (${inv.status})`) + } + + // 6. Create invoice items + const invoiceItems = [ + // Invoice 1 items (SEK goods to Germany) + { id: randomId(), invoice_id: invoices[0].id, description: 'Industrial sensors batch', quantity: 50, unit: 'st', unit_price: 1200, line_total: 60000, sort_order: 1 }, + { id: randomId(), invoice_id: invoices[0].id, description: 'Installation & calibration', quantity: 10, unit: 'tim', unit_price: 2500, line_total: 25000, sort_order: 2 }, + // Invoice 2 items (EUR services to Finland) + { id: randomId(), invoice_id: invoices[1].id, description: 'Software consulting', quantity: 40, unit: 'tim', unit_price: 125, line_total: 5000, sort_order: 1 }, + // Invoice 3 items (EUR goods to Netherlands) + { id: randomId(), invoice_id: invoices[2].id, description: 'Steel components CN:72163100', quantity: 200, unit: 'st', unit_price: 45, line_total: 9000, sort_order: 1 }, + { id: randomId(), invoice_id: invoices[2].id, description: 'Aluminium fittings CN:76169990', quantity: 100, unit: 'st', unit_price: 30, line_total: 3000, sort_order: 2 }, + // Invoice 4 items (USD goods to Netherlands) + { id: randomId(), invoice_id: invoices[3].id, description: 'Custom machine parts', quantity: 25, unit: 'st', unit_price: 340, line_total: 8500, sort_order: 1 }, + // Invoice 5 items (SEK services to Finland) + { id: randomId(), invoice_id: invoices[4].id, description: 'IT architecture review', quantity: 24, unit: 'tim', unit_price: 1750, line_total: 42000, sort_order: 1 }, + ] + + const { error: itemsError } = await supabase.from('invoice_items').insert(invoiceItems) + if (itemsError) { + console.error('Failed to create invoice items:', itemsError.message) + process.exit(1) + } + console.log(` ✓ ${invoiceItems.length} invoice items created`) + + // Update next_invoice_number + await supabase + .from('company_settings') + .update({ next_invoice_number: nextNum + 5 }) + .eq('user_id', userId) + + // 7. Create journal entries for the invoices (reverse charge: debit 1510, credit 3305/3308) + // These are needed for VAT Monitor and EU Sales List cross-check + const journalEntries = [] + const journalLines = [] + + // Get current max voucher number + const { data: maxVoucher } = await supabase + .from('journal_entries') + .select('voucher_number') + .eq('user_id', userId) + .order('voucher_number', { ascending: false }) + .limit(1) + .single() + + let voucherNum = (maxVoucher?.voucher_number || 0) + 1 + + for (const inv of invoices) { + const entryId = randomId() + const totalSEK = inv.total_sek + + // Determine revenue account: goods = 3305, services = 3308 + // moms_ruta 35 = goods, 39 = services + const revenueAccount = inv.moms_ruta === '35' ? '3305' : '3308' + + journalEntries.push({ + id: entryId, + user_id: userId, + fiscal_period_id: fiscalPeriod.id, + voucher_number: voucherNum++, + voucher_series: 'A', + entry_date: inv.invoice_date, + description: `Faktura ${inv.invoice_number} — ${customers.find(c => c.id === inv.customer_id).name}`, + source_type: 'invoice_created', + source_id: inv.id, + status: 'posted', + committed_at: new Date().toISOString(), + }) + + // Debit 1510 (accounts receivable) + journalLines.push({ + id: randomId(), + journal_entry_id: entryId, + account_number: '1510', + debit_amount: round2(totalSEK), + credit_amount: 0, + currency: inv.currency, + amount_in_currency: inv.currency !== 'SEK' ? inv.total : null, + exchange_rate: inv.exchange_rate, + line_description: `Kundfordran ${inv.invoice_number}`, + sort_order: 1, + }) + + // Credit revenue account (3305 export goods or 3308 EU services) + journalLines.push({ + id: randomId(), + journal_entry_id: entryId, + account_number: revenueAccount, + debit_amount: 0, + credit_amount: round2(totalSEK), + currency: 'SEK', + line_description: `Intäkt ${inv.invoice_number}`, + sort_order: 2, + }) + } + + // Add a payment entry for invoice 5 (paid) — debit 1930, credit 1510 + const paymentEntryId = randomId() + journalEntries.push({ + id: paymentEntryId, + user_id: userId, + fiscal_period_id: fiscalPeriod.id, + voucher_number: voucherNum++, + voucher_series: 'A', + entry_date: daysAgo(25), + description: `Betalning ${invoices[4].invoice_number} — Suomen Softworks Oy`, + source_type: 'invoice_paid', + source_id: invoices[4].id, + status: 'posted', + committed_at: new Date().toISOString(), + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: paymentEntryId, + account_number: '1930', + debit_amount: 42000, + credit_amount: 0, + currency: 'SEK', + line_description: `Inbetalning ${invoices[4].invoice_number}`, + sort_order: 1, + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: paymentEntryId, + account_number: '1510', + debit_amount: 0, + credit_amount: 42000, + currency: 'SEK', + line_description: `Reglering ${invoices[4].invoice_number}`, + sort_order: 2, + }) + + // Add a small FX gain entry (for Currency Receivables realized FX) + const fxEntryId = randomId() + journalEntries.push({ + id: fxEntryId, + user_id: userId, + fiscal_period_id: fiscalPeriod.id, + voucher_number: voucherNum++, + voucher_series: 'A', + entry_date: daysAgo(25), + description: 'Kursdifferens vid betalning', + source_type: 'invoice_paid', + status: 'posted', + committed_at: new Date().toISOString(), + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: fxEntryId, + account_number: '1930', + debit_amount: 450, + credit_amount: 0, + currency: 'SEK', + line_description: 'Valutavinst', + sort_order: 1, + }) + + journalLines.push({ + id: randomId(), + journal_entry_id: fxEntryId, + account_number: '3960', + debit_amount: 0, + credit_amount: 450, + currency: 'SEK', + line_description: 'Valutakursvinst', + sort_order: 2, + }) + + console.log(`\nCreating ${journalEntries.length} journal entries...`) + const { error: jeError } = await supabase.from('journal_entries').insert(journalEntries) + if (jeError) { + console.error('Failed to create journal entries:', jeError.message) + process.exit(1) + } + + const { error: jlError } = await supabase.from('journal_entry_lines').insert(journalLines) + if (jlError) { + console.error('Failed to create journal entry lines:', jlError.message) + console.error('Cleaning up journal entries...') + await supabase.from('journal_entries').delete().in('id', journalEntries.map(e => e.id)) + process.exit(1) + } + + for (const je of journalEntries) { + console.log(` ✓ A${je.voucher_number} — ${je.description}`) + } + + // 8. Add Intrastat product metadata via extension_data + console.log('\nCreating Intrastat product registry...') + const extensionId = 'export/intrastat' + const extensionData = [ + { + user_id: userId, + extension_id: extensionId, + key: 'product:STEEL-COMP', + value: { description: 'Steel components', cn_code: '72163100', net_weight_kg: 2.4, country_of_origin: 'SE' }, + }, + { + user_id: userId, + extension_id: extensionId, + key: 'product:ALU-FIT', + value: { description: 'Aluminium fittings', cn_code: '76169990', net_weight_kg: 0.8, country_of_origin: 'SE' }, + }, + { + user_id: userId, + extension_id: extensionId, + key: 'product:IND-SENSOR', + value: { description: 'Industrial sensors', cn_code: '90318080', net_weight_kg: 0.35, country_of_origin: 'SE' }, + }, + ] + + const { error: extError } = await supabase.from('extension_data').insert(extensionData) + if (extError) { + console.error('Warning: Failed to create extension_data (Intrastat products):', extError.message) + console.log(' (Export extensions will still work, just Intrastat product registry will be empty)') + } else { + for (const ed of extensionData) { + console.log(` ✓ ${ed.key} — ${ed.value.description} (CN: ${ed.value.cn_code})`) + } + } + + // Done + console.log('\n════════════════════════════════════════════════════') + console.log(' Seed data created successfully!') + console.log('════════════════════════════════════════════════════') + console.log('\nYou should now see data in:') + console.log(' • Periodisk sammanställning (EU Sales List) — 3 EU customers, 5 invoices') + console.log(' • Exportmoms-monitor (VAT Monitor) — journal entries on 3305/3308') + console.log(' • Intrastat — goods invoices + product registry') + console.log(' • Valutafordringar (Currency Receivables) — 3 open EUR/USD invoices') + console.log('\nSelect the current month/quarter to see the data.') +} + +main().catch(err => { + console.error('Unexpected error:', err) + process.exit(1) +})