Skip to content

Commit a64e2ae

Browse files
committed
HP-2672 Support "once" charges with a starting point (since) and enhance tests for periodic billing
1 parent 79b6e2c commit a64e2ae

File tree

8 files changed

+112
-13
lines changed

8 files changed

+112
-13
lines changed

src/charge/modifiers/Once.php

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,20 @@
99
use hiqdev\php\billing\charge\derivative\ChargeDerivativeQuery;
1010
use hiqdev\php\billing\charge\modifiers\addons\MonthPeriod;
1111
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;
1214
use hiqdev\php\billing\charge\modifiers\addons\YearPeriod;
1315
use hiqdev\php\billing\formula\FormulaEngineException;
1416
use Money\Money;
1517

1618
/**
1719
* 1. API:
1820
* - 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
1922
* - once.per('3 months') – bill every third month, starting from the month of sale of the object
2023
* - once.per('day'), once.per('1.5 months') – throws an interpret-time exception, a value must NOT be a fraction of the month
2124
* 2. In months where the formula should NOT bill, it should produce a ZERO charge.
22-
* 3. If the sale is re-opened, the formula starts over.
25+
* 3. If the sale is re-opened, the formula starts over, unless a `since` is specified.
2326
*/
2427
class Once extends Modifier
2528
{
@@ -84,6 +87,8 @@ private function createNewZeroCharge(ChargeInterface $charge): ChargeInterface
8487
$reason = $this->getReason();
8588
if ($reason) {
8689
$zeroChargeQuery->changeComment($reason->getValue());
90+
} else {
91+
$zeroChargeQuery->changeComment('Billed once per ' . $this->getPer()->toString());
8792
}
8893

8994
return $this->chargeDerivative->__invoke($charge, $zeroChargeQuery);
@@ -105,24 +110,29 @@ private function assertCharge(?ChargeInterface $charge)
105110

106111
private function isApplicable(ActionInterface $action, Period $period): bool
107112
{
108-
$saleTime = $this->getSaleTime($action);
113+
$since = $this->getSinceDate($action);
109114
$actionTime = $action->getTime();
110-
$monthsDiff = $this->calculateMonthsDifference($saleTime, $actionTime);
115+
$monthsDiff = $this->calculateMonthsDifference($since, $actionTime);
111116

112117
$intervalMonths = $this->getIntervalMonthsFromPeriod($period);
113118

114119
// Check if the current period is applicable (divisible by interval)
115120
return $monthsDiff % $intervalMonths === 0;
116121
}
117122

118-
private function getSaleTime(ActionInterface $action): DateTimeImmutable
123+
private function getSinceDate(ActionInterface $action): DateTimeImmutable
119124
{
125+
$since = $this->getSince();
126+
if ($since instanceof Since) {
127+
return $since->getValue();
128+
}
129+
120130
$sale = $action->getSale();
121-
if ($sale === null || $sale->getTime() === null) {
122-
throw new FormulaEngineException('Sale or sale time cannot be null in Once');
131+
if ($sale !== null) {
132+
return $sale->getTime();
123133
}
124134

125-
return $sale->getTime();
135+
throw new FormulaEngineException('Cannot determine initial date for "once" modifier: no "since" addon and no sale in action');
126136
}
127137

128138
private function calculateMonthsDifference(DateTimeImmutable $start, DateTimeImmutable $end): int

src/charge/modifiers/addons/Date.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public function __construct($value)
3131
$this->value = static::ensureValidValue($value);
3232
}
3333

34-
public function getValue()
34+
public function getValue(): DateTimeImmutable
3535
{
3636
return $this->value;
3737
}

src/charge/modifiers/addons/DayPeriod.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,9 @@ public function addTo(DateTimeImmutable $since): DateTimeImmutable
3131
{
3232
return $since->add(new DateInterval("P{$this->value}D"));
3333
}
34+
35+
public function __toString(): string
36+
{
37+
return $this->value . ' day' . ($this->value > 1 ? 's' : '');
38+
}
3439
}

src/charge/modifiers/addons/MonthPeriod.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,9 @@ public function addTo(DateTimeImmutable $since): DateTimeImmutable
3030
{
3131
return $since->add(new \DateInterval("P{$this->value}M"));
3232
}
33+
34+
public function __toString(): string
35+
{
36+
return $this->value . ' month' . ($this->value > 1 ? 's' : '');
37+
}
3338
}

