Commit 5da51dfe authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(billing): bill every player on the 1st, and never let a renewal run fail in silence

On 1 September OC-Sport had 230 active players due for renewal and the
nightly command raised zero invoices. It resolved the invoice actor with
User::find($enrollment->created_by); `enrollments` has no created_by
column — it is enrolled_by — and Eloquent answers null for a missing
attribute, so User::find(null) returned null and every enrolment took the
"no valid user" branch. Introduced by da344cda on 9 August, which is why
August still billed (2 and 5 August) and September did not. The command
printed a summary and exited SUCCESS the whole time, so nothing in the
system disagreed for four weeks.

The column name was the trigger; the silence was the bug. Four things
change so this class of failure cannot repeat:

* The actor comes from enrolled_by, and falls back (programme creator,
  then an academy admin) rather than skipping. A receptionist leaving
  the academy must never be the reason a paying member goes unbilled.

* A run that finds players and bills none of them exits FAILURE. The
  scheduler now reports a broken run instead of a tidy one.

* Cycles are caught up. The old code advanced next_billing_date by
  addMonth() and raised one invoice, so a run lost to a container
  restart at 07:00 skipped that month permanently. It now bills every
  cycle between next_billing_date and today.

* Invoices are dated the cycle they buy, never the day the job ran, and
  carry metadata.month — the signal SubscriptionLine trusts above
  Arabic month names and above issue_date. A September renewal raised on
  the 20th is still September money.

The "renew on the 1st" rule was hand-written in five places and three
disagreed: the command drifted the anchor a day every time a run was
late, ReconciliationWizard advanced from now() instead of from the cycle
it was closing (skipping one), and EnrollmentService ignored billing
cycles longer than a month. They now share App\Domain\Training\Support\
BillingCycle, which is the only definition of the rule.

Also fixed along the way:

* Discounts were put on the invoice header AND the line, and
  recalculateTotals() computes total = sum(line totals) - header
  discount, so every discounted renewal charged the discount twice.
  OC-Sport has an active sibling-discount rule, so this was live money.
  Lines now carry the undiscounted price, which is the convention
  ParticipantBillingService already documents.

* CollectPaymentWizard deduplicated renewals with
  notes LIKE %{programme name}% over unpaid statuses only. OC-Sport has
  three programmes called "فريق 2018", so one player suppressed
  another's; and once a renewal was paid the guard stopped seeing it and
  the next visit billed the month again. It now matches on the cycle and
  the enrolment, and invoice creation shares a transaction with the
  next_billing_date advance.

* An active paying enrolment with a null next_billing_date was invisible
  to every renewal query in the system, permanently. Such rows are now
  adopted onto the current cycle — never retroactively.

