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`}
)}