Passed
Pull Request — master (#78)
by
unknown
14:55
created

FeatureContext::assertCharge()   C

Complexity

Conditions 12
Paths 22

Size

Total Lines 41
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 8
Bugs 1 Features 0
Metric Value
cc 12
eloc 27
nc 22
nop 8
dl 0
loc 41
rs 6.9666
c 8
b 1
f 0

How to fix   Complexity    Many Parameters   

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:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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;
26
use hiqdev\php\billing\price\PriceHelper;
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
use ReflectionClass;
44
45
/**
46
 * Defines application features from the specific context.
47
 */
48
class FeatureContext implements Context
49
{
50
    protected $engine;
51
52
    /** @var Customer */
53
    protected $customer;
54
    /**
55
     * @var \hiqdev\php\billing\price\PriceInterface|\hiqdev\php\billing\charge\FormulaChargeModifierTrait
56
     *
57
     * TODO: FormulaChargeModifierTrait::setFormula() must be moved to interface
58
     */
59
    protected $price;
60
61
    /** @var string */
62
    protected $formula;
63
64
    /**
65
     * @var \hiqdev\php\billing\action\ActionInterface|\hiqdev\php\billing\action\AbstractAction
66
     */
67
    protected $action;
68
    /**
69
     * @var ChargeInterface[]
70
     */
71
    protected $charges;
72
73
    /** @var \Money\MoneyParser */
74
    protected $moneyParser;
75
76
    /** @var string */
77
    protected $expectedError;
78
79
    /**
80
     * Initializes context.
81
     */
82
    public function __construct()
83
    {
84
        error_reporting(E_ALL & ~E_DEPRECATED);
85
        date_default_timezone_set('UTC');
86
        $this->customer = new Customer(null, 'somebody');
87
        $this->moneyParser = new DecimalMoneyParser(new ISOCurrencies());
88
        $this->plan = new Plan(null, 'plan', $this->customer);
89
        $this->sale = new Sale(null, Target::any(), $this->customer, $this->plan, new DateTimeImmutable('2000-01-01'));
90
        $this->billing = SimpleBilling::fromSale($this->sale);
91
    }
92
93
    /**
94
     * @Given /(\S+) (\S+) price is ([0-9.]+) (\w+) per (\w+)(?: includes ([\d.]+))?/
95
     */
96
    public function priceIs($target, $type, $sum, $currency, $unit, $quantity = 0)
97
    {
98
        $type = Type::anyId($type);
99
        $target = new Target(Target::ANY, $target);
100
        $quantity = Quantity::create($unit, $quantity);
101
        $sum = $this->moneyParser->parse($sum, new Currency($currency));
102
        $this->setPrice(new SinglePrice(null, $type, $target, null, $quantity, $sum));
103
    }
104
105
    protected array $progressivePrice = [];
106
    /**
107
     * @Given /(\S+) progressive price for (\S+) is +(\S+) (\S+) per (\S+) (\S+) (\S+) (\S+)$/
108
     */
109
    public function progressivePrice($target, $type, $price, $currency, $unit, $sign, $quantity, $perUnit): void
110
    {
111
        if (empty($this->progressivePrice[$type])) {
112
            $this->progressivePrice[$type] = [
113
                'target' => $target,
114
                'price' => $price,
115
                'currency' => $currency,
116
                'prepaid' => $quantity,
117
                'unit' => $unit,
118
                'thresholds' => [],
119
            ];
120
        } else {
121
            $this->progressivePrice[$type]['thresholds'][] = [
122
                'price' => $price,
123
                'currency' => $currency,
124
                'unit' => $unit,
125
                'quantity' => $quantity,
126
            ];
127
        }
128
    }
129
130
    /**
131
     * @Given /^build progressive price/
132
     */
133
    public function buildProgressivePrices()
134
    {
135
        $i = 0;
136
        foreach ($this->progressivePrice as $type => $price) {
137
            $type = Type::anyId($type);
138
            $target = new Target(Target::ANY, $price['target']);
139
            $quantity = Quantity::create($price['unit'], $price['prepaid']);
140
            if ($i++ === 0) {
141
                $price['price'] *= 100;
142
            }
143
            $money = new Money($price['price'], new Currency($price['currency']));
144
            $thresholds = ProgressivePriceThresholdList::fromScalarsArray($price['thresholds']);
145
            $price = new ProgressivePrice(null, $type, $target, $quantity, $money, $thresholds);
146
            $this->setPrice($price);
147
        }
148
    }
149
150
    /**
151
     * @Given /sale close time is ([0-9.-]+)?/
152
     */
153
    public function setActionCloseTime($closeTime): void
154
    {
155
        if ($closeTime === null) {
156
            return;
157
        }
158
159
        $this->sale->close(new DateTimeImmutable($closeTime));
160
    }
161
162
    /**
163
     * @Given /sale time is (.+)$/
164
     */
165
    public function setSaleTime($time): void
166
    {
167
        $ref = new ReflectionClass($this->sale);
168
        $prop = $ref->getProperty('time');
169
        $prop->setAccessible(true);
170
        $prop->setValue($this->sale, new DateTimeImmutable($time));
171
        $prop->setAccessible(false);
172
    }
173
174
    private function setPrice($price)
175
    {
176
        $this->price = $price;
177
        $ref = new ReflectionClass($this->plan);
178
        $prop = $ref->getProperty('prices');
179
        $prop->setAccessible(true);
180
        $prop->setValue($this->plan, [$price]);
181
    }
182
183
    /**
184
     * @Given /action is (\S+) ([\w_,]+)(?: ([0-9.]+) (\S+))?(?: in (.+))?/
185
     */
186
    public function actionIs(string $target, string $type, float $amount, string $unit, ?string $date = null): void
187
    {
188
        $type = Type::anyId($type);
189
        $target = new Target(Target::ANY, $target);
190
        $time = new DateTimeImmutable($date);
191
        if ($this->sale->getCloseTime() instanceof DateTimeImmutable) {
192
            $amount = $amount * $this->getFractionOfMonth(
193
                $time, $time, $this->sale->getCloseTime()
194
            );
195
        }
196
        $quantity = Quantity::create($unit, $amount);
197
198
        $this->action = new Action(null, $type, $target, $quantity, $this->customer, $time);
199
    }
200
201
    private function getFractionOfMonth(DateTimeImmutable $month, DateTimeImmutable $startTime, DateTimeImmutable $endTime): float
202
    {
203
        // SQL function: days2quantity()
204
205
        $month = $month->modify('first day of this month 00:00');
206
        if ($startTime < $month) {
207
            $startTime = $month;
208
        }
209
        if ($endTime > $month->modify('first day of next month 00:00')) {
210
            $endTime = $month->modify('first day of next month 00:00');
211
        }
212
213
        $secondsInMonth = $month->format('t') * 24 * 60 * 60;
214
215
        return ($endTime->getTimestamp() - $startTime->getTimestamp()) / $secondsInMonth;
216
    }
217
218
    /**
219
     * @Given /formula is (.+)/
220
     */
221
    public function formulaIs(string $formula): void
222
    {
223
        $this->formula = $formula;
224
    }
225
226
    /**
227
     * @Given /formula continues (.+)/
228
     */
229
    public function formulaContinues(string $formula): void
230
    {
231
        $this->formula .= "\n" . $formula;
232
    }
233
234
    protected function getFormulaEngine()
235
    {
236
        if ($this->engine === null) {
237
            $this->engine = new FormulaEngine(new ArrayCachePool());
238
        }
239
240
        return $this->engine;
241
    }
242
243
    /**
244
     * @When /action date is (.+)/
245
     * @throws Exception
246
     */
247
    public function actionDateIs(string $date): void
248
    {
249
        $this->action->setTime(new DateTimeImmutable($date));
250
    }
251
252
    /**
253
     * @Given /^client rejected service at (.+)$/
254
     */
255
    public function actionCloseDateIs(string $close_date): void
256
    {
257
        $this->sale->close(new DateTimeImmutable($close_date));
258
    }
259
260
    /**
261
     * @Then /^error is$/m
262
     */
263
    public function multilineErrorIs(\Behat\Gherkin\Node\PyStringNode $value)
264
    {
265
        $this->expectedError = $value->getRaw();
266
    }
267
268
    /**
269
     * @Then /^error is (.+)$/
270
     *
271
     * @param string $error
272
     */
273
    public function errorIs($error): void
274
    {
275
        $this->expectedError = $error;
276
    }
277
278
    /**
279
     * @Then /^(\w+) charge is ?(?: with ?)?$/
280
     */
281
    public function emptyCharge(string $numeral): void
282
    {
283
        $this->chargeIs($numeral);
284
    }
285
286
    /**
287
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3})(?: for ([\d.]+)? (\w+)?)?(?: with (.+)?)?$/
288
     */
289
    public function chargeWithSum($numeral, $type = null, $sum = null, $currency = null, $qty = null, $unit = null, $events = null): void
290
    {
291
        $this->chargeIs($numeral, $type, $sum, $currency, null, $qty, $unit, $events);
292
    }
293
294
    /**
295
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3}) reason ([\w]+)(?: with (.+)?)?$/
296
     */
297
    public function chargeWithReason($numeral, $type = null, $sum = null, $currency = null, $reason = null, $events = null): void
298
    {
299
        $this->chargeIs($numeral, $type, $sum, $currency, $reason, null, null, $events);
300
    }
301
302
    public function chargeIs($numeral, $type = null, $sum = null, $currency = null, $reason = null, $qty = null, $unit = null, $events = null): void
303
    {
304
        $no = $this->ensureNo($numeral);
305
        if ($no === 0) {
306
            $this->calculatePrice();
307
        }
308
        $this->assertCharge($this->charges[$no] ?? null, $type, $sum, $currency, $reason, $qty, $unit, $events);
309
    }
310
311
    /**
312
     * @When /^calculating charges$/
313
     */
314
    public function calculatePrice(): void
315
    {
316
        $this->expectError(function () {
317
            if ($this->formula !== null) {
318
                $this->price->setModifier($this->getFormulaEngine()->build($this->formula));
319
            }
320
            $this->charges = $this->billing->calculateCharges($this->action);
321
        });
322
    }
323
324
    public function expectError(Closure $closure): void
325
    {
326
        try {
327
            call_user_func($closure);
328
        } catch (Exception $e) {
329
            if ($this->isExpectedError($e)) {
330
                $this->expectedError = null;
331
            } else {
332
                throw $e;
333
            }
334
        }
335
        if ($this->expectedError) {
336
            throw new Exception('failed receive expected exception');
337
        }
338
    }
339
340
    protected function isExpectedError(Exception $e): bool
341
    {
342
        return str_starts_with($e->getMessage(), $this->expectedError);
343
    }
344
345
    /**
346
     * @param ChargeInterface|Charge|null $charge
347
     * @param string|null $type
348
     * @param string|null $sum
349
     * @param string|null $currency
350
     * @param string|null $reason
351
     * @param string|null $qty
352
     * @param string|null $unit
353
     * @param string|null $events
354
     */
355
    public function assertCharge($charge, $type, $sum, $currency, $reason, $qty, $unit, $events): void
356
    {
357
        if (empty($type) && empty($sum) && empty($currency)) {
358
            is_null($charge) || Assert::assertSame('0', $charge->getSum()->getAmount());
359
            return;
360
        }
361
        Assert::assertInstanceOf(Charge::class, $charge);
362
        Assert::assertSame($type, $this->normalizeType($charge->getType()->getName()), sprintf(
363
            'Charge type %s does not match expected %s', $type, $this->normalizeType($charge->getType()->getName())
364
        ));
365
        $money = $this->moneyParser->parse($sum, new Currency($currency));
366
        Assert::assertTrue($money->equals($charge->getSum()), sprintf(
367
            'Charge sum %s does not match expected %s', $charge->getSum()->getAmount(), $money->getAmount()
368
        ));
369
        if ($reason !== null) {
370
            Assert::assertSame($reason, $charge->getComment(),
371
                sprintf('Charge comment %s does not match expected %s', $charge->getComment(), $reason)
372
            );
373
        }
374
        if ($qty !== null && $unit !== null) {
375
            Assert::assertEqualsWithDelta($qty, $charge->getUsage()->getQuantity(), 1e-7,
376
                sprintf('Charge quantity "%s" does not match expected "%s"', $charge->getUsage()->getQuantity(), $qty)
377
            );
378
            Assert::assertSame($unit, $charge->getUsage()->getUnit()->getName(),
379
                sprintf('Charge unit "%s" does not match expected "%s"', $charge->getUsage()->getUnit()->getName(), $unit)
380
            );
381
        }
382
        if ($events !== null) {
383
            $storedEvents = $charge->releaseEvents();
384
            foreach (array_map('trim', explode(',', $events)) as $eventClass) {
385
                foreach ($storedEvents as $storedEvent) {
386
                    $eventReflection = new \ReflectionObject($storedEvent);
387
                    if ($eventReflection->getShortName() === $eventClass) {
388
                        continue 2;
389
                    }
390
                }
391
392
                Assert::fail(sprintf('Event of class %s is not present is charge', $eventClass));
393
            }
394
        } else {
395
            Assert::assertEmpty($charge->releaseEvents(), 'Failed asserting that charge does not have events');
396
        }
397
    }
398
399
    private function normalizeType($string): string
400
    {
401
        switch ($string) {
402
            case 'discount,discount':
403
                return 'discount';
404
            case 'monthly,leasing':
405
                return 'leasing';
406
            case 'monthly,installment':
407
                return 'installment';
408
            default:
409
                return $string;
410
        }
411
    }
412
413
    private function ensureNo(string $numeral): int
414
    {
415
        $formatter = new NumberFormatter('en_EN', NumberFormatter::SPELLOUT);
416
        $result = $formatter->parse($numeral);
417
        if ($result === false) {
418
            throw new Exception("Wrong numeral '$numeral'");
419
        }
420
421
        return --$result;
422
    }
423
424
    /**
425
     * @Given /^progressive price calculation steps are (.*)$/
426
     */
427
    public function progressivePriceCalculationStepsAre($explanation)
428
    {
429
        if (!$this->price instanceof ProgressivePrice) {
430
            throw new Exception('Price is not progressive');
431
        }
432
433
        $traces = array_map(fn($trace) => $trace->toShortString(), $this->price->getCalculationTraces());
434
        $billed = implode(' + ', $traces);
435
        Assert::assertSame($explanation, $billed, 'Progressive price calculation steps mismatch. Expected: ' . $explanation . ', got: ' . $billed);
436
    }
437
}
438