Verified against a copy of the OC-Sport tenant: 230 invoices dated
2026-09-01, 65 members at 650 EGP and 162 non-members at 900 EGP, the
sibling discount applied once, re-running adds nothing. Six players on
عبدالعال 2012 fail loudly because that programme has no base price — a
hard fail by design, and now visible instead of silent.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 905ffefc
......@@ -19,6 +19,7 @@
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Models\Waitlist;
use App\Domain\Training\Support\BillingCycle;
use App\Models\User;
use Illuminate\Support\Facades\DB;
......@@ -432,6 +433,14 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
]);
}
/**
* A new enrolment's first renewal falls on the 1st of the next cycle —
* whatever day of the month they actually joined on.
*
* The rule lives in BillingCycle, which every other caller now shares, so
* there is one answer to "when is the next renewal" rather than five
* hand-written ones that quietly disagreed.
*/
private function calculateFirstBillingDate(?TrainingProgram $program): ?string
{
if (!$program) {
......@@ -442,17 +451,11 @@ private function calculateFirstBillingDate(?TrainingProgram $program): ?string
return null;
}
// All renewals happen on the 1st of next month
return now()->addMonth()->startOfMonth()->toDateString();
}
private function snapToDay(\Illuminate\Support\Carbon $date, ?int $billingDay): \Illuminate\Support\Carbon
{
if ($billingDay) {
$date->day = min($billingDay, $date->daysInMonth);
}
return $date;
return BillingCycle::next(
null,
$program->billing_cycle,
$program->program_duration_weeks
)->toDateString();
}
private function getStatusLabel(string $status): string
......
<?php
namespace App\Domain\Training\Support;
use Illuminate\Support\Carbon;
/**
* When a subscription renewal falls due.
*
* The academy's rule is one sentence and admits no exceptions: **every renewal
* is due on the 1st of a month.** A player who joins on the 14th still renews
* on the 1st, and a cycle that is billed late is still billed *for* the 1st —
* the money belongs to the month it bought, not to the day the job got round
* to it.
*
* That rule used to be written out five separate times — in EnrollmentService,
* in the renewal command, in CollectPaymentWizard, in ReconciliationWizard and
* in RetroactiveEnrollmentWizard — and they disagreed. The renewal command
* advanced `$current->addMonth()`, so a cycle billed one day late moved the
* anchor one day later *permanently*; the reconciliation wizard advanced from
* `now()` rather than from the cycle it was closing, which silently skipped
* one. This class exists so the rule has exactly one implementation and every
* caller is provably on it.
*
* Every date returned here is the 1st of some month. No database, no state.
*/
final class BillingCycle
{
/**
* How many whole months one cycle lasts, per `training_programs.billing_cycle`.
*
* `per_duration` prices a fixed-length course rather than a month, but it
* still renews on a 1st: its length is rounded UP to whole months, so a
* 12-week course bills quarterly and an 6-week one bills every two months.
* Rounding up rather than down is deliberate — billing a player before the
* period they paid for has run out is the worse error.
*/
public static function lengthInMonths(?string $cycle, ?int $programDurationWeeks = null): int
{
return match ($cycle) {
'quarterly' => 3,
'semi_annual' => 6,
'annual' => 12,
'per_duration' => max(1, (int) ceil(($programDurationWeeks ?: 4) / 4)),
default => 1,
};
}
/**
* The start of the cycle that contains $date — i.e. the 1st of its month.
* Defaults to the cycle we are living in right now.
*/
public static function startOf(Carbon|string|null $date = null): Carbon
{
return self::carbon($date)->startOfMonth();
}
/**
* The cycle after the one containing $from.
*
* Anchored to the month, never to the day: advancing from the 14th and
* advancing from the 1st give the same answer, which is what stops the
* billing day drifting a little further every time a run is late.
*/
public static function next(Carbon|string|null $from = null, ?string $cycle = null, ?int $programDurationWeeks = null): Carbon
{
return self::startOf($from)->addMonthsNoOverflow(self::lengthInMonths($cycle, $programDurationWeeks));
}
/**
* 'YYYY-MM' for the cycle containing $date.
*
* This is the key stamped into `invoices.metadata['month']`, which is the
* most trustworthy signal SubscriptionLine has for deciding which month an
* invoice's money belongs to — it beats reading month names out of Arabic
* free text, and it beats the issue date.
*/
public static function monthKey(Carbon|string|null $date = null): string
{
return self::startOf($date)->format('Y-m');
}
/**
* Arabic month name for a cycle, so the invoice line says out loud which
* month it is for instead of leaving the reader to infer it from a date.
*/
public static function monthLabel(Carbon|string|null $date = null): string
{
$start = self::startOf($date);
$names = [
1 => 'يناير', 2 => 'فبراير', 3 => 'مارس', 4 => 'أبريل',
5 => 'مايو', 6 => 'يونيو', 7 => 'يوليو', 8 => 'أغسطس',
9 => 'سبتمبر', 10 => 'أكتوبر', 11 => 'نوفمبر', 12 => 'ديسمبر',
];
return $names[(int) $start->month] . ' ' . $start->year;
}
/**
* Every cycle a player owes as of $today, oldest first — the catch-up.
*
* A missed run must not cost the academy a month. If the container was
* restarting at 07:00 on the 1st, or a deploy swallowed the run, or the
* command crashed, the next run bills the cycles that were skipped, each
* one dated its own 1st. Nothing is "too late" to bill; it is only ever
* billed for the right month.
*
* $cap bounds ONE run, not the backlog: whatever is left over is picked up
* by the next run, so a long outage drains over several days rather than
* dumping a year of invoices on a parent at once. The caller is expected to
* report what it left behind — a silent cap reads as "all done".
*
* @return array<int, Carbon> cycle start dates, each the 1st of a month
*/
public static function dueCycles(
Carbon|string $nextBillingDate,
Carbon|string|null $today = null,
?string $cycle = null,
?int $programDurationWeeks = null,
int $cap = 3
): array {
$length = self::lengthInMonths($cycle, $programDurationWeeks);
$cursor = self::startOf($nextBillingDate);
$currentCycle = self::startOf($today);
$due = [];
while ($cursor->lessThanOrEqualTo($currentCycle) && count($due) < $cap) {
$due[] = $cursor->copy();
$cursor = $cursor->copy()->addMonthsNoOverflow($length);
}
return $due;
}
/**
* How many cycles are still owed after a capped run has taken $cap of them.
* Zero means the player is fully caught up.
*/
public static function remainingAfter(
Carbon|string $nextBillingDate,
Carbon|string|null $today = null,
?string $cycle = null,
?int $programDurationWeeks = null,
int $cap = 3
): int {
$length = self::lengthInMonths($cycle, $programDurationWeeks);
$cursor = self::startOf($nextBillingDate);
$currentCycle = self::startOf($today);
$total = 0;
while ($cursor->lessThanOrEqualTo($currentCycle)) {
$total++;
$cursor = $cursor->copy()->addMonthsNoOverflow($length);
}
return max(0, $total - $cap);
}
private static function carbon(Carbon|string|null $date): Carbon
{
if ($date instanceof Carbon) {
return $date->copy();
}
return $date === null ? Carbon::now() : Carbon::parse($date);
}
}
......@@ -11,6 +11,7 @@
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Support\BillingCycle;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
......@@ -151,12 +152,16 @@ public function executeReconciliation(InvoiceService $invoiceService, PaymentSer
'due_date' => now()->subMonth()->endOfMonth()->toDateString(),
'contact_name' => $participant->person?->name_ar,
'notes' => 'تسوية — اشتراك الشهر السابق: ' . $program->name_ar,
// Say which month out loud. SubscriptionLine reads
// this before it reads dates or Arabic free text,
// so a settlement lands in the month it settles.
'metadata' => ['month' => BillingCycle::monthKey(now()->subMonth())],
], [
[
'description' => "اشتراك الشهر السابق: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], $actor);
......@@ -187,15 +192,21 @@ public function executeReconciliation(InvoiceService $invoiceService, PaymentSer
'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0,
'issue_date' => BillingCycle::startOf()->toDateString(),
'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar,
'notes' => 'تجديد اشتراك — ' . $program->name_ar,
'metadata' => [
'month' => BillingCycle::monthKey(),
'renewal' => true,
'renewal_enrollment_id' => (string) $enrollment->id,
],
], [
[
'description' => "تجديد اشتراك: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], $actor);
......@@ -232,19 +243,26 @@ public function executeReconciliation(InvoiceService $invoiceService, PaymentSer
'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar,
'notes' => 'تسوية — اشتراك شهرين: ' . $program->name_ar,
// One invoice covering two cycles: name both ends
// so the money is split across the months it
// bought instead of landing entirely on this one.
'metadata' => [
'period_start' => BillingCycle::startOf(now()->subMonth())->toDateString(),
'period_end' => BillingCycle::startOf()->toDateString(),
],
], [
[
'description' => "اشتراك الشهر السابق: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'discount_amount' => 0,
'tax_amount' => 0,
],
[
'description' => "تجديد الشهر الحالي: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], $actor);
......@@ -298,18 +316,28 @@ public function executeReconciliation(InvoiceService $invoiceService, PaymentSer
$this->currentStep = 3;
}
/**
* Reconciliation settles last month AND this month, so the enrolment hands
* over to the cycle after the current one.
*
* This used to advance from `now()` and snap to `billing_day`, which gave
* the right answer only while the wizard was run inside the month it was
* settling — and the wrong one, by a whole cycle, whenever it was not.
* BillingCycle is the single definition of the rule.
*/
private function advanceBillingDate(Enrollment $enrollment): void
{
$program = $enrollment->program;
$billingDay = $program->billing_day ?? 1;
$next = now()->addMonth();
$maxDay = $next->daysInMonth;
$next->day = min($billingDay, $maxDay);
$next = BillingCycle::next(
BillingCycle::startOf(),
$program?->billing_cycle,
$program?->program_duration_weeks
);
$enrollment->update([
'next_billing_date' => $next->toDateString(),
'last_billed_at' => now()->toDateString(),
'last_billed_at' => BillingCycle::startOf()->toDateString(),
'payment_status' => 'current',
]);
}
......
......@@ -15,6 +15,8 @@
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Support\BillingCycle;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -222,43 +224,67 @@ public function generateRenewalForEnrollment(int $enrollmentId): void
return;
}
$invoice = $invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $program->academy_id,
'branch_id' => $group?->branch_id ?? $participant->branch_id,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'total_amount' => $finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $chosenDiscount,
'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
'notes' => 'تجديد اشتراك — ' . $program->name_ar,
], [
[
'description' => "تجديد اشتراك: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
// The cycle being settled is the one the enrolment still owes, not
// "today". Raising September's renewal on the 20th must still date
// it 1 September, or the money is reported against the wrong month.
$cycleStart = BillingCycle::startOf($enrollment->next_billing_date ?? now());
$monthKey = BillingCycle::monthKey($cycleStart);
$monthLabel = BillingCycle::monthLabel($cycleStart);
$invoice = DB::transaction(function () use (
$invoiceService, $enrollment, $program, $participant, $group,
$priceResult, $finalAmount, $chosenDiscount, $discountLines,
$cycleStart, $monthKey, $monthLabel
) {
$invoice = $invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $program->academy_id,
'branch_id' => $group?->branch_id ?? $participant->branch_id,
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'total_amount' => $finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
// Header only. recalculateTotals() computes
// total = sum(line totals) - header discount, so repeating
// the figure on the line subtracts it twice.
'discount_amount' => $chosenDiscount,
'tax_amount' => 0,
],
], auth()->user());
'issue_date' => $cycleStart->toDateString(),
'due_date' => $cycleStart->copy()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
'notes' => "تجديد اشتراك {$monthLabel}{$program->name_ar}",
'metadata' => [
'month' => $monthKey,
'renewal' => true,
'renewal_enrollment_id' => (string) $enrollment->id,
],
], [
[
'description' => "اشتراك {$monthLabel}: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], auth()->user());
// Freeze the discount names on the invoice so the receipt stays
// readable after the rules change.
$invoice->update([
'status' => InvoiceStatus::Sent,
'metadata' => array_merge($invoice->metadata ?? [], [
'applied_discounts' => $discountLines,
]),
]);
// Freeze the discount names on the invoice so the receipt stays
// readable after the rules change.
$invoice->update([
'status' => InvoiceStatus::Sent,
'metadata' => array_merge($invoice->metadata ?? [], [
'applied_discounts' => $discountLines,
]),
]);
$this->advanceEnrollmentBillingDate($enrollment, $cycleStart);
$this->advanceEnrollmentBillingDate($enrollment);
$enrollment->update([
'last_billed_at' => $cycleStart->toDateString(),
'payment_status' => 'pending',
]);
$enrollment->update([
'last_billed_at' => now()->toDateString(),
'payment_status' => 'pending',
]);
return $invoice;
});
$this->selected_invoice_id = $invoice->id;
$this->payment_amount_display = number_format($invoice->due_amount / 100, 2, '.', '');
......@@ -291,22 +317,47 @@ private function generatePendingRenewals(): void
->get();
foreach ($enrollments as $enrollment) {
$existingUnpaid = Invoice::where('billable_type', Participant::class)
// Was this exact cycle already raised — by the nightly command, or
// by this screen a minute ago?
//
// The old guard asked a different question and got it wrong twice.
// It matched `notes LIKE '%{programme name}%'`, and OC-Sport has
// three programmes called "فريق 2018", so one player's renewal
// suppressed another's. And it only looked at UNPAID statuses, so
// the moment a renewal was paid the guard stopped seeing it and the
// next visit to this screen billed the same month again.
$monthKey = BillingCycle::monthKey($enrollment->next_billing_date ?? now());
$alreadyRaised = Invoice::where('billable_type', Participant::class)
->where('billable_id', $this->selected_participant_id)
->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue, InvoiceStatus::Draft])
->where('notes', 'like', '%' . ($enrollment->program?->name_ar ?? 'تجديد') . '%')
->where('status', '!=', InvoiceStatus::Cancelled->value)
->where('metadata->month', $monthKey)
->where('metadata->renewal_enrollment_id', (string) $enrollment->id)
->exists();
if (!$existingUnpaid) {
if (!$alreadyRaised) {
$this->generateRenewalForEnrollment($enrollment->id);
}
}
}
private function advanceEnrollmentBillingDate(Enrollment $enrollment): void
/**
* Hand over to the cycle after the one just settled.
*
* Advancing from the CYCLE rather than from `now()` is what stops a late
* renewal eating a month: settling August's cycle on 20 September moves the
* anchor to 1 September, so September is still owed and still gets billed.
* Advancing from `now()` would have jumped straight to 1 October.
*/
private function advanceEnrollmentBillingDate(Enrollment $enrollment, ?\Illuminate\Support\Carbon $cycleStart = null): void
{
// All renewals are on the 1st of next month — no exceptions
$next = now()->addMonth()->startOfMonth();
$cycleStart ??= BillingCycle::startOf($enrollment->next_billing_date ?? now());
$next = BillingCycle::next(
$cycleStart,
$enrollment->program?->billing_cycle,
$enrollment->program?->program_duration_weeks
);
$enrollment->update(['next_billing_date' => $next->toDateString()]);
}
......
......@@ -18,6 +18,7 @@
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Services\EnrollmentService;
use App\Domain\Training\Support\BillingCycle;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
......@@ -538,10 +539,12 @@ public function confirm(): void
}
}
// 10. Set next_billing_date to 1st of next month
// 10. Every month up to and including this one has just been
// raised, so hand over to the next cycle. Same rule, same
// implementation as the nightly command and the payment wizard.
$enrollment->update([
'next_billing_date' => now()->addMonth()->startOfMonth()->toDateString(),
'last_billed_at' => now()->toDateString(),
'next_billing_date' => BillingCycle::next()->toDateString(),
'last_billed_at' => BillingCycle::startOf()->toDateString(),
]);
} // end else (!is_free)
......
This diff is collapsed.
<?php
namespace Tests\Unit;
use App\Domain\Training\Support\BillingCycle;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\TestCase;
/**
* The one rule: every renewal is due on the 1st of a month, and lateness never
* moves it.
*
* Before this class existed the rule was hand-written in five places and three
* of them were wrong in different ways. These are the cases that were wrong.
*/
class BillingCycleTest extends TestCase
{
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_every_answer_is_the_first_of_a_month(): void
{
foreach (['2026-09-01', '2026-09-14', '2026-09-30', '2026-02-28'] as $date) {
$this->assertSame('01', BillingCycle::startOf($date)->format('d'));
$this->assertSame('01', BillingCycle::next($date)->format('d'));
}
}
public function test_a_late_run_does_not_move_the_billing_day(): void
{
// The old advanceNextBillingDate() did $current->addMonth(). Billing a
// cycle on the 5th because the 1st was missed pushed the anchor to the
// 5th of the next month, and the next lateness pushed it again — the
// academy's "everyone renews on the 1st" rule eroded a day at a time.
$this->assertSame('2026-09-01', BillingCycle::next('2026-08-05')->toDateString());
$this->assertSame('2026-09-01', BillingCycle::next('2026-08-01')->toDateString());
$this->assertSame('2026-09-01', BillingCycle::next('2026-08-31')->toDateString());
}
public function test_a_join_date_mid_month_still_renews_on_the_first(): void
{
Carbon::setTestNow('2026-09-14 16:00:00');
$this->assertSame('2026-10-01', BillingCycle::next()->toDateString());
}
public function test_cycle_lengths_follow_the_programme(): void
{
$this->assertSame(1, BillingCycle::lengthInMonths('monthly'));
$this->assertSame(3, BillingCycle::lengthInMonths('quarterly'));
$this->assertSame(6, BillingCycle::lengthInMonths('semi_annual'));
$this->assertSame(12, BillingCycle::lengthInMonths('annual'));
// A 12-week course bills quarterly; weeks round UP to whole months so a
// player is never billed before the period they paid for has run out.
$this->assertSame(3, BillingCycle::lengthInMonths('per_duration', 12));
$this->assertSame(2, BillingCycle::lengthInMonths('per_duration', 5));
$this->assertSame(1, BillingCycle::lengthInMonths('per_duration', null));
// An unknown value must never silently become "never bill again".
$this->assertSame(1, BillingCycle::lengthInMonths(null));
}
public function test_a_quarterly_cycle_lands_on_the_first_three_months_out(): void
{
$this->assertSame('2026-12-01', BillingCycle::next('2026-09-01', 'quarterly')->toDateString());
$this->assertSame('2027-09-01', BillingCycle::next('2026-09-20', 'annual')->toDateString());
}
public function test_nothing_is_due_when_the_next_cycle_is_still_ahead(): void
{
$this->assertSame([], BillingCycle::dueCycles('2026-10-01', '2026-09-01'));
}
public function test_the_current_cycle_is_due_on_its_own_first_day(): void
{
$due = BillingCycle::dueCycles('2026-09-01', '2026-09-01');
$this->assertCount(1, $due);
$this->assertSame('2026-09-01', $due[0]->toDateString());
}
public function test_a_missed_month_is_caught_up_dated_to_its_own_first(): void
{
// The scheduler runs at 07:00. If the container is redeploying then, the
// run is simply lost — this is what today's outage looked like. Running
// on the 20th must still produce September's invoice dated 1 September,
// not a September invoice dated the 20th and an August that never comes.
$due = BillingCycle::dueCycles('2026-08-01', '2026-09-20');
$this->assertSame(['2026-08-01', '2026-09-01'], array_map(fn ($d) => $d->toDateString(), $due));
}
public function test_a_drifted_anchor_is_repaired_to_the_first(): void
{
// A legacy row left mid-month by the old addMonth() drift still bills
// its month, dated the 1st.
$due = BillingCycle::dueCycles('2026-08-15', '2026-09-05');
$this->assertSame(['2026-08-01', '2026-09-01'], array_map(fn ($d) => $d->toDateString(), $due));
}
public function test_one_run_is_capped_but_the_backlog_is_not_lost(): void
{
$due = BillingCycle::dueCycles('2026-01-01', '2026-09-01', 'monthly', null, 3);
$this->assertCount(3, $due);
$this->assertSame('2026-01-01', $due[0]->toDateString());
// Six cycles are still owed after this run takes three. The command
// reports that number rather than letting the cap read as "all done".
$this->assertSame(6, BillingCycle::remainingAfter('2026-01-01', '2026-09-01', 'monthly', null, 3));
$this->assertSame(0, BillingCycle::remainingAfter('2026-08-01', '2026-09-01', 'monthly', null, 3));
}
public function test_the_month_key_is_what_subscription_line_reads(): void
{
// SubscriptionLine::monthsFromMetadata() requires exactly 'YYYY-MM'.
$this->assertSame('2026-09', BillingCycle::monthKey('2026-09-20'));
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}$/', BillingCycle::monthKey('2026-01-31'));
}
public function test_the_month_label_is_arabic_and_readable_by_subscription_line(): void
{
$this->assertSame('سبتمبر 2026', BillingCycle::monthLabel('2026-09-20'));
$this->assertSame('يناير 2027', BillingCycle::monthLabel('2027-01-01'));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment