Passed
Pull Request — master (#67)
by
unknown
13:46
created

FeatureContext::createProgressivePrices()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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

102
    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

102
    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...
103
    {
104
        if (empty($this->progressivePrice[$type])) {
105
            $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...
106
                'target' => $target,
107
                'unit' => $unit,
108
                'currency' => $currency,
109
                'thresholds' =>[
110
                    new ProgressivePriceThresholdsDto($price, new Currency($currency), $quantity)
111
                ],
112
            ];
113
        } else {
114
            array_push(
115
                $this->progressivePrice[$type]['thresholds'],
116
                new ProgressivePriceThresholdsDto($price, new Currency($currency), $quantity)
117
            );
118
        }
119
    }
120
121
    /**
122
     * @Given /^create progressive price/
123
     */
124
    public function createProgressivePrices()
125
    {
126
        foreach ($this->progressivePrice as $type => $price) {
127
            $type = new Type(Type::ANY, $type);
128
            $target = new Target(Target::ANY, $price['target']);
129
            $quantity = Quantity::create($price['unit'], 1);
130
            $this->setPrice(new ProgressivePrice(null, $type, $target, $quantity, $price['thresholds']));
0 ignored issues
show
Bug introduced by
The call to hiqdev\php\billing\price...ivePrice::__construct() has too few arguments starting with thresholds. ( Ignorable by Annotation )

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

130
            $this->setPrice(/** @scrutinizer ignore-call */ new ProgressivePrice(null, $type, $target, $quantity, $price['thresholds']));

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

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

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

286
                $this->price->/** @scrutinizer ignore-call */ 
287
                              setModifier($this->getFormulaEngine()->build($this->formula));
Loading history...
287
            }
288
            $this->charges = $this->billing->calculateCharges($this->action);
289
        });
290
    }
291
292
    public function expectError(Closure $closure): void
293
    {
294
        try {
295
            call_user_func($closure);
296
        } catch (Exception $e) {
297
            if ($this->isExpectedError($e)) {
298
                $this->expectedError = null;
299
            } else {
300
                throw $e;
301
            }
302
        }
303
        if ($this->expectedError) {
304
            throw new Exception('failed receive expected exception');
305
        }
306
    }
307
308
    protected function isExpectedError(Exception $e): bool
309
    {
310
        return str_starts_with($e->getMessage(), $this->expectedError);
311
    }
312
313
    /**
314
     * @param ChargeInterface|Charge|null $charge
315
     * @param string|null $type
316
     * @param string|null $sum
317
     * @param string|null $currency
318
     * @param string|null $reason
319
     * @param string|null $qty
320
     * @param string|null $unit
321
     * @param string|null $events
322
     */
323
    public function assertCharge($charge, $type, $sum, $currency, $reason, $qty, $unit, $events): void
324
    {
325
        if (empty($type) && empty($sum) && empty($currency)) {
326
            is_null($charge) || Assert::assertSame('0', $charge->getSum()->getAmount());
327
            return;
328
        }
329
        Assert::assertInstanceOf(Charge::class, $charge);
330
        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

330
        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...
331
            'Charge type %s does not match expected %s', $type, $this->normalizeType($charge->getType()->getName())
332
        ));
333
        $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

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

333
        $money = $this->moneyParser->parse(/** @scrutinizer ignore-type */ $sum, new Currency($currency));
Loading history...
334
        Assert::assertTrue($money->equals($charge->getSum()), sprintf(
335
            'Charge sum %s does not match expected %s', $charge->getSum()->getAmount(), $money->getAmount()
336
        ));
337
        if ($reason !== null) {
338
            Assert::assertSame($reason, $charge->getComment(),
339
                sprintf('Charge comment %s does not match expected %s', $charge->getComment(), $reason)
340
            );
341
        }
342
        if ($qty !== null && $unit !== null) {
343
            Assert::assertEqualsWithDelta($qty, $charge->getUsage()->getQuantity(), 1e-7,
344
                sprintf('Charge quantity "%s" does not match expected "%s"', $charge->getUsage()->getQuantity(), $qty)
345
            );
346
            Assert::assertSame($unit, $charge->getUsage()->getUnit()->getName(),
347
                sprintf('Charge unit "%s" does not match expected "%s"', $charge->getUsage()->getUnit()->getName(), $unit)
348
            );
349
        }
350
        if ($events !== null) {
351
            $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

351
            /** @scrutinizer ignore-call */ 
352
            $storedEvents = $charge->releaseEvents();
Loading history...
352
            foreach (array_map('trim', explode(',', $events)) as $eventClass) {
353
                foreach ($storedEvents as $storedEvent) {
354
                    $eventReflection = new \ReflectionObject($storedEvent);
355
                    if ($eventReflection->getShortName() === $eventClass) {
356
                        continue 2;
357
                    }
358
                }
359
360
                Assert::fail(sprintf('Event of class %s is not present is charge', $eventClass));
361
            }
362
        } else {
363
            Assert::assertEmpty($charge->releaseEvents(), 'Failed asserting that charge does not have events');
364
        }
365
    }
366
367
    private function normalizeType($string): string
368
    {
369
        switch ($string) {
370
            case 'discount,discount':
371
                return 'discount';
372
            case 'monthly,leasing':
373
                return 'leasing';
374
            case 'monthly,installment':
375
                return 'installment';
376
            default:
377
                return $string;
378
        }
379
    }
380
381
    private function ensureNo(string $numeral): int
382
    {
383
        $formatter = new NumberFormatter('en_EN', NumberFormatter::SPELLOUT);
384
        $result = $formatter->parse($numeral);
385
        if ($result === false) {
386
            throw new Exception("Wrong numeral '$numeral'");
387
        }
388
389
        return --$result;
390
    }
391
}
392