Passed
Push — master ( 54e3cc...a9003e )
by Dmitry
14:11
created

FeatureContext   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 368
Duplicated Lines 0 %

Importance

Changes 18
Bugs 3 Features 2
Metric Value
wmc 53
eloc 141
c 18
b 3
f 2
dl 0
loc 368
rs 6.96

26 Methods

Rating   Name   Duplication   Size   Complexity  
A progressivePrice() 0 17 2
A buildProgressivePrices() 0 10 2
A emptyCharge() 0 3 1
A multilineErrorIs() 0 3 1
A __construct() 0 9 1
A priceIs() 0 7 1
A progressivePriceCalculationStepsAre() 0 9 2
A getFormulaEngine() 0 7 2
A normalizeType() 0 11 4
A calculatePrice() 0 7 2
A setActionCloseTime() 0 3 1
A actionDateIs() 0 3 1
A getFractionOfMonth() 0 15 3
A chargeWithReason() 0 3 1
C assertCharge() 0 41 12
A setPrice() 0 7 1
A formulaIs() 0 3 1
A actionIs() 0 13 2
A actionCloseDateIs() 0 3 1
A isExpectedError() 0 3 1
A chargeIs() 0 7 2
A ensureNo() 0 9 2
A formulaContinues() 0 3 1
A chargeWithSum() 0 3 1
A expectError() 0 13 4
A errorIs() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like FeatureContext 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.

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 FeatureContext, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * PHP Billing Library
4
 *
5
 * @link      https://github.com/hiqdev/php-billing
6
 * @package   php-billing
7
 * @license   BSD-3-Clause
