Completed
Push — master ( 5d5769...e4de2b )
by Marcel
10s
created

VatCalculator   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 624
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 2

Importance

Changes 5
Bugs 0 Features 0
Metric Value
wmc 50
c 5
b 0
f 0
lcom 2
cbo 2
dl 0
loc 624
rs 8.5096

21 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 9 3
A getIPBasedCountry() 0 15 2
A shouldCollectVAT() 0 6 3
B calculate() 0 18 5
A getNetPrice() 0 4 1
A getCountryCode() 0 4 1
A setCountryCode() 0 4 1
A getPostalCode() 0 4 1
A setPostalCode() 0 4 1
A getTaxRate() 0 4 1
A isCompany() 0 4 1
A setCompany() 0 4 1
A setBusinessCountryCode() 0 4 1
A getTaxRateForCountry() 0 4 1
C getTaxRateForLocation() 0 25 11
A getTaxValue() 0 4 1
B getClientIP() 0 12 5
A isValidVATNumber() 0 10 2
A getVATDetails() 0 20 3
A initSoapClient() 0 11 4
A setSoapClient() 0 4 1

How to fix   Complexity   

Complex Class

Complex classes like VatCalculator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use VatCalculator, and based on these observations, apply Extract Interface, too.

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.19,
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
349
    /**
350
     * Finds the client IP address.
351
     *
352
     * @return mixed
353
     */
354
    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...
355
    {
356
        if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && $_SERVER['HTTP_X_FORWARDED_FOR']) {
357
            $clientIpAddress = $_SERVER['HTTP_X_FORWARDED_FOR'];
358
        } elseif (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR']) {
359
            $clientIpAddress = $_SERVER['REMOTE_ADDR'];
360
        } else {
361
            $clientIpAddress = '';
362
        }
363
364
        return $clientIpAddress;
365
    }
366
367
    /**
368
     * Returns the ISO 3166-1 alpha-2 two letter
369
     * country code for the client IP. If the
370
     * IP can't be resolved it returns false.
371
     *
372
     * @return bool|string
373
     */
374
    public function getIPBasedCountry()
375
    {
376
        $ip = $this->getClientIP();
377
        $url = self::GEOCODE_SERVICE_URL.$ip;
378
        $result = file_get_contents($url);
379
        switch ($result[0]) {
380
            case '1':
381
                $data = explode(';', $result);
382
383
                return $data[1];
384
                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...
385
            default:
386
                return false;
387
        }
388
    }
389
390
    /**
391
     * Determines if you need to collect VAT for the given country code.
392
     *
393
     * @param $countryCode
394
     *
395
     * @return bool
396
     */
397
    public function shouldCollectVAT($countryCode)
398
    {
399
        $taxKey = 'vat_calculator.rules.'.strtoupper($countryCode);
400
401
        return isset($this->taxRules[strtoupper($countryCode)]) || (isset($this->config) && $this->config->has($taxKey));
402
    }
403
404
    /**
405
     * Calculate the VAT based on the net price, country code and indication if the
406
     * customer is a company or not.
407
     *
408
     * @param int|float   $netPrice    The net price to use for the calculation
409
     * @param null|string $countryCode The country code to use for the rate lookup
410
     * @param null|string $postalCode  The postal code to use for the rate exception lookup
411
     * @param null|bool   $company
412
     *
413
     * @return float
414
     */
415
    public function calculate($netPrice, $countryCode = null, $postalCode = null, $company = null)
416
    {
417
        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...
418
            $this->setCountryCode($countryCode);
419
        }
420
        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...
421
            $this->setPostalCode($postalCode);
422
        }
423
        if (!is_null($company) && $company !== $this->isCompany()) {
424
            $this->setCompany($company);
425
        }
426
        $this->netPrice = floatval($netPrice);
427
        $this->taxRate = $this->getTaxRateForLocation($this->getCountryCode(), $this->getPostalCode(), $this->isCompany());
428
        $this->taxValue = $this->taxRate * $this->netPrice;
429
        $this->value = $this->netPrice + $this->taxValue;
430
431
        return $this->value;
432
    }
433
434
    /**
435
     * @return float
436
     */
437
    public function getNetPrice()
438
    {
439
        return $this->netPrice;
440
    }
441
442
    /**
443
     * @return string
444
     */
445
    public function getCountryCode()
446
    {
447
        return strtoupper($this->countryCode);
448
    }
449
450
    /**
451
     * @param mixed $countryCode
452
     */
453
    public function setCountryCode($countryCode)
454
    {
455
        $this->countryCode = $countryCode;
456
    }
457
458
    /**
459
     * @return string
460
     */
461
    public function getPostalCode()
462
    {
463
        return $this->postalCode;
464
    }
465
466
    /**
467
     * @param mixed $postalCode
468
     */
