Completed
Push — master ( 847696...e0da2f )
by Marcel
11:03
created

VatCalculator::getTaxRateForLocation()   C

Complexity

Conditions 11
Paths 8

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
c 2
b 1
f 0
dl 0
loc 27
rs 5.2653
cc 11
eloc 16
nc 8
nop 3

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Mpociot\VatCalculator;
4
5
use Illuminate\Contracts\Config\Repository;
6
use Mpociot\VatCalculator\Exceptions\VATCheckUnavailableException;
7
use SoapClient;
8
use SoapFault;
9
10
class VatCalculator
11
{
12
    /**
13
     * VAT Service check URL provided by the EU.
14
     */
15
    const VAT_SERVICE_URL = 'http://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl';
16
17
    /**
18
     * We're using the free ip2c service to lookup IP 2 country.
19
     */
20
    const GEOCODE_SERVICE_URL = 'http://ip2c.org/';
21
22
    protected $soapClient;
23
24
    /**
25
     * All available tax rules and their exceptions.
26
     *
27
     * Taken from: http://ec.europa.eu/taxation_customs/resources/documents/taxation/vat/how_vat_works/rates/vat_rates_en.pdf
28
     *
29
     * @var array
30
     */
31
    protected $taxRules = [
32
        'AT' => [ // Austria
33
            'rate'       => 0.20,
34
            'exceptions' => [
35
                'Jungholz'   => 0.19,
36
                'Mittelberg' => 0.19,
37
            ],
38
        ],
39
        'BE' => [ // Belgium
40
            'rate' => 0.21,
41
        ],
42
        'BG' => [ // Bulgaria
43
            'rate' => 0.20,
44
        ],
45
        'CY' => [ // Cyprus
46
            'rate' => 0.19,
47
        ],
48
        'CZ' => [ // Czech Republic
49
            'rate' => 0.21,
50
        ],
51
        'DE' => [ // Germany
52
            'rate'       => 0.19,
53
            'exceptions' => [
54
                'Heligoland'            => 0,
55
                'Büsingen am Hochrhein' => 0,
56
            ],
57
        ],
58
        'DK' => [ // Denmark
59
            'rate' => 0.25,
60
        ],
61
        'EE' => [ // Estonia
62
            'rate' => 0.20,
63
        ],
64
        'EL' => [ // Hellenic Republic (Greece)
65
            'rate'       => 0.24,
66
            'exceptions' => [
67
                'Mount Athos' => 0,
68
            ],
69
        ],
70
        'ES' => [ // Spain
71
            'rate'       => 0.21,
72
            'exceptions' => [
73
                'Canary Islands' => 0,
74
                'Ceuta'          => 0,
75
                'Melilla'        => 0,
76
            ],
77
        ],
78
        'FI' => [ // Finland
79
            'rate' => 0.24,
80
        ],
81
        'FR' => [ // France
82
            'rate' => 0.20,
83
        ],
84
        'GB' => [ // United Kingdom
85
            'rate'       => 0.20,
86
            'exceptions' => [
87
                // UK RAF Bases in Cyprus are taxed at Cyprus rate
88
                'Akrotiri' => 0.19,
89
                'Dhekelia' => 0.19,
90
            ],
91
        ],
92
        'GR' => [ // Greece
93
            'rate'       => 0.24,
94
            'exceptions' => [
95
                'Mount Athos' => 0,
96
            ],
97
        ],
98
        'HR' => [ // Croatia
99
            'rate' => 0.25,
100
        ],
101
        'HU' => [ // Hungary
102
            'rate' => 0.27,
103
        ],
104
        'IE' => [ // Ireland
105
            'rate' => 0.23,
106
        ],
107
        'IT' => [ // Italy
108
            'rate'       => 0.22,
109
            'exceptions' => [
110
                'Campione d\'Italia' => 0,
111
                'Livigno'            => 0,
112
            ],
113
        ],
114
        'LT' => [ // Lithuania
115
            'rate' => 0.21,
116
        ],
117
        'LU' => [ // Luxembourg
118
            'rate' => 0.17,
119
        ],
120
        'LV' => [ // Latvia
121
            'rate' => 0.21,
122
        ],
123
        'MT' => [ // Malta
124
            'rate' => 0.18,
125
        ],
126
        'NL' => [ // Netherlands
127
            'rate' => 0.21,
128
        ],
129
        'PL' => [ // Poland
130
            'rate' => 0.23,
131
        ],
132
        'PT' => [ // Portugal
133
            'rate'       => 0.23,
134
            'exceptions' => [
135
                'Azores'  => 0.18,
136
                'Madeira' => 0.22,
137
            ],
138
        ],
139
        'RO' => [ // Romania
140
            'rate' => 0.20,
141
        ],
142
        'SE' => [ // Sweden
143
            'rate' => 0.25,
144
        ],
145
        'SI' => [ // Slovenia
146
            'rate' => 0.22,
147
        ],
148
        'SK' => [ // Slovakia
149
            'rate' => 0.20,
150
        ],
151
152
        // Countries associated with EU countries that have a special VAT rate
153
        'MC' => [ // Monaco France
154
            'rate' => 0.20,
155
        ],
156
        'IM' => [ // Isle of Man - United Kingdom
157
            'rate' => 0.20,
158
        ],
159
160
        // Non-EU with their own VAT requirements
161
        'NO' => [ // Norway
162
            'rate' => 0.25,
163
        ],
164
    ];
165
166
    /**
167
     * All possible postal code exceptions.
168
     *
169
     * @var array
170
     */
171
    protected $postalCodeExceptions = [
172
        'AT' => [
173
            [
174
                'postalCode' => '/^6691$/',
175
                'code'       => 'AT',
176
                'name'       => 'Jungholz',
177
            ],
178
            [
179
                'postalCode' => '/^699[123]$/',
180
                'city'       => '/\bmittelberg\b/i',
181
                'code'       => 'AT',
182
                'name'       => 'Mittelberg',
183
            ],
184
        ],
185
        'CH' => [
186
            [
187
                'postalCode' => '/^8238$/',
188
                'code'       => 'DE',
189
                'name'       => 'Büsingen am Hochrhein',
190
            ],
191
            [
192
                'postalCode' => '/^6911$/',
193
                'code'       => 'IT',
194
                'name'       => "Campione d'Italia",
195
            ],
196
            // The Italian city of Domodossola has a Swiss post office also
197
            [
198
                'postalCode' => '/^3907$/',
199
                'code'       => 'IT',
200
            ],
201
        ],
202
        'DE' => [
203
            [
204
                'postalCode' => '/^87491$/',
205
                'code'       => 'AT',
206
                'name'       => 'Jungholz',
207
            ],
208
            [
209
                'postalCode' => '/^8756[789]$/',
210
                'city'       => '/\bmittelberg\b/i',
211
                'code'       => 'AT',
212
                'name'       => 'Mittelberg',
213
            ],
214
            [
215
                'postalCode' => '/^78266$/',
216
                'code'       => 'DE',
217
                'name'       => 'Büsingen am Hochrhein',
218
            ],
219
            [
220
                'postalCode' => '/^27498$/',
221
                'code'       => 'DE',
222
                'name'       => 'Heligoland',
223
            ],
224
        ],
225
        'ES' => [
226
            [
227
                'postalCode' => '/^(5100[1-5]|5107[0-1]|51081)$/',
228
                'code'       => 'ES',
229
                'name'       => 'Ceuta',
230
            ],
231
            [
232
                'postalCode' => '/^(5200[0-6]|5207[0-1]|52081)$/',
233
                'code'       => 'ES',
234
                'name'       => 'Melilla',
235
            ],
236
            [
237
                'postalCode' => '/^(35\d[3]|38\d[3])$/',
238
                'code'       => 'ES',
239
                'name'       => 'Canary Islands',
240
            ],
241
        ],
242
        'GB' => [
243
            // Akrotiri
244
            [
245
                'postalCode' => '/^BFPO57|BF12AT$/',
246
                'code'       => 'CY',
247
            ],
248
            // Dhekelia
249
            [
250
                'postalCode' => '/^BFPO58|BF12AU$/',
251
                'code'       => 'CY',
252
            ],
253
        ],
254
        'GR' => [
255
            [
256
                'postalCode' => '/^63086$/',
257
                'code'       => 'GR',
258
                'name'       => 'Mount Athos',
259
            ],
260
        ],
261
        'IT' => [
262
            [
263
                'postalCode' => '/^22060$/',
264
                'city'       => '/\bcampione\b/i',
265
                'code'       => 'IT',
266
                'name'       => "Campione d'Italia",
267
            ],
268
            [
269
                'postalCode' => '/^23030$/',
270
                'city'       => '/\blivigno\b/i',
271
                'code'       => 'IT',
272
                'name'       => 'Livigno',
273
            ],
274
        ],
275
        'PT' => [
276
            [
277
                'postalCode' => '/^9[0-4]\d[2,]$/',
278
                'code'       => 'PT',
279
                'name'       => 'Madeira',
280
            ],
281
            [
282
                'postalCode' => '/^9[5-9]\d[2,]$/',
283
                'code'       => 'PT',
284
                'name'       => 'Azores',
285
            ],
286
        ],
287
    ];
288
289
    /**
290
     * @var float
291
     */
292
    protected $netPrice = 0.0;
293
294
    /**
295
     * @var string
296
     */
297
    protected $countryCode;
298
299
    /**
300
     * @var string
301
     */
302
    protected $postalCode;
303
304
    /**
305
     * @var Repository
306
     */
307
    protected $config;
308
309
    /**
310
     * @var float
311
     */
312
    protected $taxValue = 0;
313
314
    /**
315
     * @var float
316
     */
317
    protected $taxRate = 0;
318
319
    /**
320
     * The calculate net + tax value.
321
     *
322
     * @var float
323
     */
324
    protected $value = 0;
325
326
    /**
327
     * @var bool
328
     */
329
    protected $company = false;
330
331
    /**
332
     * @var string
333
     */
334
    protected $businessCountryCode;
335
336
    /**
337
     * @param \Illuminate\Contracts\Config\Repository
338
     */
339
    public function __construct($config = null)
340
    {
341
        $this->config = $config;
342
343
        $businessCountryKey = 'vat_calculator.business_country_code';
344
        if (isset($this->config) && $this->config->has($businessCountryKey)) {
345
            $this->setBusinessCountryCode($this->config->get($businessCountryKey, ''));
346
        }
347
348
        try {
349
            $this->soapClient = new SoapClient(self::VAT_SERVICE_URL);
350
        } catch (SoapFault $e) {
351
            $this->soapClient = false;
352
        }
353
    }
354
355
    /**
356
     * Finds the client IP address.
357
     *
358
     * @return mixed
359
     */
360
    private function getClientIP()
0 ignored issues
show
Coding Style introduced by
getClientIP uses the super-global variable $_SERVER which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
361
    {
362
        if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && $_SERVER['HTTP_X_FORWARDED_FOR']) {
363
            $clientIpAddress = $_SERVER['HTTP_X_FORWARDED_FOR'];
364
        } elseif (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR']) {
365
            $clientIpAddress = $_SERVER['REMOTE_ADDR'];
366
        } else {
367
            $clientIpAddress = '';
368
        }
369
370
        return $clientIpAddress;
371
    }
