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