Fixed extensions bugs
This commit is contained in:
@@ -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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user