2026-01-27 00:24:22 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-01-27 16:23:12 +00:00
|
|
|
namespace Core\Mod\Commerce\Services;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-01-27 16:23:12 +00:00
|
|
|
use Core\Mod\Commerce\Contracts\Orderable;
|
|
|
|
|
use Core\Mod\Commerce\Models\TaxRate;
|
2026-01-27 17:39:12 +00:00
|
|
|
use Core\Tenant\Models\Workspace;
|
2026-01-27 00:24:22 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tax calculation service.
|
|
|
|
|
*
|
|
|
|
|
* Handles UK VAT, EU OSS, US state taxes, and Australian GST.
|
|
|
|
|
*/
|
|
|
|
|
class TaxService
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* Cache TTL for validated tax IDs (24 hours).
|
|
|
|
|
*/
|
|
|
|
|
protected const VALIDATION_CACHE_TTL = 86400;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculate tax for an amount.
|
|
|
|
|
*/
|
|
|
|
|
public function calculate(Workspace $workspace, float $amount): TaxResult
|
|
|
|
|
{
|
|
|
|
|
if (! config('commerce.tax.enabled', true)) {
|
|
|
|
|
return new TaxResult(0, 0, null, null, true, 'Tax disabled');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for tax exemption
|
|
|
|
|
if ($workspace->tax_exempt) {
|
|
|
|
|
return new TaxResult(0, 0, null, $workspace->billing_country, true, 'Tax exempt');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$country = strtoupper($workspace->billing_country ?? 'GB');
|
|
|
|
|
$state = $workspace->billing_state;
|
|
|
|
|
$taxId = $workspace->tax_id;
|
|
|
|
|
|
|
|
|
|
// B2B reverse charge for EU/UK
|
|
|
|
|
if ($taxId && $this->isValidTaxId($taxId, $country)) {
|
|
|
|
|
if ($this->isReverseChargeApplicable($country)) {
|
|
|
|
|
return new TaxResult(0, 0, 'reverse_charge', $country, true, 'B2B reverse charge');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find applicable tax rate
|
|
|
|
|
$taxRate = TaxRate::findForLocation($country, $state);
|
|
|
|
|
|
|
|
|
|
if (! $taxRate) {
|
|
|
|
|
// No tax rate found - could be a non-taxable jurisdiction
|
|
|
|
|
return new TaxResult(0, 0, null, $country, false, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$taxAmount = $taxRate->calculateTax($amount);
|
|
|
|
|
|
|
|
|
|
return new TaxResult(
|
|
|
|
|
taxAmount: $taxAmount,
|
|
|
|
|
taxRate: $taxRate->rate,
|
|
|
|
|
taxType: $taxRate->type,
|
|
|
|
|
jurisdiction: $taxRate->state_code
|
|
|
|
|
? "{$country}-{$taxRate->state_code}"
|
|
|
|
|
: $country,
|
|
|
|
|
isExempt: false,
|
|
|
|
|
exemptionReason: null
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculate tax for an Orderable (User or Workspace).
|
|
|
|
|
*/
|
|
|
|
|
public function calculateForOrderable(Orderable $orderable, float $amount): TaxResult
|
|
|
|
|
{
|
|
|
|
|
if (! config('commerce.tax.enabled', true)) {
|
|
|
|
|
return new TaxResult(0, 0, null, null, true, 'Tax disabled');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$country = strtoupper($orderable->getTaxCountry() ?? 'GB');
|
|
|
|
|
|
|
|
|
|
// Find applicable tax rate
|
|
|
|
|
$taxRate = TaxRate::findForLocation($country, null);
|
|
|
|
|
|
|
|
|
|
if (! $taxRate) {
|
|
|
|
|
return new TaxResult(0, 0, null, $country, false, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$taxAmount = $taxRate->calculateTax($amount);
|
|
|
|
|
|
|
|
|
|
return new TaxResult(
|
|
|
|
|
taxAmount: $taxAmount,
|
|
|
|
|
taxRate: $taxRate->rate,
|
|
|
|
|
taxType: $taxRate->type,
|
|
|
|
|
jurisdiction: $country,
|
|
|
|
|
isExempt: false,
|
|
|
|
|
exemptionReason: null
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get tax rate for a location.
|
|
|
|
|
*/
|
|
|
|
|
public function getRateForLocation(string $country, ?string $state = null): ?TaxRate
|
|
|
|
|
{
|
|
|
|
|
return TaxRate::findForLocation($country, $state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if reverse charge is applicable.
|
|
|
|
|
*/
|
|
|
|
|
public function isReverseChargeApplicable(string $country): bool
|
|
|
|
|
{
|
|
|
|
|
// UK B2B: No reverse charge (we're in UK)
|
|
|
|
|
if ($country === 'GB') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EU B2B: Reverse charge applies
|
|
|
|
|
if ($this->isEuCountry($country)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if country is in EU.
|
|
|
|
|
*/
|
|
|
|
|
public function isEuCountry(string $country): bool
|
|
|
|
|
{
|
|
|
|
|
$euCountries = [
|
|
|
|
|
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
|
|
|
|
|
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
|
|
|
|
|
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return in_array(strtoupper($country), $euCountries);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate a tax ID (VAT number, ABN, etc.).
|
|
|
|
|
*/
|
|
|
|
|
public function isValidTaxId(string $taxId, string $country): bool
|
|
|
|
|
{
|
|
|
|
|
if (! config('commerce.tax.validate_tax_ids', true)) {
|
|
|
|
|
// Skip validation if disabled
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$country = strtoupper($country);
|
|
|
|
|
|
|
|
|
|
return match (true) {
|
|
|
|
|
$country === 'GB' => $this->validateUkVat($taxId),
|
|
|
|
|
$this->isEuCountry($country) => $this->validateEuVat($taxId),
|
|
|
|
|
$country === 'AU' => $this->validateAbn($taxId),
|
|
|
|
|
default => true, // Accept as valid if we can't validate
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate UK VAT number via HMRC API.
|
|
|
|
|
*
|
|
|
|
|
* Uses the free HMRC VAT API to verify VAT numbers.
|
|
|
|
|
* Results are cached for 24 hours to reduce API calls.
|
|
|
|
|
*
|
|
|
|
|
* @see https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/vat-api
|
|
|
|
|
*/
|
|
|
|
|
protected function validateUkVat(string $vatNumber): bool
|
|
|
|
|
{
|
|
|
|
|
// Basic format validation: GB followed by 9 or 12 digits
|
|
|
|
|
$vatNumber = strtoupper(str_replace([' ', '-'], '', $vatNumber));
|
|
|
|
|
|
|
|
|
|
if (! preg_match('/^GB(\d{9}|\d{12})$/', $vatNumber)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract just the numeric part for HMRC API
|
|
|
|
|
$vatNumberOnly = substr($vatNumber, 2);
|
|
|
|
|
|
|
|
|
|
// Check cache first
|
|
|
|
|
$cacheKey = "vat_validation:uk:{$vatNumberOnly}";
|
|
|
|
|
if (Cache::has($cacheKey)) {
|
|
|
|
|
return Cache::get($cacheKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip API validation if disabled or in testing
|
|
|
|
|
if (! config('commerce.tax.validate_tax_ids_api', true) || app()->environment('testing')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// HMRC Check VAT Number API (free, no auth required)
|
|
|
|
|
$response = Http::timeout(10)
|
|
|
|
|
->get("https://api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup/{$vatNumberOnly}");
|
|
|
|
|
|
|
|
|
|
if ($response->successful()) {
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
$isValid = isset($data['target']['vatNumber']);
|
|
|
|
|
|
|
|
|
|
Cache::put($cacheKey, $isValid, self::VALIDATION_CACHE_TTL);
|
|
|
|
|
|
|
|
|
|
return $isValid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If API returns 404, the VAT number doesn't exist
|
|
|
|
|
if ($response->status() === 404) {
|
|
|
|
|
Cache::put($cacheKey, false, self::VALIDATION_CACHE_TTL);
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For other errors, log and allow (fail open for availability)
|
|
|
|
|
Log::warning('HMRC VAT validation API error', [
|
|
|
|
|
'vat_number' => $vatNumberOnly,
|
|
|
|
|
'status' => $response->status(),
|
|
|
|
|
'body' => $response->body(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::warning('HMRC VAT validation failed', [
|
|
|
|
|
'vat_number' => $vatNumberOnly,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Fail open - don't block transactions if API is unavailable
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate EU VAT number via VIES (VAT Information Exchange System).
|
|
|
|
|
*
|
|
|
|
|
* Uses the EU Commission's VIES SOAP service to verify VAT numbers.
|
|
|
|
|
* Results are cached for 24 hours to reduce API calls.
|
|
|
|
|
*
|
|
|
|
|
* @see https://ec.europa.eu/taxation_customs/vies/
|
|
|
|
|
*/
|
|
|
|
|
protected function validateEuVat(string $vatNumber): bool
|
|
|
|
|
{
|
|
|
|
|
// Basic format validation
|
|
|
|
|
$vatNumber = strtoupper(str_replace([' ', '-'], '', $vatNumber));
|
|
|
|
|
|
|
|
|
|
if (strlen($vatNumber) < 4) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract country code (first 2 characters)
|
|
|
|
|
$countryCode = substr($vatNumber, 0, 2);
|
|
|
|
|
$vatNumberOnly = substr($vatNumber, 2);
|
|
|
|
|
|
|
|
|
|
// Validate country code is EU
|
|
|
|
|
if (! $this->isEuCountry($countryCode)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check cache first
|
|
|
|
|
$cacheKey = "vat_validation:eu:{$countryCode}:{$vatNumberOnly}";
|
|
|
|
|
if (Cache::has($cacheKey)) {
|
|
|
|
|
return Cache::get($cacheKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip API validation if disabled or in testing
|
|
|
|
|
if (! config('commerce.tax.validate_tax_ids_api', true) || app()->environment('testing')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// VIES REST API (EU Commission)
|
|
|
|
|
$response = Http::timeout(15)
|
|
|
|
|
->asJson()
|
|
|
|
|
->post('https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number', [
|
|
|
|
|
'countryCode' => $countryCode,
|
|
|
|
|
'vatNumber' => $vatNumberOnly,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($response->successful()) {
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
$isValid = $data['valid'] ?? false;
|
|
|
|
|
|
|
|
|
|
Cache::put($cacheKey, $isValid, self::VALIDATION_CACHE_TTL);
|
|
|
|
|
|
|
|
|
|
return $isValid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Log non-success responses
|
|
|
|
|
Log::warning('VIES VAT validation API error', [
|
|
|
|
|
'country_code' => $countryCode,
|
|
|
|
|
'vat_number' => $vatNumberOnly,
|
|
|
|
|
'status' => $response->status(),
|
|
|
|
|
'body' => $response->body(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Fail open for availability
|
|
|
|
|
return true;
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::warning('VIES VAT validation failed', [
|
|
|
|
|
'country_code' => $countryCode,
|
|
|
|
|
'vat_number' => $vatNumberOnly,
|
|
|
|
|
'error' => $e->getMessage(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Fail open - don't block transactions if API is unavailable
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate Australian Business Number (ABN).
|
|
|
|
|
*/
|
|
|
|
|
protected function validateAbn(string $abn): bool
|
|
|
|
|
{
|
|
|
|
|
// ABN is 11 digits
|
|
|
|
|
$abn = preg_replace('/[^0-9]/', '', $abn);
|
|
|
|
|
|
|
|
|
|
if (strlen($abn) !== 11) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ABN checksum validation
|
|
|
|
|
$weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19];
|
|
|
|
|
|
|
|
|
|
// Subtract 1 from first digit
|
|
|
|
|
$abn[0] = (int) $abn[0] - 1;
|
|
|
|
|
|
|
|
|
|
$sum = 0;
|
|
|
|
|
for ($i = 0; $i < 11; $i++) {
|
|
|
|
|
$sum += (int) $abn[$i] * $weights[$i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $sum % 89 === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get tax type label.
|
|
|
|
|
*/
|
|
|
|
|
public function getTaxTypeLabel(string $type): string
|
|
|
|
|
{
|
|
|
|
|
return match ($type) {
|
|
|
|
|
'vat' => 'VAT',
|
|
|
|
|
'gst' => 'GST',
|
|
|
|
|
'sales_tax' => 'Sales Tax',
|
|
|
|
|
default => 'Tax',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tax calculation result.
|
|
|
|
|
*/
|
|
|
|
|
class TaxResult
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
public readonly float $taxAmount,
|
|
|
|
|
public readonly float $taxRate,
|
|
|
|
|
public readonly ?string $taxType,
|
|
|
|
|
public readonly ?string $jurisdiction,
|
|
|
|
|
public readonly bool $isExempt,
|
|
|
|
|
public readonly ?string $exemptionReason,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get total including tax.
|
|
|
|
|
*/
|
|
|
|
|
public function addToAmount(float $amount): float
|
|
|
|
|
{
|
|
|
|
|
return $amount + $this->taxAmount;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if tax applies.
|
|
|
|
|
*/
|
|
|
|
|
public function hasTax(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->taxAmount > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get formatted rate.
|
|
|
|
|
*/
|
|
|
|
|
public function getFormattedRate(): string
|
|
|
|
|
{
|
|
|
|
|
return number_format($this->taxRate, 1).'%';
|
|
|
|
|
}
|
|
|
|
|
}
|