From 0e4abcb1d3ba92bac59258e9c6b621e67acae2c9 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Fri, 5 Dec 2025 08:24:35 +0000 Subject: [PATCH] Feature: VAT # validation --- .../AccountVatSettingDomainObjectAbstract.php | 42 ++++ .../Status/VatValidationStatus.php | 18 ++ backend/app/Jobs/Vat/ValidateVatNumberJob.php | 180 ++++++++++++++++++ backend/app/Models/AccountVatSetting.php | 7 + .../Account/AccountVatSettingResource.php | 3 + .../Vat/DTO/ViesValidationResponseDTO.php | 4 + .../Vat/UpsertAccountVatSettingHandler.php | 111 ++++++++++- .../Vat/ViesValidationService.php | 134 +++++++++++-- ...00000_add_vat_validation_status_column.php | 34 ++++ .../Jobs/Vat/ValidateVatNumberJobTest.php | 154 +++++++++++++++ .../UpsertAccountVatSettingHandlerTest.php | 112 ++++++++--- .../Vat/ViesValidationServiceTest.php | 163 ++++++++++++++-- frontend/src/api/vat.client.ts | 5 + .../VatSettings/VatSettingsForm.tsx | 134 ++++++++++--- 14 files changed, 1008 insertions(+), 93 deletions(-) create mode 100644 backend/app/DomainObjects/Status/VatValidationStatus.php create mode 100644 backend/app/Jobs/Vat/ValidateVatNumberJob.php create mode 100644 backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php create mode 100644 backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php diff --git a/backend/app/DomainObjects/Generated/AccountVatSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountVatSettingDomainObjectAbstract.php index de1773e277..5560ad863c 100644 --- a/backend/app/DomainObjects/Generated/AccountVatSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AccountVatSettingDomainObjectAbstract.php @@ -22,6 +22,9 @@ abstract class AccountVatSettingDomainObjectAbstract extends \HiEvents\DomainObj final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; + final public const VAT_VALIDATION_STATUS = 'vat_validation_status'; + final public const VAT_VALIDATION_ERROR = 'vat_validation_error'; + final public const VAT_VALIDATION_ATTEMPTS = 'vat_validation_attempts'; protected int $id; protected int $account_id; @@ -35,6 +38,9 @@ abstract class AccountVatSettingDomainObjectAbstract extends \HiEvents\DomainObj protected ?string $created_at = null; protected ?string $updated_at = null; protected ?string $deleted_at = null; + protected string $vat_validation_status = 'PENDING'; + protected ?string $vat_validation_error = null; + protected int $vat_validation_attempts = 0; public function toArray(): array { @@ -51,6 +57,9 @@ public function toArray(): array 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, + 'vat_validation_status' => $this->vat_validation_status ?? null, + 'vat_validation_error' => $this->vat_validation_error ?? null, + 'vat_validation_attempts' => $this->vat_validation_attempts ?? null, ]; } @@ -185,4 +194,37 @@ public function getDeletedAt(): ?string { return $this->deleted_at; } + + public function setVatValidationStatus(string $vat_validation_status): self + { + $this->vat_validation_status = $vat_validation_status; + return $this; + } + + public function getVatValidationStatus(): string + { + return $this->vat_validation_status; + } + + public function setVatValidationError(?string $vat_validation_error): self + { + $this->vat_validation_error = $vat_validation_error; + return $this; + } + + public function getVatValidationError(): ?string + { + return $this->vat_validation_error; + } + + public function setVatValidationAttempts(int $vat_validation_attempts): self + { + $this->vat_validation_attempts = $vat_validation_attempts; + return $this; + } + + public function getVatValidationAttempts(): int + { + return $this->vat_validation_attempts; + } } diff --git a/backend/app/DomainObjects/Status/VatValidationStatus.php b/backend/app/DomainObjects/Status/VatValidationStatus.php new file mode 100644 index 0000000000..68bdb375dd --- /dev/null +++ b/backend/app/DomainObjects/Status/VatValidationStatus.php @@ -0,0 +1,18 @@ +info('VAT validation job started', [ + 'account_vat_setting_id' => $this->accountVatSettingId, + 'vat_number' => $this->maskVatNumber($this->vatNumber), + 'attempt' => $this->attempts(), + ]); + + $repository->updateFromArray($this->accountVatSettingId, [ + 'vat_validation_status' => VatValidationStatus::VALIDATING->value, + 'vat_validation_attempts' => $this->attempts(), + ]); + + $result = $viesService->validateVatNumber($this->vatNumber); + + if ($result->valid) { + $logger->info('VAT validation successful', [ + 'account_vat_setting_id' => $this->accountVatSettingId, + 'vat_number' => $this->maskVatNumber($this->vatNumber), + 'business_name' => $result->businessName, + 'attempt' => $this->attempts(), + ]); + + $repository->updateFromArray($this->accountVatSettingId, [ + 'vat_validated' => true, + 'vat_validation_status' => VatValidationStatus::VALID->value, + 'vat_validation_date' => now(), + 'business_name' => $result->businessName, + 'business_address' => $result->businessAddress, + 'vat_country_code' => $result->countryCode, + 'vat_validation_error' => null, + 'vat_validation_attempts' => $this->attempts(), + ]); + + return; + } + + if ($result->isTransientError) { + $logger->warning('VAT validation transient error - will retry', [ + 'account_vat_setting_id' => $this->accountVatSettingId, + 'vat_number' => $this->maskVatNumber($this->vatNumber), + 'error' => $result->errorMessage, + 'attempt' => $this->attempts(), + ]); + + $repository->updateFromArray($this->accountVatSettingId, [ + 'vat_validation_status' => VatValidationStatus::PENDING->value, + 'vat_validation_error' => $result->errorMessage, + 'vat_validation_attempts' => $this->attempts(), + ]); + + $this->release($this->calculateBackoff()); + + return; + } + + $logger->info('VAT validation failed - invalid VAT number', [ + 'account_vat_setting_id' => $this->accountVatSettingId, + 'vat_number' => $this->maskVatNumber($this->vatNumber), + 'error' => $result->errorMessage, + 'attempt' => $this->attempts(), + ]); + + $repository->updateFromArray($this->accountVatSettingId, [ + 'vat_validated' => false, + 'vat_validation_status' => VatValidationStatus::INVALID->value, + 'vat_validation_error' => $result->errorMessage, + 'vat_validation_attempts' => $this->attempts(), + ]); + } + + public function failed(Throwable $exception): void + { + $logger = app(LoggerInterface::class); + $repository = app(AccountVatSettingRepositoryInterface::class); + + $logger->error('VAT validation job failed permanently', [ + 'account_vat_setting_id' => $this->accountVatSettingId, + 'vat_number' => $this->maskVatNumber($this->vatNumber), + 'error' => $exception->getMessage(), + 'attempt' => $this->attempts(), + ]); + + try { + $repository->updateFromArray($this->accountVatSettingId, [ + 'vat_validated' => false, + 'vat_validation_status' => VatValidationStatus::FAILED->value, + 'vat_validation_error' => __('Validation failed after multiple attempts: :error', [ + 'error' => $exception->getMessage(), + ]), + 'vat_validation_attempts' => $this->attempts(), + ]); + } catch (Throwable $e) { + $logger->error('Failed to update VAT setting after job failure', [ + 'account_vat_setting_id' => $this->accountVatSettingId, + 'error' => $e->getMessage(), + ]); + } + } + + public function backoff(): array + { + return [ + 10, // 10s + 10, // 10s + 10, // 10s + 10, // 10s + 20, // 20s + 30, // 30s + 60, // 1m + 120, // 2m + 180, // 3m + 300, // 5m + 420, // 7m + 600, // 10m + 900, // 15m + 1200, // 20m + 1800, // 30m + ]; + } + + public function retryUntil(): DateTimeInterface + { + return now()->addHours(4); + } + + private function calculateBackoff(): int + { + $backoffs = $this->backoff(); + $attempt = $this->attempts() - 1; + + return $backoffs[$attempt] ?? end($backoffs); + } + + private function maskVatNumber(string $vatNumber): string + { + $length = strlen($vatNumber); + if ($length <= 4) { + return $vatNumber; + } + + return substr($vatNumber, 0, 2) . str_repeat('*', $length - 4) . substr($vatNumber, -2); + } +} diff --git a/backend/app/Models/AccountVatSetting.php b/backend/app/Models/AccountVatSetting.php index 4a31fd7610..8315314ef2 100644 --- a/backend/app/Models/AccountVatSetting.php +++ b/backend/app/Models/AccountVatSetting.php @@ -14,6 +14,9 @@ * @property bool $vat_registered * @property string|null $vat_number * @property bool $vat_validated + * @property string $vat_validation_status + * @property string|null $vat_validation_error + * @property int $vat_validation_attempts * @property Carbon|null $vat_validation_date * @property string|null $business_name * @property string|null $business_address @@ -31,6 +34,9 @@ protected function getFillableFields(): array 'vat_registered', 'vat_number', 'vat_validated', + 'vat_validation_status', + 'vat_validation_error', + 'vat_validation_attempts', 'vat_validation_date', 'business_name', 'business_address', @@ -43,6 +49,7 @@ protected function getCastMap(): array return [ 'vat_registered' => 'boolean', 'vat_validated' => 'boolean', + 'vat_validation_attempts' => 'integer', 'vat_validation_date' => 'datetime', ]; } diff --git a/backend/app/Resources/Account/AccountVatSettingResource.php b/backend/app/Resources/Account/AccountVatSettingResource.php index b01adb6a37..c6901bbf9e 100644 --- a/backend/app/Resources/Account/AccountVatSettingResource.php +++ b/backend/app/Resources/Account/AccountVatSettingResource.php @@ -20,6 +20,9 @@ public function toArray($request): array 'vat_registered' => $this->getVatRegistered(), 'vat_number' => $this->getVatNumber(), 'vat_validated' => $this->getVatValidated(), + 'vat_validation_status' => $this->getVatValidationStatus(), + 'vat_validation_error' => $this->getVatValidationError(), + 'vat_validation_attempts' => $this->getVatValidationAttempts(), 'vat_validation_date' => $this->getVatValidationDate(), 'business_name' => $this->getBusinessName(), 'business_address' => $this->getBusinessAddress(), diff --git a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php b/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php index bd0fa59618..aedbb1a0d9 100644 --- a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php +++ b/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php @@ -1,5 +1,7 @@ $command->vatRegistered, ]; + $shouldValidate = false; + $vatNumber = null; + if ($command->vatRegistered && $command->vatNumber) { $vatNumber = strtoupper(trim($command->vatNumber)); if (preg_match('/^[A-Z]{2}[0-9A-Z]{8,15}$/', $vatNumber)) { - $validationResult = $this->viesValidationService->validateVatNumber($vatNumber); + $vatNumberChanged = !$existing || $existing->getVatNumber() !== $vatNumber; $data['vat_number'] = $vatNumber; - $data['vat_validated'] = $validationResult->valid; - $data['vat_country_code'] = $validationResult->countryCode; - $data['business_name'] = $validationResult->businessName; - $data['business_address'] = $validationResult->businessAddress; + $data['vat_country_code'] = substr($vatNumber, 0, 2); - if ($validationResult->valid) { - $data['vat_validation_date'] = now(); + if ($vatNumberChanged) { + $shouldValidate = true; + $data = $this->trySyncValidation($vatNumber, $data); } } else { $data['vat_number'] = $vatNumber; $data['vat_validated'] = false; + $data['vat_validation_status'] = VatValidationStatus::INVALID->value; + $data['vat_validation_error'] = __('Invalid VAT number format'); $data['vat_country_code'] = substr($vatNumber, 0, 2); $data['business_name'] = null; $data['business_address'] = null; @@ -51,6 +58,9 @@ public function handle(UpsertAccountVatSettingDTO $command): AccountVatSettingDo } else { $data['vat_number'] = null; $data['vat_validated'] = false; + $data['vat_validation_status'] = VatValidationStatus::PENDING->value; + $data['vat_validation_error'] = null; + $data['vat_validation_attempts'] = 0; $data['vat_country_code'] = null; $data['business_name'] = null; $data['business_address'] = null; @@ -58,12 +68,95 @@ public function handle(UpsertAccountVatSettingDTO $command): AccountVatSettingDo } if ($existing) { - return $this->vatSettingRepository->updateFromArray( + $vatSetting = $this->vatSettingRepository->updateFromArray( id: $existing->getId(), attributes: $data ); + } else { + $vatSetting = $this->vatSettingRepository->create($data); + } + + if ($shouldValidate && $data['vat_validation_status'] === VatValidationStatus::PENDING->value) { + $this->logger->info('Sync validation failed, dispatching VAT validation job', [ + 'account_vat_setting_id' => $vatSetting->getId(), + 'account_id' => $command->accountId, + 'vat_number_masked' => $this->maskVatNumber($vatNumber), + ]); + + ValidateVatNumberJob::dispatch( + $vatSetting->getId(), + $vatNumber, + ); + } + + return $vatSetting; + } + + private function trySyncValidation(string $vatNumber, array $data): array + { + $this->logger->info('Attempting sync VAT validation', [ + 'vat_number_masked' => $this->maskVatNumber($vatNumber), + ]); + + $result = $this->viesValidationService->validateVatNumber($vatNumber); + + if ($result->valid) { + $this->logger->info('Sync VAT validation successful', [ + 'vat_number_masked' => $this->maskVatNumber($vatNumber), + 'business_name' => $result->businessName, + ]); + + $data['vat_validated'] = true; + $data['vat_validation_status'] = VatValidationStatus::VALID->value; + $data['vat_validation_error'] = null; + $data['vat_validation_attempts'] = 1; + $data['business_name'] = $result->businessName; + $data['business_address'] = $result->businessAddress; + $data['vat_validation_date'] = now(); + + return $data; + } + + if ($result->isTransientError) { + $this->logger->info('Sync VAT validation hit transient error, will queue for retry', [ + 'vat_number_masked' => $this->maskVatNumber($vatNumber), + 'error' => $result->errorMessage, + ]); + + $data['vat_validated'] = false; + $data['vat_validation_status'] = VatValidationStatus::PENDING->value; + $data['vat_validation_error'] = $result->errorMessage; + $data['vat_validation_attempts'] = 1; + $data['business_name'] = null; + $data['business_address'] = null; + $data['vat_validation_date'] = null; + + return $data; + } + + $this->logger->info('Sync VAT validation failed - invalid VAT number', [ + 'vat_number_masked' => $this->maskVatNumber($vatNumber), + 'error' => $result->errorMessage, + ]); + + $data['vat_validated'] = false; + $data['vat_validation_status'] = VatValidationStatus::INVALID->value; + $data['vat_validation_error'] = $result->errorMessage; + $data['vat_validation_attempts'] = 1; + $data['business_name'] = null; + $data['business_address'] = null; + $data['vat_validation_date'] = null; + + return $data; + } + + private function maskVatNumber(string $vatNumber): string + { + $length = strlen($vatNumber); + if ($length <= 4) { + return $vatNumber; } - return $this->vatSettingRepository->create($data); + return substr($vatNumber, 0, 2) . str_repeat('*', $length - 4) . substr($vatNumber, -2); } } diff --git a/backend/app/Services/Infrastructure/Vat/ViesValidationService.php b/backend/app/Services/Infrastructure/Vat/ViesValidationService.php index 43cf983e9d..cf4653538e 100644 --- a/backend/app/Services/Infrastructure/Vat/ViesValidationService.php +++ b/backend/app/Services/Infrastructure/Vat/ViesValidationService.php @@ -13,71 +13,175 @@ class ViesValidationService { private const VIES_API_URL = 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number'; - private const TIMEOUT_SECONDS = 10; + private const TIMEOUT_SECONDS = 15; + + private const TRANSIENT_ERRORS = [ + 'MS_MAX_CONCURRENT_REQ', + 'MS_UNAVAILABLE', + 'TIMEOUT', + 'SERVER_BUSY', + 'SERVICE_UNAVAILABLE', + 'GLOBAL_MAX_CONCURRENT_REQ', + ]; public function __construct( private readonly HttpClient $httpClient, private readonly LoggerInterface $logger, - ) - { - } + ) {} public function validateVatNumber(string $vatNumber): ViesValidationResponseDTO { $countryCode = substr($vatNumber, 0, 2); $vatNumberOnly = substr($vatNumber, 2); + $this->logger->info('VIES validation request started', [ + 'vat_number' => $this->maskVatNumber($vatNumber), + 'country_code' => $countryCode, + ]); + try { - $response = $this->httpClient->timeout(self::TIMEOUT_SECONDS)->post(self::VIES_API_URL, [ - 'countryCode' => $countryCode, - 'vatNumber' => $vatNumberOnly, + $response = $this->httpClient + ->timeout(self::TIMEOUT_SECONDS) + ->post(self::VIES_API_URL, [ + 'countryCode' => $countryCode, + 'vatNumber' => $vatNumberOnly, + ]); + + $data = $response->json() ?? []; + + $this->logger->info('VIES validation response received', [ + 'vat_number' => $this->maskVatNumber($vatNumber), + 'status_code' => $response->status(), + 'action_succeed' => $data['actionSucceed'] ?? null, + 'valid' => $data['valid'] ?? null, + 'has_errors' => !empty($data['errorWrappers']), ]); if (!$response->successful()) { - $this->logger->warning('VIES API request failed', [ - 'status' => $response->status(), + $this->logger->warning('VIES HTTP error response', [ + 'vat_number' => $this->maskVatNumber($vatNumber), + 'status_code' => $response->status(), 'body' => $response->body(), - 'vat_number' => $vatNumber, ]); return new ViesValidationResponseDTO( valid: false, countryCode: $countryCode, vatNumber: $vatNumberOnly, + isTransientError: true, + errorMessage: __('VIES service returned HTTP :status', ['status' => $response->status()]), + ); + } + + if (($data['actionSucceed'] ?? true) === false || !empty($data['errorWrappers'])) { + $errorCode = $this->extractErrorCode($data); + + $this->logger->warning('VIES API returned error', [ + 'vat_number' => $this->maskVatNumber($vatNumber), + 'error_code' => $errorCode, + 'response' => $data, + ]); + + $isTransient = $this->isTransientError($errorCode); + + return new ViesValidationResponseDTO( + valid: false, + countryCode: $countryCode, + vatNumber: $vatNumberOnly, + isTransientError: $isTransient, + errorMessage: $this->formatErrorMessage($errorCode), ); } - $data = $response->json(); + $isValid = $data['valid'] ?? false; + + $this->logger->info('VIES validation completed', [ + 'vat_number' => $this->maskVatNumber($vatNumber), + 'valid' => $isValid, + 'business_name' => $data['name'] ?? null, + ]); return new ViesValidationResponseDTO( - valid: $data['valid'] ?? false, + valid: $isValid, businessName: $data['name'] ?? null, businessAddress: $data['address'] ?? null, countryCode: $countryCode, vatNumber: $vatNumberOnly, + isTransientError: false, + errorMessage: $isValid ? null : __('VAT number is not valid according to VIES'), ); + } catch (ConnectionException $e) { - $this->logger->error('VIES API connection error', [ + $this->logger->error('VIES connection error', [ + 'vat_number' => $this->maskVatNumber($vatNumber), 'error' => $e->getMessage(), - 'vat_number' => $vatNumber, ]); return new ViesValidationResponseDTO( valid: false, countryCode: $countryCode, vatNumber: $vatNumberOnly, + isTransientError: true, + errorMessage: __('Connection error: :error', ['error' => $e->getMessage()]), ); + } catch (Exception $e) { $this->logger->error('VIES validation exception', [ + 'vat_number' => $this->maskVatNumber($vatNumber), 'error' => $e->getMessage(), - 'vat_number' => $vatNumber, + 'exception_class' => get_class($e), ]); return new ViesValidationResponseDTO( valid: false, countryCode: $countryCode, vatNumber: $vatNumberOnly, + isTransientError: true, + errorMessage: __('Validation error: :error', ['error' => $e->getMessage()]), ); } } + + private function extractErrorCode(array $data): ?string + { + if (empty($data['errorWrappers'])) { + return null; + } + + return $data['errorWrappers'][0]['error'] ?? null; + } + + private function isTransientError(?string $errorCode): bool + { + if ($errorCode === null) { + return true; + } + + return in_array($errorCode, self::TRANSIENT_ERRORS, true); + } + + private function formatErrorMessage(?string $errorCode): string + { + return match ($errorCode) { + 'MS_MAX_CONCURRENT_REQ' => __('VIES service is temporarily busy. Validation will be retried.'), + 'MS_UNAVAILABLE' => __('Member State service is temporarily unavailable. Validation will be retried.'), + 'TIMEOUT' => __('VIES service timed out. Validation will be retried.'), + 'SERVER_BUSY' => __('VIES server is busy. Validation will be retried.'), + 'SERVICE_UNAVAILABLE' => __('VIES service is unavailable. Validation will be retried.'), + 'GLOBAL_MAX_CONCURRENT_REQ' => __('VIES service has reached maximum requests. Validation will be retried.'), + 'INVALID_INPUT' => __('Invalid VAT number format.'), + 'INVALID_REQUESTER_INFO' => __('Invalid requester information.'), + default => __('VIES validation error: :code', ['code' => $errorCode ?? 'unknown']), + }; + } + + private function maskVatNumber(string $vatNumber): string + { + $length = strlen($vatNumber); + if ($length <= 4) { + return $vatNumber; + } + + return substr($vatNumber, 0, 2) . str_repeat('*', $length - 4) . substr($vatNumber, -2); + } } diff --git a/backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php b/backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php new file mode 100644 index 0000000000..6b8f58775c --- /dev/null +++ b/backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php @@ -0,0 +1,34 @@ +string('vat_validation_status', 20)->default('PENDING')->after('vat_validated'); + $table->text('vat_validation_error')->nullable()->after('vat_validation_status'); + $table->unsignedInteger('vat_validation_attempts')->default(0)->after('vat_validation_error'); + }); + + DB::table('account_vat_settings') + ->where('vat_validated', true) + ->update(['vat_validation_status' => 'VALID']); + + DB::table('account_vat_settings') + ->where('vat_validated', false) + ->whereNotNull('vat_number') + ->update(['vat_validation_status' => 'INVALID']); + } + + public function down(): void + { + Schema::table('account_vat_settings', function (Blueprint $table) { + $table->dropColumn(['vat_validation_status', 'vat_validation_error', 'vat_validation_attempts']); + }); + } +}; diff --git a/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php b/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php new file mode 100644 index 0000000000..056469f65d --- /dev/null +++ b/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php @@ -0,0 +1,154 @@ +viesService = Mockery::mock(ViesValidationService::class); + $this->repository = Mockery::mock(AccountVatSettingRepositoryInterface::class); + $this->logger = Mockery::mock(LoggerInterface::class); + $this->logger->shouldReceive('info')->byDefault(); + $this->logger->shouldReceive('warning')->byDefault(); + } + + public function testJobUpdatesSettingsOnSuccessfulValidation(): void + { + $accountVatSettingId = 123; + $vatNumber = 'IE1234567A'; + + $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber); + + $validationResponse = new ViesValidationResponseDTO( + valid: true, + businessName: 'Test Company Ltd', + businessAddress: '123 Test Street', + countryCode: 'IE', + vatNumber: '1234567A', + isTransientError: false, + ); + + $domainObject = Mockery::mock(AccountVatSettingDomainObject::class); + + $this->viesService + ->shouldReceive('validateVatNumber') + ->with($vatNumber) + ->once() + ->andReturn($validationResponse); + + $this->repository + ->shouldReceive('updateFromArray') + ->twice() + ->withArgs(function ($id, $data) use ($accountVatSettingId) { + if ($id !== $accountVatSettingId) { + return false; + } + + if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) { + return true; + } + + if (isset($data['vat_validated']) && $data['vat_validated'] === true) { + return $data['vat_validation_status'] === VatValidationStatus::VALID->value + && $data['business_name'] === 'Test Company Ltd'; + } + + return false; + }) + ->andReturn($domainObject); + + $job->handle($this->viesService, $this->repository, $this->logger); + + $this->assertTrue(true); + } + + public function testJobUpdatesSettingsOnInvalidVatNumber(): void + { + $accountVatSettingId = 123; + $vatNumber = 'IE9999999ZZ'; + + $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber); + + $validationResponse = new ViesValidationResponseDTO( + valid: false, + countryCode: 'IE', + vatNumber: '9999999ZZ', + isTransientError: false, + errorMessage: 'VAT number is not valid', + ); + + $domainObject = Mockery::mock(AccountVatSettingDomainObject::class); + + $this->viesService + ->shouldReceive('validateVatNumber') + ->with($vatNumber) + ->once() + ->andReturn($validationResponse); + + $this->repository + ->shouldReceive('updateFromArray') + ->twice() + ->withArgs(function ($id, $data) use ($accountVatSettingId) { + if ($id !== $accountVatSettingId) { + return false; + } + + if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) { + return true; + } + + if (isset($data['vat_validated']) && $data['vat_validated'] === false) { + return $data['vat_validation_status'] === VatValidationStatus::INVALID->value; + } + + return false; + }) + ->andReturn($domainObject); + + $job->handle($this->viesService, $this->repository, $this->logger); + + $this->assertTrue(true); + } + + public function testJobHasCorrectRetryConfiguration(): void + { + $job = new ValidateVatNumberJob(1, 'IE1234567A'); + + $this->assertEquals(15, $job->tries); + $this->assertEquals(15, $job->maxExceptions); + $this->assertEquals(15, $job->timeout); + } + + public function testJobBackoffConfiguration(): void + { + $job = new ValidateVatNumberJob(1, 'IE1234567A'); + + $backoffs = $job->backoff(); + + $this->assertCount(15, $backoffs); + $this->assertEquals(10, $backoffs[0]); + $this->assertEquals(1800, $backoffs[14]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php index 2650185a28..373215991e 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php @@ -3,29 +3,41 @@ namespace HiEvents\Tests\Unit\Services\Application\Handlers\Account\Vat; use HiEvents\DomainObjects\AccountVatSettingDomainObject; +use HiEvents\DomainObjects\Status\VatValidationStatus; +use HiEvents\Jobs\Vat\ValidateVatNumberJob; use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; use HiEvents\Services\Application\Handlers\Account\Vat\DTO\UpsertAccountVatSettingDTO; use HiEvents\Services\Application\Handlers\Account\Vat\DTO\ViesValidationResponseDTO; use HiEvents\Services\Application\Handlers\Account\Vat\UpsertAccountVatSettingHandler; use HiEvents\Services\Infrastructure\Vat\ViesValidationService; +use Illuminate\Support\Facades\Queue; use Mockery; +use Psr\Log\LoggerInterface; use Tests\TestCase; class UpsertAccountVatSettingHandlerTest extends TestCase { private AccountVatSettingRepositoryInterface $repository; private ViesValidationService $viesService; + private LoggerInterface $logger; private UpsertAccountVatSettingHandler $handler; protected function setUp(): void { parent::setUp(); + Queue::fake(); $this->repository = Mockery::mock(AccountVatSettingRepositoryInterface::class); $this->viesService = Mockery::mock(ViesValidationService::class); - $this->handler = new UpsertAccountVatSettingHandler($this->repository, $this->viesService); + $this->logger = Mockery::mock(LoggerInterface::class); + $this->logger->shouldReceive('info')->byDefault(); + $this->handler = new UpsertAccountVatSettingHandler( + $this->repository, + $this->viesService, + $this->logger + ); } - public function testHandleCreatesVatSettingWithValidatedNumber(): void + public function testHandleCreatesVatSettingWithSyncValidationSuccess(): void { $accountId = 123; $vatNumber = 'IE1234567A'; @@ -40,10 +52,12 @@ public function testHandleCreatesVatSettingWithValidatedNumber(): void businessName: 'Test Company Ltd', businessAddress: '123 Test Street', countryCode: 'IE', - vatNumber: '1234567A' + vatNumber: '1234567A', + isTransientError: false, ); $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); + $vatSetting->shouldReceive('getId')->andReturn(1); $this->repository ->shouldReceive('findByAccountId') @@ -65,17 +79,71 @@ public function testHandleCreatesVatSettingWithValidatedNumber(): void && $data['vat_registered'] === true && $data['vat_number'] === $vatNumber && $data['vat_validated'] === true + && $data['vat_validation_status'] === VatValidationStatus::VALID->value && $data['business_name'] === 'Test Company Ltd' - && isset($data['vat_validation_date']); + && $data['vat_country_code'] === 'IE'; + }) + ->andReturn($vatSetting); + + $result = $this->handler->handle($dto); + + $this->assertSame($vatSetting, $result); + Queue::assertNotPushed(ValidateVatNumberJob::class); + } + + public function testHandleQueuesJobOnTransientError(): void + { + $accountId = 123; + $vatNumber = 'IE1234567A'; + $dto = new UpsertAccountVatSettingDTO( + accountId: $accountId, + vatRegistered: true, + vatNumber: $vatNumber, + ); + + $validationResponse = new ViesValidationResponseDTO( + valid: false, + countryCode: 'IE', + vatNumber: '1234567A', + isTransientError: true, + errorMessage: 'VIES service is temporarily busy', + ); + + $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); + $vatSetting->shouldReceive('getId')->andReturn(1); + + $this->repository + ->shouldReceive('findByAccountId') + ->with($accountId) + ->once() + ->andReturn(null); + + $this->viesService + ->shouldReceive('validateVatNumber') + ->with($vatNumber) + ->once() + ->andReturn($validationResponse); + + $this->repository + ->shouldReceive('create') + ->once() + ->withArgs(function ($data) use ($accountId, $vatNumber) { + return $data['account_id'] === $accountId + && $data['vat_registered'] === true + && $data['vat_number'] === $vatNumber + && $data['vat_validated'] === false + && $data['vat_validation_status'] === VatValidationStatus::PENDING->value + && $data['vat_country_code'] === 'IE'; }) ->andReturn($vatSetting); $result = $this->handler->handle($dto); $this->assertSame($vatSetting, $result); + Queue::assertPushed(ValidateVatNumberJob::class); } - public function testHandleCreatesVatSettingWithInvalidNumber(): void + public function testHandleDoesNotQueueJobOnInvalidVatNumber(): void { $accountId = 123; $vatNumber = 'IE9999999ZZ'; @@ -88,10 +156,13 @@ public function testHandleCreatesVatSettingWithInvalidNumber(): void $validationResponse = new ViesValidationResponseDTO( valid: false, countryCode: 'IE', - vatNumber: '9999999ZZ' + vatNumber: '9999999ZZ', + isTransientError: false, + errorMessage: 'VAT number not found', ); $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); + $vatSetting->shouldReceive('getId')->andReturn(1); $this->repository ->shouldReceive('findByAccountId') @@ -113,13 +184,14 @@ public function testHandleCreatesVatSettingWithInvalidNumber(): void && $data['vat_registered'] === true && $data['vat_number'] === $vatNumber && $data['vat_validated'] === false - && $data['business_name'] === null; + && $data['vat_validation_status'] === VatValidationStatus::INVALID->value; }) ->andReturn($vatSetting); $result = $this->handler->handle($dto); $this->assertSame($vatSetting, $result); + Queue::assertNotPushed(ValidateVatNumberJob::class); } public function testHandleCreatesVatSettingWithInvalidFormat(): void @@ -133,6 +205,7 @@ public function testHandleCreatesVatSettingWithInvalidFormat(): void ); $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); + $vatSetting->shouldReceive('getId')->andReturn(1); $this->repository ->shouldReceive('findByAccountId') @@ -151,6 +224,7 @@ public function testHandleCreatesVatSettingWithInvalidFormat(): void && $data['vat_registered'] === true && $data['vat_number'] === 'INVALID' && $data['vat_validated'] === false + && $data['vat_validation_status'] === VatValidationStatus::INVALID->value && $data['business_name'] === null; }) ->andReturn($vatSetting); @@ -158,6 +232,7 @@ public function testHandleCreatesVatSettingWithInvalidFormat(): void $result = $this->handler->handle($dto); $this->assertSame($vatSetting, $result); + Queue::assertNotPushed(ValidateVatNumberJob::class); } public function testHandleCreatesVatSettingForNonRegistered(): void @@ -169,6 +244,7 @@ public function testHandleCreatesVatSettingForNonRegistered(): void ); $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class); + $vatSetting->shouldReceive('getId')->andReturn(1); $this->repository ->shouldReceive('findByAccountId') @@ -193,9 +269,10 @@ public function testHandleCreatesVatSettingForNonRegistered(): void $result = $this->handler->handle($dto); $this->assertSame($vatSetting, $result); + Queue::assertNotPushed(ValidateVatNumberJob::class); } - public function testHandleUpdatesExistingVatSetting(): void + public function testHandleDoesNotValidateIfVatNumberUnchanged(): void { $accountId = 123; $existingId = 456; @@ -203,6 +280,7 @@ public function testHandleUpdatesExistingVatSetting(): void $existing = Mockery::mock(AccountVatSettingDomainObject::class); $existing->shouldReceive('getId')->andReturn($existingId); + $existing->shouldReceive('getVatNumber')->andReturn($vatNumber); $dto = new UpsertAccountVatSettingDTO( accountId: $accountId, @@ -210,15 +288,8 @@ public function testHandleUpdatesExistingVatSetting(): void vatNumber: $vatNumber, ); - $validationResponse = new ViesValidationResponseDTO( - valid: true, - businessName: 'Test GmbH', - businessAddress: 'Berlin, Germany', - countryCode: 'DE', - vatNumber: '123456789' - ); - $updated = Mockery::mock(AccountVatSettingDomainObject::class); + $updated->shouldReceive('getId')->andReturn($existingId); $this->repository ->shouldReceive('findByAccountId') @@ -227,10 +298,7 @@ public function testHandleUpdatesExistingVatSetting(): void ->andReturn($existing); $this->viesService - ->shouldReceive('validateVatNumber') - ->with($vatNumber) - ->once() - ->andReturn($validationResponse); + ->shouldNotReceive('validateVatNumber'); $this->repository ->shouldReceive('updateFromArray') @@ -239,14 +307,14 @@ public function testHandleUpdatesExistingVatSetting(): void return $data['account_id'] === $accountId && $data['vat_registered'] === true && $data['vat_number'] === $vatNumber - && $data['vat_validated'] === true - && $data['business_name'] === 'Test GmbH'; + && !isset($data['vat_validated']); })) ->andReturn($updated); $result = $this->handler->handle($dto); $this->assertSame($updated, $result); + Queue::assertNotPushed(ValidateVatNumberJob::class); } protected function tearDown(): void diff --git a/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php index 9a51f3d3ae..4040d1bcf4 100644 --- a/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php +++ b/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php @@ -4,7 +4,6 @@ use HiEvents\Services\Infrastructure\Vat\ViesValidationService; use Illuminate\Http\Client\Factory as HttpClient; -use Illuminate\Http\Client\Request; use Illuminate\Http\Client\Response; use Illuminate\Http\Client\ConnectionException; use Psr\Log\LoggerInterface; @@ -22,6 +21,7 @@ protected function setUp(): void parent::setUp(); $this->httpClient = Mockery::mock(HttpClient::class); $this->logger = Mockery::mock(LoggerInterface::class); + $this->logger->shouldReceive('info')->byDefault(); $this->service = new ViesValidationService($this->httpClient, $this->logger); } @@ -32,7 +32,7 @@ public function testValidVatNumberReturnsSuccessResponse(): void $this->httpClient ->shouldReceive('timeout') - ->with(10) + ->with(15) ->andReturnSelf(); $this->httpClient @@ -48,6 +48,10 @@ public function testValidVatNumberReturnsSuccessResponse(): void ->shouldReceive('successful') ->andReturn(true); + $response + ->shouldReceive('status') + ->andReturn(200); + $response ->shouldReceive('json') ->andReturn([ @@ -59,6 +63,7 @@ public function testValidVatNumberReturnsSuccessResponse(): void $result = $this->service->validateVatNumber($vatNumber); $this->assertTrue($result->valid); + $this->assertFalse($result->isTransientError); $this->assertEquals('Test Company Ltd', $result->businessName); $this->assertEquals('123 Test Street, Dublin', $result->businessAddress); $this->assertEquals('IE', $result->countryCode); @@ -81,6 +86,10 @@ public function testInvalidVatNumberReturnsFailureResponse(): void ->shouldReceive('successful') ->andReturn(true); + $response + ->shouldReceive('status') + ->andReturn(200); + $response ->shouldReceive('json') ->andReturn(['valid' => false]); @@ -88,10 +97,133 @@ public function testInvalidVatNumberReturnsFailureResponse(): void $result = $this->service->validateVatNumber($vatNumber); $this->assertFalse($result->valid); + $this->assertFalse($result->isTransientError); $this->assertNull($result->businessName); } - public function testApiErrorLogsWarningAndReturnsFalse(): void + public function testMsMaxConcurrentReqReturnsTransientError(): void + { + $vatNumber = 'DE123456789'; + $response = Mockery::mock(Response::class); + + $this->httpClient + ->shouldReceive('timeout') + ->andReturnSelf(); + + $this->httpClient + ->shouldReceive('post') + ->andReturn($response); + + $response + ->shouldReceive('successful') + ->andReturn(true); + + $response + ->shouldReceive('status') + ->andReturn(200); + + $response + ->shouldReceive('json') + ->andReturn([ + 'actionSucceed' => false, + 'errorWrappers' => [ + ['error' => 'MS_MAX_CONCURRENT_REQ'] + ] + ]); + + $this->logger + ->shouldReceive('warning') + ->once() + ->with('VIES API returned error', Mockery::any()); + + $result = $this->service->validateVatNumber($vatNumber); + + $this->assertFalse($result->valid); + $this->assertTrue($result->isTransientError); + $this->assertNotNull($result->errorMessage); + } + + public function testMsUnavailableReturnsTransientError(): void + { + $vatNumber = 'FR12345678901'; + $response = Mockery::mock(Response::class); + + $this->httpClient + ->shouldReceive('timeout') + ->andReturnSelf(); + + $this->httpClient + ->shouldReceive('post') + ->andReturn($response); + + $response + ->shouldReceive('successful') + ->andReturn(true); + + $response + ->shouldReceive('status') + ->andReturn(200); + + $response + ->shouldReceive('json') + ->andReturn([ + 'actionSucceed' => false, + 'errorWrappers' => [ + ['error' => 'MS_UNAVAILABLE'] + ] + ]); + + $this->logger + ->shouldReceive('warning') + ->once(); + + $result = $this->service->validateVatNumber($vatNumber); + + $this->assertFalse($result->valid); + $this->assertTrue($result->isTransientError); + } + + public function testInvalidInputReturnsNonTransientError(): void + { + $vatNumber = 'XX12345678'; + $response = Mockery::mock(Response::class); + + $this->httpClient + ->shouldReceive('timeout') + ->andReturnSelf(); + + $this->httpClient + ->shouldReceive('post') + ->andReturn($response); + + $response + ->shouldReceive('successful') + ->andReturn(true); + + $response + ->shouldReceive('status') + ->andReturn(200); + + $response + ->shouldReceive('json') + ->andReturn([ + 'actionSucceed' => false, + 'errorWrappers' => [ + ['error' => 'INVALID_INPUT'] + ] + ]); + + $this->logger + ->shouldReceive('warning') + ->once(); + + $result = $this->service->validateVatNumber($vatNumber); + + $this->assertFalse($result->valid); + $this->assertFalse($result->isTransientError); + } + + public function testHttpErrorReturnsTransientError(): void { $vatNumber = 'DE123456789'; $response = Mockery::mock(Response::class); @@ -116,20 +248,22 @@ public function testApiErrorLogsWarningAndReturnsFalse(): void ->shouldReceive('body') ->andReturn('Service Unavailable'); + $response + ->shouldReceive('json') + ->andReturn([]); + $this->logger ->shouldReceive('warning') ->once() - ->with('VIES API request failed', Mockery::on(function ($context) use ($vatNumber) { - return $context['status'] === 503 - && $context['vat_number'] === $vatNumber; - })); + ->with('VIES HTTP error response', Mockery::any()); $result = $this->service->validateVatNumber($vatNumber); $this->assertFalse($result->valid); + $this->assertTrue($result->isTransientError); } - public function testConnectionExceptionLogsErrorAndReturnsFalse(): void + public function testConnectionExceptionReturnsTransientError(): void { $vatNumber = 'FR12345678901'; @@ -144,18 +278,16 @@ public function testConnectionExceptionLogsErrorAndReturnsFalse(): void $this->logger ->shouldReceive('error') ->once() - ->with('VIES API connection error', Mockery::on(function ($context) use ($vatNumber) { - return str_contains($context['error'], 'Connection timeout') - && $context['vat_number'] === $vatNumber; - })); + ->with('VIES connection error', Mockery::any()); $result = $this->service->validateVatNumber($vatNumber); $this->assertFalse($result->valid); + $this->assertTrue($result->isTransientError); $this->assertEquals('FR', $result->countryCode); } - public function testUnexpectedExceptionLogsErrorAndReturnsFalse(): void + public function testUnexpectedExceptionReturnsTransientError(): void { $vatNumber = 'ES12345678'; @@ -170,13 +302,12 @@ public function testUnexpectedExceptionLogsErrorAndReturnsFalse(): void $this->logger ->shouldReceive('error') ->once() - ->with('VIES validation exception', Mockery::on(function ($context) { - return str_contains($context['error'], 'Unexpected error'); - })); + ->with('VIES validation exception', Mockery::any()); $result = $this->service->validateVatNumber($vatNumber); $this->assertFalse($result->valid); + $this->assertTrue($result->isTransientError); } protected function tearDown(): void diff --git a/frontend/src/api/vat.client.ts b/frontend/src/api/vat.client.ts index 825c8e7bfd..68068b4de2 100644 --- a/frontend/src/api/vat.client.ts +++ b/frontend/src/api/vat.client.ts @@ -1,12 +1,17 @@ import {api} from "./client.ts"; import {GenericDataResponse, IdParam} from "../types.ts"; +export type VatValidationStatus = 'PENDING' | 'VALIDATING' | 'VALID' | 'INVALID' | 'FAILED'; + export interface AccountVatSetting { id: number; account_id: number; vat_registered: boolean; vat_number: string | null; vat_validated: boolean; + vat_validation_status: VatValidationStatus; + vat_validation_error: string | null; + vat_validation_attempts: number; vat_validation_date: string | null; business_name: string | null; business_address: string | null; diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettingsForm.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettingsForm.tsx index 630cd88e91..5322d6e039 100644 --- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettingsForm.tsx +++ b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettingsForm.tsx @@ -1,12 +1,13 @@ import {useEffect, useState} from 'react'; import {t} from '@lingui/macro'; -import {Alert, Button, Group, Radio, Stack, Text, TextInput} from '@mantine/core'; -import {IconAlertCircle, IconCheck} from '@tabler/icons-react'; +import {Alert, Button, Group, Loader, Radio, Stack, Text, TextInput} from '@mantine/core'; +import {IconAlertCircle, IconCheck, IconClock, IconRefresh} from '@tabler/icons-react'; import {Card} from '../../../../../../common/Card'; import {useGetAccountVatSetting} from '../../../../../../../queries/useGetAccountVatSetting.ts'; import {useUpsertAccountVatSetting} from '../../../../../../../mutations/useUpsertAccountVatSetting.ts'; import {showError, showSuccess} from '../../../../../../../utilites/notifications.tsx'; import {Account} from '../../../../../../../types.ts'; +import {VatValidationStatus} from '../../../../../../../api/vat.client.ts'; interface VatSettingsFormProps { account: Account; @@ -35,12 +36,91 @@ const validateVatNumber = (vatNumber: string): {valid: boolean; error?: string} return {valid: true}; }; +const ValidationStatusAlert = ({ + status, + error, + businessName, + attempts, +}: { + status: VatValidationStatus; + error: string | null; + businessName: string | null; + attempts: number; +}) => { + switch (status) { + case 'VALID': + return ( + }> + + {t`VAT number validated successfully`} + + {businessName && ( + + {businessName} + + )} + + ); + + case 'PENDING': + case 'VALIDATING': + return ( + : }> + + {status === 'VALIDATING' + ? t`Validating your VAT number...` + : t`Your VAT number is queued for validation`} + + + {t`We'll validate your VAT number in the background. If there are any issues, we'll let you know.`} + + + ); + + case 'INVALID': + return ( + }> + + {t`VAT number validation failed`} + + + {error || t`The VAT number could not be validated. Please check the number and try again.`} + + + ); + + case 'FAILED': + return ( + }> + + {t`VAT validation service temporarily unavailable`} + + + {t`We were unable to validate your VAT number after multiple attempts. We'll continue trying in the background. Please check back later.`} + {attempts > 0 && ` (${t`Attempts`}: ${attempts})`} + + + ); + + default: + return null; + } +}; + export const VatSettingsForm = ({account, onSuccess, showCard = true}: VatSettingsFormProps) => { const [vatRegistered, setVatRegistered] = useState(''); const [vatNumber, setVatNumber] = useState(''); const [vatError, setVatError] = useState(); - const vatSettingQuery = useGetAccountVatSetting(account.id); + const vatSettingQuery = useGetAccountVatSetting(account.id, { + refetchInterval: (query) => { + const data = query.state.data; + if (data?.vat_validation_status === 'PENDING' || data?.vat_validation_status === 'VALIDATING') { + return 5000; + } + return false; + }, + }); const upsertMutation = useUpsertAccountVatSetting(account.id); const existingSettings = vatSettingQuery.data; @@ -80,16 +160,20 @@ export const VatSettingsForm = ({account, onSuccess, showCard = true}: VatSettin vat_number: vatRegistered === 'yes' ? vatNumber.toUpperCase().trim() : null, }); - if (result.data.vat_registered && result.data.vat_validated) { - showSuccess(t`VAT settings saved and validated successfully`); - } else if (result.data.vat_registered && !result.data.vat_validated) { - showError(t`VAT settings saved but validation failed. Please check your VAT number.`); + if (!result.data.vat_registered) { + showSuccess(t`VAT settings saved successfully`); + } else if (result.data.vat_validation_status === 'VALID') { + showSuccess(t`VAT number validated successfully`); + } else if (result.data.vat_validation_status === 'INVALID') { + showError(t`VAT number validation failed. Please check your VAT number.`); + } else if (result.data.vat_validation_status === 'PENDING') { + showSuccess(t`VAT settings saved. We're validating your VAT number in the background.`); } else { showSuccess(t`VAT settings saved successfully`); } onSuccess?.(); - } catch (error) { + } catch { showError(t`Failed to save VAT settings. Please try again.`); } }; @@ -99,6 +183,8 @@ export const VatSettingsForm = ({account, onSuccess, showCard = true}: VatSettin (vatRegistered === 'yes' && vatNumber.trim().length >= 10) ); + const isCurrentVatNumber = existingSettings?.vat_number?.toUpperCase() === vatNumber.toUpperCase().trim(); + const formContent = ( - {existingSettings?.vat_validated && !vatError && - existingSettings.vat_number?.toUpperCase() === vatNumber.toUpperCase().trim() && ( - }> - - {t`Valid VAT number`} - - {existingSettings.business_name && ( - - {existingSettings.business_name} - - )} - - )} - - {existingSettings?.vat_number && !existingSettings.vat_validated && !vatError && - existingSettings.vat_number?.toUpperCase() === vatNumber.toUpperCase().trim() && ( - }> - - {t`VAT number validation failed. Please check your number and try again.`} - - + {existingSettings?.vat_number && !vatError && isCurrentVatNumber && ( + )} )} @@ -165,9 +237,9 @@ export const VatSettingsForm = ({account, onSuccess, showCard = true}: VatSettin > {t`Save VAT Settings`} - {vatRegistered === 'yes' && ( + {vatRegistered === 'yes' && !isCurrentVatNumber && ( - {t`Your VAT number will be validated automatically when you save`} + {t`Your VAT number will be validated when you save`} )}