src/charge/modifiers/addons/Period.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ public static function ensureValidValue($value)
6969
return (int) $value;
7070
}
7171

72+
abstract public function __toString(): string;
73+
74+
public function toString(): string
75+
{
76+
return $this->__toString();
77+
}
78+
7279
/**
7380
* Adds current period to the passed $startTime
7481
*

src/charge/modifiers/addons/YearPeriod.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,9 @@ public function addTo(DateTimeImmutable $since): DateTimeImmutable
3030
{
3131
return $since->add(new \DateInterval("P{$this->value}Y"));
3232
}
33+
34+
public function __toString(): string
35+
{
36+
return $this->value . ' year' . ($this->value > 1 ? 's' : '');
37+
}
3338
}

tests/unit/action/ActionTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ public function assertZeroCharge(ChargeInterface $charge): void
130130
$this->assertSame('0', $charge->getSum()->getAmount());
131131
}
132132

133+
public function assertNonZeroCharge(ChargeInterface $charge): void
134+
{
135+
$this->assertNotSame('0', $charge->getSum()->getAmount(), 'Charge is zero');
136+
}
137+
133138
public function testChargesForNextMonthSalesAreNotCalculated()
134139
{
135140
$action = $this->createAction($this->prepaid->multiply(2));

tests/unit/charge/modifiers/OnceTest.php

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use hiqdev\php\billing\tests\unit\action\ActionTest;
1717
use hiqdev\php\billing\type\Type;
1818
use hiqdev\php\billing\type\TypeInterface;
19+
use hiqdev\php\units\Quantity;
1920
use hiqdev\php\units\QuantityInterface;
2021

2122
class OnceTest extends ActionTest
@@ -24,6 +25,7 @@ protected function setUp(): void
2425
{
2526
parent::setUp();
2627

28+
$this->prepaid = Quantity::item(0);
2729
$this->type = $this->createType('monthly,monthly');
2830
$this->price = $this->createPrice($this->type);
2931
}
@@ -92,7 +94,7 @@ public function testPerOneYear_WithOneYearLaterShouldApplyCharge(): void
9294

9395
$saleTime = new DateTimeImmutable('22-11-2023');
9496
$actionTime = $saleTime->modify('+1 year');
95-
$action = $this->createActionWithSale($this->prepaid->multiply(2), $actionTime, $saleTime);
97+
$action = $this->createActionWithSale($actionTime, $saleTime);
9698
$type = $this->createType('monthly,monthly');
9799
$price = $this->createPrice($type);
98100

@@ -104,6 +106,25 @@ public function testPerOneYear_WithOneYearLaterShouldApplyCharge(): void
104106
$this->assertSame($charge, $charges[0]);
105107
}
106108

109+
public function testPerOneYear_ShouldChargeInFirstMonthOfSale(): void
110+
{
111+
$once = $this->buildOnce('1 year');
112+
$saleTime = new DateTimeImmutable('01-01-2025 12:31:00');
113+
$actionTime = $saleTime->modify('first day of this month 00:00:00');
114+
$action = $this->createActionWithSale($actionTime, $saleTime);
115+
$type = $this->createType('monthly,monthly');
116+
$price = $this->createPrice($type);
117+
118+
$charge = $this->calculator->calculateCharge($price, $action);
119+
$this->assertNonZeroCharge($charge);
120+
121+
$charges = $once->modifyCharge($charge, $action);
122+
$this->assertIsArray($charges);
123+
$this->assertCount(1, $charges);
124+
$this->assertSame($charge, $charges[0]);
125+
$this->assertNonZeroCharge($charges[0]);
126+
}
127+
107128
private function createActionWithCustomTime(QuantityInterface $quantity, DateTimeImmutable $time): Action
108129
{
109130
return new Action(null, $this->type, $this->target, $quantity, $this->customer, $time);
@@ -115,22 +136,23 @@ public function testPerOneYear_With11MonthsLaterShouldReturnZeroCharge(): void
115136

116137
$saleTime = new DateTimeImmutable('22-10-2023');
117138
$actionTime = $saleTime->modify('+11 months');
118-
$action = $this->createActionWithSale($this->prepaid->multiply(2), $actionTime, $saleTime);
139+
$action = $this->createActionWithSale($actionTime, $saleTime);
119140
$type = $this->createType('monthly,monthly');
120141
$price = $this->createPrice($type);
121142

122143
$charge = $this->calculator->calculateCharge($price, $action);
144+
$this->assertNonZeroCharge($charge);
123145

124146
$charges = $once->modifyCharge($charge, $action);
125147
$this->assertCount(1, $charges);
126148
$this->assertZeroCharge($charges[0]);
127149
}
128150

129151
private function createActionWithSale(
130-
QuantityInterface $quantity,
131152
DateTimeImmutable $actionTime,
132153
DateTimeImmutable $saleTime
133154
): ActionInterface {
155+
$quantity = Quantity::item(1);
134156
$action = $this->createActionWithCustomTime($quantity, $actionTime);
135157

136158
$plan = new Plan(null, '', $this->customer, [$this->price]);
@@ -159,7 +181,7 @@ public function testPerThreeMonths_After3Months_ShouldApplyCharge(): void
159181

160182
$saleTime = new DateTimeImmutable('22-01-2024');
161183
$actionTime = $saleTime->modify('+3 months');
162-
$action = $this->createActionWithSale($this->prepaid->multiply(2), $actionTime, $saleTime);
184+
$action = $this->createActionWithSale($actionTime, $saleTime);
163185
$type = $this->createType('monthly,monthly');
164186
$price = $this->createPrice($type);
165187

@@ -177,7 +199,7 @@ public function testPerThreeMonths_After2Months_ShouldReturnZeroCharge(): void
177199

178200
$saleTime = new DateTimeImmutable('22-01-2024');
179201
$actionTime = $saleTime->modify('+2 months');
180-
$action = $this->createActionWithSale($this->prepaid->multiply(2), $actionTime, $saleTime);
202+
$action = $this->createActionWithSale($actionTime, $saleTime);
181203
$type = $this->createType('monthly,monthly');
182204
$price = $this->createPrice($type);
183205

@@ -187,4 +209,44 @@ public function testPerThreeMonths_After2Months_ShouldReturnZeroCharge(): void
187209
$this->assertCount(1, $charges);
188210
$this->assertZeroCharge($charges[0]);
189211
}
212+
213+
public function testPerOneYearSinceDate(): void
214+
{
215+
$once = $this->buildOnce('1 year')->since('04.2025');
216+
217+
$saleTime = new DateTimeImmutable('2023-01-01'); // Sale time should be ignored, since is set
218+
$price = $this->createPrice($this->createType('monthly,monthly'));
219+
220+
// Action in the same month and year as since: applies
221+
$actionTime1 = new DateTimeImmutable('2025-04-30');
222+
$action1 = $this->createActionWithSale($actionTime1, $saleTime);
223+
$charge1 = $this->calculator->calculateCharge($price, $action1);
224+
$charges1 = $once->modifyCharge($charge1, $action1);
225+
$this->assertCount(1, $charges1);
226+
$this->assertEquals($charge1, $charges1[0]);
227+
228+
// Action one year later in same month: applies
229+
$actionTime2 = new DateTimeImmutable('2026-04-01');
230+
$action2 = $this->createActionWithSale($actionTime2, $saleTime);
231+
$charge2 = $this->calculator->calculateCharge($price, $action2);
232+
$charges2 = $once->modifyCharge($charge2, $action2);
233+
$this->assertCount(1, $charges2);
234+
$this->assertEquals($charge2, $charges2[0]);
235+
236+
// Action one year later in different month: zero charge
237+
$actionTime3 = new DateTimeImmutable('2026-05-01');
238+
$action3 = $this->createActionWithSale($actionTime3, $saleTime);
239+
$charge3 = $this->calculator->calculateCharge($price, $action3);
240+
$charges3 = $once->modifyCharge($charge3, $action3);
241+
$this->assertCount(1, $charges3);
242+
$this->assertZeroCharge($charges3[0]);
243+
244+
// Action before since date: zero charge
245+
$actionTime4 = new DateTimeImmutable('2025-03-01');
246+
$action4 = $this->createActionWithSale($actionTime4, $saleTime);
247+
$charge4 = $this->calculator->calculateCharge($price, $action4);
248+
$charges4 = $once->modifyCharge($charge4, $action4);
249+
$this->assertCount(1, $charges4);
250+
$this->assertZeroCharge($charges4[0]);
251+
}
190252
}

0 commit comments

Comments
 (0)