Fixed extensions bugs

This commit is contained in:
Emil
2026-02-24 16:06:36 +01:00
parent 0764bc11a6
commit e0b66fe397
26 changed files with 3394 additions and 167 deletions
+171
View File
@@ -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)
)
})
})
+147
View File
@@ -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.',
}
}
}