Fixed extensions bugs
This commit is contained in:
@@ -26,7 +26,18 @@
|
||||
"Bash(git commit:*)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"WebFetch(domain:support.fortnox.se)",
|
||||
"Bash(npx vitest run:*)"
|
||||
"Bash(npx vitest run:*)",
|
||||
"WebFetch(domain:www.bjornlunden.se)",
|
||||
"WebFetch(domain:www.scb.se)",
|
||||
"WebFetch(domain:stripe.com)",
|
||||
"WebFetch(domain:tullify.se)",
|
||||
"WebFetch(domain:www.momsens.se)",
|
||||
"WebFetch(domain:www.bokforingstips.se)",
|
||||
"WebFetch(domain:rattsakuten.se)",
|
||||
"WebFetch(domain:www.faronline.se)",
|
||||
"WebFetch(domain:www.worldstopexports.com)",
|
||||
"WebFetch(domain:www.riksbank.se)",
|
||||
"WebFetch(domain:www.avalara.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateCustomerSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api/customers/[id]')
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -95,6 +99,56 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Auto-validate VAT number when it changes on an EU business customer (non-blocking)
|
||||
const isEuBusiness = (body.customer_type || data.customer_type) === 'eu_business'
|
||||
if (body.vat_number !== undefined && isEuBusiness) {
|
||||
try {
|
||||
if (body.vat_number) {
|
||||
const vatResult = await validateVatNumber(body.vat_number)
|
||||
if (vatResult.valid) {
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
data.vat_number_validated = true
|
||||
data.vat_number_validated_at = new Date().toISOString()
|
||||
} else {
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number_validated: false,
|
||||
vat_number_validated_at: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
data.vat_number_validated = false
|
||||
data.vat_number_validated_at = null
|
||||
}
|
||||
} else {
|
||||
// VAT number cleared
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number_validated: false,
|
||||
vat_number_validated_at: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
data.vat_number_validated = false
|
||||
data.vat_number_validated_at = null
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Auto-VIES validation failed on customer update:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateCustomerSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { Customer } from '@/types'
|
||||
|
||||
const log = createLogger('api/customers')
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET() {
|
||||
@@ -68,6 +72,28 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Auto-validate VAT number for EU business customers (non-blocking)
|
||||
if (body.customer_type === 'eu_business' && body.vat_number) {
|
||||
try {
|
||||
const vatResult = await validateVatNumber(body.vat_number)
|
||||
if (vatResult.valid) {
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', data.id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
data.vat_number_validated = true
|
||||
data.vat_number_validated_at = new Date().toISOString()
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Auto-VIES validation failed on customer create:', err)
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'customer.created',
|
||||
payload: { customer: data as Customer, userId: user.id },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') })
|
||||
})
|
||||
})
|
||||
+18
-110
@@ -1,12 +1,9 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { ValidateVatNumberSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
|
||||
/**
|
||||
* Validate EU VAT number using VIES (VAT Information Exchange System)
|
||||
*
|
||||
* The EU provides a SOAP-based API, but we'll use a REST wrapper
|
||||
* In production, you might want to use the official SOAP API or a dedicated service
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -16,113 +13,24 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { vat_number, customer_id } = await request.json()
|
||||
const result = await validateBody(request, ValidateVatNumberSchema)
|
||||
if (!result.success) return result.response
|
||||
const { vat_number, customer_id } = result.data
|
||||
|
||||
if (!vat_number) {
|
||||
return NextResponse.json({ error: 'VAT number is required' }, { status: 400 })
|
||||
}
|
||||
const validation = await validateVatNumber(vat_number)
|
||||
|
||||
// Extract country code and number
|
||||
const countryCode = vat_number.substring(0, 2).toUpperCase()
|
||||
const vatNumber = vat_number.substring(2).replace(/\s/g, '')
|
||||
|
||||
try {
|
||||
// Use the EU VIES validation API
|
||||
// Note: In production, you should use the official SOAP API or a reliable service
|
||||
const response = await fetch(
|
||||
`https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${countryCode}/vat/${vatNumber}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
// If VIES is unavailable, return a soft error
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'VAT validation service unavailable. Please try again later.',
|
||||
// Update customer record if customer_id provided and VAT is valid
|
||||
if (customer_id && validation.valid) {
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number: validation.vat_number,
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const isValid = data.isValid === true
|
||||
|
||||
// Update customer if customer_id provided
|
||||
if (customer_id && isValid) {
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number: vat_number.toUpperCase(),
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', customer_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: isValid,
|
||||
name: data.name || null,
|
||||
address: data.address || null,
|
||||
country_code: countryCode,
|
||||
vat_number: vat_number.toUpperCase(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('VAT validation error:', error)
|
||||
|
||||
// Fallback: basic format validation
|
||||
const isValidFormat = validateVatNumberFormat(countryCode, vatNumber)
|
||||
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: 'Could not verify VAT number. Service temporarily unavailable.',
|
||||
format_valid: isValidFormat,
|
||||
})
|
||||
.eq('id', customer_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic VAT number format validation by country
|
||||
*/
|
||||
function validateVatNumberFormat(countryCode: string, vatNumber: string): boolean {
|
||||
const patterns: Record<string, RegExp> = {
|
||||
AT: /^U\d{8}$/,
|
||||
BE: /^0\d{9}$/,
|
||||
BG: /^\d{9,10}$/,
|
||||
CY: /^\d{8}[A-Z]$/,
|
||||
CZ: /^\d{8,10}$/,
|
||||
DE: /^\d{9}$/,
|
||||
DK: /^\d{8}$/,
|
||||
EE: /^\d{9}$/,
|
||||
EL: /^\d{9}$/, // Greece
|
||||
ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/,
|
||||
FI: /^\d{8}$/,
|
||||
FR: /^[A-Z0-9]{2}\d{9}$/,
|
||||
HR: /^\d{11}$/,
|
||||
HU: /^\d{8}$/,
|
||||
IE: /^[0-9A-Z]{8,9}$/,
|
||||
IT: /^\d{11}$/,
|
||||
LT: /^\d{9,12}$/,
|
||||
LU: /^\d{8}$/,
|
||||
LV: /^\d{11}$/,
|
||||
MT: /^\d{8}$/,
|
||||
NL: /^\d{9}B\d{2}$/,
|
||||
PL: /^\d{10}$/,
|
||||
PT: /^\d{9}$/,
|
||||
RO: /^\d{2,10}$/,
|
||||
SE: /^\d{12}$/,
|
||||
SI: /^\d{8}$/,
|
||||
SK: /^\d{10}$/,
|
||||
}
|
||||
|
||||
const pattern = patterns[countryCode]
|
||||
if (!pattern) {
|
||||
return false
|
||||
}
|
||||
|
||||
return pattern.test(vatNumber)
|
||||
return NextResponse.json(validation)
|
||||
}
|
||||
|
||||
@@ -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<string, string>[]): ReportData {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const receivables: ForeignReceivable[] = rows.map(r => {
|
||||
const foreignAmount = parseFloat(r.foreignAmount || '0') || 0
|
||||
const bookedRate = parseFloat(r.bookedRate || '0') || 0
|
||||
const currentRate = parseFloat(r.currentRate || '0') || bookedRate
|
||||
const bookedSek = parseFloat(r.bookedSekAmount || '0') || Math.round(foreignAmount * bookedRate * 100) / 100
|
||||
const currentSek = parseFloat(r.currentSekAmount || '0') || Math.round(foreignAmount * currentRate * 100) / 100
|
||||
const invoiceDate = r.invoiceDate || today
|
||||
const dueDate = r.dueDate || today
|
||||
const daysOutstanding = Math.max(0, Math.floor((Date.now() - new Date(invoiceDate).getTime()) / 86400000))
|
||||
|
||||
return {
|
||||
invoiceId: r.invoiceNumber || '',
|
||||
invoiceNumber: r.invoiceNumber || '',
|
||||
customerName: r.customerName || '',
|
||||
customerCountry: '',
|
||||
currency: r.currency || 'EUR',
|
||||
foreignAmount,
|
||||
bookedSekAmount: bookedSek,
|
||||
bookedRate,
|
||||
currentSekAmount: currentSek,
|
||||
currentRate,
|
||||
unrealizedGainLoss: Math.round((currentSek - bookedSek) * 100) / 100,
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
daysOutstanding,
|
||||
}
|
||||
})
|
||||
|
||||
// Group by currency for exposure
|
||||
const currencyMap = new Map<string, CurrencyExposure>()
|
||||
for (const r of receivables) {
|
||||
const existing = currencyMap.get(r.currency)
|
||||
if (existing) {
|
||||
existing.totalForeignAmount += r.foreignAmount
|
||||
existing.bookedSekValue += r.bookedSekAmount
|
||||
existing.currentSekValue += r.currentSekAmount
|
||||
existing.unrealizedGainLoss += r.unrealizedGainLoss
|
||||
existing.invoiceCount++
|
||||
} else {
|
||||
currencyMap.set(r.currency, {
|
||||
currency: r.currency,
|
||||
totalForeignAmount: r.foreignAmount,
|
||||
bookedSekValue: r.bookedSekAmount,
|
||||
currentSekValue: r.currentSekAmount,
|
||||
unrealizedGainLoss: r.unrealizedGainLoss,
|
||||
invoiceCount: 1,
|
||||
averageBookedRate: r.bookedRate,
|
||||
currentRate: r.currentRate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const exposureByCurrency = Array.from(currencyMap.values())
|
||||
const totalBookedSek = receivables.reduce((s, r) => s + r.bookedSekAmount, 0)
|
||||
const totalCurrentSek = receivables.reduce((s, r) => s + r.currentSekAmount, 0)
|
||||
const totalUnrealized = Math.round((totalCurrentSek - totalBookedSek) * 100) / 100
|
||||
|
||||
return {
|
||||
referenceDate: today,
|
||||
exchangeRates: exposureByCurrency.map(e => ({ currency: e.currency, rate: e.currentRate, date: today })),
|
||||
exposureByCurrency,
|
||||
receivables,
|
||||
realizedGainLoss: { year: new Date().getFullYear(), gains: 0, losses: 0, net: 0 },
|
||||
monthlyTrend: [],
|
||||
revalPreview: {
|
||||
totalUnrealizedGainLoss: totalUnrealized,
|
||||
gains: Math.max(0, totalUnrealized),
|
||||
losses: Math.abs(Math.min(0, totalUnrealized)),
|
||||
},
|
||||
totals: {
|
||||
bookedSekValue: totalBookedSek,
|
||||
currentSekValue: totalCurrentSek,
|
||||
totalUnrealizedGainLoss: totalUnrealized,
|
||||
receivableCount: receivables.length,
|
||||
currencyCount: exposureByCurrency.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.receivables) && !Array.isArray(obj.exposureByCurrency)) {
|
||||
return { valid: false, error: 'Fältet "receivables" eller "exposureByCurrency" saknas' }
|
||||
}
|
||||
if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'currency-receivables')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
@@ -217,12 +363,27 @@ export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceCompon
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing} className="ml-auto">
|
||||
<RefreshCw className={cn('h-4 w-4 mr-1.5', refreshing && 'animate-spin')} />
|
||||
{refreshing ? 'Uppdaterar...' : 'Uppdatera kurser'}
|
||||
</Button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => setImportDialogOpen(true)}>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-1.5', refreshing && 'animate-spin')} />
|
||||
{refreshing ? 'Uppdaterar...' : 'Uppdatera kurser'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
@@ -454,6 +615,18 @@ export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceCompon
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="currency-receivables-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>[]): ReportData {
|
||||
const lines: ECSalesListLine[] = rows.map(r => ({
|
||||
customerVatNumber: r.customerVatNumber || '',
|
||||
customerName: r.customerName || '',
|
||||
customerCountry: r.customerCountry || '',
|
||||
customerId: r.customerVatNumber || '',
|
||||
goodsAmount: parseFloat(r.goodsAmount || '0') || 0,
|
||||
servicesAmount: parseFloat(r.servicesAmount || '0') || 0,
|
||||
triangulationAmount: parseFloat(r.triangulationAmount || '0') || 0,
|
||||
invoiceCount: parseInt(r.invoiceCount || '1', 10) || 1,
|
||||
}))
|
||||
|
||||
const goods = lines.reduce((s, l) => s + l.goodsAmount, 0)
|
||||
const services = lines.reduce((s, l) => s + l.servicesAmount, 0)
|
||||
const triangulation = lines.reduce((s, l) => s + l.triangulationAmount, 0)
|
||||
const invoiceCount = lines.reduce((s, l) => s + l.invoiceCount, 0)
|
||||
|
||||
return {
|
||||
period: { year: new Date().getFullYear(), quarter: Math.ceil((new Date().getMonth() + 1) / 3) },
|
||||
filingType: 'quarterly',
|
||||
reporterVatNumber: 'SE000000000001',
|
||||
reporterName: 'Testdata',
|
||||
lines,
|
||||
totals: { goods, services, triangulation, total: goods + services + triangulation },
|
||||
warnings: [],
|
||||
crossCheck: null,
|
||||
invoiceCount,
|
||||
customerCount: lines.length,
|
||||
deadline: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
|
||||
daysUntilDeadline: 30,
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' }
|
||||
if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'eu-sales-list')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
// Period selection state
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly')
|
||||
@@ -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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
@@ -309,8 +409,16 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download buttons */}
|
||||
{/* Download + Import buttons */}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -332,6 +440,15 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Error state ────────────────────────────────────── */}
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
@@ -578,6 +695,18 @@ export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="eu-sales-list-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>[]): ReportData {
|
||||
const lines: IntrastatLine[] = rows.map(r => ({
|
||||
cnCode: r.cnCode || '00000000',
|
||||
partnerCountry: r.partnerCountry || '',
|
||||
countryOfOrigin: r.countryOfOrigin || 'SE',
|
||||
transactionNature: r.transactionNature || '11',
|
||||
deliveryTerms: r.deliveryTerms || 'DAP',
|
||||
invoicedValue: parseFloat(r.invoicedValue || '0') || 0,
|
||||
netMass: parseFloat(r.netMass || '0') || 0,
|
||||
supplementaryUnit: null,
|
||||
supplementaryUnitType: null,
|
||||
partnerVatId: r.partnerVatId || '',
|
||||
}))
|
||||
|
||||
const invoicedValue = lines.reduce((s, l) => s + l.invoicedValue, 0)
|
||||
const netMass = lines.reduce((s, l) => s + l.netMass, 0)
|
||||
|
||||
return {
|
||||
period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 },
|
||||
reporterVatNumber: 'SE000000000001',
|
||||
reporterName: 'Testdata',
|
||||
lines,
|
||||
totals: { invoicedValue, netMass, lineCount: lines.length },
|
||||
thresholdStatus: {
|
||||
cumulativeValue: invoicedValue,
|
||||
threshold: 9000000,
|
||||
isObligated: invoicedValue >= 9000000,
|
||||
percentageUsed: Math.round(invoicedValue / 9000000 * 100),
|
||||
},
|
||||
warnings: [],
|
||||
invoiceCount: lines.length,
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' }
|
||||
if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'intrastat')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
|
||||
@@ -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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
@@ -275,6 +369,13 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps)
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleDownload}
|
||||
@@ -286,6 +387,15 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
@@ -529,6 +639,18 @@ export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps)
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="intrastat-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
|
||||
{/* ── Product Dialog ────────────────────────────────────── */}
|
||||
<Dialog open={productDialogOpen} onOpenChange={setProductDialogOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -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<RevenueBreakdown, 'totalRevenue'>; 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<string, string>[]): ReportData {
|
||||
const boxes: VatBoxData[] = rows.map(r => ({
|
||||
boxNumber: r.boxNumber || '',
|
||||
label: r.label || '',
|
||||
amount: parseFloat(r.amount || '0') || 0,
|
||||
accounts: r.accounts ? r.accounts.split(',').map(a => a.trim()) : [],
|
||||
}))
|
||||
|
||||
// Derive revenue breakdown from box values
|
||||
const getBox = (num: string) => boxes.find(b => b.boxNumber === num)?.amount || 0
|
||||
const domestic = getBox('05')
|
||||
const euGoods = getBox('35')
|
||||
const euServices = getBox('36')
|
||||
const exportGoods = getBox('38')
|
||||
const exportServices = 0
|
||||
const triangular = getBox('39')
|
||||
const totalRevenue = domestic + euGoods + euServices + exportGoods + exportServices + triangular
|
||||
|
||||
const revenueBreakdown: RevenueBreakdown = {
|
||||
domestic: { amount: domestic, percentage: totalRevenue > 0 ? Math.round(domestic / totalRevenue * 100) : 0 },
|
||||
euGoods: { amount: euGoods, percentage: totalRevenue > 0 ? Math.round(euGoods / totalRevenue * 100) : 0 },
|
||||
euServices: { amount: euServices, percentage: totalRevenue > 0 ? Math.round(euServices / totalRevenue * 100) : 0 },
|
||||
exportGoods: { amount: exportGoods, percentage: totalRevenue > 0 ? Math.round(exportGoods / totalRevenue * 100) : 0 },
|
||||
exportServices: { amount: exportServices, percentage: 0 },
|
||||
triangular: { amount: triangular, percentage: totalRevenue > 0 ? Math.round(triangular / totalRevenue * 100) : 0 },
|
||||
totalRevenue,
|
||||
}
|
||||
|
||||
const outputVat25 = getBox('10')
|
||||
const outputVat12 = getBox('11')
|
||||
const outputVat6 = getBox('12')
|
||||
const inputVat = getBox('48')
|
||||
const netVat = getBox('49')
|
||||
|
||||
return {
|
||||
period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 },
|
||||
boxes,
|
||||
revenueBreakdown,
|
||||
vatSummary: {
|
||||
outputVat25,
|
||||
outputVat12,
|
||||
outputVat6,
|
||||
totalOutputVat: outputVat25 + outputVat12 + outputVat6,
|
||||
inputVat,
|
||||
netVat,
|
||||
isRefund: netVat < 0,
|
||||
},
|
||||
warnings: [],
|
||||
comparison: null,
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.boxes)) return { valid: false, error: 'Fältet "boxes" saknas eller är inte en array' }
|
||||
if (!obj.revenueBreakdown || typeof obj.revenueBreakdown !== 'object') {
|
||||
return { valid: false, error: 'Fältet "revenueBreakdown" saknas' }
|
||||
}
|
||||
if (!obj.vatSummary || typeof obj.vatSummary !== 'object') {
|
||||
return { valid: false, error: 'Fältet "vatSummary" saknas' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'vat-monitor')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('monthly')
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
@@ -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 <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
@@ -254,7 +384,15 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto">
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant={compareEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
@@ -266,6 +404,15 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Error state ────────────────────────────────────── */}
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
@@ -462,6 +609,18 @@ export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="vat-monitor-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FlaskConical, X, Replace } from 'lucide-react'
|
||||
|
||||
interface MockDataBannerProps {
|
||||
importedAt: string | null
|
||||
onClear: () => void
|
||||
onReplace: () => void
|
||||
}
|
||||
|
||||
export default function MockDataBanner({ importedAt, onClear, onReplace }: MockDataBannerProps) {
|
||||
const formatted = importedAt
|
||||
? new Date(importedAt).toLocaleString('sv-SE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-amber-500 bg-amber-50/50 dark:bg-amber-950/20 dark:border-l-amber-400">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||
Testdata aktivt
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
Rapporten visar importerad testdata istället för bokföringsdata.
|
||||
{formatted && <> Importerat {formatted}.</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={onReplace} className="h-7 text-xs">
|
||||
<Replace className="h-3.5 w-3.5 mr-1" />
|
||||
Ersätt
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClear} className="h-7 text-xs">
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
Rensa
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Upload, FileJson, FileSpreadsheet, Download, AlertCircle, Check,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface CsvFieldDef {
|
||||
key: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface MockDataImportDialogProps<T> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
csvFields: CsvFieldDef[]
|
||||
defaultMappings?: Record<string, string>
|
||||
parseCsvRows: (rows: Record<string, string>[]) => T
|
||||
validateReport: (data: unknown) => { valid: boolean; error?: string }
|
||||
templateCsvContent: string
|
||||
templateFileName: string
|
||||
onImport: (report: T, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => Promise<void>
|
||||
}
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) return { headers: [], rows: [] }
|
||||
|
||||
const separator = lines[0].includes(';') ? ';' : ','
|
||||
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
|
||||
const rows = lines.slice(1).map(line =>
|
||||
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
|
||||
)
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
type Step = 'upload' | 'map-csv' | 'preview-json' | 'importing'
|
||||
|
||||
export default function MockDataImportDialog<T>({
|
||||
open,
|
||||
onOpenChange,
|
||||
csvFields,
|
||||
defaultMappings,
|
||||
parseCsvRows,
|
||||
validateReport,
|
||||
templateCsvContent,
|
||||
templateFileName,
|
||||
onImport,
|
||||
}: MockDataImportDialogProps<T>) {
|
||||
const [step, setStep] = useState<Step>('upload')
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// CSV state
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
|
||||
const [csvRows, setCsvRows] = useState<string[][]>([])
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({})
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
// JSON state
|
||||
const [jsonReport, setJsonReport] = useState<T | null>(null)
|
||||
const [jsonSummary, setJsonSummary] = useState('')
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep('upload')
|
||||
setError(null)
|
||||
setCsvHeaders([])
|
||||
setCsvRows([])
|
||||
setMappings({})
|
||||
setFileName('')
|
||||
setJsonReport(null)
|
||||
setJsonSummary('')
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) reset()
|
||||
onOpenChange(open)
|
||||
}, [onOpenChange, reset])
|
||||
|
||||
const processFile = useCallback((file: File) => {
|
||||
setError(null)
|
||||
setFileName(file.name)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
|
||||
if (file.name.endsWith('.json')) {
|
||||
// JSON path
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const validation = validateReport(parsed)
|
||||
if (!validation.valid) {
|
||||
setError(validation.error || 'Ogiltig JSON-struktur')
|
||||
return
|
||||
}
|
||||
setJsonReport(parsed as T)
|
||||
|
||||
// Build summary
|
||||
const keys = Object.keys(parsed)
|
||||
const lines = Array.isArray(parsed.lines) ? parsed.lines.length
|
||||
: Array.isArray(parsed.receivables) ? parsed.receivables.length
|
||||
: Array.isArray(parsed.boxes) ? parsed.boxes.length
|
||||
: null
|
||||
setJsonSummary(
|
||||
`${keys.length} fält` + (lines !== null ? `, ${lines} rader` : '')
|
||||
)
|
||||
setStep('preview-json')
|
||||
} catch {
|
||||
setError('Kunde inte tolka JSON-filen. Kontrollera formatet.')
|
||||
}
|
||||
} else {
|
||||
// CSV path
|
||||
const parsed = parseCsv(text)
|
||||
if (parsed.headers.length === 0 || parsed.rows.length === 0) {
|
||||
setError('Ingen data hittades i CSV-filen.')
|
||||
return
|
||||
}
|
||||
|
||||
setCsvHeaders(parsed.headers)
|
||||
setCsvRows(parsed.rows)
|
||||
|
||||
// Auto-map columns
|
||||
const autoMappings: Record<string, string> = {}
|
||||
for (const field of csvFields) {
|
||||
const defaultCol = defaultMappings?.[field.key]
|
||||
if (defaultCol && parsed.headers.includes(defaultCol)) {
|
||||
autoMappings[field.key] = defaultCol
|
||||
} else {
|
||||
const match = parsed.headers.find(
|
||||
h => h.toLowerCase() === field.key.toLowerCase() ||
|
||||
h.toLowerCase() === field.label.toLowerCase()
|
||||
)
|
||||
if (match) autoMappings[field.key] = match
|
||||
}
|
||||
}
|
||||
setMappings(autoMappings)
|
||||
setStep('map-csv')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [csvFields, defaultMappings, validateReport])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) processFile(file)
|
||||
}, [processFile])
|
||||
|
||||
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
}, [processFile])
|
||||
|
||||
const handleCsvImport = useCallback(async () => {
|
||||
setStep('importing')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const mappedRows = csvRows.map(row => {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
|
||||
const colIdx = csvHeaders.indexOf(csvCol)
|
||||
if (colIdx >= 0 && row[colIdx]) {
|
||||
obj[fieldKey] = row[colIdx]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}).filter(row => Object.keys(row).length > 0)
|
||||
|
||||
const report = parseCsvRows(mappedRows)
|
||||
await onImport(report, { source: 'csv', fileName, rowCount: mappedRows.length })
|
||||
handleOpenChange(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import misslyckades')
|
||||
setStep('map-csv')
|
||||
}
|
||||
}, [csvRows, csvHeaders, mappings, parseCsvRows, onImport, fileName, handleOpenChange])
|
||||
|
||||
const handleJsonImport = useCallback(async () => {
|
||||
if (!jsonReport) return
|
||||
setStep('importing')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onImport(jsonReport, { source: 'json', fileName, rowCount: 0 })
|
||||
handleOpenChange(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import misslyckades')
|
||||
setStep('preview-json')
|
||||
}
|
||||
}, [jsonReport, onImport, fileName, handleOpenChange])
|
||||
|
||||
const downloadTemplate = useCallback(() => {
|
||||
const blob = new Blob([templateCsvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = templateFileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [templateCsvContent, templateFileName])
|
||||
|
||||
const requiredFieldsMapped = csvFields
|
||||
.filter(f => f.required)
|
||||
.every(f => mappings[f.key])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'upload' && 'Importera testdata'}
|
||||
{step === 'map-csv' && 'Kolumnmappning'}
|
||||
{step === 'preview-json' && 'Förhandsgranska JSON'}
|
||||
{step === 'importing' && 'Importerar...'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Error ─────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/5 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Upload ──────────────────────────────── */}
|
||||
{step === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-10 text-center transition-colors',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm font-medium mb-1">
|
||||
Dra och släpp en fil här
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
CSV (.csv) eller JSON (.json)
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Välj fil
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Ladda ner CSV-mall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Map CSV ─────────────────────────────── */}
|
||||
{step === 'map-csv' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
<span>{fileName} — {csvRows.length} rader</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{csvFields.map(field => (
|
||||
<div key={field.key} className="flex items-center gap-3">
|
||||
<Label className="w-40 text-sm shrink-0">
|
||||
{field.label}{field.required && ' *'}
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings[field.key] ?? '___none___'}
|
||||
onValueChange={(val) => setMappings(prev => ({
|
||||
...prev,
|
||||
[field.key]: val === '___none___' ? '' : val,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Välj kolumn..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="___none___">— Välj kolumn —</SelectItem>
|
||||
{csvHeaders.map(h => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview first 5 rows */}
|
||||
{csvRows.length > 0 && (
|
||||
<div className="rounded-lg border overflow-auto max-h-48">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{csvHeaders.map(h => (
|
||||
<TableHead key={h} className="text-xs whitespace-nowrap">{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{csvRows.slice(0, 5).map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={reset}>Tillbaka</Button>
|
||||
<Button
|
||||
onClick={handleCsvImport}
|
||||
disabled={!requiredFieldsMapped}
|
||||
>
|
||||
Importera {csvRows.length} rader
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Preview JSON ────────────────────────── */}
|
||||
{step === 'preview-json' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileJson className="h-4 w-4" />
|
||||
<span>{fileName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
<span>Giltig JSON — {jsonSummary}</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={reset}>Tillbaka</Button>
|
||||
<Button onClick={handleJsonImport}>
|
||||
Importera testdata
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Importing ───────────────────────────── */}
|
||||
{step === 'importing' && (
|
||||
<div className="py-8 text-center">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Importerar testdata...</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
fetchExchangeRate,
|
||||
fetchMultipleRates,
|
||||
fetchRateRange,
|
||||
fetchLatestRate,
|
||||
convertToSEK,
|
||||
formatCurrencyAmount,
|
||||
} from '../riksbanken'
|
||||
|
||||
// Mock logger to suppress output
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('fetchExchangeRate', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns rate 1 for SEK without fetching', async () => {
|
||||
const fetchSpy = vi.spyOn(global, 'fetch')
|
||||
const result = await fetchExchangeRate('SEK')
|
||||
|
||||
expect(result).toEqual({
|
||||
currency: 'SEK',
|
||||
rate: 1,
|
||||
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
})
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parses EUR rate from API response', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'))
|
||||
|
||||
expect(result).toEqual({
|
||||
currency: 'EUR',
|
||||
rate: 11.42,
|
||||
date: '2025-01-15',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns fallback rate on fetch error', async () => {
|
||||
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fetchExchangeRate('EUR')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.currency).toBe('EUR')
|
||||
expect(result!.rate).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('tries fallback URL when primary returns non-200', async () => {
|
||||
vi.spyOn(global, 'fetch')
|
||||
.mockResolvedValueOnce(new Response('Not Found', { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([
|
||||
{ value: '10.80', date: '2025-01-13' },
|
||||
{ value: '10.85', date: '2025-01-14' },
|
||||
]), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await fetchExchangeRate('USD', new Date('2025-01-15'))
|
||||
|
||||
expect(result).toEqual({
|
||||
currency: 'USD',
|
||||
rate: 10.85,
|
||||
date: '2025-01-14',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMultipleRates', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns Map with all requested currencies', async () => {
|
||||
vi.spyOn(global, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ value: '10.50', date: '2025-01-15' }]), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await fetchMultipleRates(['EUR', 'USD'])
|
||||
|
||||
expect(result.size).toBe(3) // EUR, USD, + always SEK
|
||||
expect(result.get('SEK')!.rate).toBe(1)
|
||||
expect(result.get('EUR')!.rate).toBe(11.42)
|
||||
expect(result.get('USD')!.rate).toBe(10.50)
|
||||
})
|
||||
|
||||
it('handles partial failure — returns fallback for failed currencies', async () => {
|
||||
vi.spyOn(global, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fetchMultipleRates(['EUR', 'GBP'])
|
||||
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.get('EUR')!.rate).toBe(11.42)
|
||||
// GBP gets fallback rate (from the catch in fetchExchangeRate)
|
||||
expect(result.get('GBP')).toBeDefined()
|
||||
expect(result.get('GBP')!.rate).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns only SEK when given empty array', async () => {
|
||||
const result = await fetchMultipleRates([])
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.get('SEK')!.rate).toBe(1)
|
||||
})
|
||||
|
||||
it('handles SEK in the input array without duplicate fetch', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await fetchMultipleRates(['SEK', 'EUR'])
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.get('SEK')!.rate).toBe(1)
|
||||
expect(result.get('EUR')!.rate).toBe(11.42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchRateRange', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns sorted array of rates', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([
|
||||
{ value: '11.40', date: '2025-01-13' },
|
||||
{ value: '11.45', date: '2025-01-15' },
|
||||
{ value: '11.42', date: '2025-01-14' },
|
||||
]), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await fetchRateRange(
|
||||
'EUR',
|
||||
new Date('2025-01-13'),
|
||||
new Date('2025-01-15')
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].date).toBe('2025-01-13')
|
||||
expect(result[1].date).toBe('2025-01-14')
|
||||
expect(result[2].date).toBe('2025-01-15')
|
||||
})
|
||||
|
||||
it('returns [rate:1] for SEK', async () => {
|
||||
const fetchSpy = vi.spyOn(global, 'fetch')
|
||||
const result = await fetchRateRange(
|
||||
'SEK',
|
||||
new Date('2025-01-13'),
|
||||
new Date('2025-01-15')
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].rate).toBe(1)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fetchRateRange(
|
||||
'EUR',
|
||||
new Date('2025-01-13'),
|
||||
new Date('2025-01-15')
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array on non-200 response', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response('Not Found', { status: 404 })
|
||||
)
|
||||
|
||||
const result = await fetchRateRange(
|
||||
'EUR',
|
||||
new Date('2025-01-13'),
|
||||
new Date('2025-01-15')
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchLatestRate', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the last item from API response', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([
|
||||
{ value: '11.40', date: '2025-01-13' },
|
||||
{ value: '11.42', date: '2025-01-14' },
|
||||
{ value: '11.45', date: '2025-01-15' },
|
||||
]), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await fetchLatestRate('EUR')
|
||||
|
||||
expect(result).toEqual({
|
||||
currency: 'EUR',
|
||||
rate: 11.45,
|
||||
date: '2025-01-15',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns rate 1 for SEK', async () => {
|
||||
const fetchSpy = vi.spyOn(global, 'fetch')
|
||||
const result = await fetchLatestRate('SEK')
|
||||
|
||||
expect(result).toEqual({
|
||||
currency: 'SEK',
|
||||
rate: 1,
|
||||
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
})
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns fallback on error', async () => {
|
||||
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fetchLatestRate('EUR')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.currency).toBe('EUR')
|
||||
expect(result!.rate).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns null on empty API response', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([]), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await fetchLatestRate('EUR')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertToSEK', () => {
|
||||
it('converts amount correctly', () => {
|
||||
expect(convertToSEK(100, 11.42)).toBe(1142)
|
||||
})
|
||||
|
||||
it('handles zero amount', () => {
|
||||
expect(convertToSEK(0, 11.42)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCurrencyAmount', () => {
|
||||
it('formats EUR with symbol prefix', () => {
|
||||
const result = formatCurrencyAmount(1234.56, 'EUR')
|
||||
// sv-SE uses non-breaking space as thousands separator
|
||||
expect(result).toContain('€')
|
||||
expect(result).toContain('1')
|
||||
expect(result).toContain('234')
|
||||
})
|
||||
|
||||
it('formats SEK with currency suffix', () => {
|
||||
const result = formatCurrencyAmount(1234.56, 'SEK')
|
||||
expect(result).toContain('SEK')
|
||||
})
|
||||
|
||||
it('formats NOK with currency suffix', () => {
|
||||
const result = formatCurrencyAmount(100, 'NOK')
|
||||
expect(result).toContain('NOK')
|
||||
})
|
||||
})
|
||||
+164
-15
@@ -3,6 +3,16 @@ import type { Currency, ExchangeRate } from '@/types'
|
||||
|
||||
const log = createLogger('riksbanken')
|
||||
|
||||
/** Riksbanken series IDs for each currency */
|
||||
const SERIES_IDS: Record<Currency, string> = {
|
||||
SEK: '',
|
||||
EUR: 'SEKEURPMI',
|
||||
USD: 'SEKUSDPMI',
|
||||
GBP: 'SEKGBPPMI',
|
||||
NOK: 'SEKNOKPMI',
|
||||
DKK: 'SEKDKKPMI',
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch exchange rates from Riksbanken API
|
||||
* Uses their public API for daily exchange rates
|
||||
@@ -22,17 +32,7 @@ export async function fetchExchangeRate(
|
||||
const targetDate = date || new Date()
|
||||
const formattedDate = targetDate.toISOString().split('T')[0]
|
||||
|
||||
// Riksbanken uses specific series IDs for each currency
|
||||
const seriesIds: Record<Currency, string> = {
|
||||
SEK: '',
|
||||
EUR: 'SEKEURPMI',
|
||||
USD: 'SEKUSDPMI',
|
||||
GBP: 'SEKGBPPMI',
|
||||
NOK: 'SEKNOKPMI',
|
||||
DKK: 'SEKDKKPMI',
|
||||
}
|
||||
|
||||
const seriesId = seriesIds[currency]
|
||||
const seriesId = SERIES_IDS[currency]
|
||||
if (!seriesId) {
|
||||
log.error(`Unknown currency: ${currency}`)
|
||||
return null
|
||||
@@ -49,9 +49,16 @@ export async function fetchExchangeRate(
|
||||
next: { revalidate: 3600 }, // Cache for 1 hour
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// If no rate for the specific date, try getting the latest available
|
||||
const fallbackUrl = `https://api.riksbank.se/swea/v1/Observations/${seriesId}`
|
||||
// 204 = no data for this date (e.g. rate not published yet today)
|
||||
// Also handle non-ok responses by falling back to a recent date range
|
||||
if (!response.ok || response.status === 204) {
|
||||
// Fetch the last 7 days to find the most recent available rate
|
||||
const to = formattedDate
|
||||
const fromDate = new Date(targetDate)
|
||||
fromDate.setDate(fromDate.getDate() - 7)
|
||||
const from = fromDate.toISOString().split('T')[0]
|
||||
|
||||
const fallbackUrl = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${from}/${to}`
|
||||
const fallbackResponse = await fetch(fallbackUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -59,7 +66,7 @@ export async function fetchExchangeRate(
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
if (!fallbackResponse.ok || fallbackResponse.status === 204) {
|
||||
throw new Error(`Failed to fetch exchange rate: ${fallbackResponse.status}`)
|
||||
}
|
||||
|
||||
@@ -151,3 +158,145 @@ export function formatCurrencyAmount(
|
||||
|
||||
return `${formatted} ${currency}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch exchange rates for multiple currencies in parallel.
|
||||
* Returns a Map with all requested currencies. Individual failures
|
||||
* use fallback rates so the Map is always fully populated.
|
||||
* SEK is always included with rate 1.
|
||||
*/
|
||||
export async function fetchMultipleRates(
|
||||
currencies: Currency[],
|
||||
date?: Date
|
||||
): Promise<Map<Currency, ExchangeRate>> {
|
||||
const results = new Map<Currency, ExchangeRate>()
|
||||
|
||||
// Always include SEK
|
||||
results.set('SEK', {
|
||||
currency: 'SEK',
|
||||
rate: 1,
|
||||
date: (date || new Date()).toISOString().split('T')[0],
|
||||
})
|
||||
|
||||
const nonSek = currencies.filter(c => c !== 'SEK')
|
||||
if (nonSek.length === 0) return results
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
nonSek.map(currency => fetchExchangeRate(currency, date))
|
||||
)
|
||||
|
||||
for (let i = 0; i < nonSek.length; i++) {
|
||||
const currency = nonSek[i]
|
||||
const outcome = settled[i]
|
||||
|
||||
if (outcome.status === 'fulfilled' && outcome.value) {
|
||||
results.set(currency, outcome.value)
|
||||
} else {
|
||||
// fetchExchangeRate already returns fallback on error,
|
||||
// but if it returned null or the promise rejected, use fallback
|
||||
results.set(currency, getFallbackRate(currency))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch exchange rates for a currency over a date range.
|
||||
* Uses the Riksbanken date-range endpoint. Returns a sorted array.
|
||||
*/
|
||||
export async function fetchRateRange(
|
||||
currency: Currency,
|
||||
fromDate: Date,
|
||||
toDate: Date
|
||||
): Promise<ExchangeRate[]> {
|
||||
if (currency === 'SEK') {
|
||||
return [{
|
||||
currency: 'SEK',
|
||||
rate: 1,
|
||||
date: fromDate.toISOString().split('T')[0],
|
||||
}]
|
||||
}
|
||||
|
||||
const seriesId = SERIES_IDS[currency]
|
||||
if (!seriesId) {
|
||||
log.error(`Unknown currency: ${currency}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const from = fromDate.toISOString().split('T')[0]
|
||||
const to = toDate.toISOString().split('T')[0]
|
||||
|
||||
try {
|
||||
const url = `https://api.riksbank.se/swea/v1/Observations/${seriesId}/${from}/${to}`
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.error(`Failed to fetch rate range: ${response.status}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
return data
|
||||
.map((item: { date: string; value: string }) => ({
|
||||
currency,
|
||||
rate: parseFloat(item.value),
|
||||
date: item.date,
|
||||
}))
|
||||
.sort((a: ExchangeRate, b: ExchangeRate) => a.date.localeCompare(b.date))
|
||||
} catch (error) {
|
||||
log.error('Error fetching rate range:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest available exchange rate for a currency.
|
||||
* Useful when today's rate hasn't been published yet.
|
||||
*/
|
||||
export async function fetchLatestRate(
|
||||
currency: Currency
|
||||
): Promise<ExchangeRate | null> {
|
||||
if (currency === 'SEK') {
|
||||
return {
|
||||
currency: 'SEK',
|
||||
rate: 1,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
}
|
||||
}
|
||||
|
||||
const seriesId = SERIES_IDS[currency]
|
||||
if (!seriesId) {
|
||||
log.error(`Unknown currency: ${currency}`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.riksbank.se/swea/v1/Observations/${seriesId}`
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.error(`Failed to fetch latest rate: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (!Array.isArray(data) || data.length === 0) return null
|
||||
|
||||
const latest = data[data.length - 1]
|
||||
return {
|
||||
currency,
|
||||
rate: parseFloat(latest.value),
|
||||
date: latest.date,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error fetching latest rate:', error)
|
||||
return getFallbackRate(currency)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useExtensionData } from './use-extension-data'
|
||||
|
||||
interface MockMeta {
|
||||
importedAt: string
|
||||
source: 'csv' | 'json'
|
||||
fileName: string
|
||||
rowCount: number
|
||||
}
|
||||
|
||||
interface UseMockDataResult<T> {
|
||||
mockReport: T | null
|
||||
isMockActive: boolean
|
||||
isLoading: boolean
|
||||
importedAt: string | null
|
||||
meta: MockMeta | null
|
||||
saveMockData: (report: T, meta: Omit<MockMeta, 'importedAt'>) => Promise<void>
|
||||
clearMockData: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useMockData<T>(sector: string, slug: string): UseMockDataResult<T> {
|
||||
const { getByKey, save, remove, isLoading } = useExtensionData(sector, slug)
|
||||
|
||||
const [mockReport, setMockReport] = useState<T | null>(null)
|
||||
const [isMockActive, setIsMockActive] = useState(false)
|
||||
const [meta, setMeta] = useState<MockMeta | null>(null)
|
||||
|
||||
// Read mock state from extension data on load
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
|
||||
const enabledRecord = getByKey('mock:enabled')
|
||||
const reportRecord = getByKey('mock:report')
|
||||
const metaRecord = getByKey('mock:meta')
|
||||
|
||||
if (enabledRecord && (enabledRecord.value as { enabled?: boolean }).enabled && reportRecord) {
|
||||
setIsMockActive(true)
|
||||
setMockReport(reportRecord.value as T)
|
||||
if (metaRecord) {
|
||||
setMeta(metaRecord.value as unknown as MockMeta)
|
||||
}
|
||||
} else {
|
||||
setIsMockActive(false)
|
||||
setMockReport(null)
|
||||
setMeta(null)
|
||||
}
|
||||
}, [isLoading, getByKey])
|
||||
|
||||
const saveMockData = useCallback(async (report: T, metaInput: Omit<MockMeta, 'importedAt'>) => {
|
||||
const fullMeta: MockMeta = {
|
||||
...metaInput,
|
||||
importedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
await save('mock:enabled', { enabled: true })
|
||||
await save('mock:report', report as unknown as Record<string, unknown>)
|
||||
await save('mock:meta', fullMeta as unknown as Record<string, unknown>)
|
||||
|
||||
setIsMockActive(true)
|
||||
setMockReport(report)
|
||||
setMeta(fullMeta)
|
||||
}, [save])
|
||||
|
||||
const clearMockData = useCallback(async () => {
|
||||
await remove('mock:enabled')
|
||||
await remove('mock:report')
|
||||
await remove('mock:meta')
|
||||
|
||||
setIsMockActive(false)
|
||||
setMockReport(null)
|
||||
setMeta(null)
|
||||
}, [remove])
|
||||
|
||||
return {
|
||||
mockReport,
|
||||
isMockActive,
|
||||
isLoading,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
meta,
|
||||
saveMockData,
|
||||
clearMockData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { parseVatNumber, validateVatFormat, validateVatNumber } from '../vies-client'
|
||||
|
||||
// Mock logger to suppress output
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('parseVatNumber', () => {
|
||||
it('parses a DE VAT number', () => {
|
||||
const result = parseVatNumber('DE123456789')
|
||||
expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' })
|
||||
})
|
||||
|
||||
it('parses a SE VAT number', () => {
|
||||
const result = parseVatNumber('SE123456789012')
|
||||
expect(result).toEqual({ viesPrefix: 'SE', vatNumber: '123456789012' })
|
||||
})
|
||||
|
||||
it('maps GR to EL for Greece', () => {
|
||||
const result = parseVatNumber('GR123456789')
|
||||
expect(result).toEqual({ viesPrefix: 'EL', vatNumber: '123456789' })
|
||||
})
|
||||
|
||||
it('accepts EL prefix directly', () => {
|
||||
const result = parseVatNumber('EL123456789')
|
||||
expect(result).toEqual({ viesPrefix: 'EL', vatNumber: '123456789' })
|
||||
})
|
||||
|
||||
it('strips whitespace', () => {
|
||||
const result = parseVatNumber('DE 123 456 789')
|
||||
expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' })
|
||||
})
|
||||
|
||||
it('converts to uppercase', () => {
|
||||
const result = parseVatNumber('de123456789')
|
||||
expect(result).toEqual({ viesPrefix: 'DE', vatNumber: '123456789' })
|
||||
})
|
||||
|
||||
it('rejects non-EU country prefix', () => {
|
||||
expect(parseVatNumber('US123456789')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects too-short input', () => {
|
||||
expect(parseVatNumber('DE')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses FR VAT number with letters', () => {
|
||||
const result = parseVatNumber('FRXX999999999')
|
||||
expect(result).toEqual({ viesPrefix: 'FR', vatNumber: 'XX999999999' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateVatFormat', () => {
|
||||
it('validates DE format (9 digits)', () => {
|
||||
expect(validateVatFormat('DE', '123456789')).toBe(true)
|
||||
expect(validateVatFormat('DE', '12345678')).toBe(false)
|
||||
expect(validateVatFormat('DE', '1234567890')).toBe(false)
|
||||
})
|
||||
|
||||
it('validates SE format (12 digits)', () => {
|
||||
expect(validateVatFormat('SE', '123456789012')).toBe(true)
|
||||
expect(validateVatFormat('SE', '12345678901')).toBe(false)
|
||||
})
|
||||
|
||||
it('validates EL (Greece) format (9 digits)', () => {
|
||||
expect(validateVatFormat('EL', '123456789')).toBe(true)
|
||||
expect(validateVatFormat('EL', '12345678')).toBe(false)
|
||||
})
|
||||
|
||||
it('validates AT format (U + 8 digits)', () => {
|
||||
expect(validateVatFormat('AT', 'U12345678')).toBe(true)
|
||||
expect(validateVatFormat('AT', '12345678')).toBe(false)
|
||||
})
|
||||
|
||||
it('validates NL format (9 digits + B + 2 digits)', () => {
|
||||
expect(validateVatFormat('NL', '123456789B12')).toBe(true)
|
||||
expect(validateVatFormat('NL', '123456789A12')).toBe(false)
|
||||
})
|
||||
|
||||
it('validates FR format (2 alphanums + 9 digits)', () => {
|
||||
expect(validateVatFormat('FR', 'XX999999999')).toBe(true)
|
||||
expect(validateVatFormat('FR', '9999999999')).toBe(false) // only 10 chars
|
||||
})
|
||||
|
||||
it('returns false for unknown prefix', () => {
|
||||
expect(validateVatFormat('XX', '123456789')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateVatNumber', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns error for non-EU prefix', async () => {
|
||||
const result = await validateVatNumber('US123456789')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('non-EU')
|
||||
})
|
||||
|
||||
it('returns error for invalid format without calling VIES', async () => {
|
||||
const fetchSpy = vi.spyOn(global, 'fetch')
|
||||
const result = await validateVatNumber('DE12345') // too short for DE
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('format')
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns valid result from VIES API', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
isValid: true,
|
||||
name: 'Test Company GmbH',
|
||||
address: 'Berlin, Germany',
|
||||
}), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await validateVatNumber('DE123456789')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.name).toBe('Test Company GmbH')
|
||||
expect(result.address).toBe('Berlin, Germany')
|
||||
expect(result.country_code).toBe('DE')
|
||||
expect(result.vat_number).toBe('DE123456789')
|
||||
})
|
||||
|
||||
it('returns invalid result from VIES API', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ isValid: false }), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await validateVatNumber('DE123456789')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.country_code).toBe('DE')
|
||||
})
|
||||
|
||||
it('handles VIES service unavailable (non-200)', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response('Service Unavailable', { status: 503 })
|
||||
)
|
||||
|
||||
const result = await validateVatNumber('DE123456789')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('unavailable')
|
||||
})
|
||||
|
||||
it('handles network error gracefully', async () => {
|
||||
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await validateVatNumber('DE123456789')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('unavailable')
|
||||
})
|
||||
|
||||
it('handles GR→EL mapping in API call', async () => {
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ isValid: true }), { status: 200 })
|
||||
)
|
||||
|
||||
await validateVatNumber('GR123456789')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/ms/EL/vat/'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { EU_COUNTRIES } from '@/extensions/export/shared/eu-countries'
|
||||
import type { VatValidationResult } from '@/types'
|
||||
|
||||
const log = createLogger('vies-client')
|
||||
|
||||
const VIES_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* VAT format patterns per VIES country prefix.
|
||||
* Greece uses 'EL' as its VIES prefix (not 'GR').
|
||||
*/
|
||||
const VAT_FORMAT_PATTERNS: Record<string, RegExp> = {
|
||||
AT: /^U\d{8}$/,
|
||||
BE: /^0\d{9}$/,
|
||||
BG: /^\d{9,10}$/,
|
||||
CY: /^\d{8}[A-Z]$/,
|
||||
CZ: /^\d{8,10}$/,
|
||||
DE: /^\d{9}$/,
|
||||
DK: /^\d{8}$/,
|
||||
EE: /^\d{9}$/,
|
||||
EL: /^\d{9}$/,
|
||||
ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/,
|
||||
FI: /^\d{8}$/,
|
||||
FR: /^[A-Z0-9]{2}\d{9}$/,
|
||||
HR: /^\d{11}$/,
|
||||
HU: /^\d{8}$/,
|
||||
IE: /^[0-9A-Z]{8,9}$/,
|
||||
IT: /^\d{11}$/,
|
||||
LT: /^\d{9,12}$/,
|
||||
LU: /^\d{8}$/,
|
||||
LV: /^\d{11}$/,
|
||||
MT: /^\d{8}$/,
|
||||
NL: /^\d{9}B\d{2}$/,
|
||||
PL: /^\d{10}$/,
|
||||
PT: /^\d{9}$/,
|
||||
RO: /^\d{2,10}$/,
|
||||
SE: /^\d{12}$/,
|
||||
SI: /^\d{8}$/,
|
||||
SK: /^\d{10}$/,
|
||||
}
|
||||
|
||||
/** Valid VIES prefixes (derived from EU_COUNTRIES vatPrefix values) */
|
||||
const VALID_VIES_PREFIXES = new Set(EU_COUNTRIES.map(c => c.vatPrefix))
|
||||
|
||||
/**
|
||||
* Parse a raw VAT number into its VIES prefix and numeric part.
|
||||
* Handles the GR → EL mapping automatically.
|
||||
*
|
||||
* @returns `{ viesPrefix, vatNumber }` or `null` if the prefix is not a valid EU country
|
||||
*/
|
||||
export function parseVatNumber(raw: string): { viesPrefix: string; vatNumber: string } | null {
|
||||
const cleaned = raw.replace(/\s/g, '').toUpperCase()
|
||||
|
||||
if (cleaned.length < 3) return null
|
||||
|
||||
const countryPrefix = cleaned.substring(0, 2)
|
||||
const vatNumber = cleaned.substring(2)
|
||||
|
||||
// Map GR → EL for Greece (VIES uses EL, not GR)
|
||||
let viesPrefix = countryPrefix
|
||||
if (countryPrefix === 'GR') {
|
||||
viesPrefix = 'EL'
|
||||
}
|
||||
|
||||
if (!VALID_VIES_PREFIXES.has(viesPrefix)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { viesPrefix, vatNumber }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the format of a VAT number against country-specific patterns.
|
||||
*/
|
||||
export function validateVatFormat(viesPrefix: string, vatNumber: string): boolean {
|
||||
const pattern = VAT_FORMAT_PATTERNS[viesPrefix]
|
||||
if (!pattern) return false
|
||||
return pattern.test(vatNumber)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a VAT number against the EU VIES REST API.
|
||||
*
|
||||
* 1. Parses the prefix and number
|
||||
* 2. Checks format locally
|
||||
* 3. Calls the VIES REST API with a 10s timeout
|
||||
* 4. Returns a VatValidationResult
|
||||
*/
|
||||
export async function validateVatNumber(rawVatNumber: string): Promise<VatValidationResult> {
|
||||
const parsed = parseVatNumber(rawVatNumber)
|
||||
|
||||
if (!parsed) {
|
||||
return { valid: false, error: 'Invalid or non-EU country prefix' }
|
||||
}
|
||||
|
||||
const { viesPrefix, vatNumber } = parsed
|
||||
|
||||
if (!validateVatFormat(viesPrefix, vatNumber)) {
|
||||
return {
|
||||
valid: false,
|
||||
country_code: viesPrefix,
|
||||
vat_number: `${viesPrefix}${vatNumber}`,
|
||||
error: 'Invalid VAT number format',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), VIES_TIMEOUT_MS)
|
||||
|
||||
const response = await fetch(
|
||||
`https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${viesPrefix}/vat/${vatNumber}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'VAT validation service unavailable. Please try again later.',
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const isValid = data.isValid === true
|
||||
|
||||
return {
|
||||
valid: isValid,
|
||||
name: data.name || undefined,
|
||||
address: data.address || undefined,
|
||||
country_code: viesPrefix,
|
||||
vat_number: `${viesPrefix}${vatNumber}`,
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('VIES API error:', error)
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Could not verify VAT number. Service temporarily unavailable.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"period": { "year": 2025, "month": 12 },
|
||||
"boxes": [
|
||||
{ "boxNumber": "05", "label": "Momspliktiga intakter", "amount": 1850000, "accounts": ["3001", "3002", "3003"] },
|
||||
{ "boxNumber": "10", "label": "Utgaende moms 25%", "amount": 375000, "accounts": ["2611"] },
|
||||
{ "boxNumber": "11", "label": "Utgaende moms 12%", "amount": 18000, "accounts": ["2621"] },
|
||||
{ "boxNumber": "12", "label": "Utgaende moms 6%", "amount": 4500, "accounts": ["2631"] },
|
||||
{ "boxNumber": "35", "label": "Varuforsal jning till annat EU-land", "amount": 711500, "accounts": ["3305"] },
|
||||
{ "boxNumber": "36", "label": "Tjansteforsal jning till annat EU-land", "amount": 405000, "accounts": ["3308"] },
|
||||
{ "boxNumber": "38", "label": "Exportforsal jning utanfor EU", "amount": 230000, "accounts": ["3305"] },
|
||||
{ "boxNumber": "39", "label": "Omvand skattskyldighet — inkop", "amount": 60000, "accounts": [] },
|
||||
{ "boxNumber": "40", "label": "Inkop varor fran EU", "amount": 185000, "accounts": ["4515"] },
|
||||
{ "boxNumber": "48", "label": "Ingaende moms", "amount": 289000, "accounts": ["2641", "2645"] },
|
||||
{ "boxNumber": "49", "label": "Moms att betala", "amount": 108500, "accounts": [] }
|
||||
],
|
||||
"revenueBreakdown": {
|
||||
"domestic": { "amount": 1850000, "percentage": 57 },
|
||||
"euGoods": { "amount": 711500, "percentage": 22 },
|
||||
"euServices": { "amount": 405000, "percentage": 12 },
|
||||
"exportGoods": { "amount": 230000, "percentage": 7 },
|
||||
"exportServices": { "amount": 0, "percentage": 0 },
|
||||
"triangular": { "amount": 54000, "percentage": 2 },
|
||||
"totalRevenue": 3250500
|
||||
},
|
||||
"vatSummary": {
|
||||
"outputVat25": 375000,
|
||||
"outputVat12": 18000,
|
||||
"outputVat6": 4500,
|
||||
"totalOutputVat": 397500,
|
||||
"inputVat": 289000,
|
||||
"netVat": 108500,
|
||||
"isRefund": false
|
||||
},
|
||||
"warnings": [
|
||||
{
|
||||
"type": "box_mismatch",
|
||||
"severity": "warning",
|
||||
"message": "Ruta 39 (omvand skattskyldighet) har 60 000 SEK men inga matchande kontoposter hittades. Kontrollera bokforingen."
|
||||
},
|
||||
{
|
||||
"type": "high_input_vat_ratio",
|
||||
"severity": "warning",
|
||||
"message": "Ingaende moms (289 000 SEK) utgor 73% av utgaende moms. Kontrollera att alla avdrag ar korrekta."
|
||||
}
|
||||
],
|
||||
"comparison": {
|
||||
"domestic": { "current": 1850000, "previous": 1620000, "change": 230000, "changePercent": 14 },
|
||||
"euGoods": { "current": 711500, "previous": 580000, "change": 131500, "changePercent": 23 },
|
||||
"euServices": { "current": 405000, "previous": 390000, "change": 15000, "changePercent": 4 },
|
||||
"exportGoods": { "current": 230000, "previous": 310000, "change": -80000, "changePercent": -26 },
|
||||
"exportServices": { "current": 0, "previous": 0, "change": 0, "changePercent": null },
|
||||
"triangular": { "current": 54000, "previous": 0, "change": 54000, "changePercent": null },
|
||||
"totalRevenue": { "current": 3250500, "previous": 2900000, "change": 350500, "changePercent": 12 },
|
||||
"netVat": { "current": 108500, "previous": 95200, "change": 13300, "changePercent": 14 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"period": { "year": 2025, "month": 12 },
|
||||
"reporterVatNumber": "SE556677889901",
|
||||
"reporterName": "Testbolaget AB",
|
||||
"lines": [
|
||||
{
|
||||
"cnCode": "72163100",
|
||||
"partnerCountry": "DE",
|
||||
"countryOfOrigin": "SE",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "DAP",
|
||||
"invoicedValue": 245000,
|
||||
"netMass": 4500,
|
||||
"supplementaryUnit": null,
|
||||
"supplementaryUnitType": null,
|
||||
"partnerVatId": "DE123456789"
|
||||
},
|
||||
{
|
||||
"cnCode": "84713000",
|
||||
"partnerCountry": "FR",
|
||||
"countryOfOrigin": "CN",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "EXW",
|
||||
"invoicedValue": 128000,
|
||||
"netMass": 85,
|
||||
"supplementaryUnit": 40,
|
||||
"supplementaryUnitType": "st",
|
||||
"partnerVatId": "FR98765432101"
|
||||
},
|
||||
{
|
||||
"cnCode": "39269090",
|
||||
"partnerCountry": "NL",
|
||||
"countryOfOrigin": "SE",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "FCA",
|
||||
"invoicedValue": 78000,
|
||||
"netMass": 620,
|
||||
"supplementaryUnit": null,
|
||||
"supplementaryUnitType": null,
|
||||
"partnerVatId": "NL456789012B01"
|
||||
},
|
||||
{
|
||||
"cnCode": "85176200",
|
||||
"partnerCountry": "FI",
|
||||
"countryOfOrigin": "SE",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "DAP",
|
||||
"invoicedValue": 56000,
|
||||
"netMass": 12,
|
||||
"supplementaryUnit": 200,
|
||||
"supplementaryUnitType": "st",
|
||||
"partnerVatId": "FI12345678"
|
||||
},
|
||||
{
|
||||
"cnCode": "72163100",
|
||||
"partnerCountry": "ES",
|
||||
"countryOfOrigin": "SE",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "CIF",
|
||||
"invoicedValue": 132000,
|
||||
"netMass": 2800,
|
||||
"supplementaryUnit": null,
|
||||
"supplementaryUnitType": null,
|
||||
"partnerVatId": "ES87654321A"
|
||||
},
|
||||
{
|
||||
"cnCode": "73064090",
|
||||
"partnerCountry": "IT",
|
||||
"countryOfOrigin": "SE",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "DAP",
|
||||
"invoicedValue": 67500,
|
||||
"netMass": 1450,
|
||||
"supplementaryUnit": null,
|
||||
"supplementaryUnitType": null,
|
||||
"partnerVatId": "IT01234567890"
|
||||
},
|
||||
{
|
||||
"cnCode": "44079910",
|
||||
"partnerCountry": "PL",
|
||||
"countryOfOrigin": "SE",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "FCA",
|
||||
"invoicedValue": 189000,
|
||||
"netMass": 18600,
|
||||
"supplementaryUnit": null,
|
||||
"supplementaryUnitType": null,
|
||||
"partnerVatId": "PL5678901234"
|
||||
},
|
||||
{
|
||||
"cnCode": "84713000",
|
||||
"partnerCountry": "DE",
|
||||
"countryOfOrigin": "TW",
|
||||
"transactionNature": "11",
|
||||
"deliveryTerms": "DAP",
|
||||
"invoicedValue": 94000,
|
||||
"netMass": 62,
|
||||
"supplementaryUnit": 30,
|
||||
"supplementaryUnitType": "st",
|
||||
"partnerVatId": "DE123456789"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"invoicedValue": 989500,
|
||||
"netMass": 28129,
|
||||
"lineCount": 8
|
||||
},
|
||||
"thresholdStatus": {
|
||||
"cumulativeValue": 7850000,
|
||||
"threshold": 9000000,
|
||||
"isObligated": false,
|
||||
"percentageUsed": 87
|
||||
},
|
||||
"warnings": [],
|
||||
"invoiceCount": 14
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"period": { "year": 2025, "quarter": 4 },
|
||||
"filingType": "quarterly",
|
||||
"reporterVatNumber": "SE556677889901",
|
||||
"reporterName": "Testbolaget AB",
|
||||
"lines": [
|
||||
{
|
||||
"customerVatNumber": "DE123456789",
|
||||
"customerName": "Berliner Maschinenbau GmbH",
|
||||
"customerCountry": "DE",
|
||||
"customerId": "cust-001",
|
||||
"goodsAmount": 245000,
|
||||
"servicesAmount": 0,
|
||||
"triangulationAmount": 0,
|
||||
"invoiceCount": 4
|
||||
},
|
||||
{
|
||||
"customerVatNumber": "FR98765432101",
|
||||
"customerName": "Lyon Digital SARL",
|
||||
"customerCountry": "FR",
|
||||
"customerId": "cust-002",
|
||||
"goodsAmount": 0,
|
||||
"servicesAmount": 185000,
|
||||
"triangulationAmount": 0,
|
||||
"invoiceCount": 3
|
||||
},
|
||||
{
|
||||
"customerVatNumber": "NL456789012B01",
|
||||
"customerName": "Amsterdam Trading BV",
|
||||
"customerCountry": "NL",
|
||||
"customerId": "cust-003",
|
||||
"goodsAmount": 78000,
|
||||
"servicesAmount": 42000,
|
||||
"triangulationAmount": 0,
|
||||
"invoiceCount": 2
|
||||
},
|
||||
{
|
||||
"customerVatNumber": "FI12345678",
|
||||
"customerName": "Helsinki Solutions Oy",
|
||||
"customerCountry": "FI",
|
||||
"customerId": "cust-004",
|
||||
"goodsAmount": 0,
|
||||
"servicesAmount": 96000,
|
||||
"triangulationAmount": 0,
|
||||
"invoiceCount": 1
|
||||
},
|
||||
{
|
||||
"customerVatNumber": "ES87654321A",
|
||||
"customerName": "Barcelona Componentes SL",
|
||||
"customerCountry": "ES",
|
||||
"customerId": "cust-005",
|
||||
"goodsAmount": 132000,
|
||||
"servicesAmount": 0,
|
||||
"triangulationAmount": 54000,
|
||||
"invoiceCount": 3
|
||||
},
|
||||
{
|
||||
"customerVatNumber": "IT01234567890",
|
||||
"customerName": "Milano Engineering SpA",
|
||||
"customerCountry": "IT",
|
||||
"customerId": "cust-006",
|
||||
"goodsAmount": 67500,
|
||||
"servicesAmount": 28000,
|
||||
"triangulationAmount": 0,
|
||||
"invoiceCount": 2
|
||||
},
|
||||
{
|
||||
"customerVatNumber": "PL5678901234",
|
||||
"customerName": "Warszawa Logistik Sp. z o.o.",
|
||||
"customerCountry": "PL",
|
||||
"customerId": "cust-007",
|
||||
"goodsAmount": 189000,
|
||||
"servicesAmount": 0,
|
||||
"triangulationAmount": 0,
|
||||
"invoiceCount": 5
|
||||
},
|
||||
{
|
||||
"customerVatNumber": "DK12345678",
|
||||
"customerName": "Kobenhavn Konsult ApS",
|
||||
"customerCountry": "DK",
|
||||
"customerId": "cust-008",
|
||||
"goodsAmount": 0,
|
||||
"servicesAmount": 54000,
|
||||
"triangulationAmount": 0,
|
||||
"invoiceCount": 1
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"goods": 711500,
|
||||
"services": 405000,
|
||||
"triangulation": 54000,
|
||||
"total": 1170500
|
||||
},
|
||||
"warnings": [
|
||||
{
|
||||
"type": "missing_vat_validation",
|
||||
"severity": "warning",
|
||||
"customerId": "cust-005",
|
||||
"customerName": "Barcelona Componentes SL",
|
||||
"message": "VAT-nummer ES87654321A har inte validerats mot VIES. Verifiera innan inlämning."
|
||||
}
|
||||
],
|
||||
"crossCheck": {
|
||||
"box35Match": true,
|
||||
"box35ReportTotal": 711500,
|
||||
"box35GLTotal": 711500,
|
||||
"box39Match": false,
|
||||
"box39ReportTotal": 405000,
|
||||
"box39GLTotal": 403800
|
||||
},
|
||||
"invoiceCount": 21,
|
||||
"customerCount": 8,
|
||||
"deadline": "2026-02-20",
|
||||
"daysUntilDeadline": 14
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"referenceDate": "2025-12-15",
|
||||
"exchangeRates": [
|
||||
{ "currency": "EUR", "rate": 11.4215, "date": "2025-12-15" },
|
||||
{ "currency": "USD", "rate": 10.3870, "date": "2025-12-15" },
|
||||
{ "currency": "GBP", "rate": 13.5420, "date": "2025-12-15" },
|
||||
{ "currency": "NOK", "rate": 0.9845, "date": "2025-12-15" }
|
||||
],
|
||||
"exposureByCurrency": [
|
||||
{
|
||||
"currency": "EUR",
|
||||
"totalForeignAmount": 48500,
|
||||
"bookedSekValue": 541350,
|
||||
"currentSekValue": 553943,
|
||||
"unrealizedGainLoss": 12593,
|
||||
"invoiceCount": 4,
|
||||
"averageBookedRate": 11.1619,
|
||||
"currentRate": 11.4215
|
||||
},
|
||||
{
|
||||
"currency": "USD",
|
||||
"totalForeignAmount": 72000,
|
||||
"bookedSekValue": 741600,
|
||||
"currentSekValue": 747864,
|
||||
"unrealizedGainLoss": 6264,
|
||||
"invoiceCount": 3,
|
||||
"averageBookedRate": 10.3000,
|
||||
"currentRate": 10.3870
|
||||
},
|
||||
{
|
||||
"currency": "GBP",
|
||||
"totalForeignAmount": 15000,
|
||||
"bookedSekValue": 199500,
|
||||
"currentSekValue": 203130,
|
||||
"unrealizedGainLoss": 3630,
|
||||
"invoiceCount": 1,
|
||||
"averageBookedRate": 13.3000,
|
||||
"currentRate": 13.5420
|
||||
},
|
||||
{
|
||||
"currency": "NOK",
|
||||
"totalForeignAmount": 320000,
|
||||
"bookedSekValue": 316800,
|
||||
"currentSekValue": 315040,
|
||||
"unrealizedGainLoss": -1760,
|
||||
"invoiceCount": 2,
|
||||
"averageBookedRate": 0.9900,
|
||||
"currentRate": 0.9845
|
||||
}
|
||||
],
|
||||
"receivables": [
|
||||
{
|
||||
"invoiceId": "inv-1001",
|
||||
"invoiceNumber": "1001",
|
||||
"customerName": "Berliner Maschinenbau GmbH",
|
||||
"customerCountry": "DE",
|
||||
"currency": "EUR",
|
||||
"foreignAmount": 22000,
|
||||
"bookedSekAmount": 245300,
|
||||
"bookedRate": 11.15,
|
||||
"currentSekAmount": 251273,
|
||||
"currentRate": 11.4215,
|
||||
"unrealizedGainLoss": 5973,
|
||||
"invoiceDate": "2025-10-15",
|
||||
"dueDate": "2025-12-15",
|
||||
"daysOutstanding": 61
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1008",
|
||||
"invoiceNumber": "1008",
|
||||
"customerName": "Lyon Digital SARL",
|
||||
"customerCountry": "FR",
|
||||
"currency": "EUR",
|
||||
"foreignAmount": 14500,
|
||||
"bookedSekAmount": 163050,
|
||||
"bookedRate": 11.245,
|
||||
"currentSekAmount": 165612,
|
||||
"currentRate": 11.4215,
|
||||
"unrealizedGainLoss": 2562,
|
||||
"invoiceDate": "2025-11-05",
|
||||
"dueDate": "2026-01-05",
|
||||
"daysOutstanding": 40
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1012",
|
||||
"invoiceNumber": "1012",
|
||||
"customerName": "Amsterdam Trading BV",
|
||||
"customerCountry": "NL",
|
||||
"currency": "EUR",
|
||||
"foreignAmount": 8000,
|
||||
"bookedSekAmount": 89600,
|
||||
"bookedRate": 11.20,
|
||||
"currentSekAmount": 91372,
|
||||
"currentRate": 11.4215,
|
||||
"unrealizedGainLoss": 1772,
|
||||
"invoiceDate": "2025-11-20",
|
||||
"dueDate": "2025-12-20",
|
||||
"daysOutstanding": 25
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1015",
|
||||
"invoiceNumber": "1015",
|
||||
"customerName": "Helsinki Solutions Oy",
|
||||
"customerCountry": "FI",
|
||||
"currency": "EUR",
|
||||
"foreignAmount": 4000,
|
||||
"bookedSekAmount": 43400,
|
||||
"bookedRate": 10.85,
|
||||
"currentSekAmount": 45686,
|
||||
"currentRate": 11.4215,
|
||||
"unrealizedGainLoss": 2286,
|
||||
"invoiceDate": "2025-12-01",
|
||||
"dueDate": "2026-01-01",
|
||||
"daysOutstanding": 14
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1003",
|
||||
"invoiceNumber": "1003",
|
||||
"customerName": "New York Consulting Inc",
|
||||
"customerCountry": "US",
|
||||
"currency": "USD",
|
||||
"foreignAmount": 35000,
|
||||
"bookedSekAmount": 360500,
|
||||
"bookedRate": 10.30,
|
||||
"currentSekAmount": 363545,
|
||||
"currentRate": 10.3870,
|
||||
"unrealizedGainLoss": 3045,
|
||||
"invoiceDate": "2025-09-28",
|
||||
"dueDate": "2025-11-28",
|
||||
"daysOutstanding": 78
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1009",
|
||||
"invoiceNumber": "1009",
|
||||
"customerName": "Chicago Parts LLC",
|
||||
"customerCountry": "US",
|
||||
"currency": "USD",
|
||||
"foreignAmount": 22000,
|
||||
"bookedSekAmount": 224400,
|
||||
"bookedRate": 10.20,
|
||||
"currentSekAmount": 228514,
|
||||
"currentRate": 10.3870,
|
||||
"unrealizedGainLoss": 4114,
|
||||
"invoiceDate": "2025-10-20",
|
||||
"dueDate": "2025-12-20",
|
||||
"daysOutstanding": 56
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1018",
|
||||
"invoiceNumber": "1018",
|
||||
"customerName": "San Francisco Tech Corp",
|
||||
"customerCountry": "US",
|
||||
"currency": "USD",
|
||||
"foreignAmount": 15000,
|
||||
"bookedSekAmount": 156700,
|
||||
"bookedRate": 10.4467,
|
||||
"currentSekAmount": 155805,
|
||||
"currentRate": 10.3870,
|
||||
"unrealizedGainLoss": -895,
|
||||
"invoiceDate": "2025-12-05",
|
||||
"dueDate": "2026-02-05",
|
||||
"daysOutstanding": 10
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1010",
|
||||
"invoiceNumber": "1010",
|
||||
"customerName": "London Engineering Ltd",
|
||||
"customerCountry": "GB",
|
||||
"currency": "GBP",
|
||||
"foreignAmount": 15000,
|
||||
"bookedSekAmount": 199500,
|
||||
"bookedRate": 13.30,
|
||||
"currentSekAmount": 203130,
|
||||
"currentRate": 13.5420,
|
||||
"unrealizedGainLoss": 3630,
|
||||
"invoiceDate": "2025-11-01",
|
||||
"dueDate": "2026-01-01",
|
||||
"daysOutstanding": 44
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1005",
|
||||
"invoiceNumber": "1005",
|
||||
"customerName": "Oslo Shipping AS",
|
||||
"customerCountry": "NO",
|
||||
"currency": "NOK",
|
||||
"foreignAmount": 200000,
|
||||
"bookedSekAmount": 198000,
|
||||
"bookedRate": 0.99,
|
||||
"currentSekAmount": 196900,
|
||||
"currentRate": 0.9845,
|
||||
"unrealizedGainLoss": -1100,
|
||||
"invoiceDate": "2025-10-10",
|
||||
"dueDate": "2025-12-10",
|
||||
"daysOutstanding": 66
|
||||
},
|
||||
{
|
||||
"invoiceId": "inv-1016",
|
||||
"invoiceNumber": "1016",
|
||||
"customerName": "Bergen Industri AS",
|
||||
"customerCountry": "NO",
|
||||
"currency": "NOK",
|
||||
"foreignAmount": 120000,
|
||||
"bookedSekAmount": 118800,
|
||||
"bookedRate": 0.99,
|
||||
"currentSekAmount": 118140,
|
||||
"currentRate": 0.9845,
|
||||
"unrealizedGainLoss": -660,
|
||||
"invoiceDate": "2025-11-22",
|
||||
"dueDate": "2026-01-22",
|
||||
"daysOutstanding": 23
|
||||
}
|
||||
],
|
||||
"realizedGainLoss": {
|
||||
"year": 2025,
|
||||
"gains": 28450,
|
||||
"losses": 7820,
|
||||
"net": 20630
|
||||
},
|
||||
"monthlyTrend": [
|
||||
{ "month": "2025-01", "realizedGains": 1200, "realizedLosses": 0, "netRealized": 1200 },
|
||||
{ "month": "2025-02", "realizedGains": 0, "realizedLosses": 890, "netRealized": -890 },
|
||||
{ "month": "2025-03", "realizedGains": 3400, "realizedLosses": 0, "netRealized": 3400 },
|
||||
{ "month": "2025-04", "realizedGains": 2100, "realizedLosses": 1250, "netRealized": 850 },
|
||||
{ "month": "2025-05", "realizedGains": 0, "realizedLosses": 2300, "netRealized": -2300 },
|
||||
{ "month": "2025-06", "realizedGains": 4500, "realizedLosses": 0, "netRealized": 4500 },
|
||||
{ "month": "2025-07", "realizedGains": 1850, "realizedLosses": 680, "netRealized": 1170 },
|
||||
{ "month": "2025-08", "realizedGains": 3200, "realizedLosses": 0, "netRealized": 3200 },
|
||||
{ "month": "2025-09", "realizedGains": 5600, "realizedLosses": 1400, "netRealized": 4200 },
|
||||
{ "month": "2025-10", "realizedGains": 2800, "realizedLosses": 0, "netRealized": 2800 },
|
||||
{ "month": "2025-11", "realizedGains": 1500, "realizedLosses": 1300, "netRealized": 200 },
|
||||
{ "month": "2025-12", "realizedGains": 2300, "realizedLosses": 0, "netRealized": 2300 }
|
||||
],
|
||||
"revalPreview": {
|
||||
"totalUnrealizedGainLoss": 20727,
|
||||
"gains": 22487,
|
||||
"losses": 1760
|
||||
},
|
||||
"totals": {
|
||||
"bookedSekValue": 1799250,
|
||||
"currentSekValue": 1819977,
|
||||
"totalUnrealizedGainLoss": 20727,
|
||||
"receivableCount": 10,
|
||||
"currencyCount": 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* Seed script: populate data for export extensions
|
||||
*
|
||||
* Creates EU customers, foreign-currency invoices, and journal entries
|
||||
* so that all 4 export extensions (EU Sales List, VAT Monitor, Intrastat,
|
||||
* Currency Receivables) have data to display.
|
||||
*
|
||||
* Usage: node scripts/seed-export-data.mjs
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import 'dotenv/config'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function round2(n) {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function daysAgo(n) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function daysFromNow(n) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + n)
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
// 1. Find the user
|
||||
const { data: { users }, error: usersError } = await supabase.auth.admin.listUsers()
|
||||
if (usersError) {
|
||||
console.error('Failed to list users:', usersError.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please sign up first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const user = users[0]
|
||||
const userId = user.id
|
||||
console.log(`Using user: ${user.email} (${userId})`)
|
||||
|
||||
// 2. Ensure company settings exist
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
console.error('No company_settings found. Complete onboarding first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Company: ${company.company_name || '(unnamed)'}`)
|
||||
|
||||
// 3. Ensure fiscal period exists for current year
|
||||
const year = new Date().getFullYear()
|
||||
const periodStart = `${year}-01-01`
|
||||
const periodEnd = `${year}-12-31`
|
||||
|
||||
let { data: fiscalPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.lte('period_start', today())
|
||||
.gte('period_end', today())
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (!fiscalPeriod) {
|
||||
console.log(`Creating fiscal period for ${year}...`)
|
||||
const { data: newPeriod, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
name: `Räkenskapsår ${year}`,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
is_closed: false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (periodError) {
|
||||
console.error('Failed to create fiscal period:', periodError.message)
|
||||
process.exit(1)
|
||||
}
|
||||
fiscalPeriod = newPeriod
|
||||
}
|
||||
|
||||
console.log(`Fiscal period: ${fiscalPeriod.name} (${fiscalPeriod.period_start} – ${fiscalPeriod.period_end})`)
|
||||
|
||||
// 4. Create EU customers
|
||||
const customers = [
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
name: 'TechHaus GmbH',
|
||||
customer_type: 'eu_business',
|
||||
email: 'billing@techhaus.de',
|
||||
country: 'Germany',
|
||||
org_number: 'HRB 12345',
|
||||
vat_number: 'DE123456789',
|
||||
vat_number_validated: true,
|
||||
default_payment_terms: 30,
|
||||
address_line1: 'Friedrichstraße 42',
|
||||
postal_code: '10117',
|
||||
city: 'Berlin',
|
||||
},
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
name: 'Suomen Softworks Oy',
|
||||
customer_type: 'eu_business',
|
||||
email: 'invoices@suomensoftworks.fi',
|
||||
country: 'Finland',
|
||||
org_number: '1234567-8',
|
||||
vat_number: 'FI12345678',
|
||||
vat_number_validated: true,
|
||||
default_payment_terms: 30,
|
||||
address_line1: 'Mannerheimintie 10',
|
||||
postal_code: '00100',
|
||||
city: 'Helsinki',
|
||||
},
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
name: 'Oranje Logistics B.V.',
|
||||
customer_type: 'eu_business',
|
||||
email: 'finance@oranjelogistics.nl',
|
||||
country: 'Netherlands',
|
||||
org_number: 'KvK 87654321',
|
||||
vat_number: 'NL123456789B01',
|
||||
vat_number_validated: true,
|
||||
default_payment_terms: 14,
|
||||
address_line1: 'Keizersgracht 120',
|
||||
postal_code: '1015 AA',
|
||||
city: 'Amsterdam',
|
||||
},
|
||||
]
|
||||
|
||||
console.log('\nCreating 3 EU customers...')
|
||||
const { error: custError } = await supabase.from('customers').insert(customers)
|
||||
if (custError) {
|
||||
console.error('Failed to create customers:', custError.message)
|
||||
process.exit(1)
|
||||
}
|
||||
for (const c of customers) {
|
||||
console.log(` ✓ ${c.name} (${c.vat_number})`)
|
||||
}
|
||||
|
||||
// 5. Create invoices
|
||||
// Mix of: SEK reverse_charge, EUR reverse_charge, USD
|
||||
const nextNum = company.next_invoice_number || 1
|
||||
|
||||
const invoices = [
|
||||
// Invoice 1: SEK reverse_charge to German customer (for EU Sales List — goods)
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
customer_id: customers[0].id,
|
||||
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum).padStart(4, '0')}`,
|
||||
invoice_date: daysAgo(20),
|
||||
due_date: daysFromNow(10),
|
||||
status: 'sent',
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice',
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_rate: 0,
|
||||
subtotal: 85000,
|
||||
subtotal_sek: 85000,
|
||||
vat_amount: 0,
|
||||
vat_amount_sek: 0,
|
||||
total: 85000,
|
||||
total_sek: 85000,
|
||||
exchange_rate: null,
|
||||
moms_ruta: '35',
|
||||
reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
||||
},
|
||||
// Invoice 2: EUR reverse_charge to Finnish customer (for EU Sales List — services + Currency Receivables)
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
customer_id: customers[1].id,
|
||||
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 1).padStart(4, '0')}`,
|
||||
invoice_date: daysAgo(15),
|
||||
due_date: daysFromNow(15),
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
document_type: 'invoice',
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_rate: 0,
|
||||
subtotal: 5000,
|
||||
subtotal_sek: 57500,
|
||||
vat_amount: 0,
|
||||
vat_amount_sek: 0,
|
||||
total: 5000,
|
||||
total_sek: 57500,
|
||||
exchange_rate: 11.50,
|
||||
exchange_rate_date: daysAgo(15),
|
||||
moms_ruta: '39',
|
||||
reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
||||
},
|
||||
// Invoice 3: EUR reverse_charge to Dutch customer — goods (for EU Sales List + Currency Receivables)
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
customer_id: customers[2].id,
|
||||
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 2).padStart(4, '0')}`,
|
||||
invoice_date: daysAgo(10),
|
||||
due_date: daysFromNow(20),
|
||||
status: 'sent',
|
||||
currency: 'EUR',
|
||||
document_type: 'invoice',
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_rate: 0,
|
||||
subtotal: 12000,
|
||||
subtotal_sek: 138000,
|
||||
vat_amount: 0,
|
||||
vat_amount_sek: 0,
|
||||
total: 12000,
|
||||
total_sek: 138000,
|
||||
exchange_rate: 11.50,
|
||||
exchange_rate_date: daysAgo(10),
|
||||
moms_ruta: '35',
|
||||
reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
||||
},
|
||||
// Invoice 4: USD to Dutch customer — overdue (for Currency Receivables)
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
customer_id: customers[2].id,
|
||||
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 3).padStart(4, '0')}`,
|
||||
invoice_date: daysAgo(45),
|
||||
due_date: daysAgo(15),
|
||||
status: 'overdue',
|
||||
currency: 'USD',
|
||||
document_type: 'invoice',
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_rate: 0,
|
||||
subtotal: 8500,
|
||||
subtotal_sek: 91800,
|
||||
vat_amount: 0,
|
||||
vat_amount_sek: 0,
|
||||
total: 8500,
|
||||
total_sek: 91800,
|
||||
exchange_rate: 10.80,
|
||||
exchange_rate_date: daysAgo(45),
|
||||
moms_ruta: '35',
|
||||
reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
||||
},
|
||||
// Invoice 5: Paid SEK reverse_charge to Finnish customer (for EU Sales List history)
|
||||
{
|
||||
id: randomId(),
|
||||
user_id: userId,
|
||||
customer_id: customers[1].id,
|
||||
invoice_number: `${company.invoice_prefix || 'F'}${String(nextNum + 4).padStart(4, '0')}`,
|
||||
invoice_date: daysAgo(60),
|
||||
due_date: daysAgo(30),
|
||||
status: 'paid',
|
||||
currency: 'SEK',
|
||||
document_type: 'invoice',
|
||||
vat_treatment: 'reverse_charge',
|
||||
vat_rate: 0,
|
||||
subtotal: 42000,
|
||||
subtotal_sek: 42000,
|
||||
vat_amount: 0,
|
||||
vat_amount_sek: 0,
|
||||
total: 42000,
|
||||
total_sek: 42000,
|
||||
exchange_rate: null,
|
||||
moms_ruta: '39',
|
||||
reverse_charge_text: 'Reverse charge – VAT to be accounted for by the recipient according to article 196 Council Directive 2006/112/EC',
|
||||
},
|
||||
]
|
||||
|
||||
console.log('\nCreating 5 invoices...')
|
||||
const { error: invError } = await supabase.from('invoices').insert(invoices)
|
||||
if (invError) {
|
||||
console.error('Failed to create invoices:', invError.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const inv of invoices) {
|
||||
const cust = customers.find(c => c.id === inv.customer_id)
|
||||
console.log(` ✓ ${inv.invoice_number} — ${cust.name} — ${inv.currency} ${inv.total} (${inv.status})`)
|
||||
}
|
||||
|
||||
// 6. Create invoice items
|
||||
const invoiceItems = [
|
||||
// Invoice 1 items (SEK goods to Germany)
|
||||
{ id: randomId(), invoice_id: invoices[0].id, description: 'Industrial sensors batch', quantity: 50, unit: 'st', unit_price: 1200, line_total: 60000, sort_order: 1 },
|
||||
{ id: randomId(), invoice_id: invoices[0].id, description: 'Installation & calibration', quantity: 10, unit: 'tim', unit_price: 2500, line_total: 25000, sort_order: 2 },
|
||||
// Invoice 2 items (EUR services to Finland)
|
||||
{ id: randomId(), invoice_id: invoices[1].id, description: 'Software consulting', quantity: 40, unit: 'tim', unit_price: 125, line_total: 5000, sort_order: 1 },
|
||||
// Invoice 3 items (EUR goods to Netherlands)
|
||||
{ id: randomId(), invoice_id: invoices[2].id, description: 'Steel components CN:72163100', quantity: 200, unit: 'st', unit_price: 45, line_total: 9000, sort_order: 1 },
|
||||
{ id: randomId(), invoice_id: invoices[2].id, description: 'Aluminium fittings CN:76169990', quantity: 100, unit: 'st', unit_price: 30, line_total: 3000, sort_order: 2 },
|
||||
// Invoice 4 items (USD goods to Netherlands)
|
||||
{ id: randomId(), invoice_id: invoices[3].id, description: 'Custom machine parts', quantity: 25, unit: 'st', unit_price: 340, line_total: 8500, sort_order: 1 },
|
||||
// Invoice 5 items (SEK services to Finland)
|
||||
{ id: randomId(), invoice_id: invoices[4].id, description: 'IT architecture review', quantity: 24, unit: 'tim', unit_price: 1750, line_total: 42000, sort_order: 1 },
|
||||
]
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(invoiceItems)
|
||||
if (itemsError) {
|
||||
console.error('Failed to create invoice items:', itemsError.message)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(` ✓ ${invoiceItems.length} invoice items created`)
|
||||
|
||||
// Update next_invoice_number
|
||||
await supabase
|
||||
.from('company_settings')
|
||||
.update({ next_invoice_number: nextNum + 5 })
|
||||
.eq('user_id', userId)
|
||||
|
||||
// 7. Create journal entries for the invoices (reverse charge: debit 1510, credit 3305/3308)
|
||||
// These are needed for VAT Monitor and EU Sales List cross-check
|
||||
const journalEntries = []
|
||||
const journalLines = []
|
||||
|
||||
// Get current max voucher number
|
||||
const { data: maxVoucher } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('voucher_number')
|
||||
.eq('user_id', userId)
|
||||
.order('voucher_number', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
let voucherNum = (maxVoucher?.voucher_number || 0) + 1
|
||||
|
||||
for (const inv of invoices) {
|
||||
const entryId = randomId()
|
||||
const totalSEK = inv.total_sek
|
||||
|
||||
// Determine revenue account: goods = 3305, services = 3308
|
||||
// moms_ruta 35 = goods, 39 = services
|
||||
const revenueAccount = inv.moms_ruta === '35' ? '3305' : '3308'
|
||||
|
||||
journalEntries.push({
|
||||
id: entryId,
|
||||
user_id: userId,
|
||||
fiscal_period_id: fiscalPeriod.id,
|
||||
voucher_number: voucherNum++,
|
||||
voucher_series: 'A',
|
||||
entry_date: inv.invoice_date,
|
||||
description: `Faktura ${inv.invoice_number} — ${customers.find(c => c.id === inv.customer_id).name}`,
|
||||
source_type: 'invoice_created',
|
||||
source_id: inv.id,
|
||||
status: 'posted',
|
||||
committed_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Debit 1510 (accounts receivable)
|
||||
journalLines.push({
|
||||
id: randomId(),
|
||||
journal_entry_id: entryId,
|
||||
account_number: '1510',
|
||||
debit_amount: round2(totalSEK),
|
||||
credit_amount: 0,
|
||||
currency: inv.currency,
|
||||
amount_in_currency: inv.currency !== 'SEK' ? inv.total : null,
|
||||
exchange_rate: inv.exchange_rate,
|
||||
line_description: `Kundfordran ${inv.invoice_number}`,
|
||||
sort_order: 1,
|
||||
})
|
||||
|
||||
// Credit revenue account (3305 export goods or 3308 EU services)
|
||||
journalLines.push({
|
||||
id: randomId(),
|
||||
journal_entry_id: entryId,
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: round2(totalSEK),
|
||||
currency: 'SEK',
|
||||
line_description: `Intäkt ${inv.invoice_number}`,
|
||||
sort_order: 2,
|
||||
})
|
||||
}
|
||||
|
||||
// Add a payment entry for invoice 5 (paid) — debit 1930, credit 1510
|
||||
const paymentEntryId = randomId()
|
||||
journalEntries.push({
|
||||
id: paymentEntryId,
|
||||
user_id: userId,
|
||||
fiscal_period_id: fiscalPeriod.id,
|
||||
voucher_number: voucherNum++,
|
||||
voucher_series: 'A',
|
||||
entry_date: daysAgo(25),
|
||||
description: `Betalning ${invoices[4].invoice_number} — Suomen Softworks Oy`,
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoices[4].id,
|
||||
status: 'posted',
|
||||
committed_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
journalLines.push({
|
||||
id: randomId(),
|
||||
journal_entry_id: paymentEntryId,
|
||||
account_number: '1930',
|
||||
debit_amount: 42000,
|
||||
credit_amount: 0,
|
||||
currency: 'SEK',
|
||||
line_description: `Inbetalning ${invoices[4].invoice_number}`,
|
||||
sort_order: 1,
|
||||
})
|
||||
|
||||
journalLines.push({
|
||||
id: randomId(),
|
||||
journal_entry_id: paymentEntryId,
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: 42000,
|
||||
currency: 'SEK',
|
||||
line_description: `Reglering ${invoices[4].invoice_number}`,
|
||||
sort_order: 2,
|
||||
})
|
||||
|
||||
// Add a small FX gain entry (for Currency Receivables realized FX)
|
||||
const fxEntryId = randomId()
|
||||
journalEntries.push({
|
||||
id: fxEntryId,
|
||||
user_id: userId,
|
||||
fiscal_period_id: fiscalPeriod.id,
|
||||
voucher_number: voucherNum++,
|
||||
voucher_series: 'A',
|
||||
entry_date: daysAgo(25),
|
||||
description: 'Kursdifferens vid betalning',
|
||||
source_type: 'invoice_paid',
|
||||
status: 'posted',
|
||||
committed_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
journalLines.push({
|
||||
id: randomId(),
|
||||
journal_entry_id: fxEntryId,
|
||||
account_number: '1930',
|
||||
debit_amount: 450,
|
||||
credit_amount: 0,
|
||||
currency: 'SEK',
|
||||
line_description: 'Valutavinst',
|
||||
sort_order: 1,
|
||||
})
|
||||
|
||||
journalLines.push({
|
||||
id: randomId(),
|
||||
journal_entry_id: fxEntryId,
|
||||
account_number: '3960',
|
||||
debit_amount: 0,
|
||||
credit_amount: 450,
|
||||
currency: 'SEK',
|
||||
line_description: 'Valutakursvinst',
|
||||
sort_order: 2,
|
||||
})
|
||||
|
||||
console.log(`\nCreating ${journalEntries.length} journal entries...`)
|
||||
const { error: jeError } = await supabase.from('journal_entries').insert(journalEntries)
|
||||
if (jeError) {
|
||||
console.error('Failed to create journal entries:', jeError.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { error: jlError } = await supabase.from('journal_entry_lines').insert(journalLines)
|
||||
if (jlError) {
|
||||
console.error('Failed to create journal entry lines:', jlError.message)
|
||||
console.error('Cleaning up journal entries...')
|
||||
await supabase.from('journal_entries').delete().in('id', journalEntries.map(e => e.id))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (const je of journalEntries) {
|
||||
console.log(` ✓ A${je.voucher_number} — ${je.description}`)
|
||||
}
|
||||
|
||||
// 8. Add Intrastat product metadata via extension_data
|
||||
console.log('\nCreating Intrastat product registry...')
|
||||
const extensionId = 'export/intrastat'
|
||||
const extensionData = [
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: extensionId,
|
||||
key: 'product:STEEL-COMP',
|
||||
value: { description: 'Steel components', cn_code: '72163100', net_weight_kg: 2.4, country_of_origin: 'SE' },
|
||||
},
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: extensionId,
|
||||
key: 'product:ALU-FIT',
|
||||
value: { description: 'Aluminium fittings', cn_code: '76169990', net_weight_kg: 0.8, country_of_origin: 'SE' },
|
||||
},
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: extensionId,
|
||||
key: 'product:IND-SENSOR',
|
||||
value: { description: 'Industrial sensors', cn_code: '90318080', net_weight_kg: 0.35, country_of_origin: 'SE' },
|
||||
},
|
||||
]
|
||||
|
||||
const { error: extError } = await supabase.from('extension_data').insert(extensionData)
|
||||
if (extError) {
|
||||
console.error('Warning: Failed to create extension_data (Intrastat products):', extError.message)
|
||||
console.log(' (Export extensions will still work, just Intrastat product registry will be empty)')
|
||||
} else {
|
||||
for (const ed of extensionData) {
|
||||
console.log(` ✓ ${ed.key} — ${ed.value.description} (CN: ${ed.value.cn_code})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Done
|
||||
console.log('\n════════════════════════════════════════════════════')
|
||||
console.log(' Seed data created successfully!')
|
||||
console.log('════════════════════════════════════════════════════')
|
||||
console.log('\nYou should now see data in:')
|
||||
console.log(' • Periodisk sammanställning (EU Sales List) — 3 EU customers, 5 invoices')
|
||||
console.log(' • Exportmoms-monitor (VAT Monitor) — journal entries on 3305/3308')
|
||||
console.log(' • Intrastat — goods invoices + product registry')
|
||||
console.log(' • Valutafordringar (Currency Receivables) — 3 open EUR/USD invoices')
|
||||
console.log('\nSelect the current month/quarter to see the data.')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Unexpected error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user