372
373
    /**
374
     * Returns the ISO 3166-1 alpha-2 two letter
375
     * country code for the client IP. If the
376
     * IP can't be resolved it returns false.
377
     *
378
     * @return bool|string
379
     */
380
    public function getIPBasedCountry()
381
    {
382
        $ip = $this->getClientIP();
383
        $url = self::GEOCODE_SERVICE_URL.$ip;
384
        $result = file_get_contents($url);
385
        switch ($result[0]) {
386
            case '1':
387
                $data = explode(';', $result);
388
389
                return $data[1];
390
                break;
0 ignored issues
show
Unused Code introduced by
break is not strictly necessary here and could be removed.

The break statement is not necessary if it is preceded for example by a return statement:

switch ($x) {
    case 1:
        return 'foo';
        break; // This break is not necessary and can be left off.
}

If you would like to keep this construct to be consistent with other case statements, you can safely mark this issue as a false-positive.

Loading history...
391
            default:
392
                return false;
393
        }
394
    }
395
396
    /**
397
     * Determines if you need to collect VAT for the given country code.
398
     *
399
     * @param $countryCode
400
     *
401
     * @return bool
402
     */
403
    public function shouldCollectVAT($countryCode)
404
    {
405
        $taxKey = 'vat_calculator.rules.'.strtoupper($countryCode);
406
407
        return isset($this->taxRules[strtoupper($countryCode)]) || (isset($this->config) && $this->config->has($taxKey));
408
    }