8
 * @copyright Copyright (c) 2017-2020, HiQDev (http://hiqdev.com/)
9
 */
10
11
namespace hiqdev\php\billing\tests\behat\bootstrap;
12
13
use Behat\Behat\Context\Context;
14
use Behat\Behat\Tester\Exception\PendingException;
15
use Cache\Adapter\PHPArray\ArrayCachePool;
16
use Closure;
17
use DateTimeImmutable;
18
use Exception;
19
use hiqdev\php\billing\action\Action;
20
use hiqdev\php\billing\charge\Charge;
21
use hiqdev\php\billing\charge\ChargeInterface;
22
use hiqdev\php\billing\customer\Customer;
23
use hiqdev\php\billing\formula\FormulaEngine;
24
use hiqdev\php\billing\plan\Plan;
25
use hiqdev\php\billing\price\MoneyBuilder;
0 ignored issues
show
Bug introduced by
The type hiqdev\php\billing\price\MoneyBuilder was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
26
use hiqdev\php\billing\price\PriceHelper;
0 ignored issues
show
Bug introduced by
The type hiqdev\php\billing\price\PriceHelper was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
27
use hiqdev\php\billing\price\ProgressivePrice;
28
use hiqdev\php\billing\price\ProgressivePriceThreshold;
29
use hiqdev\php\billing\price\ProgressivePriceThresholdList;
30
use hiqdev\php\billing\sale\Sale;
31
use hiqdev\php\billing\price\SinglePrice;
32
use hiqdev\php\billing\target\Target;
33
use hiqdev\php\billing\tests\support\order\SimpleBilling;
34
use hiqdev\php\billing\type\Type;
35
use hiqdev\php\units\Quantity;
36
use hiqdev\php\units\Unit;
37
use Money\Currencies\ISOCurrencies;
38
use Money\Currency;
39
use Money\Money;
40
use Money\Parser\DecimalMoneyParser;
41
use NumberFormatter;
42
use PHPUnit\Framework\Assert;
43
44
/**
45
 * Defines application features from the specific context.
46
 */
47
class FeatureContext implements Context
48
{
49
    protected $engine;
50
51
    /** @var Customer */
52
    protected $customer;
53
    /**
54
     * @var \hiqdev\php\billing\price\PriceInterface|\hiqdev\php\billing\charge\FormulaChargeModifierTrait
0 ignored issues
show
Bug introduced by
The type hiqdev\php\billing\charg...mulaChargeModifierTrait was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
55
     *
56
     * TODO: FormulaChargeModifierTrait::setFormula() must be moved to interface
57
     */
58
    protected $price;
59
60
    /** @var string */
61
    protected $formula;
62
63
    /**
64
     * @var \hiqdev\php\billing\action\ActionInterface|\hiqdev\php\billing\action\AbstractAction
65
     */
66
    protected $action;
67
    /**
68
     * @var ChargeInterface[]
69
     */
70
    protected $charges;
71
72
    /** @var \Money\MoneyParser */
73
    protected $moneyParser;
74
75
    /** @var string */
76
    protected $expectedError;
77
78
    /**
79
     * Initializes context.
80
     */
81
    public function __construct()
82
    {
83
        error_reporting(E_ALL & ~E_DEPRECATED);
84
        date_default_timezone_set('UTC');
85
        $this->customer = new Customer(null, 'somebody');
86
        $this->moneyParser = new DecimalMoneyParser(new ISOCurrencies());
87
        $this->plan = new Plan(null, 'plan', $this->customer);
0 ignored issues
show
Bug Best Practice introduced by
The property plan does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
88
        $this->sale = new Sale(null, Target::any(), $this->customer, $this->plan, new DateTimeImmutable('2000-01-01'));
0 ignored issues
show
Bug Best Practice introduced by
The property sale does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
89
        $this->billing = SimpleBilling::fromSale($this->sale);
0 ignored issues
show
Bug Best Practice introduced by
The property billing does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
90
    }
91
92
    /**
93
     * @Given /(\S+) (\S+) price is ([0-9.]+) (\w+) per (\w+)(?: includes ([\d.]+))?/
94
     */
95
    public function priceIs($target, $type, $sum, $currency, $unit, $quantity = 0)
96
    {
97
        $type = new Type(Type::ANY, $type);
98
        $target = new Target(Target::ANY, $target);
99
        $quantity = Quantity::create($unit, $quantity);
100
        $sum = $this->moneyParser->parse($sum, new Currency($currency));
101
        $this->setPrice(new SinglePrice(null, $type, $target, null, $quantity, $sum));
102
    }
103
104
    protected array $progressivePrice = [];
105
    /**
106
     * @Given /(\S+) progressive price for (\S+) is +(\S+) (\S+) per (\S+) (\S+) (\S+) (\S+)$/
107
     */
108
    public function progressivePrice($target, $type, $price, $currency, $unit, $sign, $quantity, $perUnit): void
0 ignored issues
show
Unused Code introduced by
The parameter $sign is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

108
    public function progressivePrice($target, $type, $price, $currency, $unit, /** @scrutinizer ignore-unused */ $sign, $quantity, $perUnit): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $perUnit is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

108
    public function progressivePrice($target, $type, $price, $currency, $unit, $sign, $quantity, /** @scrutinizer ignore-unused */ $perUnit): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
109
    {
110
        if (empty($this->progressivePrice[$type])) {
111
            $this->progressivePrice[$type] = [
112
                'target' => $target,
113
                'price' => $price,
114
                'currency' => $currency,
115
                'prepaid' => $quantity,
116
                'unit' => $unit,
117
                'thresholds' => [],
118
            ];
119
        } else {
120
            $this->progressivePrice[$type]['thresholds'][] = [
121
                'price' => $price,
122
                'currency' => $currency,
123
                'unit' => $unit,
124
                'quantity' => $quantity,
125
            ];
126
        }
127
    }
128
129
    /**
130
     * @Given /^build progressive price/
131
     */
132
    public function buildProgressivePrices()
133
    {
134
        foreach ($this->progressivePrice as $type => $price) {
135
            $type = new Type(Type::ANY, $type);
136
            $target = new Target(Target::ANY, $price['target']);
137
            $quantity = Quantity::create($price['unit'], $price['prepaid']);
138
            $money = new Money($price['price'], new Currency($price['currency']));
139
            $thresholds = ProgressivePriceThresholdList::fromScalarsArray($price['thresholds']);
140
            $price = new ProgressivePrice(null, $type, $target, $quantity, $money, $thresholds);
141
            $this->setPrice($price);
142
        }
143
    }
144
145
    /**
146
     * @Given /sale close time is ([0-9.-]+)/
147
     */
148
    public function setActionCloseTime($closeTime): void
149
    {
150
        $this->sale->close(new DateTimeImmutable($closeTime));
151
    }
152
153
    private function setPrice($price)
154
    {
155
        $this->price = $price;
156
        $ref = new \ReflectionClass($this->plan);
157
        $prop = $ref->getProperty('prices');
158
        $prop->setAccessible(true);
159
        $prop->setValue($this->plan, [$price]);
160
    }
161
162
    /**
163
     * @Given /action is (\S+) ([\w_,]+)(?: ([0-9.]+) (\S+))?(?: in (.+))?/
164
     */
165
    public function actionIs(string $target, string $type, float $amount, string $unit, ?string $date = null): void
166
    {
167
        $type = new Type(Type::ANY, $type);
168
        $target = new Target(Target::ANY, $target);
169
        $time = new DateTimeImmutable($date);
170
        if ($this->sale->getCloseTime() instanceof DateTimeImmutable) {
171
            $amount = $amount * $this->getFractionOfMonth(
172
                $time, $time, $this->sale->getCloseTime()
173
            );
174
        }
175
        $quantity = Quantity::create($unit, $amount);
176
177
        $this->action = new Action(null, $type, $target, $quantity, $this->customer, $time);
178
    }
179
180
    private function getFractionOfMonth(DateTimeImmutable $month, DateTimeImmutable $startTime, DateTimeImmutable $endTime): float
181
    {
182
        // SQL function: days2quantity()
183
184
        $month = $month->modify('first day of this month 00:00');
185
        if ($startTime < $month) {
186
            $startTime = $month;
187
        }
188
        if ($endTime > $month->modify('first day of next month 00:00')) {
189
            $endTime = $month->modify('first day of next month 00:00');
190
        }
191
192
        $secondsInMonth = $month->format('t') * 24 * 60 * 60;
193
194
        return ($endTime->getTimestamp() - $startTime->getTimestamp()) / $secondsInMonth;
195
    }
196
197
    /**
198
     * @Given /formula is (.+)/
199
     */
200
    public function formulaIs(string $formula): void
201
    {
202
        $this->formula = $formula;
203
    }
204
205
    /**
206
     * @Given /formula continues (.+)/
207
     */
208
    public function formulaContinues(string $formula): void
209
    {
210
        $this->formula .= "\n" . $formula;
211
    }
212
213
    protected function getFormulaEngine()
214
    {
215
        if ($this->engine === null) {
216
            $this->engine = new FormulaEngine(new ArrayCachePool());
217
        }
218
219
        return $this->engine;
220
    }
221
222
    /**
223
     * @When /action date is (.+)/
224
     * @throws Exception
225
     */
226
    public function actionDateIs(string $date): void
227
    {
228
        $this->action->setTime(new DateTimeImmutable($date));
0 ignored issues
show
Bug introduced by
The method setTime() does not exist on hiqdev\php\billing\action\ActionInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to hiqdev\php\billing\action\ActionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

228
        $this->action->/** @scrutinizer ignore-call */ 
229
                       setTime(new DateTimeImmutable($date));
Loading history...
229
    }
230
231
    /**
232
     * @Given /^client rejected service at (.+)$/
233
     */
234
    public function actionCloseDateIs(string $close_date): void
235
    {
236
        $this->sale->close(new DateTimeImmutable($close_date));
237
    }
238
239
    /**
240
     * @Then /^error is$/m
241
     */
242
    public function multilineErrorIs(\Behat\Gherkin\Node\PyStringNode $value)
243
    {
244
        $this->expectedError = $value->getRaw();
245
    }
246
247
    /**
248
     * @Then /^error is (.+)$/
249
     *
250
     * @param string $error
251
     */
252
    public function errorIs($error): void
253
    {
254
        $this->expectedError = $error;
255
    }
256
257
    /**
258
     * @Then /^(\w+) charge is ?(?: with ?)?$/
259
     */
260
    public function emptyCharge(string $numeral): void
261
    {
262
        $this->chargeIs($numeral);
263
    }
264
265
    /**
266
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3})(?: for ([\d.]+)? (\w+)?)?(?: with (.+)?)?$/
267
     */
268
    public function chargeWithSum($numeral, $type = null, $sum = null, $currency = null, $qty = null, $unit = null, $events = null): void
269
    {
270
        $this->chargeIs($numeral, $type, $sum, $currency, null, $qty, $unit, $events);
271
    }
272
273
    /**
274
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3}) reason ([\w]+)(?: with (.+)?)?$/
275
     */
276
    public function chargeWithReason($numeral, $type = null, $sum = null, $currency = null, $reason = null, $events = null): void
277
    {
278
        $this->chargeIs($numeral, $type, $sum, $currency, $reason, null, null, $events);
279
    }
280
281
    public function chargeIs($numeral, $type = null, $sum = null, $currency = null, $reason = null, $qty = null, $unit = null, $events = null): void
282
    {
283
        $no = $this->ensureNo($numeral);
284
        if ($no === 0) {
285
            $this->calculatePrice();
286
        }
287
        $this->assertCharge($this->charges[$no] ?? null, $type, $sum, $currency, $reason, $qty, $unit, $events);
288
    }
289
290
    /**
291
     * @When /^calculating charges$/
292
     */
293
    public function calculatePrice(): void
294
    {
295
        $this->expectError(function () {
296
            if ($this->formula !== null) {
297
                $this->price->setModifier($this->getFormulaEngine()->build($this->formula));
0 ignored issues
show
Bug introduced by
The method setModifier() does not exist on hiqdev\php\billing\price\PriceInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to hiqdev\php\billing\price\PriceInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

297
                $this->price->/** @scrutinizer ignore-call */ 
298
                              setModifier($this->getFormulaEngine()->build($this->formula));
Loading history...
298
            }
299
            $this->charges = $this->billing->calculateCharges($this->action);
300
        });
301
    }
302
303
    public function expectError(Closure $closure): void
304
    {
305
        try {
306
            call_user_func($closure);
307
        } catch (Exception $e) {
308
            if ($this->isExpectedError($e)) {
309
                $this->expectedError = null;
310
            } else {
311
                throw $e;
312
            }
313
        }
314
        if ($this->expectedError) {
315
            throw new Exception('failed receive expected exception');
316
        }
317
    }
318
319
    protected function isExpectedError(Exception $e): bool
320
    {
321
        return str_starts_with($e->getMessage(), $this->expectedError);
322
    }
323
324
    /**
325
     * @param ChargeInterface|Charge|null $charge
326
     * @param string|null $type
327
     * @param string|null $sum
328
     * @param string|null $currency
329
     * @param string|null $reason
330
     * @param string|null $qty
331
     * @param string|null $unit
332
     * @param string|null $events
333
     */
334
    public function assertCharge($charge, $type, $sum, $currency, $reason, $qty, $unit, $events): void
335
    {
336
        if (empty($type) && empty($sum) && empty($currency)) {
337
            is_null($charge) || Assert::assertSame('0', $charge->getSum()->getAmount());
338
            return;
339
        }
340
        Assert::assertInstanceOf(Charge::class, $charge);
341
        Assert::assertSame($type, $this->normalizeType($charge->getType()->getName()), sprintf(
0 ignored issues
show
Bug introduced by
The method getType() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

341
        Assert::assertSame($type, $this->normalizeType($charge->/** @scrutinizer ignore-call */ getType()->getName()), sprintf(

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
342
            'Charge type %s does not match expected %s', $type, $this->normalizeType($charge->getType()->getName())
343
        ));
344
        $money = $this->moneyParser->parse($sum, new Currency($currency));
0 ignored issues
show
Bug introduced by
It seems like $currency can also be of type null; however, parameter $code of Money\Currency::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

344
        $money = $this->moneyParser->parse($sum, new Currency(/** @scrutinizer ignore-type */ $currency));
Loading history...
Bug introduced by
It seems like $sum can also be of type null; however, parameter $money of Money\MoneyParser::parse() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

344
        $money = $this->moneyParser->parse(/** @scrutinizer ignore-type */ $sum, new Currency($currency));
Loading history...
345
        Assert::assertTrue($money->equals($charge->getSum()), sprintf(
346
            'Charge sum %s does not match expected %s', $charge->getSum()->getAmount(), $money->getAmount()
347
        ));
348
        if ($reason !== null) {
349
            Assert::assertSame($reason, $charge->getComment(),
350
                sprintf('Charge comment %s does not match expected %s', $charge->getComment(), $reason)
351
            );
352
        }
353
        if ($qty !== null && $unit !== null) {
354
            Assert::assertEqualsWithDelta($qty, $charge->getUsage()->getQuantity(), 1e-7,
355
                sprintf('Charge quantity "%s" does not match expected "%s"', $charge->getUsage()->getQuantity(), $qty)
356
            );
357
            Assert::assertSame($unit, $charge->getUsage()->getUnit()->getName(),
358
                sprintf('Charge unit "%s" does not match expected "%s"', $charge->getUsage()->getUnit()->getName(), $unit)
359
            );
360
        }
361
        if ($events !== null) {
362
            $storedEvents = $charge->releaseEvents();
0 ignored issues
show
Bug introduced by
The method releaseEvents() does not exist on hiqdev\php\billing\charge\ChargeInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to hiqdev\php\billing\charge\ChargeInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

362
            /** @scrutinizer ignore-call */ 
363
            $storedEvents = $charge->releaseEvents();
Loading history...
363
            foreach (array_map('trim', explode(',', $events)) as $eventClass) {
364
                foreach ($storedEvents as $storedEvent) {
365
                    $eventReflection = new \ReflectionObject($storedEvent);
366
                    if ($eventReflection->getShortName() === $eventClass) {
367
                        continue 2;
368
                    }
369
                }
370
371
                Assert::fail(sprintf('Event of class %s is not present is charge', $eventClass));
372
            }
373
        } else {
374
            Assert::assertEmpty($charge->releaseEvents(), 'Failed asserting that charge does not have events');
375
        }
376
    }
377
378
    private function normalizeType($string): string
379
    {
380
        switch ($string) {
381
            case 'discount,discount':
382
                return 'discount';
383
            case 'monthly,leasing':
384
                return 'leasing';
385
            case 'monthly,installment':
386
                return 'installment';
387
            default:
388
                return $string;
389
        }
390
    }
391
392
    private function ensureNo(string $numeral): int
393
    {
394
        $formatter = new NumberFormatter('en_EN', NumberFormatter::SPELLOUT);
395
        $result = $formatter->parse($numeral);
396
        if ($result === false) {
397
            throw new Exception("Wrong numeral '$numeral'");
398
        }
399
400
        return --$result;
401
    }
402
403
    /**
404
     * @Given /^progressive price calculation steps are (.*)$/
405
     */
406
    public function progressivePriceCalculationStepsAre($explanation)
407
    {
408
        if (!$this->price instanceof ProgressivePrice) {
409
            throw new Exception('Price is not progressive');
410
        }
411
412
        $traces = array_map(fn($trace) => $trace->toShortString(), $this->price->getCalculationTraces());
413
        $billed = implode(' + ', $traces);
414
        Assert::assertSame($explanation, $billed, 'Progressive price calculation steps mismatch. Expected: ' . $explanation . ', got: ' . $billed);
415
    }
416
}
417