php-commerce/Services/TaxService.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:23:12 +00:00

386 lines
11 KiB
PHP

<?php
namespace Core\Mod\Commerce\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Core\Mod\Commerce\Contracts\Orderable;
use Core\Mod\Commerce\Models\TaxRate;
use Core\Mod\Tenant\Models\Workspace;
/**
* 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).'%';
}
}