409
410
    /**
411
     * Calculate the VAT based on the net price, country code and indication if the
412
     * customer is a company or not.
413
     *
414
     * @param int|float   $netPrice    The net price to use for the calculation
415
     * @param null|string $countryCode The country code to use for the rate lookup
416
     * @param null|string $postalCode  The postal code to use for the rate exception lookup
417
     * @param null|bool   $company
418
     *
419
     * @return float
420
     */
421
    public function calculate($netPrice, $countryCode = null, $postalCode = null, $company = null)
422
    {
423
        if ($countryCode) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $countryCode of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
424
            $this->setCountryCode($countryCode);
425
        }
426
        if ($postalCode) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $postalCode of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
427
            $this->setPostalCode($postalCode);
428
        }
429
        if (!is_null($company) && $company !== $this->isCompany()) {
430
            $this->setCompany($company);
431
        }
432
        $this->netPrice = floatval($netPrice);
433
        $this->taxRate = $this->getTaxRateForLocation($this->getCountryCode(), $this->getPostalCode(), $this->isCompany());
434
        $this->taxValue = $this->taxRate * $this->netPrice;
435
        $this->value = $this->netPrice + $this->taxValue;
436
437
        return $this->value;
438
    }
439
440
    /**
441
     * @return float
442
     */
