Passed
Push — master ( a64e2a...e71307 )
by Dmitry
08:00
created

Once::getSinceDate()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 1
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace hiqdev\php\billing\charge\modifiers;
4
5
use DateTimeImmutable;
6
use hiqdev\php\billing\action\ActionInterface;
7
use hiqdev\php\billing\charge\ChargeInterface;
8
use hiqdev\php\billing\charge\derivative\ChargeDerivative;
9
use hiqdev\php\billing\charge\derivative\ChargeDerivativeQuery;
10
use hiqdev\php\billing\charge\modifiers\addons\MonthPeriod;
11
use hiqdev\php\billing\charge\modifiers\addons\Period;
12
use hiqdev\php\billing\charge\modifiers\addons\Since;
13
use hiqdev\php\billing\charge\modifiers\addons\WithSince;
14
use hiqdev\php\billing\charge\modifiers\addons\YearPeriod;
15
use hiqdev\php\billing\formula\FormulaEngineException;
16
use Money\Money;
17
18
/**
19
 * 1. API:
20
 * - once.per('1 year') – bill every month that matches the month of sale of the object
21
 * - once.per('1 year').since('04.2025') – bill every year in April, starting from 2025
22
 * - once.per('3 months') – bill every third month, starting from the month of sale of the object
23
 * - once.per('day'), once.per('1.5 months') – throws an interpret-time exception, a value must NOT be a fraction of the month
24
 * 2. In months where the formula should NOT bill, it should produce a ZERO charge.
25
 * 3. If the sale is re-opened, the formula starts over, unless a `since` is specified.
26
 */
27
class Once extends Modifier
28
{
29
    private const MONTHS_IN_YEAR_ON_EARTH = 12;
30
31
    protected ChargeDerivative $chargeDerivative;
32
33
    public function __construct(array $addons = [])
34
    {
35
        parent::__construct($addons);
36
37
        $this->chargeDerivative = new ChargeDerivative();
38
    }
39
40
    public function per(string $interval): self
41
    {
42
        return $this->addAddon('per', $this->createPeriod($interval));
43
    }
44
45
    private function createPeriod(string $interval): Period
46
    {
47
        $period = Period::fromString($interval);
48
49
        if ($this->isSupportedPeriod($period)) {
50
            return $period;
51
        }
52
53
        throw new FormulaEngineException("Invalid interval. Supported: whole months or years.");
54
    }
55
56
57
    private function isSupportedPeriod(Period $period): bool
58
    {
59
        return $period instanceof MonthPeriod || $period instanceof YearPeriod;
60
    }
61
62
    public function getPer(): ?Period
63
    {
64
        return $this->getAddon('per');
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getAddon('per') could return the type hiqdev\php\billing\charge\modifiers\AddonInterface which includes types incompatible with the type-hinted return hiqdev\php\billing\charg...iers\addons\Period|null. Consider adding an additional type-check to rule them out.
Loading history...
65
    }
66
67
    public function modifyCharge(?ChargeInterface $charge, ActionInterface $action): array
68
    {
69
        $period = $this->getPer();
70
71
        $this->assertPeriod($period);
72
        $this->assertCharge($charge);
73
74
        // Apply the charge if applicable based on the action and interval period
75
        if ($this->isApplicable($action, $period)) {
76
            return [$charge];
77
        }
78
79
        // Return zero charge if the period is not applicable
80
        return [$this->createNewZeroCharge($charge)];
81
    }
82
83
    private function createNewZeroCharge(ChargeInterface $charge): ChargeInterface
84
    {
85
        $zeroChargeQuery = new ChargeDerivativeQuery();
86
        $zeroChargeQuery->changeSum(new Money(0, $charge->getSum()->getCurrency()));
87
        $reason = $this->getReason();
88
        if ($reason) {
0 ignored issues
show
introduced by
$reason is of type hiqdev\php\billing\charge\modifiers\addons\Reason, thus it always evaluated to true.
Loading history...
89
            $zeroChargeQuery->changeComment($reason->getValue());
90
        } else {
91
            $zeroChargeQuery->changeComment('Billed once per ' . $this->getPer()->toString());
92
        }
93
94
        return $this->chargeDerivative->__invoke($charge, $zeroChargeQuery);
95
    }
96
97
    private function assertPeriod(?Period $period)
98
    {
99
        if ($period === null) {
100
            throw new FormulaEngineException('Period cannot be null in Once');
101
        }
102
    }
103
104
    private function assertCharge(?ChargeInterface $charge)
105
    {
106
        if ($charge === null) {
107
            throw new FormulaEngineException('Charge cannot be null in Once');
108
        }
109
    }
110
111
    private function isApplicable(ActionInterface $action, Period $period): bool
112
    {
113
        $since = $this->getSinceDate($action);
114
        $actionTime = $action->getTime();
115
        $monthsDiff = $this->calculateMonthsDifference($since, $actionTime);
116
117
        $intervalMonths = $this->getIntervalMonthsFromPeriod($period);
118
119
        // Check if the current period is applicable (divisible by interval)
120
        return $monthsDiff % $intervalMonths === 0;
121
    }
122
123
    private function getSinceDate(ActionInterface $action): DateTimeImmutable
124
    {
125
        $since = $this->getSince();
126
        if ($since instanceof Since) {
0 ignored issues
show
introduced by
$since is always a sub-type of hiqdev\php\billing\charge\modifiers\addons\Since.
Loading history...
127
            return $since->getValue();
128
        }
129
130
        $sale = $action->getSale();
131
        if ($sale !== null) {
132
            return $sale->getTime();
133
        }
134
135
        throw new FormulaEngineException('Cannot determine initial date for "once" modifier: no "since" addon and no sale in action');
136
    }
137
138
    private function calculateMonthsDifference(DateTimeImmutable $start, DateTimeImmutable $end): int
139
    {
140
        $interval = $end->diff($start);
141
        return ($interval->y * self::MONTHS_IN_YEAR_ON_EARTH) + $interval->m;
142
    }
143
144
    private function getIntervalMonthsFromPeriod(Period $period): int
145
    {
146
        if ($period instanceof YearPeriod) {
147
            return self::MONTHS_IN_YEAR_ON_EARTH;
148
        } elseif ($period instanceof MonthPeriod) {
149
            return $period->getValue();
150
        }
151
152
        throw new FormulaEngineException('Unsupported interval period in Once');
153
    }
154
}
155