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
......@@ -2,89 +2,267 @@
namespace App\Console\Commands;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
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 App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Bill every active enrolment for each subscription cycle it owes.
*
* The academy's rule: every player renews on the 1st of the month, and every
* already-registered player owes an invoice from the 1st for the full
* programme subscription. This command is the only thing that makes that true
* on its own, so its failure modes matter more than its happy path.
*
* Three properties are non-negotiable here, and each one is a bug that already
* happened:
*
* 1. **It must not fail silently.** The previous version 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
* an attribute that does not exist, so `User::find(null)` returned null and
* every single enrolment hit the `continue`. The command still printed a
* summary and still returned SUCCESS. OC-Sport went a whole billing month
* with 230 players due and zero invoices raised, and nothing anywhere said
* so. A run that bills none of the players it found is now a FAILURE exit.
*
* 2. **A missed day must not cost a month.** The old code advanced
* `next_billing_date` by `addMonth()` from whatever it happened to be and
* only ever raised one invoice, so a run lost to a container restart at
* 07:00 skipped that cycle permanently. Cycles are now caught up: the
* command bills every cycle between `next_billing_date` and today, each
* invoice dated its own 1st.
*
* 3. **The money must land in the month it bought.** An invoice raised on the
* 20th because the 1st was missed is still September's money. `issue_date`
* is the cycle start, never `now()`, and `metadata['month']` carries the
* cycle key — the highest-precedence signal SubscriptionLine has, above
* reading Arabic month names out of free text and above the issue date.
*
* Invoice creation and the `next_billing_date` advance share one transaction,
* so a crash mid-run can neither double-bill nor lose a cycle.
*/
class GenerateRenewalInvoices extends Command
{
protected $signature = 'enrollments:generate-renewals {--dry-run : Show what would be billed without creating invoices}';
protected $description = 'Generate renewal invoices for active enrollments with billing due today or earlier';
protected $signature = 'enrollments:generate-renewals
{--dry-run : Show what would be billed without creating invoices}
{--max-catchup=3 : How many missed cycles one enrolment may be billed for in a single run}';
protected $description = 'Bill every active enrolment for each subscription cycle it owes, dated the 1st of that cycle';
private int $created = 0;
private int $failed = 0;
private int $alreadyBilled = 0;
private int $waived = 0;
private int $adopted = 0;
private int $deferred = 0;
/** @var array<int, User|null> academy id => the user renewal invoices are attributed to */
private array $academyActors = [];
public function handle(InvoiceService $invoiceService, PricingService $pricingService): int
{
$dryRun = $this->option('dry-run');
$dryRun = (bool) $this->option('dry-run');
$cap = max(1, (int) $this->option('max-catchup'));
$today = Carbon::today();
$enrollments = Enrollment::where('status', EnrollmentStatus::Active)
->whereNotNull('next_billing_date')
->where('next_billing_date', '<=', now()->toDateString())
// `next_billing_date` is deliberately NOT filtered in SQL. A row that is
// active, paying and on a renewing programme but has a null billing date
// is invisible to every renewal query in the system — it simply never
// gets billed, forever, and nobody finds out. Loading them here is what
// lets the command adopt them into the cycle instead of skipping them.
$enrollments = Enrollment::query()
->where('status', EnrollmentStatus::Active)
->whereHas('program', function ($q) {
$q->whereIn('renewal_policy', [
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
])
->whereNotNull('billing_cycle');
})
->whereHas('participant', function ($q) {
$q->where('is_free', false);
])->whereNotNull('billing_cycle');
})
->whereHas('participant', fn ($q) => $q->where('is_free', false))
->with(['program', 'participant.person', 'group'])
->get();
if ($enrollments->isEmpty()) {
$this->info('No renewal invoices due today.');
$this->info('No renewing enrolments found.');
return self::SUCCESS;
}
$this->info("Found {$enrollments->count()} enrollment(s) due for renewal.");
$created = 0;
$failed = 0;
$this->info("Checking {$enrollments->count()} renewing enrolment(s) against cycle {$today->format('Y-m')}.");
foreach ($enrollments as $enrollment) {
$participant = $enrollment->participant;
$program = $enrollment->program;
$group = $enrollment->group;
$this->process($enrollment, $today, $cap, $dryRun, $invoiceService, $pricingService);
}
return $this->report($dryRun);
}
if (!$participant || !$program) {
$failed++;
continue;
private function process(
Enrollment $enrollment,
Carbon $today,
int $cap,
bool $dryRun,
InvoiceService $invoiceService,
PricingService $pricingService
): void {
$program = $enrollment->program;
$participant = $enrollment->participant;
if (!$program || !$participant) {
$this->failed++;
Log::error('Renewal skipped — enrolment has no programme or participant', [
'enrollment_id' => $enrollment->id,
]);
return;
}
// An active payer with no billing date has never been on the cycle.
// Adopt them from the CURRENT cycle forward — never retroactively,
// because we have no evidence about months nobody ever billed them for.
if (!$enrollment->next_billing_date) {
$this->adopted++;
$start = BillingCycle::startOf($today);
$this->line(" [ADOPT] {$this->name($enrollment)} — had no billing date, joining cycle {$start->format('Y-m')}");
if (!$dryRun) {
$enrollment->update(['next_billing_date' => $start->toDateString()]);
}
if ($dryRun) {
$this->line(" [DRY] {$participant->person?->name_ar}{$program->name_ar} (due: {$enrollment->next_billing_date->format('Y-m-d')})");
$created++;
continue;
$enrollment->next_billing_date = $start;
}
$cycles = BillingCycle::dueCycles(
$enrollment->next_billing_date,
$today,
$program->billing_cycle,
$program->program_duration_weeks,
$cap
);
if (empty($cycles)) {
return;
}
$remaining = BillingCycle::remainingAfter(
$enrollment->next_billing_date,
$today,
$program->billing_cycle,
$program->program_duration_weeks,
$cap
);
if ($remaining > 0) {
// Never let a cap read as "all done": say what was left, so a long
// outage draining over several days is visible while it happens.
$this->deferred += $remaining;
$this->warn(" [DEFER] {$this->name($enrollment)}{$remaining} older cycle(s) left for the next run");
}
foreach ($cycles as $cycleStart) {
$this->bill($enrollment, $cycleStart, $dryRun, $invoiceService, $pricingService);
}
}
private function bill(
Enrollment $enrollment,
Carbon $cycleStart,
bool $dryRun,
InvoiceService $invoiceService,
PricingService $pricingService
): void {
$program = $enrollment->program;
$participant = $enrollment->participant;
$group = $enrollment->group;
$monthKey = BillingCycle::monthKey($cycleStart);
if ($this->alreadyInvoiced($enrollment, $monthKey)) {
// The receptionist raised this cycle by hand from the payment
// wizard before the cron got to it. Move the anchor on rather than
// billing it twice.
$this->alreadyBilled++;
if (!$dryRun) {
$this->advance($enrollment, $cycleStart);
}
try {
$priceResult = $pricingService->calculate(
priceable: $program,
participant: $participant,
branchId: $group?->branch_id ?? $program->branch_id,
);
if ($priceResult->finalAmount <= 0) {
$this->advanceNextBillingDate($enrollment);
continue;
}
$actor = User::find($enrollment->created_by);
if (!$actor) {
Log::error('Renewal invoice skipped — enrollment has no valid created_by user', [
'enrollment_id' => $enrollment->id,
'created_by' => $enrollment->created_by,
]);
$failed++;
continue;
}
return;
}
if ($dryRun) {
$this->line(" [DRY] {$this->name($enrollment)}{$program->name_ar} — cycle {$monthKey}");
$this->created++;
return;
}
try {
// Price AS OF the cycle being billed, not as of today: a cycle
// caught up in September must be charged September's price list,
// and a rule that expired last week must not silently rewrite a
// month it was live for.
$priceResult = $pricingService->calculate(
priceable: $program,
participant: $participant,
branchId: $group?->branch_id ?? $program->branch_id,
date: $cycleStart->toDateString(),
);
} catch (DomainException $e) {
// "No active base price" is a hard fail by design — never guess a
// price. But a player we cannot price is a player we are not
// billing, which is the exact silence this command exists to break.
$this->failed++;
Log::error('Renewal invoice failed — programme could not be priced', [
'enrollment_id' => $enrollment->id,
'program_id' => $program->id,
'cycle' => $monthKey,
'reason' => $e->getMessage(),
]);
$this->error(" [FAIL] {$this->name($enrollment)}{$program->name_ar}: {$e->getMessage()}");
return;
}
if ($priceResult->finalAmount <= 0) {
// A real zero (a full-value discount) is a waiver, not a bug, but
// it is rare enough that it should be visible rather than inferred
// from a missing invoice.
$this->waived++;
Log::info('Renewal waived — price resolved to zero', [
'enrollment_id' => $enrollment->id,
'cycle' => $monthKey,
]);
$this->advance($enrollment, $cycleStart);
return;
}
$actor = $this->actorFor($enrollment);
if (!$actor) {
$this->failed++;
Log::error('Renewal invoice failed — no user exists to attribute the invoice to', [
'enrollment_id' => $enrollment->id,
'academy_id' => $enrollment->academy_id,
]);
return;
}
try {
DB::transaction(function () use ($enrollment, $program, $participant, $group, $priceResult, $cycleStart, $monthKey, $invoiceService, $actor) {
$label = BillingCycle::monthLabel($cycleStart);
$invoice = $invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $program->academy_id,
......@@ -92,81 +270,169 @@ public function handle(InvoiceService $invoiceService, PricingService $pricingSe
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'number' => $invoiceService->generateNumber($program->academy_id),
'total_amount' => $priceResult->finalAmount,
'subtotal_amount' => $priceResult->baseAmount,
// The discount belongs on the header and nowhere else.
// recalculateTotals() computes total = sum(line totals) -
// header discount, so putting the same figure on the line
// as well subtracts it twice and undercharges the academy.
'discount_amount' => $priceResult->totalDiscount,
'total_amount' => $priceResult->finalAmount,
'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(),
// Dated the cycle it buys, not the day the job ran. This is
// what puts the money in the right month when a run is late.
'issue_date' => $cycleStart->toDateString(),
'due_date' => $cycleStart->copy()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
'notes' => 'تجديد تلقائي — ' . $program->name_ar,
'notes' => "تجديد اشتراك {$label}{$program->name_ar}",
'metadata' => [
// SubscriptionLine trusts this above everything else,
// so the cycle survives however the text is worded.
'month' => $monthKey,
'renewal' => true,
'renewal_enrollment_id' => (string) $enrollment->id,
],
], [
[
'description' => "تجديد اشتراك: {$program->name_ar}",
'description' => "اشتراك {$label}: {$program->name_ar}",
'quantity' => 1,
'unit_price' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount,
'discount_amount' => 0,
'tax_amount' => 0,
],
], $actor);
// Mark as sent immediately so it appears in payment wizard
$invoice->update(['status' => \App\Domain\Financial\Enums\InvoiceStatus::Sent]);
// Sent immediately, so it is collectable from the payment
// wizard the moment it exists rather than sitting in draft.
$invoice->update(['status' => InvoiceStatus::Sent]);
$this->advanceNextBillingDate($enrollment);
$this->advance($enrollment, $cycleStart);
$enrollment->update([
'last_billed_at' => now()->toDateString(),
'last_billed_at' => $cycleStart->toDateString(),
'payment_status' => 'pending',
]);
});
$created++;
} catch (DomainException $e) {
Log::warning('Renewal invoice skipped', [
'enrollment_id' => $enrollment->id,
'reason' => $e->getMessage(),
]);
$failed++;
} catch (\Throwable $e) {
Log::error('Renewal invoice failed', [
'enrollment_id' => $enrollment->id,
'error' => $e->getMessage(),
]);
$failed++;
}
$this->created++;
} catch (\Throwable $e) {
$this->failed++;
Log::error('Renewal invoice failed', [
'enrollment_id' => $enrollment->id,
'cycle' => $monthKey,
'error' => $e->getMessage(),
]);
$this->error(" [FAIL] {$this->name($enrollment)}{$e->getMessage()}");
}
}
$label = $dryRun ? 'Would create' : 'Created';
$this->info("{$label} {$created} renewal invoice(s). Failed: {$failed}.");
return self::SUCCESS;
/**
* Has this cycle already been raised for this enrolment?
*
* Both renewal producers stamp `metadata.month` and
* `metadata.renewal_enrollment_id`, as strings so the comparison behaves the
* same on Postgres and on the SQLite the tests run against. This is the
* second line of defence only — the first is that the invoice and the
* `next_billing_date` advance commit together.
*/
private function alreadyInvoiced(Enrollment $enrollment, string $monthKey): bool
{
return Invoice::query()
->where('billable_type', $enrollment->participant->getMorphClass())
->where('billable_id', $enrollment->participant_id)
->where('status', '!=', InvoiceStatus::Cancelled->value)
->where('metadata->month', $monthKey)
->where('metadata->renewal_enrollment_id', (string) $enrollment->id)
->exists();
}
private function advanceNextBillingDate(Enrollment $enrollment): void
/**
* Move the anchor to the cycle after the one just settled.
*
* Advancing from the CYCLE rather than from `now()` is the whole point: a
* cycle billed three weeks late still hands over to the 1st of the next
* one, so lateness never compounds.
*/
private function advance(Enrollment $enrollment, Carbon $cycleStart): void
{
$program = $enrollment->program;
$current = $enrollment->next_billing_date;
$next = match ($program->billing_cycle) {
'monthly' => $this->advanceMonthly($current, $program->billing_day),
'quarterly' => $current->copy()->addMonths(3),
'semi_annual' => $current->copy()->addMonths(6),
'annual' => $current->copy()->addYear(),
'per_duration' => $current->copy()->addWeeks($program->program_duration_weeks ?? 4),
default => $current->copy()->addMonth(),
};
$next = BillingCycle::next(
$cycleStart,
$program?->billing_cycle,
$program?->program_duration_weeks
);
$enrollment->update(['next_billing_date' => $next->toDateString()]);
$enrollment->next_billing_date = $next;
}
private function advanceMonthly(Carbon $current, ?int $billingDay): Carbon
/**
* Who the invoice is attributed to.
*
* Prefer the person who actually enrolled the player, because that is the
* honest audit answer. Fall back rather than skip: a receptionist leaving
* the academy must never be the reason a paying member goes unbilled — that
* trade-off, made the other way round, is what caused the outage this
* command's docblock describes.
*/
private function actorFor(Enrollment $enrollment): ?User
{
$next = $current->copy()->addMonth();
if ($enrollment->enrolled_by && $actor = User::find($enrollment->enrolled_by)) {
return $actor;
}
if ($billingDay) {
$maxDay = $next->daysInMonth;
$next->day = min($billingDay, $maxDay);
if ($enrollment->program?->created_by && $actor = User::find($enrollment->program->created_by)) {
return $actor;
}
return $next;
$academyId = $enrollment->academy_id ?? $enrollment->program?->academy_id;
return $this->academyActors[$academyId] ??= User::query()
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->orderByDesc('is_super_admin')
->orderBy('id')
->first()
?? User::query()->orderBy('id')->first();
}
private function name(Enrollment $enrollment): string
{
return $enrollment->participant?->person?->name_ar
?? $enrollment->participant?->person?->name
?? "#{$enrollment->participant_id}";
}
/**
* A run that found players to bill and billed none of them is a failure,
* not a quiet success. That distinction is the whole reason a broken
* command could run daily for a month without anyone noticing.
*/
private function report(bool $dryRun): int
{
$verb = $dryRun ? 'Would create' : 'Created';
$this->info("{$verb} {$this->created} renewal invoice(s).");
foreach ([
'already billed this cycle' => $this->alreadyBilled,
'adopted onto the cycle' => $this->adopted,
'waived (price resolved to zero)' => $this->waived,
'older cycles deferred to the next run' => $this->deferred,
'FAILED' => $this->failed,
] as $label => $count) {
if ($count > 0) {
$this->line(" {$count} {$label}");
}
}
if ($this->failed > 0) {
Log::error('Renewal run finished with failures', [
'created' => $this->created,
'failed' => $this->failed,
]);
return self::FAILURE;
}
return self::SUCCESS;
}
}
......@@ -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)
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* The monthly renewal run.
*
* On 1 September 2026 OC-Sport had 230 active players due for renewal and the
* nightly command raised zero invoices — for the whole month. It resolved the
* invoice's actor with `User::find($enrollment->created_by)`, and `enrollments`
* has no `created_by` column; Eloquent answers null for a missing attribute, so
* every enrolment took the "no valid user" branch. The command printed a tidy
* summary and exited SUCCESS. Nothing in the system disagreed.
*
* Nothing pinned any of it, which is why a one-word column name could stop an
* academy billing anybody and nobody found out for four weeks. These tests run
* the real command against a real schema, with the real pricing engine, and
* assert on invoices that actually exist.
*/
class GenerateRenewalInvoicesTest extends TestCase
{
private const ACADEMY = 1;
private const BRANCH = 1;
private const PROGRAM = 7;
private const MEMBER_PRICE = 65000;
private const NON_MEMBER_PRICE = 90000;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
Carbon::setTestNow('2026-09-01 07:00:00');
Event::fake();
$this->createSchema();
$this->seedAcademy();
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
// ---- the outage ------------------------------------------------------
public function test_the_first_of_the_month_bills_every_active_player(): void
{
$this->player(1, 'member');
$this->player(2, 'non_member');
$this->player(3, 'non_member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(3, Invoice::count(), 'Every active enrolment due on the 1st must be billed.');
}
public function test_a_run_that_bills_nobody_it_found_reports_failure(): void
{
// The whole point. A command that cannot bill must not exit SUCCESS —
// that silence is what let a broken run repeat every day for a month.
$this->player(1, 'member');
DB::table('base_prices')->delete();
$this->artisan('enrollments:generate-renewals')->assertFailed();
$this->assertSame(0, Invoice::count());
}
public function test_the_actor_comes_from_enrolled_by_not_a_column_that_does_not_exist(): void
{
$this->assertFalse(
Schema::hasColumn('enrollments', 'created_by'),
'enrollments has never had a created_by column — code reading one gets null, silently.'
);
$this->player(1, 'member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(9, Invoice::first()->created_by, 'The invoice is attributed to whoever enrolled the player.');
}
public function test_a_deleted_receptionist_does_not_stop_a_player_being_billed(): void
{
$this->player(1, 'member');
DB::table('enrollments')->update(['enrolled_by' => 999]); // long gone
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(1, Invoice::count());
$this->assertNotNull(Invoice::first()->created_by);
}
// ---- member vs non-member --------------------------------------------
public function test_a_member_is_billed_the_member_price_and_a_non_member_the_non_member_price(): void
{
$this->player(1, 'member');
$this->player(2, 'non_member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(
self::MEMBER_PRICE,
(int) Invoice::where('billable_id', 1)->value('total_amount')
);
$this->assertSame(
self::NON_MEMBER_PRICE,
(int) Invoice::where('billable_id', 2)->value('total_amount')
);
}
public function test_the_full_subscription_price_is_billed_not_a_prorated_one(): void
{
$this->player(1, 'non_member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$invoice = Invoice::first();
$this->assertSame(self::NON_MEMBER_PRICE, (int) $invoice->subtotal_amount);
$this->assertSame(self::NON_MEMBER_PRICE, (int) $invoice->total_amount);
}
public function test_a_programme_with_no_price_fails_loudly_instead_of_billing_zero(): void
{
// "No active base price = hard fail" is a project invariant. Production
// has one such programme (عبدالعال 2012) with six players on it: they
// must show up as failures, never as a silent zero-value invoice.
$this->player(1, 'member');
$this->player(2, 'member', programId: 99); // programme with no base price
$this->artisan('enrollments:generate-renewals')->assertFailed();
$this->assertSame(1, Invoice::count(), 'The priced player is still billed.');
$this->assertSame(1, Invoice::where('billable_id', 1)->count());
}
// ---- which month the money belongs to --------------------------------
public function test_the_invoice_is_dated_the_first_even_when_the_run_is_late(): void
{
Carbon::setTestNow('2026-09-20 07:00:00');
$this->player(1, 'member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$invoice = Invoice::first();
$this->assertSame('2026-09-01', $invoice->issue_date->toDateString(), 'September money, dated September.');
$this->assertSame('2026-09', $invoice->metadata['month']);
}
public function test_the_month_is_stamped_where_subscription_line_looks_first(): void
{
$this->player(1, 'member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$months = \App\Domain\Financial\Support\SubscriptionLine::monthsCovered(
DB::table('invoice_items')->value('description'),
Invoice::first()->notes,
Invoice::first()->metadata ?? [],
Invoice::first()->issue_date->toDateString(),
);
$this->assertSame(['2026-09'], $months);
}
// ---- a missed run must not cost a month ------------------------------
public function test_a_missed_month_is_caught_up_with_each_invoice_on_its_own_first(): void
{
// The container was redeploying at 07:00 on 1 August, so August never
// ran. The old code advanced by one month from whatever it found and
// raised a single invoice, so August was skipped permanently.
$this->player(1, 'member', nextBillingDate: '2026-08-01');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$dates = Invoice::orderBy('issue_date')->pluck('issue_date')->map->toDateString()->all();
$this->assertSame(['2026-08-01', '2026-09-01'], $dates);
$months = Invoice::orderBy('issue_date')->get()->map(fn ($i) => $i->metadata['month'])->all();
$this->assertSame(['2026-08', '2026-09'], $months);
}
public function test_the_next_cycle_is_the_first_of_next_month_however_late_the_run(): void
{
Carbon::setTestNow('2026-09-25 07:00:00');
$this->player(1, 'member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(
'2026-10-01',
Enrollment::first()->next_billing_date->toDateString(),
'Lateness must never move the billing day.'
);
}
public function test_running_twice_in_a_day_does_not_bill_twice(): void
{
$this->player(1, 'member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(1, Invoice::count());
}
public function test_a_cycle_already_raised_by_hand_is_not_billed_again(): void
{
$this->player(1, 'member');
// The receptionist raised September from the payment wizard this
// morning; the cron must recognise it rather than duplicate it.
DB::table('invoices')->insert([
'academy_id' => self::ACADEMY,
'number' => 'INV-MANUAL',
'status' => 'sent',
'billable_type' => (new \App\Domain\Participant\Models\Participant())->getMorphClass(),
'billable_id' => 1,
'subtotal_amount' => self::MEMBER_PRICE,
'discount_amount' => 0,
'tax_amount' => 0,
'total_amount' => self::MEMBER_PRICE,
'paid_amount' => 0,
'due_amount' => self::MEMBER_PRICE,
'issue_date' => '2026-09-01',
'due_date' => '2026-09-08',
'metadata' => json_encode(['month' => '2026-09', 'renewal_enrollment_id' => '1']),
'created_at' => now(),
'updated_at' => now(),
]);
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(1, Invoice::count());
$this->assertSame('2026-10-01', Enrollment::first()->next_billing_date->toDateString());
}
// ---- who is in and who is out ----------------------------------------
public function test_a_free_player_is_never_billed(): void
{
$this->player(1, 'member', isFree: true);
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(0, Invoice::count());
}
public function test_a_player_with_no_billing_date_is_adopted_rather_than_ignored_forever(): void
{
// Every renewal query in the system filters `next_billing_date IS NOT
// NULL`, so a paying active player with a null one is invisible to all
// of them — permanently, and with nothing to show it.
$this->player(1, 'member', nextBillingDate: null);
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(1, Invoice::count());
$this->assertSame('2026-09-01', Invoice::first()->issue_date->toDateString());
$this->assertSame('2026-10-01', Enrollment::first()->next_billing_date->toDateString());
}
public function test_a_player_whose_cycle_is_still_ahead_is_left_alone(): void
{
$this->player(1, 'member', nextBillingDate: '2026-10-01');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(0, Invoice::count());
}
public function test_a_cancelled_enrolment_is_not_billed(): void
{
$this->player(1, 'member');
DB::table('enrollments')->update(['status' => 'cancelled']);
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$this->assertSame(0, Invoice::count());
}
// ---- discounts --------------------------------------------------------
public function test_a_pricing_discount_is_subtracted_once_not_twice(): void
{
// recalculateTotals() computes total = sum(line totals) - header
// discount. The old code put the same discount on BOTH, so every
// discounted renewal charged half the discount too little. OC-Sport has
// an active sibling-discount rule, so this was live money.
DB::table('pricing_rules')->insert([
'academy_id' => self::ACADEMY,
'name' => 'Flat discount',
'name_ar' => 'خصم',
'rule_type' => 'sibling_order',
'adjustment_type' => 'fixed_discount',
'adjustment_value' => 5000,
'priority' => 1,
'is_active' => true,
'is_manual' => false,
'is_stackable' => true,
'conditions' => json_encode([]),
'created_at' => now(),
'updated_at' => now(),
]);
$this->player(1, 'member');
$this->artisan('enrollments:generate-renewals')->assertSuccessful();
$invoice = Invoice::first();
$this->assertSame(5000, (int) $invoice->discount_amount);
$this->assertSame(self::MEMBER_PRICE, (int) $invoice->subtotal_amount, 'Lines carry the undiscounted price.');
$this->assertSame(self::MEMBER_PRICE - 5000, (int) $invoice->total_amount, 'Discount applied exactly once.');
}
// ---- fixtures ---------------------------------------------------------
private function player(
int $id,
string $membership,
?string $nextBillingDate = '2026-09-01',
bool $isFree = false,
int $programId = self::PROGRAM
): void {
DB::table('people')->insert([
'id' => $id, 'academy_id' => self::ACADEMY, 'name' => "Player {$id}",
'name_ar' => "لاعب {$id}", 'gender' => 'male', 'date_of_birth' => '2014-05-05',
'created_at' => now(), 'updated_at' => now(),
]);
DB::table('participants')->insert([
'id' => $id, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'person_id' => $id, 'status' => 'active', 'membership_type' => $membership,
'is_free' => $isFree, 'classification' => 'regular',
'created_at' => now(), 'updated_at' => now(),
]);
DB::table('enrollments')->insert([
'id' => $id, 'academy_id' => self::ACADEMY, 'participant_id' => $id,
'training_group_id' => 1, 'training_program_id' => $programId,
'enrollment_date' => '2026-07-01', 'start_date' => '2026-07-01',
'status' => 'active', 'enrolled_by' => 9, 'payment_status' => 'pending',
'next_billing_date' => $nextBillingDate,
'created_at' => now(), 'updated_at' => now(),
]);
}
private function seedAcademy(): void
{
DB::table('users')->insert([
'id' => 9, 'academy_id' => self::ACADEMY, 'name' => 'Reception',
'email' => 'reception@example.test', 'password' => 'x', 'is_super_admin' => false,
'created_at' => now(), 'updated_at' => now(),
]);
foreach ([self::PROGRAM, 99] as $programId) {
DB::table('training_programs')->insert([
'id' => $programId, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'name' => "Program {$programId}", 'name_ar' => "برنامج {$programId}",
'renewal_policy' => 'auto_renew', 'billing_cycle' => 'monthly', 'billing_day' => 1,
'created_by' => 9, 'created_at' => now(), 'updated_at' => now(),
]);
}
DB::table('training_groups')->insert([
'id' => 1, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'training_program_id' => self::PROGRAM, 'name' => 'G1', 'name_ar' => 'مجموعة',
'status' => 'active', 'created_at' => now(), 'updated_at' => now(),
]);
// Exactly the shape production uses: branch-scoped, tier in metadata,
// member cheaper and at a lower priority number than non-member.
foreach ([['member', self::MEMBER_PRICE, 1], ['non_member', self::NON_MEMBER_PRICE, 2]] as [$tier, $amount, $priority]) {
DB::table('base_prices')->insert([
'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'priceable_type' => 'App\\Domain\\Training\\Models\\TrainingProgram',
'priceable_id' => self::PROGRAM,
'name' => $tier, 'name_ar' => $tier, 'amount' => $amount, 'currency' => 'EGP',
'effective_from' => '2026-01-01', 'is_active' => true, 'priority' => $priority,
'metadata' => json_encode(['membership_type' => $tier]),
'created_at' => now(), 'updated_at' => now(),
]);
}
}
private function createSchema(): void
{
Schema::create('academies', function (Blueprint $t) {
$t->id();
$t->string('name')->nullable();
$t->timestamps();
});
DB::table('academies')->insert(['id' => self::ACADEMY, 'name' => 'OC']);
Schema::create('users', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->string('email')->nullable();
$t->string('password')->nullable();
$t->boolean('is_super_admin')->default(false);
$t->timestamps();
$t->softDeletes();
});
Schema::create('people', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->string('gender')->nullable();
$t->date('date_of_birth')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('participants', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->unsignedBigInteger('person_id')->nullable();
$t->string('status')->default('active');
$t->string('membership_type')->nullable();
$t->string('classification')->nullable();
$t->boolean('is_free')->default(false);
$t->timestamps();
$t->softDeletes();
});
Schema::create('guardians', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('person_id')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('guardian_participant', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('guardian_id');
$t->unsignedBigInteger('participant_id');
$t->timestamps();
});
Schema::create('training_programs', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->string('renewal_policy')->nullable();
$t->string('billing_cycle')->nullable();
$t->unsignedTinyInteger('billing_day')->nullable();
$t->unsignedInteger('program_duration_weeks')->nullable();
$t->unsignedInteger('total_sessions')->nullable();
$t->unsignedBigInteger('created_by')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('training_groups', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->unsignedBigInteger('training_program_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->string('status')->default('active');
$t->timestamps();
$t->softDeletes();
});
// Deliberately mirrors the real migration: enrolled_by, and NO
// created_by. A test that invented a created_by column would have
// agreed with the broken code and shipped the outage all over again.
Schema::create('enrollments', function (Blueprint $t) {
$t->id();
$t->uuid('uuid')->nullable();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('participant_id');
$t->unsignedBigInteger('training_group_id')->nullable();
$t->unsignedBigInteger('training_program_id')->nullable();
$t->date('enrollment_date')->nullable();
$t->date('start_date')->nullable();
$t->date('end_date')->nullable();
$t->string('status')->default('active');
$t->unsignedBigInteger('enrolled_by')->nullable();
$t->unsignedBigInteger('invoice_id')->nullable();
$t->string('payment_status')->nullable();
$t->integer('sessions_attended')->nullable();
$t->integer('sessions_total')->nullable();
$t->date('next_billing_date')->nullable();
$t->date('last_billed_at')->nullable();
$t->text('notes')->nullable();
$t->json('metadata')->nullable();
$t->timestamps();
});
Schema::create('invoices', function (Blueprint $t) {
$t->id();
$t->uuid('uuid')->nullable();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->string('number')->nullable();
$t->string('type')->nullable();
$t->string('status')->default('draft');
$t->string('billable_type')->nullable();
$t->unsignedBigInteger('billable_id')->nullable();
$t->string('contact_name')->nullable();
$t->bigInteger('subtotal_amount')->default(0);
$t->bigInteger('discount_amount')->default(0);
$t->bigInteger('tax_amount')->default(0);
$t->bigInteger('service_fee_amount')->default(0);
$t->bigInteger('total_amount')->default(0);
$t->bigInteger('paid_amount')->default(0);
$t->bigInteger('due_amount')->default(0);
$t->string('currency')->default('EGP');
$t->date('issue_date')->nullable();
$t->date('due_date')->nullable();
$t->timestamp('paid_at')->nullable();
$t->timestamp('cancelled_at')->nullable();
$t->text('notes')->nullable();
$t->json('metadata')->nullable();
$t->unsignedBigInteger('created_by')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('invoice_items', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('invoice_id');
$t->string('itemable_type')->nullable();
$t->unsignedBigInteger('itemable_id')->nullable();
$t->string('description')->nullable();
$t->integer('quantity')->default(1);
$t->bigInteger('unit_price')->default(0);
$t->bigInteger('discount_amount')->default(0);
$t->bigInteger('tax_amount')->default(0);
$t->bigInteger('total_amount')->default(0);
$t->json('metadata')->nullable();
$t->timestamps();
});
Schema::create('base_prices', function (Blueprint $t) {
$t->id();
$t->uuid('uuid')->nullable();
$t->unsignedBigInteger('academy_id')->nullable();
$t->string('priceable_type');
$t->unsignedBigInteger('priceable_id');
$t->unsignedBigInteger('branch_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->bigInteger('amount')->default(0);
$t->string('currency')->default('EGP');
$t->date('effective_from')->nullable();
$t->date('effective_to')->nullable();
$t->boolean('is_active')->default(true);
$t->integer('priority')->default(0);
$t->json('metadata')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('pricing_rules', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->string('rule_type');
$t->string('adjustment_type');
$t->bigInteger('adjustment_value')->default(0);
$t->integer('priority')->default(0);
$t->integer('max_discount_percent')->nullable();
$t->boolean('is_active')->default(true);
$t->boolean('is_manual')->default(false);
$t->boolean('is_stackable')->default(true);
$t->date('effective_from')->nullable();
$t->date('effective_to')->nullable();
$t->json('conditions')->nullable();
$t->string('target_type')->nullable();
$t->unsignedBigInteger('target_id')->nullable();
$t->integer('usage_limit')->nullable();
$t->integer('usage_count')->default(0);
$t->json('metadata')->nullable();
$t->unsignedBigInteger('created_by')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('pricing_rule_branches', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('pricing_rule_id');
$t->unsignedBigInteger('branch_id');
});
Schema::create('pricing_rule_targets', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('pricing_rule_id');
$t->string('targetable_type')->nullable();
$t->unsignedBigInteger('targetable_id')->nullable();
});
Schema::create('invoice_number_counters', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->unique();
$t->unsignedBigInteger('next_number')->default(1);
$t->timestamps();
});
Schema::create('audit_logs', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->string('auditable_type')->nullable();
$t->unsignedBigInteger('auditable_id')->nullable();
$t->string('event')->nullable();
$t->json('old_values')->nullable();
$t->json('new_values')->nullable();
$t->unsignedBigInteger('user_id')->nullable();
$t->string('ip_address')->nullable();
$t->string('user_agent')->nullable();
$t->timestamps();
});
}
}
<?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