443
    public function getNetPrice()
444
    {
445
        return $this->netPrice;
446
    }
447
448
    /**
449
     * @return string
450
     */
451
    public function getCountryCode()
452
    {
453
        return strtoupper($this->countryCode);
454
    }
455
456
    /**
457
     * @param mixed $countryCode
458
     */
459
    public function setCountryCode($countryCode)
460
    {
461
        $this->countryCode = $countryCode;
462
    }
463
464
    /**
465
     * @return string
466
     */
467
    public function getPostalCode()
468
    {
469
        return $this->postalCode;
470
    }
471
472
    /**
473
     * @param mixed $postalCode
474
     */
475
    public function setPostalCode($postalCode)
476
    {
477
        $this->postalCode = $postalCode;
478
    }
479
480
    /**
481
     * @return float
482
     */
483
    public function getTaxRate()
484
    {
485
        return $this->taxRate;
486
    }
487
488
    /**
489
     * @return bool
490
     */
491
    public function isCompany()
492
    {
493
        return $this->company;
494
    }
495
496
    /**
497
     * @param bool $company
498
     */
499
    public function setCompany($company)
500
    {
501
        $this->company = $company;
502
    }
503
504
    /**
505
     * @param string $businessCountryCode
506
     */
507
    public function setBusinessCountryCode($businessCountryCode)
508
    {
509
        $this->businessCountryCode = $businessCountryCode;
510
    }
511
512
    /**
513
     * Returns the tax rate for the given country code.
514
     * This method is used to allow backwards compatibility.
515
     *
516
     * @param $countryCode
517
     * @param bool $company
518
     *
519
     * @return float
520
     */
521
    public function getTaxRateForCountry($countryCode, $company = false)
522
    {
523
        return $this->getTaxRateForLocation($countryCode, null, $company);
524
    }
525
526
    /**
527
     * Returns the tax rate for the given country code.
528
     * If a postal code is provided, it will try to lookup the different
529
     * postal code exceptions that are possible.
530
     *
531
     * @param string      $countryCode
532
     * @param string|null $postalCode
533
     * @param bool|false  $company
534
     *
535
     * @return float
536
     */
537
    public function getTaxRateForLocation($countryCode, $postalCode = null, $company = false)
538
    {
539
        if ($company && strtoupper($countryCode) !== strtoupper($this->businessCountryCode)) {
540
            return 0;
541
        }
542
        $taxKey = 'vat_calculator.rules.'.strtoupper($countryCode);
543
        if (isset($this->config) && $this->config->has($taxKey)) {
544
            return $this->config->get($taxKey, 0);
545
        }
546
547
        if (!isset($this->postalCodeExceptions[$countryCode]) || $postalCode === null) {
548
            return isset($this->taxRules[strtoupper($countryCode)]['rate']) ? $this->taxRules[strtoupper($countryCode)]['rate'] : 0;
549
        } else {
550
            foreach ($this->postalCodeExceptions[$countryCode] as $postalCodeException) {
551
                if (!preg_match($postalCodeException['postalCode'], $postalCode)) {
552
                    continue;
553
                }
554
                if (isset($postalCodeException['name'])) {
555
                    return $this->taxRules[$postalCodeException['code']]['exceptions'][$postalCodeException['name']];
556
                }
557
558
                return $this->taxRules[$postalCodeException['code']]['rate'];
559
            }
560
561
            return 0;
562
        }
563
    }
564
565
    /**
566
     * @return float
567
     */
568
    public function getTaxValue()
569
    {
570
        return $this->taxValue;
571
    }
572
573
    /**
574
     * @param $vatNumber
575
     *
576
     * @throws VATCheckUnavailableException
577
     *
578
     * @return bool
579
     */
580
    public function isValidVATNumber($vatNumber)
581
    {
582
        $vatNumber = str_replace([' ', '-', '.', ','], '', trim($vatNumber));
583
        $countryCode = substr($vatNumber, 0, 2);
584
        $vatNumber = substr($vatNumber, 2);
585
586
        $client = $this->soapClient;
587
        if ($client) {
588
            try {
589
                $result = $client->checkVat([
590
                    'countryCode' => $countryCode,
591
                    'vatNumber'   => $vatNumber,
592
                ]);
593
594
                return $result->valid;
595
            } catch (SoapFault $e) {
596
                return false;
597
            }
598
        }
599
        throw new VATCheckUnavailableException('The VAT check service is currently unavailable. Please try again later.');
600
    }
601
602
    /**
603
     * @param SoapClient $soapClient
604
     */
605
    public function setSoapClient($soapClient)
606
    {
607
        $this->soapClient = $soapClient;
608
    }
609
}
610