Passed
Pull Request — master (#67)
by
unknown
14:21
created

FeatureContext::emptyCharge()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 1
Metric Value
cc 1
eloc 1
c 3
b 0
f 1
nc 1
nop 1
dl 0
loc 3
rs 10
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\PriceHelper;
26
use hiqdev\php\billing\price\ProgressivePrice;
27
use hiqdev\php\billing\price\ProgressivePriceThreshold;
28
use hiqdev\php\billing\sale\Sale;
29
use hiqdev\php\billing\price\SinglePrice;
30
use hiqdev\php\billing\target\Target;
31
use hiqdev\php\billing\tests\support\order\SimpleBilling;
32
use hiqdev\php\billing\type\Type;
33
use hiqdev\php\units\Quantity;
34
use hiqdev\php\units\Unit;
35
use Money\Currencies\ISOCurrencies;
36
use Money\Currency;
37
use Money\Money;
38
use Money\Parser\DecimalMoneyParser;
39
use NumberFormatter;
40
use PHPUnit\Framework\Assert;
41
42
/**
43
 * Defines application features from the specific context.
44
 */
45
class FeatureContext implements Context
46
{
47
    protected $engine;
48
49
    /** @var Customer */
50
    protected $customer;
51
    /**
52
     * @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...
53
     *
54
     * TODO: FormulaChargeModifierTrait::setFormula() must be moved to interface
55
     */
56
    protected $price;
57
58
    /** @var string */
59
    protected $formula;
60
61
    /**
62
     * @var \hiqdev\php\billing\action\ActionInterface|\hiqdev\php\billing\action\AbstractAction
63
     */
64
    protected $action;
65
    /**
66
     * @var ChargeInterface[]
67
     */
68
    protected $charges;
69
70
    /** @var \Money\MoneyParser */
71
    protected $moneyParser;
72
73
    /** @var string */
74
    protected $expectedError;
75
76
    /**
77
     * Initializes context.
78
     */
79
    public function __construct()
80
    {
81
        error_reporting(E_ALL & ~E_DEPRECATED);
82
        date_default_timezone_set('UTC');
83
        $this->customer = new Customer(null, 'somebody');
84
        $this->moneyParser = new DecimalMoneyParser(new ISOCurrencies());
85
        $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...
86
        $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...
87
        $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...
88
    }
89
90
    /**
91
     * @Given /(\S+) (\S+) price is ([0-9.]+) (\w+) per (\w+)(?: includes ([\d.]+))?/
92
     */
93
    public function priceIs($target, $type, $sum, $currency, $unit, $quantity = 0)
94
    {
95
        $type = new Type(Type::ANY, $type);
96
        $target = new Target(Target::ANY, $target);
97
        $quantity = Quantity::create($unit, $quantity);
98
        $sum = $this->moneyParser->parse($sum, new Currency($currency));
99
        $this->setPrice(new SinglePrice(null, $type, $target, null, $quantity, $sum));
100
    }
101
102
    /**
103
     * @Given /(\S+) progressive price for (\S+) is +(\S+) (\S+) per (\S+) (\S+) (\S+) (\S+)$/
104
     */
105
    public function progressivePrice($target, $type, $price, $currency, $unit, $sign, $quantity, $perUnit): void
0 ignored issues
show
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

105
    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...
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

105
    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...
106
    {
107
        if (empty($this->progressivePrice[$type])) {
108
            $this->progressivePrice[$type] = [
0 ignored issues
show
Bug Best Practice introduced by
The property progressivePrice does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
109
                'target' => $target,
110
                'unit' => $unit,
111
                'price' => $price,
112
                'currency' => $currency,
113
                'thresholds' =>[
114
                    [
115
                        'price' => $price,
116
                        'currency' => $currency,
117
                        'unit' => $unit,
118
                        'quantity' => $quantity,
119
                    ]
120
                ],
121
            ];
122
        } else {
123
            array_push(
124
                $this->progressivePrice[$type]['thresholds'],
125
                [
126
                    'price' => $price,
127
                    'currency' => $currency,
128
                    'unit' => $unit,
129
                    'quantity' => $quantity,
130
                ]
131
            );
132
        }
133
    }
134
135
    /**
136
     * @Given /^create progressive price/
137
     */
138
    public function createProgressivePrices()
139
    {
140
        foreach ($this->progressivePrice as $type => $price) {
141
            $type = new Type(Type::ANY, $type);
142
            $target = new Target(Target::ANY, $price['target']);
143
            $quantity = Quantity::create(Unit::create($price['unit']), 1);
0 ignored issues
show
Bug introduced by
hiqdev\php\units\Unit::create($price['unit']) of type hiqdev\php\units\Unit is incompatible with the type string expected by parameter $unit of hiqdev\php\units\AbstractQuantity::create(). ( Ignorable by Annotation )

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

143
            $quantity = Quantity::create(/** @scrutinizer ignore-type */ Unit::create($price['unit']), 1);
Loading history...
144
            $money = PriceHelper::buildMoney($price['price'], $price['currency']);
145
            $this->setPrice(new ProgressivePrice(null, $type, $target, $quantity, $money, $price['thresholds']));
146
        }
147
    }
148
149
    /**
150
     * @Given /sale close time is ([0-9.-]+)/
151
     */
152
    public function setActionCloseTime($closeTime): void
153
    {
154
        $this->sale->close(new DateTimeImmutable($closeTime));
155
    }
156
157
    private function setPrice($price)
158
    {
159
        $this->price = $price;
160
        $ref = new \ReflectionClass($this->plan);
161
        $prop = $ref->getProperty('prices');
162
        $prop->setAccessible(true);
163
        $prop->setValue($this->plan, [$price]);
164
    }
165
166
    /**
167
     * @Given /action is (\S+) ([\w_,]+)(?: ([0-9.]+) (\S+))?(?: in (.+))?/
168
     */
169
    public function actionIs(string $target, string $type, float $amount, string $unit, ?string $date = null): void
170
    {
171
        $type = new Type(Type::ANY, $type);
172
        $target = new Target(Target::ANY, $target);
173
        $time = new DateTimeImmutable($date);
174
        if ($this->sale->getCloseTime() instanceof DateTimeImmutable) {
175
            $amount = $amount * $this->getFractionOfMonth(
176
                $time, $time, $this->sale->getCloseTime()
177
            );
178
        }
179
        $quantity = Quantity::create($unit, $amount);
180
181
        $this->action = new Action(null, $type, $target, $quantity, $this->customer, $time);
182
    }
183
184
    private function getFractionOfMonth(DateTimeImmutable $month, DateTimeImmutable $startTime, DateTimeImmutable $endTime): float
185
    {
186
        // SQL function: days2quantity()
187
188
        $month = $month->modify('first day of this month 00:00');
189
        if ($startTime < $month) {
190
            $startTime = $month;
191
        }
192
        if ($endTime > $month->modify('first day of next month 00:00')) {
193
            $endTime = $month->modify('first day of next month 00:00');
194
        }
195
196
        $secondsInMonth = $month->format('t') * 24 * 60 * 60;
197
198
        return ($endTime->getTimestamp() - $startTime->getTimestamp()) / $secondsInMonth;
199
    }
200
201
    /**
202
     * @Given /formula is (.+)/
203
     */
204
    public function formulaIs(string $formula): void
205
    {
206
        $this->formula = $formula;
207
    }
208
209
    /**
210
     * @Given /formula continues (.+)/
211
     */
212
    public function formulaContinues(string $formula): void
213
    {
214
        $this->formula .= "\n" . $formula;
215
    }
216
217
    protected function getFormulaEngine()
218
    {
219
        if ($this->engine === null) {
220
            $this->engine = new FormulaEngine(new ArrayCachePool());
221
        }
222
223
        return $this->engine;
224
    }
225
226
    /**
227
     * @When /action date is (.+)/
228
     * @throws Exception
229
     */
230
    public function actionDateIs(string $date): void
231
    {
232
        $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

232
        $this->action->/** @scrutinizer ignore-call */ 
233
                       setTime(new DateTimeImmutable($date));
Loading history...
233
    }
234
235
    /**
236
     * @Given /^client rejected service at (.+)$/
237
     */
238
    public function actionCloseDateIs(string $close_date): void
239
    {
240
        $this->sale->close(new DateTimeImmutable($close_date));
241
    }
242
243
    /**
244
     * @Then /^error is$/m
245
     */
246
    public function multilineErrorIs(\Behat\Gherkin\Node\PyStringNode $value)
247
    {
248
        $this->expectedError = $value->getRaw();
249
    }
250
251
    /**
252
     * @Then /^error is (.+)$/
253
     *
254
     * @param string $error
255
     */
256
    public function errorIs($error): void
257
    {
258
        $this->expectedError = $error;
259
    }
260
261
    /**
262
     * @Then /^(\w+) charge is ?(?: with ?)?$/
263
     */
264
    public function emptyCharge(string $numeral): void
265
    {
266
        $this->chargeIs($numeral);
267
    }
268
269
    /**
270
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3})(?: for ([\d.]+)? (\w+)?)?(?: with (.+)?)?$/
271
     */
272
    public function chargeWithSum($numeral, $type = null, $sum = null, $currency = null, $qty = null, $unit = null, $events = null): void
273
    {
274
        $this->chargeIs($numeral, $type, $sum, $currency, null, $qty, $unit, $events);
275
    }
276
277
    /**
278
     * @Then /^(\w+) charge is (\S+) +(-?[0-9.]+) ([A-Z]{3}) reason ([\w]+)(?: with (.+)?)?$/
279
     */
280
    public function chargeWithReason($numeral, $type = null, $sum = null, $currency = null, $reason = null, $events = null): void
281
    {
282
        $this->chargeIs($numeral, $type, $sum, $currency, $reason, null, null, $events);
283
    }
284
285
    public function chargeIs($numeral, $type = null, $sum = null, $currency = null, $reason = null, $qty = null, $unit = null, $events = null): void
286
    {
287
        $no = $this->ensureNo($numeral);
288
        if ($no === 0) {
289
            $this->calculatePrice();
290
        }
291
        $this->assertCharge($this->charges[$no] ?? null, $type, $sum, $currency, $reason, $qty, $unit, $events);
292
    }
293
294
    /**
295
     * @When /^calculating charges$/
296
     */
297
    public function calculatePrice(): void
298
    {
299
        $this->expectError(function () {
300
            if ($this->formula !== null) {
301
                $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

301
                $this->price->/** @scrutinizer ignore-call */ 
302
                              setModifier($this->getFormulaEngine()->build($this->formula));
Loading history...
302
            }
303
            $this->charges = $this->billing->calculateCharges($this->action);
304
        });
305
    }
306
307
    public function expectError(Closure $closure): void
308
    {
309
        try {
310
            call_user_func($closure);
311
        } catch (Exception $e) {
312
            if ($this->isExpectedError($e)) {
313
                $this->expectedError = null;
314
            } else {
315
                throw $e;
316
            }
317
        }
318
        if ($this->expectedError) {
319
            throw new Exception('failed receive expected exception');
320
        }
321
    }
322
323
    protected function isExpectedError(Exception $e): bool
324
    {
325
        return str_starts_with($e->getMessage(), $this->expectedError);
326
    }
327
328
    /**
329
     * @param ChargeInterface|Charge|null $charge
330
     * @param string|null $type
331
     * @param string|null $sum
332
     * @param string|null $currency
333
     * @param string|null $reason
334
     * @param string|null $qty
335
     * @param string|null $unit
336
     * @param string|null $events
337
     */
338
    public function assertCharge($charge, $type, $sum, $currency, $reason, $qty, $unit, $events): void
339
    {
340
        if (empty($type) && empty($sum) && empty($currency)) {
341
            is_null($charge) || Assert::assertSame('0', $charge->getSum()->getAmount());
342
            return;
343
        }
344
        Assert::assertInstanceOf(Charge::class, $charge);
345
        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

345
        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...
346
            'Charge type %s does not match expected %s', $type, $this->normalizeType($charge->getType()->getName())
347
        ));
348
        $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

348
        $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

348
        $money = $this->moneyParser->parse(/** @scrutinizer ignore-type */ $sum, new Currency($currency));
Loading history...
349
        Assert::assertTrue($money->equals($charge->getSum()), sprintf(
350
            'Charge sum %s does not match expected %s', $charge->getSum()->getAmount(), $money->getAmount()
351
        ));
352
        if ($reason !== null) {
353
            Assert::assertSame($reason, $charge->getComment(),
354
                sprintf('Charge comment %s does not match expected %s', $charge->getComment(), $reason)
355
            );
356
        }
357
        if ($qty !== null && $unit !== null) {
358
            Assert::assertEqualsWithDelta($qty, $charge->getUsage()->getQuantity(), 1e-7,
359
                sprintf('Charge quantity "%s" does not match expected "%s"', $charge->getUsage()->getQuantity(), $qty)
360
            );
361
            Assert::assertSame($unit, $charge->getUsage()->getUnit()->getName(),
362
                sprintf('Charge unit "%s" does not match expected "%s"', $charge->getUsage()->getUnit()->getName(), $unit)
363
            );
364
        }
365
        if ($events !== null) {
366
            $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

366
            /** @scrutinizer ignore-call */ 
367
            $storedEvents = $charge->releaseEvents();
Loading history...
367
            foreach (array_map('trim', explode(',', $events)) as $eventClass) {
368
                foreach ($storedEvents as $storedEvent) {
369
                    $eventReflection = new \ReflectionObject($storedEvent);
370
                    if ($eventReflection->getShortName() === $eventClass) {
371
                        continue 2;
372
                    }
373
                }
374
375
                Assert::fail(sprintf('Event of class %s is not present is charge', $eventClass));
376
            }
377
        } else {
378
            Assert::assertEmpty($charge->releaseEvents(), 'Failed asserting that charge does not have events');
379
        }
380
    }
381
382
    private function normalizeType($string): string
383
    {
384
        switch ($string) {
385
            case 'discount,discount':
386
                return 'discount';
387
            case 'monthly,leasing':
388
                return 'leasing';
389
            case 'monthly,installment':
390
                return 'installment';
391
            default:
392
                return $string;
393
        }
394
    }
395
396
    private function ensureNo(string $numeral): int
397
    {
398
        $formatter = new NumberFormatter('en_EN', NumberFormatter::SPELLOUT);
399
        $result = $formatter->parse($numeral);
400
        if ($result === false) {
401
            throw new Exception("Wrong numeral '$numeral'");
402
        }
403
404
        return --$result;
405
    }
406
}
407