469
    public function setPostalCode($postalCode)
470
    {
471
        $this->postalCode = $postalCode;
472
    }
473
474
    /**
475
     * @return float
476
     */
477
    public function getTaxRate()
478
    {
479
        return $this->taxRate;
480
    }
481
482
    /**
483
     * @return bool
484
     */
485
    public function isCompany()
486
    {
487
        return $this->company;
488
    }
489
490
    /**
491
     * @param bool $company
492
     */
493
    public function setCompany($company)
494
    {
495
        $this->company = $company;
496
    }
497
498
    /**
499
     * @param string $businessCountryCode
500
     */
501
    public function setBusinessCountryCode($businessCountryCode)
502
    {
503
        $this->businessCountryCode = $businessCountryCode;
504
    }
505
506
    /**
507
     * Returns the tax rate for the given country code.
508
     * This method is used to allow backwards compatibility.
509
     *
510
     * @param $countryCode
511
     * @param bool $company
512
     *
513
     * @return float
514
     */
515
    public function getTaxRateForCountry($countryCode, $company = false)
516
    {
517
        return $this->getTaxRateForLocation($countryCode, null, $company);
518
    }
519
520
    /**
521
     * Returns the tax rate for the given country code.
522
     * If a postal code is provided, it will try to lookup the different
523
     * postal code exceptions that are possible.
524
     *
525
     * @param string      $countryCode
526
     * @param string|null $postalCode
527
     * @param bool|false  $company
528
     *
529
     * @return float
530
     */
531
    public function getTaxRateForLocation($countryCode, $postalCode = null, $company = false)
532
    {
533
        if ($company && strtoupper($countryCode) !== strtoupper($this->businessCountryCode)) {
534
            return 0;
535
        }
536
        $taxKey = 'vat_calculator.rules.'.strtoupper($countryCode);
537
        if (isset($this->config) && $this->config->has($taxKey)) {
538
            return $this->config->get($taxKey, 0);
539
        }
540
541
        if (isset($this->postalCodeExceptions[$countryCode]) && $postalCode !== null) {
542
            foreach ($this->postalCodeExceptions[$countryCode] as $postalCodeException) {
543
                if (!preg_match($postalCodeException['postalCode'], $postalCode)) {
544
                    continue;
545
                }
546
                if (isset($postalCodeException['name'])) {
547
                    return $this->taxRules[$postalCodeException['code']]['exceptions'][$postalCodeException['name']];
548
                }
549
550
                return $this->taxRules[$postalCodeException['code']]['rate'];
551
            }
552
        }
553
554
        return isset($this->taxRules[strtoupper($countryCode)]['rate']) ? $this->taxRules[strtoupper($countryCode)]['rate'] : 0;
555
    }
556
557
    /**
558
     * @return float
559
     */
560
    public function getTaxValue()
561
    {
562
        return $this->taxValue;
563
    }
564
565
    /**
566
     * @param $vatNumber
567
     *
568
     * @throws VATCheckUnavailableException
569
     *
570
     * @return bool
571
     */
572
    public function isValidVATNumber($vatNumber)
573
    {
574
        $details = self::getVATDetails($vatNumber);
575
        
576
        if ($details) {
577
            return $details->valid;
578
        } else {
579
            return false;
580
        }
581
    }
582
    
583
    /**
584
     * @param $vatNumber
585
     *
586
     * @throws VATCheckUnavailableException
587
     *
588
     * @return object|false
589
     */
590
    public function getVATDetails($vatNumber)
591
    {
592
        $vatNumber = str_replace([' ', '-', '.', ','], '', trim($vatNumber));
593
        $countryCode = substr($vatNumber, 0, 2);
594
        $vatNumber = substr($vatNumber, 2);
595
        $this->initSoapClient();
596
        $client = $this->soapClient;
597
        if ($client) {
598
            try {
599
                $result = $client->checkVat([
600
                    'countryCode' => $countryCode,
601
                    'vatNumber' => $vatNumber,
602
                ]);
603
                return $result;
604
            } catch (SoapFault $e) {
605
                return false;
606
            }
607
        }
608
        throw new VATCheckUnavailableException('The VAT check service is currently unavailable. Please try again later.');
609
    }
610
611
    /**
612
     * @return void
613
     */
614
    public function initSoapClient()
615
    {
616
        if (is_object($this->soapClient) || $this->soapClient === false) {
617
            return;
618
        }
619
        try {
620
            $this->soapClient = new SoapClient(self::VAT_SERVICE_URL);
621
        } catch (SoapFault $e) {
622
            $this->soapClient = false;
623
        }
624
    }
625
626
    /**
627
     * @param SoapClient $soapClient
628
     */
629
    public function setSoapClient($soapClient)
630
    {
631
        $this->soapClient = $soapClient;
632
    }
633
}
634