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