├── .github ├── dependabot.yml └── workflows │ ├── coding-standards.yml │ ├── update-changelog.yml │ └── tests.yml ├── src ├── Exceptions │ └── VATCheckUnavailableException.php ├── Facades │ └── VatCalculator.php ├── Rules │ └── ValidVatNumber.php ├── VatCalculatorServiceProvider.php ├── Traits │ └── BillableWithinTheEU.php ├── Http │ └── CurlClient.php └── VatCalculator.php ├── LICENSE.md ├── composer.json └── config └── vat_calculator.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: friday 8 | -------------------------------------------------------------------------------- /.github/workflows/coding-standards.yml: -------------------------------------------------------------------------------- 1 | name: Coding Standards 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | uses: laravel/.github/.github/workflows/coding-standards.yml@main 8 | -------------------------------------------------------------------------------- /src/Exceptions/VATCheckUnavailableException.php: -------------------------------------------------------------------------------- 1 | mergeConfig(); 14 | $this->registerVatCalculator(); 15 | } 16 | 17 | protected function mergeConfig(): void 18 | { 19 | $this->mergeConfigFrom(__DIR__.'/../config/vat_calculator.php', 'vat_calculator'); 20 | } 21 | 22 | protected function registerVatCalculator(): void 23 | { 24 | $this->app->bind(VatCalculator::class, function ($app) { 25 | $config = $app->make(Repository::class); 26 | 27 | return new VatCalculator($config); 28 | }); 29 | 30 | $this->app->bind('vatcalculator', VatCalculator::class); 31 | } 32 | 33 | public function boot(): void 34 | { 35 | $this->publishes([ 36 | __DIR__.'/../config/vat_calculator.php' => config_path('vat_calculator.php'), 37 | ]); 38 | } 39 | 40 | public function provides(): array 41 | { 42 | return [VatCalculator::class, 'vatcalculator']; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mpociot/vat-calculator", 3 | "description": "EU VAT calculation, the way it should be.", 4 | "keywords": ["VAT", "Tax", "EU MOSS", "VAT ID", "Tax calculation", "VAT calculation", "Cashier"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Marcel Pociot", 9 | "email": "m.pociot@gmail.com" 10 | }, 11 | { 12 | "name": "Dries Vints", 13 | "homepage": "https://driesvints.com" 14 | } 15 | ], 16 | "require": { 17 | "php": "^7.3|~8.0.0|~8.1.0|~8.2.0|~8.3.0|~8.4.0|~8.5.0", 18 | "ext-json": "*", 19 | "ext-soap": "*" 20 | }, 21 | "require-dev": { 22 | "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", 23 | "mockery/mockery": "^1.3.3", 24 | "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", 25 | "phpunit/phpunit": "^9.5|^10.0|^11.0" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Mpociot\\VatCalculator\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Tests\\": "tests" 35 | } 36 | }, 37 | "extra": { 38 | "branch-alias": { 39 | "dev-master": "3.x-dev" 40 | }, 41 | "laravel": { 42 | "providers": [ 43 | "Mpociot\\VatCalculator\\VatCalculatorServiceProvider" 44 | ], 45 | "aliases": { 46 | "VatCalculator": "Mpociot\\VatCalculator\\Facades\\VatCalculator" 47 | } 48 | } 49 | }, 50 | "config": { 51 | "sort-packages": true 52 | }, 53 | "minimum-stability": "dev", 54 | "prefer-stable": true 55 | } 56 | -------------------------------------------------------------------------------- /src/Traits/BillableWithinTheEU.php: -------------------------------------------------------------------------------- 1 | userCountryCode = $countryCode; 32 | $this->userIsCompany = $company; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * @param string $countryCode 39 | * @return $this 40 | */ 41 | public function useTaxFrom($countryCode) 42 | { 43 | $this->userCountryCode = $countryCode ?? ''; 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @return $this 50 | */ 51 | public function asBusiness() 52 | { 53 | $this->userIsCompany = true; 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * @return $this 60 | */ 61 | public function asIndividual() 62 | { 63 | $this->userIsCompany = false; 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * Get the tax percentage to apply to the subscription. 70 | * 71 | * @return int 72 | */ 73 | public function getTaxPercent() 74 | { 75 | return VatCalculator::getTaxRateForCountry($this->userCountryCode, $this->userIsCompany) * 100; 76 | } 77 | 78 | /** 79 | * Get the tax percentage to apply to the subscription for Cashier > 6.0. 80 | * 81 | * @return int 82 | */ 83 | public function taxPercentage() 84 | { 85 | return $this->getTaxPercent(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /config/vat_calculator.php: -------------------------------------------------------------------------------- 1 | [ 18 | // Simple country code => rate mapping... 19 | // 'XX' => 0.17, 20 | 21 | // Country code with different rates and exceptions... 22 | // 'YY' => [ 23 | // 'rate' => 0.20, 24 | // 'rates' => [ 25 | // 'high' => 0.20, 26 | // 'low' => 0.09, 27 | // ], 28 | // 'exceptions' => [ 29 | // 'City' => 0.19, 30 | // 'Town' => [ 31 | // 'rate' => 0.10, 32 | // 'rates' => [ 33 | // 'high' => 0.10, 34 | // 'low' => 0.05, 35 | // ], 36 | // ], 37 | // ], 38 | // ], 39 | ], 40 | 41 | /* 42 | |-------------------------------------------------------------------------- 43 | | Business country code 44 | |-------------------------------------------------------------------------- 45 | | 46 | | This should be the country code where your business is located. 47 | | The business country code is used to calculate the correct VAT rate 48 | | when charging a B2B (company) customer inside your business country. 49 | | 50 | */ 51 | 52 | 'business_country_code' => '', 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Enable SOAP fault exception throwing 57 | |-------------------------------------------------------------------------- 58 | | 59 | | By default, SOAP faults for the VIES VAT API checks are handled 60 | | gracefully by returning them as false. However, you can enable 61 | | this setting to throw them as exceptions instead. 62 | | 63 | */ 64 | 65 | 'forward_soap_faults' => false, 66 | 67 | /* 68 | |-------------------------------------------------------------------------- 69 | | Change the SOAP timeout 70 | |-------------------------------------------------------------------------- 71 | | 72 | | By default, SOAP aborts the request to VIES after 30 seconds. 73 | | If you do not want to wait that long, you can reduce the timeout. 74 | | The timeout is specified in seconds. 75 | | 76 | */ 77 | 78 | 'soap_timeout' => 30, 79 | 80 | 'hmrc' => [ 81 | 'client_id' => env('HMRC_CLIENT_ID'), 82 | 'client_secret' => env('HMRC_CLIENT_SECRET'), 83 | ], 84 | 85 | ]; 86 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - '*.x' 8 | pull_request: 9 | schedule: 10 | - cron: '0 0 * * *' 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: true 18 | matrix: 19 | php: [7.3, 7.4, '8.0', 8.1, 8.2, 8.3, 8.4, 8.5] 20 | laravel: [6, 7, 8, 9, 10, 11, 12] 21 | exclude: 22 | - php: 7.3 23 | laravel: 9 24 | - php: 7.3 25 | laravel: 10 26 | - php: 7.3 27 | laravel: 11 28 | - php: 7.3 29 | laravel: 12 30 | - php: 7.4 31 | laravel: 9 32 | - php: 7.4 33 | laravel: 10 34 | - php: 7.4 35 | laravel: 11 36 | - php: 7.4 37 | laravel: 12 38 | - php: '8.0' 39 | laravel: 10 40 | - php: '8.0' 41 | laravel: 11 42 | - php: '8.0' 43 | laravel: 12 44 | - php: 8.1 45 | laravel: 6 46 | - php: 8.1 47 | laravel: 7 48 | - php: 8.1 49 | laravel: 11 50 | - php: 8.1 51 | laravel: 12 52 | - php: 8.2 53 | laravel: 6 54 | - php: 8.2 55 | laravel: 7 56 | - php: 8.2 57 | laravel: 8 58 | - php: 8.3 59 | laravel: 6 60 | - php: 8.3 61 | laravel: 7 62 | - php: 8.3 63 | laravel: 8 64 | - php: 8.3 65 | laravel: 9 66 | - php: 8.4 67 | laravel: 6 68 | - php: 8.4 69 | laravel: 7 70 | - php: 8.4 71 | laravel: 8 72 | - php: 8.4 73 | laravel: 9 74 | - php: 8.4 75 | laravel: 10 76 | - php: 8.5 77 | laravel: 6 78 | - php: 8.5 79 | laravel: 7 80 | - php: 8.5 81 | laravel: 8 82 | - php: 8.5 83 | laravel: 9 84 | - php: 8.5 85 | laravel: 10 86 | 87 | name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} 88 | 89 | steps: 90 | - name: Checkout code 91 | uses: actions/checkout@v6 92 | 93 | - name: Setup PHP 94 | uses: shivammathur/setup-php@v2 95 | with: 96 | php-version: ${{ matrix.php }} 97 | extensions: dom, curl, libxml, mbstring, zip, soap 98 | coverage: none 99 | 100 | - name: Install dependencies 101 | run: | 102 | composer require "illuminate/contracts=^${{ matrix.laravel }}" --dev --prefer-dist --no-interaction --no-update 103 | composer update --prefer-dist --no-interaction --no-progress 104 | 105 | - name: Execute tests 106 | run: vendor/bin/phpunit 107 | -------------------------------------------------------------------------------- /src/Http/CurlClient.php: -------------------------------------------------------------------------------- 1 | true, 18 | CURLOPT_HTTPHEADER => $headers, 19 | ]); 20 | 21 | $response = curl_exec($ch); 22 | 23 | if ($response === false) { 24 | throw new \RuntimeException('cURL GET error: '.curl_error($ch)); 25 | } 26 | 27 | curl_close($ch); 28 | 29 | return $response; 30 | } 31 | 32 | /** 33 | * Send a POST request with JSON body. 34 | * 35 | * @param array|string $data 36 | * 37 | * @throws \RuntimeException on cURL error 38 | */ 39 | public function post(string $url, array $headers = [], $data = [], bool $json = true): string 40 | { 41 | $ch = curl_init($url); 42 | 43 | $postFields = $json ? json_encode($data) : (is_array($data) ? http_build_query($data) : $data); 44 | 45 | if ($json) { 46 | $headers = array_merge(['Content-Type: application/json'], $headers); 47 | } 48 | 49 | curl_setopt_array($ch, [ 50 | CURLOPT_RETURNTRANSFER => true, 51 | CURLOPT_HTTPHEADER => $headers, 52 | CURLOPT_POST => true, 53 | CURLOPT_POSTFIELDS => $postFields, 54 | ]); 55 | 56 | $response = curl_exec($ch); 57 | 58 | if ($response === false) { 59 | throw new \RuntimeException('cURL POST error: '.curl_error($ch)); 60 | } 61 | 62 | curl_close($ch); 63 | 64 | return $response; 65 | } 66 | 67 | /** 68 | * Send a GET request and return response with HTTP status code and headers. 69 | * 70 | * @param string $url The URL to send the GET request to 71 | * @param array $headers Optional array of HTTP headers to include in the request 72 | * @return array Associative array containing statusCode, headers, and body 73 | * 74 | * @throws \RuntimeException on cURL error 75 | */ 76 | public function getWithStatus(string $url, array $headers = []): array 77 | { 78 | $ch = curl_init($url); 79 | 80 | curl_setopt_array($ch, [ 81 | CURLOPT_RETURNTRANSFER => true, 82 | CURLOPT_HTTPHEADER => $headers, 83 | CURLOPT_TIMEOUT => 30, 84 | CURLOPT_HEADER => true, // We want headers in output 85 | ]); 86 | 87 | $response = curl_exec($ch); 88 | 89 | if ($response === false) { 90 | throw new \RuntimeException('cURL GET error: '.curl_error($ch)); 91 | } 92 | 93 | $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); 94 | $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 95 | 96 | curl_close($ch); 97 | 98 | $header = substr($response, 0, $headerSize); 99 | $body = substr($response, $headerSize); 100 | 101 | return [ 102 | 'statusCode' => $statusCode, 103 | 'headers' => $header, 104 | 'body' => $body, 105 | ]; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/VatCalculator.php: -------------------------------------------------------------------------------- 1 | [ // Austria 38 | 'rate' => 0.20, 39 | 'exceptions' => [ 40 | 'Jungholz' => 0.19, 41 | 'Mittelberg' => 0.19, 42 | ], 43 | 'rates' => [ 44 | 'high' => 0.20, 45 | 'low' => 0.10, 46 | 'low1' => 0.13, 47 | 'parking' => 0.13, 48 | ], 49 | ], 50 | 'BE' => [ // Belgium 51 | 'rate' => 0.21, 52 | 'rates' => [ 53 | 'high' => 0.21, 54 | 'low' => 0.06, 55 | 'low1' => 0.12, 56 | 'parking' => 0.12, 57 | ], 58 | ], 59 | 'BG' => [ // Bulgaria 60 | 'rate' => 0.20, 61 | 'rates' => [ 62 | 'high' => 0.20, 63 | 'low' => 0.09, 64 | ], 65 | ], 66 | 'CY' => [ // Cyprus 67 | 'rate' => 0.19, 68 | 'rates' => [ 69 | 'high' => 0.19, 70 | 'low' => 0.05, 71 | 'low1' => 0.09, 72 | ], 73 | ], 74 | 'CZ' => [ // Czech Republic 75 | 'rate' => 0.21, 76 | 'rates' => [ 77 | 'high' => 0.21, 78 | 'low' => 0.12, 79 | ], 80 | ], 81 | 'DE' => [ // Germany 82 | 'rate' => 0.19, 83 | 'exceptions' => [ 84 | 'Heligoland' => 0, 85 | 'Büsingen am Hochrhein' => 0, 86 | ], 87 | 'rates' => [ 88 | 'high' => 0.19, 89 | 'low' => 0.07, 90 | ], 91 | ], 92 | 'DK' => [ // Denmark 93 | 'rate' => 0.25, 94 | 'rates' => [ 95 | 'high' => 0.25, 96 | ], 97 | ], 98 | 'EE' => [ // Estonia 99 | 'rate' => 0.24, 100 | 'rates' => [ 101 | 'high' => 0.24, 102 | 'low' => 0.09, 103 | ], 104 | ], 105 | 'EL' => [ // Hellenic Republic (Greece) 106 | 'rate' => 0.24, 107 | 'exceptions' => [ 108 | 'Mount Athos' => 0, 109 | ], 110 | 'rates' => [ 111 | 'high' => 0.24, 112 | 'low' => 0.06, 113 | 'low1' => 0.13, 114 | ], 115 | ], 116 | 'ES' => [ // Spain 117 | 'rate' => 0.21, 118 | 'exceptions' => [ 119 | 'Canary Islands' => 0, 120 | 'Ceuta' => 0, 121 | 'Melilla' => 0, 122 | ], 123 | 'rates' => [ 124 | 'high' => 0.21, 125 | 'low' => 0.10, 126 | 'super-reduced' => 0.04, 127 | ], 128 | ], 129 | 'FI' => [ // Finland 130 | 'rate' => 0.255, 131 | 'rates' => [ 132 | 'high' => 0.255, 133 | 'low' => 0.10, 134 | 'low1' => 0.14, 135 | ], 136 | ], 137 | 'FR' => [ // France 138 | 'rate' => 0.20, 139 | 'exceptions' => [ 140 | // Overseas France 141 | 'Reunion' => [ 142 | 'rate' => 0.085, 143 | 'rates' => [ 144 | 'high' => 0.085, 145 | 'low' => 0.021, 146 | 'low1' => 0.021, 147 | 'super-reduced' => 0.021, 148 | ], 149 | ], 150 | 'Martinique' => [ 151 | 'rate' => 0.085, 152 | 'rates' => [ 153 | 'high' => 0.085, 154 | 'low' => 0.021, 155 | 'low1' => 0.021, 156 | 'super-reduced' => 0.021, 157 | ], 158 | ], 159 | 'Guadeloupe' => [ 160 | 'rate' => 0.085, 161 | 'rates' => [ 162 | 'high' => 0.085, 163 | 'low' => 0.021, 164 | 'low1' => 0.021, 165 | 'super-reduced' => 0.021, 166 | ], 167 | ], 168 | 'Guyane' => 0, 169 | 'Mayotte' => 0, 170 | 'Saint-Barthélemy' => 0, 171 | 'Saint-Martin' => 0, 172 | 'Saint-Pierre-et-Miquelon' => 0, 173 | 'Wallis-et-Futuna' => 0, 174 | 'Polynésie française' => 0, 175 | 'Nouvelle-Calédonie' => 0, 176 | 'Terres australes et antarctiques françaises' => 0, 177 | 'Île de Clipperton' => 0, 178 | ], 179 | 'rates' => [ 180 | 'high' => 0.20, 181 | 'low' => 0.055, 182 | 'low1' => 0.10, 183 | 'super-reduced' => 0.021, 184 | ], 185 | ], 186 | 'GR' => [ // Greece 187 | 'rate' => 0.24, 188 | 'exceptions' => [ 189 | 'Mount Athos' => 0, 190 | ], 191 | 'rates' => [ 192 | 'high' => 0.24, 193 | 'low' => 0.06, 194 | 'low1' => 0.13, 195 | ], 196 | ], 197 | 'HR' => [ // Croatia 198 | 'rate' => 0.25, 199 | 'rates' => [ 200 | 'high' => 0.25, 201 | 'low' => 0.05, 202 | 'low1' => 0.13, 203 | ], 204 | ], 205 | 'HU' => [ // Hungary 206 | 'rate' => 0.27, 207 | 'rates' => [ 208 | 'high' => 0.27, 209 | 'low' => 0.05, 210 | 'low1' => 0.18, 211 | ], 212 | ], 213 | 'IE' => [ // Ireland 214 | 'rate' => 0.23, 215 | 'rates' => [ 216 | 'high' => 0.23, 217 | 'low' => 0.09, 218 | 'low1' => 0.135, 219 | 'super-reduced' => 0.048, 220 | 'parking' => 0.135, 221 | ], 222 | ], 223 | 'IT' => [ // Italy 224 | 'rate' => 0.22, 225 | 'exceptions' => [ 226 | 'Campione d\'Italia' => 0, 227 | 'Livigno' => 0, 228 | ], 229 | 'rates' => [ 230 | 'high' => 0.22, 231 | 'low' => 0.05, 232 | 'low1' => 0.10, 233 | 'super-reduced' => 0.04, 234 | ], 235 | ], 236 | 'LT' => [ // Lithuania 237 | 'rate' => 0.21, 238 | 'rates' => [ 239 | 'high' => 0.21, 240 | 'low' => 0.05, 241 | 'low1' => 0.09, 242 | ], 243 | ], 244 | 'LU' => [ // Luxembourg 245 | 'rate' => 0.17, 246 | 'rates' => [ 247 | 'high' => 0.17, 248 | 'low' => 0.08, 249 | 'super-reduced' => 0.03, 250 | 'parking' => 0.14, 251 | ], 252 | ], 253 | 'LV' => [ // Latvia 254 | 'rate' => 0.21, 255 | 'rates' => [ 256 | 'high' => 0.21, 257 | 'low' => 0.05, 258 | 'low1' => 0.12, 259 | ], 260 | ], 261 | 'MT' => [ // Malta 262 | 'rate' => 0.18, 263 | 'rates' => [ 264 | 'high' => 0.18, 265 | 'low' => 0.05, 266 | 'low1' => 0.07, 267 | ], 268 | ], 269 | 'NL' => [ // Netherlands 270 | 'rate' => 0.21, 271 | 'rates' => [ 272 | 'high' => 0.21, 273 | 'low' => 0.09, 274 | ], 275 | ], 276 | 'PL' => [ // Poland 277 | 'rate' => 0.23, 278 | 'rates' => [ 279 | 'high' => 0.23, 280 | 'low' => 0.05, 281 | 'low1' => 0.08, 282 | ], 283 | ], 284 | 'PT' => [ // Portugal 285 | 'rate' => 0.23, 286 | 'exceptions' => [ 287 | 'Azores' => 0.16, 288 | 'Madeira' => 0.22, 289 | ], 290 | 'rates' => [ 291 | 'high' => 0.23, 292 | 'low' => 0.06, 293 | 'low1' => 0.13, 294 | 'parking' => 0.13, 295 | ], 296 | ], 297 | 'RO' => [ // Romania 298 | 'rate' => 0.21, 299 | 'rates' => [ 300 | 'high' => 0.21, 301 | 'low' => 0.11, 302 | 'low1' => 0.09, // certain housing supplies will remain at the reduced rate of 9% during a transition period from August 2025 until 1 August 2026 303 | ], 304 | ], 305 | 'SE' => [ // Sweden 306 | 'rate' => 0.25, 307 | 'rates' => [ 308 | 'high' => 0.25, 309 | 'low' => 0.06, 310 | 'low1' => 0.12, 311 | ], 312 | ], 313 | 'SI' => [ // Slovenia 314 | 'rate' => 0.22, 315 | 'rates' => [ 316 | 'high' => 0.22, 317 | 'low' => 0.05, 318 | 'low1' => 0.095, 319 | ], 320 | ], 321 | 'SK' => [ // Slovakia 322 | 'rate' => 0.23, 323 | 'rates' => [ 324 | 'high' => 0.23, 325 | 'low' => 0.05, 326 | 'low1' => 0.19, 327 | ], 328 | ], 329 | 330 | // Countries associated with EU countries that have a special VAT rate -- https://www.easytax.co/en/countries/monaco/ 331 | 'MC' => [ // Monaco France 332 | 'rate' => 0.20, 333 | 'rates' => [ 334 | 'high' => 0.20, 335 | 'low' => 0.10, 336 | 'low1' => 0.055, 337 | ], 338 | ], 339 | 'IM' => [ // Isle of Man - United Kingdom -- https://www.gov.im/categories/tax-vat-and-your-money/customs-and-excise/technical-information-vat-duty-and-interest-rates/vat-rates/ 340 | 'rate' => 0.20, 341 | 'rates' => [ 342 | 'high' => 0.20, 343 | 'low' => 0.05, 344 | ], 345 | ], 346 | 347 | // Non-EU with their own VAT requirements -- https://www.estv.admin.ch/estv/en/home/value-added-tax/vat-rates-switzerland.html 348 | 'CH' => [ // Switzerland 349 | 'rate' => 0.081, 350 | 'rates' => [ 351 | 'high' => 0.081, 352 | 'low' => 0.026, 353 | 'super-reduced' => 0.038, 354 | ], 355 | ], 356 | ]; 357 | 358 | /** 359 | * All possible postal code exceptions. 360 | * 361 | * @var array 362 | */ 363 | protected $postalCodeExceptions = [ 364 | 'AT' => [ 365 | [ 366 | 'postalCode' => '/^6691$/', 367 | 'code' => 'AT', 368 | 'name' => 'Jungholz', 369 | ], 370 | [ 371 | 'postalCode' => '/^699[123]$/', 372 | 'city' => '/\bmittelberg\b/i', 373 | 'code' => 'AT', 374 | 'name' => 'Mittelberg', 375 | ], 376 | ], 377 | 'CH' => [ 378 | [ 379 | 'postalCode' => '/^8238$/', 380 | 'code' => 'DE', 381 | 'name' => 'Büsingen am Hochrhein', 382 | ], 383 | // The Italian city of Domodossola has a Swiss post office also 384 | [ 385 | 'postalCode' => '/^3907$/', 386 | 'code' => 'IT', 387 | ], 388 | ], 389 | 'DE' => [ 390 | [ 391 | 'postalCode' => '/^87491$/', 392 | 'code' => 'AT', 393 | 'name' => 'Jungholz', 394 | ], 395 | [ 396 | 'postalCode' => '/^8756[789]$/', 397 | 'city' => '/\bmittelberg\b/i', 398 | 'code' => 'AT', 399 | 'name' => 'Mittelberg', 400 | ], 401 | [ 402 | 'postalCode' => '/^78266$/', 403 | 'code' => 'DE', 404 | 'name' => 'Büsingen am Hochrhein', 405 | ], 406 | [ 407 | 'postalCode' => '/^27498$/', 408 | 'code' => 'DE', 409 | 'name' => 'Heligoland', 410 | ], 411 | ], 412 | 'ES' => [ 413 | [ 414 | 'postalCode' => '/^(5100[1-5]|5107[0-1]|51081)$/', 415 | 'code' => 'ES', 416 | 'name' => 'Ceuta', 417 | ], 418 | [ 419 | 'postalCode' => '/^(5200[0-6]|5207[0-1]|52081)$/', 420 | 'code' => 'ES', 421 | 'name' => 'Melilla', 422 | ], 423 | [ 424 | 'postalCode' => '/^(35\d{3}|38\d{3})$/', 425 | 'code' => 'ES', 426 | 'name' => 'Canary Islands', 427 | ], 428 | ], 429 | 'FR' => [ 430 | [ 431 | 'postalCode' => '/^971\d{2,}$/', 432 | 'code' => 'FR', 433 | 'name' => 'Guadeloupe', 434 | ], 435 | [ 436 | 'postalCode' => '/^972\d{2,}$/', 437 | 'code' => 'FR', 438 | 'name' => 'Martinique', 439 | ], 440 | [ 441 | 'postalCode' => '/^973\d{2,}$/', 442 | 'code' => 'FR', 443 | 'name' => 'Guyane', 444 | ], 445 | [ 446 | 'postalCode' => '/^974\d{2,}$/', 447 | 'code' => 'FR', 448 | 'name' => 'Reunion', 449 | ], 450 | [ 451 | 'postalCode' => '/^975\d{2,}$/', 452 | 'code' => 'FR', 453 | 'name' => 'Saint-Pierre-et-Miquelon', 454 | ], 455 | [ 456 | 'postalCode' => '/^976\d{2,}$/', 457 | 'code' => 'FR', 458 | 'name' => 'Mayotte', 459 | ], 460 | [ 461 | 'postalCode' => '/^977\d{2,}$/', 462 | 'code' => 'FR', 463 | 'name' => 'Saint-Barthélemy', 464 | ], 465 | [ 466 | 'postalCode' => '/^978\d{2,}$/', 467 | 'code' => 'FR', 468 | 'name' => 'Saint-Martin', 469 | ], 470 | [ 471 | 'postalCode' => '/^984\d{2,}$/', 472 | 'code' => 'FR', 473 | 'name' => 'Terres australes et antarctiques françaises', 474 | ], 475 | [ 476 | 'postalCode' => '/^986\d{2,}$/', 477 | 'code' => 'FR', 478 | 'name' => 'Wallis-et-Futuna', 479 | ], 480 | [ 481 | 'postalCode' => '/^987\d{2,}$/', 482 | 'code' => 'FR', 483 | 'name' => 'Polynésie française', 484 | ], 485 | [ 486 | 'postalCode' => '/^988\d{2,}$/', 487 | 'code' => 'FR', 488 | 'name' => 'Nouvelle-Calédonie', 489 | ], 490 | [ 491 | 'postalCode' => '/^989\d{2,}$/', 492 | 'code' => 'FR', 493 | 'name' => 'Île de Clipperton', 494 | ], 495 | ], 496 | 'GB' => [ 497 | // Akrotiri 498 | [ 499 | 'postalCode' => '/^BFPO57|BF12AT$/', 500 | 'code' => 'CY', 501 | ], 502 | // Dhekelia 503 | [ 504 | 'postalCode' => '/^BFPO58|BF12AU$/', 505 | 'code' => 'CY', 506 | ], 507 | ], 508 | 'GR' => [ 509 | [ 510 | 'postalCode' => '/^63086$/', 511 | 'code' => 'GR', 512 | 'name' => 'Mount Athos', 513 | ], 514 | ], 515 | 'IT' => [ 516 | [ 517 | 'postalCode' => '/^22061$/', 518 | 'city' => '/\bcampione\b/i', 519 | 'code' => 'IT', 520 | 'name' => "Campione d'Italia", 521 | ], 522 | [ 523 | 'postalCode' => '/^23041$/', 524 | 'city' => '/\blivigno\b/i', 525 | 'code' => 'IT', 526 | 'name' => 'Livigno', 527 | ], 528 | ], 529 | 'PT' => [ 530 | [ 531 | 'postalCode' => '/^9[0-4]\d{2,}(?:-\d+)*$/', 532 | 'code' => 'PT', 533 | 'name' => 'Madeira', 534 | ], 535 | [ 536 | 'postalCode' => '/^9[5-9]\d{2,}(?:-\d+)*$/', 537 | 'code' => 'PT', 538 | 'name' => 'Azores', 539 | ], 540 | ], 541 | ]; 542 | 543 | /** 544 | * Regular expression patterns per country code for VAT. 545 | * 546 | * @var array 547 | * 548 | * @link https://ec.europa.eu/taxation_customs/vies/faq.html?locale=en#item_11 549 | */ 550 | protected $patterns = [ 551 | 'AT' => 'U[A-Z\d]{8}', 552 | 'BE' => '(0\d{9}|\d{10})', 553 | 'BG' => '\d{9,10}', 554 | 'CY' => '\d{8}[A-Z]', 555 | 'CZ' => '\d{8,10}', 556 | 'DE' => '\d{9}', 557 | 'DK' => '(\d{2} ?){3}\d{2}', 558 | 'EE' => '\d{9}', 559 | 'EL' => '\d{9}', 560 | 'ES' => '([A-Z]\d{7}[A-Z]|\d{8}[A-Z]|[A-Z]\d{8})', 561 | 'FI' => '\d{8}', 562 | 'FR' => '[A-Z\d]{2}\d{9}', 563 | 'GB' => '(\d{9}|\d{12}|(GD|HA)\d{3})', 564 | 'HR' => '\d{11}', 565 | 'HU' => '\d{8}', 566 | 'IE' => '([A-Z\d]{8}|[A-Z\d]{9})', 567 | 'IT' => '\d{11}', 568 | 'LT' => '(\d{9}|\d{12})', 569 | 'LU' => '\d{8}', 570 | 'LV' => '\d{11}', 571 | 'MT' => '\d{8}', 572 | 'NL' => '\d{9}B\d{2}', 573 | 'PL' => '\d{10}', 574 | 'PT' => '\d{9}', 575 | 'RO' => '\d{2,10}', 576 | 'SE' => '\d{12}', 577 | 'SI' => '\d{8}', 578 | 'SK' => '\d{10}', 579 | ]; 580 | 581 | /** 582 | * @var float 583 | */ 584 | protected $netPrice = 0.0; 585 | 586 | /** 587 | * @var string 588 | */ 589 | protected $countryCode = ''; 590 | 591 | /** 592 | * @var string 593 | */ 594 | protected $postalCode = ''; 595 | 596 | /** 597 | * @var array 598 | */ 599 | protected $config; 600 | 601 | /** 602 | * @var float 603 | */ 604 | protected $taxValue = 0; 605 | 606 | /** 607 | * @var float 608 | */ 609 | protected $taxRate = 0; 610 | 611 | /** 612 | * The calculate net + tax value. 613 | * 614 | * @var float 615 | */ 616 | protected $value = 0; 617 | 618 | /** 619 | * @var bool 620 | */ 621 | protected $company = false; 622 | 623 | /** 624 | * @var string 625 | */ 626 | protected $businessCountryCode = ''; 627 | 628 | /** 629 | * @var string 630 | */ 631 | protected $ukHmrcTokenEndpoint = 'https://api.service.hmrc.gov.uk/oauth/token'; 632 | 633 | /** 634 | * @var string 635 | */ 636 | protected $ukValidationEndpoint = 'https://api.service.hmrc.gov.uk'; 637 | 638 | /** 639 | * @param \Illuminate\Contracts\Config\Repository|array 640 | */ 641 | public function __construct($config = []) 642 | { 643 | $this->curlClient = new CurlClient; 644 | 645 | $this->config = $config instanceof Repository ? $config->get('vat_calculator', []) : $config; 646 | 647 | if (isset($this->config['business_country_code'])) { 648 | $this->setBusinessCountryCode($this->config['business_country_code']); 649 | } 650 | } 651 | 652 | /** 653 | * Determines if you need to collect VAT for the given country code. 654 | * 655 | * @param string $countryCode 656 | * @return bool 657 | */ 658 | public function shouldCollectVAT($countryCode) 659 | { 660 | $countryCode = strtoupper($countryCode); 661 | 662 | return isset($this->taxRules[$countryCode]) || isset($this->config['rules'][$countryCode]); 663 | } 664 | 665 | /** 666 | * Calculate the VAT based on the net price, country code and indication if the 667 | * customer is a company or not. 668 | * 669 | * @param int|float $netPrice The net price to use for the calculation 670 | * @param null|string $countryCode The country code to use for the rate lookup 671 | * @param null|string $postalCode The postal code to use for the rate exception lookup 672 | * @param null|bool $company 673 | * @param null|string $type The type can be low or high 674 | * @return float 675 | */ 676 | public function calculate($netPrice, $countryCode = null, $postalCode = null, $company = null, $type = null) 677 | { 678 | if ($countryCode) { 679 | $this->setCountryCode($countryCode); 680 | } 681 | 682 | if ($postalCode) { 683 | $this->setPostalCode($postalCode); 684 | } 685 | 686 | if ($company && $company !== $this->isCompany()) { 687 | $this->setCompany($company); 688 | } 689 | 690 | $this->netPrice = floatval($netPrice); 691 | $this->taxRate = $this->getTaxRateForLocation($this->getCountryCode(), $this->getPostalCode(), $this->isCompany(), $type); 692 | $this->taxValue = round($this->taxRate * $this->netPrice, 2); 693 | $this->value = round($this->netPrice + $this->taxValue, 2); 694 | 695 | return $this->value; 696 | } 697 | 698 | /** 699 | * Calculate the net price on the gross price, country code and indication if the 700 | * customer is a company or not. 701 | * 702 | * @param int|float $gross The gross price to use for the calculation 703 | * @param null|string $countryCode The country code to use for the rate lookup 704 | * @param null|string $postalCode The postal code to use for the rate exception lookup 705 | * @param null|bool $company 706 | * @param null|string $type The type can be low or high 707 | * @return float 708 | */ 709 | public function calculateNet($gross, $countryCode = null, $postalCode = null, $company = null, $type = null) 710 | { 711 | if ($countryCode) { 712 | $this->setCountryCode($countryCode); 713 | } 714 | 715 | if ($postalCode) { 716 | $this->setPostalCode($postalCode); 717 | } 718 | 719 | if ($company && $company !== $this->isCompany()) { 720 | $this->setCompany($company); 721 | } 722 | 723 | $this->value = floatval($gross); 724 | $this->taxRate = $this->getTaxRateForLocation($this->getCountryCode(), $this->getPostalCode(), $this->isCompany(), $type); 725 | $this->taxValue = round($this->taxRate > 0 ? $this->value / (1 + $this->taxRate) * $this->taxRate : 0, 2); 726 | $this->netPrice = round($this->value - $this->taxValue, 2); 727 | 728 | return $this->netPrice; 729 | } 730 | 731 | /** 732 | * @return float 733 | */ 734 | public function getNetPrice() 735 | { 736 | return $this->netPrice; 737 | } 738 | 739 | /** 740 | * @return string 741 | */ 742 | public function getCountryCode() 743 | { 744 | return strtoupper($this->countryCode); 745 | } 746 | 747 | /** 748 | * @param mixed $countryCode 749 | */ 750 | public function setCountryCode($countryCode) 751 | { 752 | $this->countryCode = $countryCode ?? ''; 753 | } 754 | 755 | /** 756 | * @return string 757 | */ 758 | public function getPostalCode() 759 | { 760 | return $this->postalCode; 761 | } 762 | 763 | /** 764 | * @param mixed $postalCode 765 | */ 766 | public function setPostalCode($postalCode) 767 | { 768 | $this->postalCode = $postalCode ?? ''; 769 | } 770 | 771 | /** 772 | * @return float 773 | */ 774 | public function getTaxRate() 775 | { 776 | return $this->taxRate; 777 | } 778 | 779 | /** 780 | * @return bool 781 | */ 782 | public function isCompany() 783 | { 784 | return $this->company; 785 | } 786 | 787 | /** 788 | * @param bool $company 789 | */ 790 | public function setCompany($company) 791 | { 792 | $this->company = $company; 793 | } 794 | 795 | /** 796 | * @param string $businessCountryCode 797 | */ 798 | public function setBusinessCountryCode($businessCountryCode) 799 | { 800 | $this->businessCountryCode = $businessCountryCode; 801 | } 802 | 803 | /** 804 | * Returns the tax rate for the given country code. 805 | * This method is used to allow backwards compatibility. 806 | * 807 | * @param string $countryCode 808 | * @param bool $company 809 | * @param string|null $type 810 | * @return float 811 | */ 812 | public function getTaxRateForCountry($countryCode, $company = false, $type = null) 813 | { 814 | return $this->getTaxRateForLocation($countryCode, '', $company, $type); 815 | } 816 | 817 | /** 818 | * Returns all tax rates for the given country code. 819 | * 820 | * @param string $countryCode 821 | * @return array 822 | */ 823 | public function getTaxRatesForCountry($countryCode) 824 | { 825 | return $this->taxRules[$countryCode]['rates']; 826 | } 827 | 828 | /** 829 | * Returns the tax rate for the given country code. 830 | * If a postal code is provided, it will try to lookup the different 831 | * postal code exceptions that are possible. 832 | * 833 | * @param string $countryCode 834 | * @param string|null $postalCode 835 | * @param bool $company 836 | * @param string|null $type 837 | * @return float 838 | */ 839 | public function getTaxRateForLocation($countryCode, $postalCode = null, $company = false, $type = null) 840 | { 841 | $countryCode = strtoupper($countryCode); 842 | 843 | if ($company && $countryCode !== strtoupper($this->businessCountryCode)) { 844 | return 0; 845 | } 846 | 847 | $taxRules = $this->taxRules; 848 | 849 | if (isset($this->config['rules'][$countryCode])) { 850 | $configTax = $this->config['rules'][$countryCode]; 851 | 852 | if (is_array($configTax)) { 853 | $taxRules[$countryCode] = $configTax; 854 | } else { 855 | $taxRules[$countryCode]['rate'] = $configTax; 856 | } 857 | } 858 | 859 | if (isset($this->postalCodeExceptions[$countryCode]) && $postalCode) { 860 | foreach ($this->postalCodeExceptions[$countryCode] as $postalCodeException) { 861 | if (! preg_match($postalCodeException['postalCode'], $postalCode)) { 862 | continue; 863 | } 864 | 865 | if (isset($postalCodeException['name'])) { 866 | $rate = $taxRules[$postalCodeException['code']]['exceptions'][$postalCodeException['name']]; 867 | if (is_array($rate)) { 868 | $rate = $type 869 | ? ($rate['rates'][$type] ?? 0) 870 | : ($rate['rate'] ?? 0); 871 | } 872 | 873 | return $rate; 874 | } 875 | 876 | return $taxRules[$postalCodeException['code']]['rate']; 877 | } 878 | } 879 | 880 | if ($type) { 881 | return $taxRules[strtoupper($countryCode)]['rates'][$type] ?? 0; 882 | } 883 | 884 | return $taxRules[strtoupper($countryCode)]['rate'] ?? 0; 885 | } 886 | 887 | /** 888 | * @return float 889 | */ 890 | public function getTaxValue() 891 | { 892 | return $this->taxValue; 893 | } 894 | 895 | /** 896 | * Validate a VAT number format without checking if the VAT number was really issued. 897 | * 898 | * @param string $vatNumber 899 | * @return bool 900 | */ 901 | public function isValidVatNumberFormat($vatNumber) 902 | { 903 | $vatNumber = str_replace([' ', "\xC2\xA0", "\xA0", '-', '.', ','], '', trim($vatNumber)); 904 | 905 | if ($vatNumber === '') { 906 | return false; 907 | } 908 | 909 | $countryCode = substr($vatNumber, 0, 2); 910 | $vatNumber = substr($vatNumber, 2); 911 | 912 | if (! isset($this->patterns[$countryCode])) { 913 | return false; 914 | } 915 | 916 | return preg_match('/^'.$this->patterns[$countryCode].'$/', $vatNumber) > 0; 917 | } 918 | 919 | /** 920 | * @param string $vatNumber 921 | * @return bool 922 | * 923 | * @throws VATCheckUnavailableException 924 | */ 925 | public function isValidVATNumber($vatNumber) 926 | { 927 | $details = $this->getVATDetails($vatNumber); 928 | 929 | if ($details) { 930 | return is_array($details) ? isset($details['vatNumber']) : $details->valid; 931 | } 932 | 933 | return false; 934 | } 935 | 936 | /** 937 | * Get or refresh HMRC access token 938 | */ 939 | private function getHmrcAccessToken() 940 | { 941 | // Get token from HMRC 942 | $clientId = $this->config['hmrc']['client_id']; 943 | $clientSecret = $this->config['hmrc']['client_secret']; 944 | 945 | if (! $clientId || ! $clientSecret) { 946 | throw new VATCheckUnavailableException('HMRC API credentials not configured'); 947 | } 948 | 949 | // Note: This endpoint requires x-www-form-urlencoded, so override the content-type. 950 | $headers = [ 951 | 'Content-Type: application/x-www-form-urlencoded', 952 | ]; 953 | 954 | $response = $this->curlClient->post($this->ukHmrcTokenEndpoint, $headers, http_build_query([ 955 | 'grant_type' => 'client_credentials', 956 | 'client_id' => $clientId, 957 | 'client_secret' => $clientSecret, 958 | ]), false); 959 | 960 | $data = json_decode($response, true); 961 | 962 | if (! isset($data['access_token'])) { 963 | throw new VATCheckUnavailableException('Failed to retrieve HMRC access token'); 964 | } 965 | 966 | return $data['access_token']; 967 | } 968 | 969 | /** 970 | * @param string $vatNumber 971 | * @return object|false 972 | * 973 | * @throws VATCheckUnavailableException 974 | */ 975 | public function getVATDetails($vatNumber) 976 | { 977 | $vatNumber = str_replace([' ', "\xC2\xA0", "\xA0", '-', '.', ','], '', trim($vatNumber)); 978 | $countryCode = substr($vatNumber, 0, 2); 979 | $vatNumber = substr($vatNumber, 2); 980 | 981 | if (strtoupper($countryCode) === 'GB') { 982 | try { 983 | $accessToken = $this->getHmrcAccessToken(); 984 | 985 | $responseData = $this->curlClient->getWithStatus( 986 | "$this->ukValidationEndpoint/organisations/vat/check-vat-number/lookup/$vatNumber", 987 | [ 988 | "Authorization: Bearer $accessToken", 989 | 'Accept: application/vnd.hmrc.2.0+json', 990 | ] 991 | ); 992 | 993 | $apiStatusCode = $responseData['statusCode']; 994 | 995 | if ($apiStatusCode === 400 || $apiStatusCode === 404) { 996 | return false; 997 | } 998 | 999 | if ($apiStatusCode === 200) { 1000 | $apiResponse = json_decode($responseData['body'], true); 1001 | 1002 | if (json_last_error() !== JSON_ERROR_NONE) { 1003 | throw new VATCheckUnavailableException('Invalid JSON response from UK VAT check service'); 1004 | } 1005 | 1006 | return $apiResponse['target'] ?? false; 1007 | } 1008 | 1009 | throw new VATCheckUnavailableException("The UK VAT check service is currently unavailable (status code $apiStatusCode). Please try again later."); 1010 | } catch (VATCheckUnavailableException $e) { 1011 | throw $e; 1012 | } catch (Exception $e) { 1013 | throw new VATCheckUnavailableException('An unexpected error occurred while validating the VAT number'); 1014 | } 1015 | } else { 1016 | $this->initSoapClient(); 1017 | $client = $this->soapClient; 1018 | 1019 | if ($client) { 1020 | try { 1021 | return $client->checkVat([ 1022 | 'countryCode' => $countryCode, 1023 | 'vatNumber' => $vatNumber, 1024 | ]); 1025 | } catch (SoapFault $e) { 1026 | if ($this->config['forward_soap_faults'] ?? false) { 1027 | throw new VATCheckUnavailableException($e->getMessage(), $e->getCode(), $e->getPrevious()); 1028 | } 1029 | 1030 | return false; 1031 | } 1032 | } 1033 | 1034 | throw new VATCheckUnavailableException('The VAT check service is currently unavailable. Please try again later.'); 1035 | } 1036 | } 1037 | 1038 | /** 1039 | * @return void 1040 | * 1041 | * @throws VATCheckUnavailableException 1042 | */ 1043 | public function initSoapClient() 1044 | { 1045 | if (is_object($this->soapClient) || $this->soapClient === false) { 1046 | return; 1047 | } 1048 | 1049 | // Set's default timeout time. 1050 | $timeout = 30; 1051 | 1052 | if (isset($this->config['soap_timeout'])) { 1053 | $timeout = $this->config['soap_timeout']; 1054 | } 1055 | 1056 | $context = stream_context_create(['http' => ['timeout' => $timeout]]); 1057 | 1058 | try { 1059 | $this->soapClient = new SoapClient(self::VAT_SERVICE_URL, ['stream_context' => $context]); 1060 | } catch (SoapFault $e) { 1061 | if ($this->config['forward_soap_faults'] ?? false) { 1062 | throw new VATCheckUnavailableException($e->getMessage(), $e->getCode(), $e->getPrevious()); 1063 | } 1064 | 1065 | $this->soapClient = false; 1066 | } 1067 | } 1068 | 1069 | /** 1070 | * @param SoapClient $soapClient 1071 | */ 1072 | public function setSoapClient($soapClient) 1073 | { 1074 | $this->soapClient = $soapClient; 1075 | } 1076 | 1077 | public function setupCurlClient($curlClient) 1078 | { 1079 | $this->curlClient = $curlClient; 1080 | } 1081 | 1082 | /** 1083 | * @return $this 1084 | * 1085 | * @internal This method is not covered by our BC policy. 1086 | */ 1087 | public function testing($curlClient) 1088 | { 1089 | $this->ukHmrcTokenEndpoint = 'https://test-api.service.hmrc.gov.uk/oauth/token'; 1090 | $this->ukValidationEndpoint = 'https://test-api.service.hmrc.gov.uk'; 1091 | 1092 | $this->config['hmrc']['client_id'] = 'test-client-id'; 1093 | $this->config['hmrc']['client_secret'] = 'test-client-secret'; 1094 | 1095 | $this->setupCurlClient($curlClient); 1096 | 1097 | return $this; 1098 | } 1099 | } 1100 | --------------------------------------------------------------------------------