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
+54
View File
@@ -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 })
}
+26
View File
@@ -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
View File
@@ -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)
}