Commit ecae5ecb authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(billing): charge a mid-month joiner for trainings, not for days

Proration counted remaining days on a hardcoded 30-day month, a unit the
academy never sold. A programme meeting Sunday and Tuesday holds no
sessions over a long weekend, so someone joining on the 22nd was billed a
third of a month for perhaps two trainings — and the calendar never
noticed. September 2026, Sun+Tue: three of the month's nine sessions
remain on the 22nd, not nine of thirty days.

SessionCountService counts from the timetable rather than from
training_sessions, because the generator only materialises rows about a
week ahead and counting rows would under-report the rest of the month —
exactly the question proration asks. Its rules are the generator's,
deliberately identical: an active schedule row naming the weekday,
effective that day, with no training-affecting holiday on it.

The desk now chooses per registration: شهر كامل, نص شهر, or باقي تمرينات
الشهر. The mode is settable from the browser by design, and safe to be —
the academy setting remains the gate, and an unrecognised value falls back
to the default rather than being honoured. Joining after the month's last
session owes nothing for that month, so no invoice is raised at all; a
zero-total one is what AccountAnomalyScanner reports as corruption.

Also: a branch whose takings never pass through the system. A partner-run
site bills nobody — participants enrol unbilled, enrolments are marked
waived, and the branch's income is entered afterwards on the
external-revenue screen. Skipping the invoice rather than writing a zero
one, for the same reason as above. The guard sits before the renewal
command's adoption step, not after: those enrolments carry no billing date
precisely because they are off the cycle, and adoption would read that as
an oversight and put every one of them onto it.

Fixes a latent crash on the way: BranchSettingsService called
app('current_academy') unguarded, which throws rather than returning null
outside a request. The renewal command binds no academy, so asking it the
billing question from the console would have taken the nightly run down.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 243cac65
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
use App\Domain\Financial\Enums\InvoiceStatus; use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\InvoiceService; use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Pricing\Services\PricingService; use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\EnrollmentStatus; use App\Domain\Training\Enums\EnrollmentStatus;
...@@ -129,6 +130,15 @@ private function process( ...@@ -129,6 +130,15 @@ private function process(
return; return;
} }
// A branch whose money is handled outside the system bills nobody.
// This guard has to come before the adoption below, not after: those
// enrolments are created with no billing date precisely because they
// are not on the cycle, and adoption would read that as an oversight
// and put every one of them onto it.
if (app(BranchSettingsService::class)->billingHandledExternally($enrollment->branch_id)) {
return;
}
// An active payer with no billing date has never been on the cycle. // An active payer with no billing date has never been on the cycle.
// Adopt them from the CURRENT cycle forward — never retroactively, // Adopt them from the CURRENT cycle forward — never retroactively,
// because we have no evidence about months nobody ever billed them for. // because we have no evidence about months nobody ever billed them for.
......
...@@ -7,6 +7,46 @@ ...@@ -7,6 +7,46 @@
class BranchSettingsService class BranchSettingsService
{ {
/**
* A branch whose takings never pass through this system.
*
* A partner-run site, or one whose money is banked elsewhere: participants
* enrol there without being billed, and the branch's income is entered
* afterwards on the external-revenue screen as a single figure.
*/
public const KEY_BILLING_EXTERNAL = 'billing.handled_externally';
/** Answers within one request, since the gates below ask per enrolment. */
private array $externalBillingCache = [];
/**
* Whether this branch's subscription money is handled outside the system.
*
* This is the branch-wide counterpart of participants.is_free, and it
* behaves identically: no invoice is raised at all rather than a zero one.
* A zero-total invoice is precisely what AccountAnomalyScanner reports as
* corruption, so writing one per registration would fill the anomaly
* worklist with rows that are working as intended.
*/
public function billingHandledExternally(?int $branchId): bool
{
if ($branchId === null) {
return false;
}
// Read straight off the branch row rather than through get(). Two
// reasons: this key is per branch and has no academy-wide meaning, so
// the fallback could only ever match a system setting that happened to
// share its name; and the renewal command asks this question from the
// console, where there is no current academy to fall back through.
return $this->externalBillingCache[$branchId] ??= filter_var(
BranchSetting::where('branch_id', $branchId)
->where('key', self::KEY_BILLING_EXTERNAL)
->value('value'),
FILTER_VALIDATE_BOOLEAN
);
}
/** /**
* Get a setting value for a branch. Falls back to academy-level system_settings. * Get a setting value for a branch. Falls back to academy-level system_settings.
*/ */
...@@ -20,8 +60,11 @@ public function get(int $branchId, string $key, mixed $default = null): mixed ...@@ -20,8 +60,11 @@ public function get(int $branchId, string $key, mixed $default = null): mixed
return $setting->value; return $setting->value;
} }
// Fall back to academy-level setting // Fall back to academy-level setting. app()->has() first: outside a
$academy = app('current_academy'); // request — a console command, a queued job — nothing has bound
// current_academy, and app() on an unbound key throws rather than
// returning null.
$academy = app()->has('current_academy') ? app('current_academy') : null;
if ($academy) { if ($academy) {
$systemSetting = \DB::table('system_settings') $systemSetting = \DB::table('system_settings')
->where('academy_id', $academy->id) ->where('academy_id', $academy->id)
...@@ -41,7 +84,7 @@ public function get(int $branchId, string $key, mixed $default = null): mixed ...@@ -41,7 +84,7 @@ public function get(int $branchId, string $key, mixed $default = null): mixed
*/ */
public function set(int $branchId, string $key, mixed $value, string $group = 'general'): void public function set(int $branchId, string $key, mixed $value, string $group = 'general'): void
{ {
$academy = app('current_academy'); $academy = app()->has('current_academy') ? app('current_academy') : null;
BranchSetting::updateOrCreate( BranchSetting::updateOrCreate(
['branch_id' => $branchId, 'key' => $key], ['branch_id' => $branchId, 'key' => $key],
...@@ -62,7 +105,7 @@ public function allForBranch(int $branchId): array ...@@ -62,7 +105,7 @@ public function allForBranch(int $branchId): array
->pluck('value', 'key') ->pluck('value', 'key')
->toArray(); ->toArray();
$academy = app('current_academy'); $academy = app()->has('current_academy') ? app('current_academy') : null;
$academySettings = []; $academySettings = [];
if ($academy) { if ($academy) {
$academySettings = \DB::table('system_settings') $academySettings = \DB::table('system_settings')
......
...@@ -2,14 +2,36 @@ ...@@ -2,14 +2,36 @@
namespace App\Domain\Shared\DTOs; namespace App\Domain\Shared\DTOs;
use App\Domain\Shared\Enums\ProrationMode;
readonly class ProrationResult readonly class ProrationResult
{ {
public function __construct( public function __construct(
public bool $applied, public bool $applied,
public int $originalAmount, public int $originalAmount,
public int $proratedAmount, public int $proratedAmount,
public int $remainingDays, public ProrationMode $mode,
public int $renewalDay, public int $renewalDay,
public string $description, public string $description,
/** Sessions the group holds in the whole month; 0 when unknown. */
public int $totalSessions = 0,
/** Sessions still to come on or after the join date. */
public int $remainingSessions = 0,
) {} ) {}
/**
* A result that charges the full amount — the answer whenever proration is
* off, the mode is a full month, or there is no timetable to count.
*/
public static function fullMonth(int $amount, int $renewalDay, string $description = ''): self
{
return new self(
applied: false,
originalAmount: $amount,
proratedAmount: $amount,
mode: ProrationMode::FullMonth,
renewalDay: $renewalDay,
description: $description,
);
}
} }
<?php
namespace App\Domain\Shared\Enums;
/**
* What a participant joining part-way through a month pays for.
*
* RemainingSessions replaced an older "remaining days" rule. Days were the
* wrong unit: a programme training Sunday and Tuesday holds no sessions at all
* over a long weekend, so someone joining on the 22nd was charged for a third
* of a month in which they might train twice. Sessions are what is sold, so
* sessions are what is counted.
*/
enum ProrationMode: string
{
case FullMonth = 'full_month';
case HalfMonth = 'half_month';
case RemainingSessions = 'remaining_sessions';
public function label(): string
{
return match ($this) {
self::FullMonth => 'شهر كامل',
self::HalfMonth => 'نصف شهر',
self::RemainingSessions => 'باقي تمرينات الشهر',
};
}
public function hint(): string
{
return match ($this) {
self::FullMonth => 'الاشتراك كامل زي أي حد بدأ من أول الشهر',
self::HalfMonth => 'نصف قيمة الاشتراك',
self::RemainingSessions => 'بيدفع على عدد التمرينات الباقية في الشهر بس',
};
}
/** @return array<string, string> value => label, for a picker. */
public static function options(): array
{
$out = [];
foreach (self::cases() as $case) {
$out[$case->value] = $case->label();
}
return $out;
}
}
...@@ -3,12 +3,26 @@ ...@@ -3,12 +3,26 @@
namespace App\Domain\Shared\Services; namespace App\Domain\Shared\Services;
use App\Domain\Shared\DTOs\ProrationResult; use App\Domain\Shared\DTOs\ProrationResult;
use App\Domain\Shared\Enums\ProrationMode;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\SessionCountService;
use Carbon\Carbon; use Carbon\Carbon;
/**
* What someone joining part-way through a month pays.
*
* Three answers are offered, and the desk picks one per registration: the full
* month, half of it, or only the sessions left in the month. The last is the
* default and replaced an older rule that charged for remaining *days* on a
* hardcoded 30-day month — a unit the academy never sold. A programme training
* Sunday and Tuesday holds no sessions over a long weekend, so a player joining
* on the 22nd was billed a third of a month for perhaps two trainings.
*/
class ProrationService class ProrationService
{ {
public function __construct( public function __construct(
private readonly SettingsService $settings, private readonly SettingsService $settings,
private readonly SessionCountService $sessions,
) {} ) {}
public function isEnabled(): bool public function isEnabled(): bool
...@@ -22,42 +36,109 @@ public function renewalDay(): int ...@@ -22,42 +36,109 @@ public function renewalDay(): int
} }
/** /**
* Calculate prorated fee for a mid-month enrollment. * What the desk gets before it chooses. Full month when proration is off —
* If today is on or before the renewal day, no proration applies (full price). * the other two are not offered at all in that case.
*/ */
public function calculate(int $baseAmount, ?Carbon $enrollmentDate = null): ProrationResult public function defaultMode(): ProrationMode
{ {
$today = $enrollmentDate ?? now(); return $this->isEnabled() ? ProrationMode::RemainingSessions : ProrationMode::FullMonth;
}
/**
* @param int $baseAmount the full monthly fee, in piasters
* @param TrainingGroup|null $group whose timetable says how many sessions the month holds
*/
public function calculate(
int $baseAmount,
?Carbon $enrollmentDate = null,
?TrainingGroup $group = null,
ProrationMode|string|null $mode = null,
): ProrationResult {
$date = $enrollmentDate ?? now();
$renewalDay = $this->renewalDay(); $renewalDay = $this->renewalDay();
$currentDay = (int) $today->day;
// No proration if enrolling on or before the renewal day $mode = is_string($mode) ? ProrationMode::tryFrom($mode) : $mode;
if ($currentDay <= $renewalDay) { $mode ??= $this->defaultMode();
// The setting is the gate. A mode posted from a browser cannot switch
// proration on for an academy that turned it off.
if (! $this->isEnabled()) {
$mode = ProrationMode::FullMonth;
}
return match ($mode) {
ProrationMode::FullMonth => ProrationResult::fullMonth($baseAmount, $renewalDay),
ProrationMode::HalfMonth => $this->halfMonth($baseAmount, $renewalDay),
ProrationMode::RemainingSessions => $this->remainingSessions($baseAmount, $date, $group, $renewalDay),
};
}
private function halfMonth(int $baseAmount, int $renewalDay): ProrationResult
{
// Rounded up, like every other partial charge here: the academy does
// not lose a piaster to a division, and the player is never surprised
// by more than one.
$amount = (int) ceil($baseAmount / 2);
return new ProrationResult(
applied: true,
originalAmount: $baseAmount,
proratedAmount: $amount,
mode: ProrationMode::HalfMonth,
renewalDay: $renewalDay,
description: 'متناسب: نصف شهر',
);
}
private function remainingSessions(
int $baseAmount,
Carbon $date,
?TrainingGroup $group,
int $renewalDay,
): ProrationResult {
// No group, or a group with no timetable, cannot answer "how many
// trainings are left". Charging the full month is the conservative
// answer and says so, rather than blocking a registration over a
// programme whose schedule nobody has filled in yet.
if (! $group) {
return ProrationResult::fullMonth($baseAmount, $renewalDay, 'لا توجد مجموعة لحساب التمرينات — شهر كامل');
}
['total' => $total, 'remaining' => $remaining] = $this->sessions->forMonth($group, $date);
if ($total <= 0) {
return ProrationResult::fullMonth($baseAmount, $renewalDay, 'لا يوجد جدول تمرين — شهر كامل');
}
// Joining before the month's first session buys the whole month; there
// is nothing to discount.
if ($remaining >= $total) {
return new ProrationResult( return new ProrationResult(
applied: false, applied: false,
originalAmount: $baseAmount, originalAmount: $baseAmount,
proratedAmount: $baseAmount, proratedAmount: $baseAmount,
remainingDays: 30, mode: ProrationMode::RemainingSessions,
renewalDay: $renewalDay, renewalDay: $renewalDay,
description: '', description: '',
totalSessions: $total,
remainingSessions: $remaining,
); );
} }
// Days remaining until next renewal: renewal_day + 30 - today $amount = (int) ceil($baseAmount * $remaining / $total);
$remainingDays = $renewalDay + 30 - $currentDay;
$remainingDays = max(1, $remainingDays);
$proratedAmount = (int) ceil($baseAmount * $remainingDays / 30);
$description = "متناسب: {$remainingDays} من 30 يوم";
return new ProrationResult( return new ProrationResult(
applied: true, applied: true,
originalAmount: $baseAmount, originalAmount: $baseAmount,
proratedAmount: $proratedAmount, proratedAmount: $amount,
remainingDays: $remainingDays, mode: ProrationMode::RemainingSessions,
renewalDay: $renewalDay, renewalDay: $renewalDay,
description: $description, // The Arabic word "متناسب" is load-bearing: ParticipantBillingService
// reads it off the invoice line to recognise a prorated subscription,
// there being no column that records the fact.
description: "متناسب: {$remaining} من {$total} تمرين",
totalSessions: $total,
remainingSessions: $remaining,
); );
} }
} }
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Domain\Training\Services; namespace App\Domain\Training\Services;
use App\Domain\Financial\Services\InvoiceService; use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService; use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
...@@ -31,8 +32,23 @@ public function __construct( ...@@ -31,8 +32,23 @@ public function __construct(
private readonly InvoiceService $invoiceService, private readonly InvoiceService $invoiceService,
private readonly SettingsService $settings, private readonly SettingsService $settings,
private readonly ProrationService $prorationService, private readonly ProrationService $prorationService,
private readonly BranchSettingsService $branchSettings,
) {} ) {}
/**
* Whether this enrolment should be billed at all.
*
* Two ways it should not: the participant is waived, or the branch's money
* is handled outside the system entirely. Both mean no invoice — never a
* zero-value one — and both leave the enrolment marked waived so the
* renewal command does not come back for it next month.
*/
private function billingIsWaived(Participant $participant, TrainingGroup $group): bool
{
return $participant->is_free
|| $this->branchSettings->billingHandledExternally($group->branch_id ?? $participant->branch_id);
}
public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment public function enroll(Participant $participant, TrainingGroup $group, User $actor, ?array $options = []): Enrollment
{ {
return DB::transaction(function () use ($participant, $group, $actor, $options) { return DB::transaction(function () use ($participant, $group, $actor, $options) {
...@@ -90,6 +106,8 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act ...@@ -90,6 +106,8 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
throw new DomainException('المشترك والمجموعة في فرعين مختلفين'); throw new DomainException('المشترك والمجموعة في فرعين مختلفين');
} }
$waived = $this->billingIsWaived($participant, $group);
$enrollment = Enrollment::create([ $enrollment = Enrollment::create([
// The enrolment belongs to the group's branch, not to whichever // The enrolment belongs to the group's branch, not to whichever
// branch the person doing the enrolling was looking at. // branch the person doing the enrolling was looking at.
...@@ -100,11 +118,11 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act ...@@ -100,11 +118,11 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
'enrollment_date' => now()->toDateString(), 'enrollment_date' => now()->toDateString(),
'start_date' => $options['start_date'] ?? $group->start_date ?? now()->toDateString(), 'start_date' => $options['start_date'] ?? $group->start_date ?? now()->toDateString(),
'end_date' => $options['end_date'] ?? $group->end_date, 'end_date' => $options['end_date'] ?? $group->end_date,
'next_billing_date' => $participant->is_free ? null : $this->calculateFirstBillingDate($group->program), 'next_billing_date' => $waived ? null : $this->calculateFirstBillingDate($group->program),
'status' => 'active', 'status' => 'active',
'enrolled_by' => $actor->id, 'enrolled_by' => $actor->id,
'invoice_id' => $options['invoice_id'] ?? null, 'invoice_id' => $options['invoice_id'] ?? null,
'payment_status' => $participant->is_free ? 'waived' : ($options['payment_status'] ?? 'pending'), 'payment_status' => $waived ? 'waived' : ($options['payment_status'] ?? 'pending'),
'sessions_total' => $group->program?->total_sessions, 'sessions_total' => $group->program?->total_sessions,
]); ]);
...@@ -116,9 +134,15 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act ...@@ -116,9 +134,15 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
$participant->update(['status' => 'active']); $participant->update(['status' => 'active']);
} }
// Auto-create invoice if program has a price (skip if invoice already provided, explicitly skipped, or free player) // Auto-create invoice if program has a price (skip if invoice already provided, explicitly skipped, waived player, or a branch that bills nothing)
if (empty($options['invoice_id']) && empty($options['skip_auto_invoice']) && !$participant->is_free && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) { if (empty($options['invoice_id']) && empty($options['skip_auto_invoice']) && !$waived && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) {
$this->createEnrollmentInvoice($enrollment, $participant, $group, $actor); $this->createEnrollmentInvoice(
$enrollment,
$participant,
$group,
$actor,
isset($options['proration_mode']) ? (string) $options['proration_mode'] : null,
);
} }
EnrollmentCreated::dispatch($enrollment, $actor); EnrollmentCreated::dispatch($enrollment, $actor);
...@@ -413,7 +437,11 @@ public function processWaitlist(TrainingGroup $group): void ...@@ -413,7 +437,11 @@ public function processWaitlist(TrainingGroup $group): void
* Auto-create an invoice for a new enrollment if the program has a base price. * Auto-create an invoice for a new enrollment if the program has a base price.
* Uses PricingService to calculate the final price (applies rules/discounts). * Uses PricingService to calculate the final price (applies rules/discounts).
*/ */
private function createEnrollmentInvoice(Enrollment $enrollment, Participant $participant, TrainingGroup $group, User $actor): void /**
* @param string|null $prorationMode what the desk chose the joiner pays
* for this month; null takes the default
*/
private function createEnrollmentInvoice(Enrollment $enrollment, Participant $participant, TrainingGroup $group, User $actor, ?string $prorationMode = null): void
{ {
$program = $group->program; $program = $group->program;
if (!$program) { if (!$program) {
...@@ -442,11 +470,29 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -442,11 +470,29 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
$lineDescription = "اشتراك: {$program->name_ar}"; $lineDescription = "اشتراك: {$program->name_ar}";
if ($this->prorationService->isEnabled()) { if ($this->prorationService->isEnabled()) {
$proration = $this->prorationService->calculate($priceResult->finalAmount); // The group is what carries the timetable, so it has to be handed
// over: without it the session count is unanswerable and the whole
// month is charged.
$proration = $this->prorationService->calculate(
$priceResult->finalAmount,
$enrollment->start_date ? \Carbon\Carbon::parse($enrollment->start_date) : null,
$group,
$prorationMode,
);
$finalAmount = $proration->proratedAmount; $finalAmount = $proration->proratedAmount;
if ($proration->applied) { if ($proration->applied) {
$lineDescription .= " ({$proration->description})"; $lineDescription .= " ({$proration->description})";
} }
// Joining after the month's last session leaves nothing to charge
// for. Billing zero would raise an invoice the anomaly scanner
// reports as corruption; the subscription simply starts on the next
// cycle, which next_billing_date already points at.
if ($finalAmount <= 0) {
$enrollment->update(['payment_status' => 'paid']);
return;
}
} }
$invoice = $this->invoiceService->create([ $invoice = $this->invoiceService->create([
......
<?php
namespace App\Domain\Training\Services;
use App\Domain\Training\Models\Holiday;
use App\Domain\Training\Models\TrainingGroup;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
/**
* How many training sessions a group actually holds between two dates.
*
* Counted from the timetable, not from the training_sessions table.
* SessionGeneratorService only materialises rows about a week ahead, so
* counting rows would under-report every question about the rest of the month —
* which is exactly the question proration asks.
*
* The rules are the generator's, kept deliberately identical: a date counts
* when an active schedule row names its weekday, the row is effective that day,
* and no training-affecting holiday falls on it.
*/
class SessionCountService
{
/**
* Sessions the group holds in [$from, $to], both ends inclusive.
*/
public function between(TrainingGroup $group, Carbon $from, Carbon $to): int
{
if ($to->lt($from)) {
return 0;
}
$schedules = $group->schedules()->active()->get();
if ($schedules->isEmpty()) {
return 0;
}
$holidays = $this->holidays($group, $from, $to);
$count = 0;
// Per schedule row, not per date: a group that trains twice on the same
// weekday — an early group and a late one — holds two sessions that day,
// and a player joining owes for both.
foreach ($schedules as $schedule) {
foreach (CarbonPeriod::create($from->copy()->startOfDay(), $to->copy()->startOfDay()) as $date) {
if ($date->dayOfWeek !== $schedule->day_of_week) {
continue;
}
if (isset($holidays[$date->toDateString()])) {
continue;
}
if ($schedule->effective_from && $date->lt($schedule->effective_from)) {
continue;
}
if ($schedule->effective_until && $date->gt($schedule->effective_until)) {
continue;
}
$count++;
}
}
return $count;
}
/**
* Sessions in the whole calendar month containing $date, and those still to
* come on or after $date.
*
* Both figures come from the same walk so they cannot disagree — the price
* of a session is the monthly fee over the first, and what is owed is that
* times the second.
*
* @return array{total: int, remaining: int}
*/
public function forMonth(TrainingGroup $group, Carbon $date): array
{
$monthStart = $date->copy()->startOfMonth();
$monthEnd = $date->copy()->endOfMonth();
return [
'total' => $this->between($group, $monthStart, $monthEnd),
'remaining' => $this->between($group, $date->copy()->startOfDay(), $monthEnd),
];
}
/**
* Holiday dates that cancel training, keyed by Y-m-d for lookup.
*
* @return array<string, true>
*/
private function holidays(TrainingGroup $group, Carbon $from, Carbon $to): array
{
$dates = Holiday::where('academy_id', $group->academy_id)
->where(function ($q) use ($group) {
$q->whereNull('branch_id')->orWhere('branch_id', $group->branch_id);
})
->affectsTraining()
->whereBetween('date', [$from->toDateString(), $to->toDateString()])
->pluck('date');
$out = [];
foreach ($dates as $date) {
$out[$date instanceof \DateTimeInterface ? $date->format('Y-m-d') : (string) $date] = true;
}
return $out;
}
}
...@@ -82,7 +82,7 @@ class SystemSettings extends Component ...@@ -82,7 +82,7 @@ class SystemSettings extends Component
'coupon_max_uses_default' => ['type' => 'number', 'label' => 'الحد الافتراضي لاستخدام الكوبون', 'step' => '1', 'min' => 1], 'coupon_max_uses_default' => ['type' => 'number', 'label' => 'الحد الافتراضي لاستخدام الكوبون', 'step' => '1', 'min' => 1],
], ],
'enrollment' => [ 'enrollment' => [
'allow_proration' => ['type' => 'boolean', 'label' => 'تفعيل الدفع النسبي (باقي الشهر فقط)', 'hint' => 'عند التفعيل: اللاعب يدفع فقط الأيام المتبقية من الشهر حتى يوم التجديد'], 'allow_proration' => ['type' => 'boolean', 'label' => 'تفعيل الدفع النسبي (باقي الشهر)', 'hint' => 'عند التفعيل: شاشة التسجيل بتسأل اللاعب هيدفع شهر كامل ولا نص شهر ولا باقي تمرينات الشهر بس'],
'renewal_day' => ['type' => 'number', 'label' => 'يوم التجديد الشهري', 'step' => '1', 'min' => 1, 'max' => 28, 'hint' => 'اليوم الذي يتجدد فيه الاشتراك كل شهر (مثال: 1 = أول الشهر)'], 'renewal_day' => ['type' => 'number', 'label' => 'يوم التجديد الشهري', 'step' => '1', 'min' => 1, 'max' => 28, 'hint' => 'اليوم الذي يتجدد فيه الاشتراك كل شهر (مثال: 1 = أول الشهر)'],
'allow_waitlist' => ['type' => 'boolean', 'label' => 'تفعيل قائمة الانتظار'], 'allow_waitlist' => ['type' => 'boolean', 'label' => 'تفعيل قائمة الانتظار'],
'auto_promote_waitlist' => ['type' => 'boolean', 'label' => 'ترقية تلقائية من قائمة الانتظار'], 'auto_promote_waitlist' => ['type' => 'boolean', 'label' => 'ترقية تلقائية من قائمة الانتظار'],
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Domain\Identity\Models\Branch; use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Services\BranchService; use App\Domain\Identity\Services\BranchService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User; use App\Models\User;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
...@@ -31,6 +32,16 @@ class BranchForm extends Component ...@@ -31,6 +32,16 @@ class BranchForm extends Component
public bool $is_main = false; public bool $is_main = false;
public bool $is_active = true; public bool $is_active = true;
/**
* This branch's money never passes through the system: nobody enrolled here
* is billed, and the branch's income is entered afterwards as one figure on
* the external-revenue screen.
*
* Stored in branch_settings rather than on the branches row — it is a
* setting, and branch_settings is where per-branch settings live.
*/
public bool $billing_handled_externally = false;
// Operating hours // Operating hours
public array $operating_hours = []; public array $operating_hours = [];
...@@ -55,6 +66,8 @@ public function mount(?Branch $branch = null): void ...@@ -55,6 +66,8 @@ public function mount(?Branch $branch = null): void
$this->is_main = $branch->is_main; $this->is_main = $branch->is_main;
$this->is_active = $branch->is_active; $this->is_active = $branch->is_active;
$this->operating_hours = $branch->operating_hours ?? []; $this->operating_hours = $branch->operating_hours ?? [];
$this->billing_handled_externally = app(BranchSettingsService::class)
->billingHandledExternally($branch->id);
} else { } else {
$this->authorize('branches.create'); $this->authorize('branches.create');
} }
...@@ -89,6 +102,7 @@ public function rules(): array ...@@ -89,6 +102,7 @@ public function rules(): array
->whereNull('deleted_at')], ->whereNull('deleted_at')],
'is_main' => 'boolean', 'is_main' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'billing_handled_externally' => 'boolean',
]; ];
} }
...@@ -138,9 +152,11 @@ public function save(BranchService $branchService): void ...@@ -138,9 +152,11 @@ public function save(BranchService $branchService): void
try { try {
if ($this->editing) { if ($this->editing) {
$branchService->update($this->branch, $data, auth()->user()); $branchService->update($this->branch, $data, auth()->user());
$this->saveBillingMode($this->branch->id);
session()->flash('success', 'تم تحديث الفرع بنجاح'); session()->flash('success', 'تم تحديث الفرع بنجاح');
} else { } else {
$branchService->create($data, auth()->user()); $branch = $branchService->create($data, auth()->user());
$this->saveBillingMode($branch->id);
session()->flash('success', 'تم إنشاء الفرع بنجاح'); session()->flash('success', 'تم إنشاء الفرع بنجاح');
} }
...@@ -150,6 +166,20 @@ public function save(BranchService $branchService): void ...@@ -150,6 +166,20 @@ public function save(BranchService $branchService): void
} }
} }
/**
* The toggle lives in branch_settings, so it is written after the branch
* row exists — a new branch has no id until BranchService::create returns.
*/
private function saveBillingMode(int $branchId): void
{
app(BranchSettingsService::class)->set(
$branchId,
BranchSettingsService::KEY_BILLING_EXTERNAL,
$this->billing_handled_externally ? '1' : '0',
'financial',
);
}
public function render() public function render()
{ {
return view('livewire.branches.branch-form', [ return view('livewire.branches.branch-form', [
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
use App\Domain\Pricing\Models\BasePrice; use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Pricing\Services\PricingService; use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\DTOs\ProrationResult; use App\Domain\Shared\DTOs\ProrationResult;
use App\Domain\Shared\Enums\ProrationMode;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService; use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
...@@ -39,6 +40,12 @@ class EnrollExistingWizard extends Component ...@@ -39,6 +40,12 @@ class EnrollExistingWizard extends Component
// Free player toggle // Free player toggle
public bool $is_free = false; public bool $is_free = false;
/**
* What a mid-month joiner pays for. Settable from the browser by design;
* see the same property on NewRegistrationWizard for why that is safe.
*/
public string $proration_mode = ProrationMode::RemainingSessions->value;
// Step 3: Payment // Step 3: Payment
public bool $pay_now = false; public bool $pay_now = false;
public string $payment_method = 'cash'; public string $payment_method = 'cash';
...@@ -166,6 +173,10 @@ public function confirm(): void ...@@ -166,6 +173,10 @@ public function confirm(): void
[ [
'pay_now' => $this->is_free ? false : $this->pay_now, 'pay_now' => $this->is_free ? false : $this->pay_now,
'payment_method' => (!$this->is_free && $this->pay_now) ? $this->payment_method : null, 'payment_method' => (!$this->is_free && $this->pay_now) ? $this->payment_method : null,
// What step 3 showed the desk is what gets invoiced.
// Without this the service would fall back to its default
// and quietly bill a different figure from the one quoted.
'proration_mode' => $this->proration_mode,
] ]
); );
...@@ -184,6 +195,19 @@ public function confirm(): void ...@@ -184,6 +195,19 @@ public function confirm(): void
} }
} }
/**
* TrainingProgram carries BranchScope, so a programme id posted by hand
* that belongs to another branch resolves to null here rather than to that
* branch's timetable.
*/
#[Computed]
public function selectedProgram(): ?TrainingProgram
{
return $this->selected_program_id
? TrainingProgram::find($this->selected_program_id)
: null;
}
#[Computed] #[Computed]
public function selectedProgramFee(): int public function selectedProgramFee(): int
{ {
...@@ -224,16 +248,15 @@ public function proratedProgramFee(): ProrationResult ...@@ -224,16 +248,15 @@ public function proratedProgramFee(): ProrationResult
$baseFee = $this->selectedProgramFee; $baseFee = $this->selectedProgramFee;
$service = app(ProrationService::class); $service = app(ProrationService::class);
if (!$service->isEnabled() || $baseFee === 0) { if (!$service->isEnabled() || $baseFee === 0) {
return new ProrationResult( return ProrationResult::fullMonth($baseFee, $service->renewalDay());
applied: false,
originalAmount: $baseFee,
proratedAmount: $baseFee,
remainingDays: 30,
renewalDay: $service->renewalDay(),
description: '',
);
} }
return $service->calculate($baseFee);
return $service->calculate(
$baseFee,
null,
$this->selectedProgram?->defaultGroup(),
$this->proration_mode,
);
} }
public function render() public function render()
......
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
use App\Domain\Participant\Services\ParticipantService; use App\Domain\Participant\Services\ParticipantService;
use App\Domain\Pricing\Models\BasePrice; use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Shared\DTOs\ProrationResult; use App\Domain\Shared\DTOs\ProrationResult;
use App\Domain\Shared\Enums\ProrationMode;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\PlatformFeeService; use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Shared\Services\ProrationService; use App\Domain\Shared\Services\ProrationService;
...@@ -85,6 +87,15 @@ class NewRegistrationWizard extends Component ...@@ -85,6 +87,15 @@ class NewRegistrationWizard extends Component
// Step 5: Enrollment options // Step 5: Enrollment options
public ?string $enrollment_start_date = null; public ?string $enrollment_start_date = null;
/**
* What a mid-month joiner pays for: the whole month, half of it, or only
* the sessions left in it. Settable from the browser by design — this is a
* choice the desk makes per registration — and it is safe to be: the
* service refuses every mode but a full month when the academy has
* proration switched off, and ProrationMode::tryFrom rejects anything else.
*/
public string $proration_mode = ProrationMode::RemainingSessions->value;
// Step 6: Payment // Step 6: Payment
public bool $pay_now = false; public bool $pay_now = false;
public string $payment_method = 'cash'; public string $payment_method = 'cash';
...@@ -861,17 +872,22 @@ public function proratedProgramFee(): ProrationResult ...@@ -861,17 +872,22 @@ public function proratedProgramFee(): ProrationResult
$baseFee = $this->selectedProgramFee; $baseFee = $this->selectedProgramFee;
$service = app(ProrationService::class); $service = app(ProrationService::class);
if (!$service->isEnabled() || $baseFee === 0) { if (!$service->isEnabled() || $baseFee === 0) {
return new ProrationResult( return ProrationResult::fullMonth($baseFee, $service->renewalDay());
applied: false,
originalAmount: $baseFee,
proratedAmount: $baseFee,
remainingDays: 30,
renewalDay: $service->renewalDay(),
description: '',
);
} }
$startDate = $this->enrollment_start_date ? \Carbon\Carbon::parse($this->enrollment_start_date) : null; $startDate = $this->enrollment_start_date ? \Carbon\Carbon::parse($this->enrollment_start_date) : null;
return $service->calculate($baseFee, $startDate);
return $service->calculate($baseFee, $startDate, $this->prorationGroup(), $this->proration_mode);
}
/**
* The group whose timetable prices "باقي تمرينات الشهر".
*
* The desk picks a programme, not a group, so this is the programme's
* default group — the same one ProgramForm writes the timetable onto.
*/
private function prorationGroup(): ?TrainingGroup
{
return $this->selectedProgram?->defaultGroup();
} }
#[Computed] #[Computed]
...@@ -1067,9 +1083,16 @@ public function confirm(): void ...@@ -1067,9 +1083,16 @@ public function confirm(): void
]); ]);
} }
// 8. Create invoice if there's a fee or hot-buy items (skip for free players) // 8. Create invoice if there's a fee or hot-buy items. Skipped
// for a waived player, and for a branch whose money is handled
// outside the system — that branch's income arrives later as
// one figure on the external-revenue screen, so billing the
// player here would demand the same money twice.
$invoice = null; $invoice = null;
if (!$this->is_free && ($subtotal > 0 || $finalTotal !== $computedTotal)) { $billsNothing = app(\App\Domain\Identity\Services\BranchSettingsService::class)
->billingHandledExternally($this->getActiveBranchId());
if (!$this->is_free && !$billsNothing && ($subtotal > 0 || $finalTotal !== $computedTotal)) {
$invoiceItems = []; $invoiceItems = [];
// When override is active, compute how much the program fee is adjusted. // When override is active, compute how much the program fee is adjusted.
......
...@@ -47,6 +47,19 @@ ...@@ -47,6 +47,19 @@
<span class="text-sm text-gray-700">نشط</span> <span class="text-sm text-gray-700">نشط</span>
</label> </label>
</div> </div>
{{-- A branch whose takings never pass through the system. Nobody
enrolled here is billed at all, and the branch's income arrives
afterwards as one figure on the external-revenue screen. --}}
<div class="mt-4 pt-4 border-t border-gray-100">
<label class="min-h-[44px] flex items-start gap-2 cursor-pointer">
<input type="checkbox" wire:model="billing_handled_externally" class="mt-1 w-4 h-4 rounded border-gray-300 text-amber-600">
<span>
<span class="block text-sm font-medium text-gray-700">{{ __('الإيرادات بتتحصل خارج النظام') }}</span>
<span class="block text-xs text-gray-500 mt-0.5">{{ __('مفيش فواتير اشتراك بتتعمل لأي حد في الفرع ده، والإيراد بيتسجل بعدين من شاشة "تسجيل إيراد خارجي".') }}</span>
</span>
</label>
</div>
</div> </div>
<!-- Contact Info --> <!-- Contact Info -->
......
...@@ -318,6 +318,27 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-emerald-600 text-whi ...@@ -318,6 +318,27 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-emerald-600 text-whi
<span class="font-bold text-green-700 ms-1" dir="ltr">{{ number_format($proration->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span> <span class="font-bold text-green-700 ms-1" dir="ltr">{{ number_format($proration->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
@endif @endif
</div> </div>
{{-- Only offered when the academy has proration on;
otherwise everyone pays the full month. --}}
@if(app(\App\Domain\Shared\Services\ProrationService::class)->isEnabled())
<div class="col-span-2 pt-2 mt-1 border-t border-gray-100">
<p class="text-xs font-medium text-gray-600 mb-2">{{ __('بيدفع إيه عن الشهر ده؟') }}</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
@foreach(\App\Domain\Shared\Enums\ProrationMode::cases() as $mode)
<label class="flex flex-col gap-0.5 p-2.5 border rounded-lg cursor-pointer transition
{{ $proration_mode === $mode->value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50' }}">
<span class="flex items-center gap-2">
<input type="radio" wire:model.live="proration_mode" value="{{ $mode->value }}"
class="w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm font-medium text-gray-800">{{ __($mode->label()) }}</span>
</span>
<span class="text-[11px] text-gray-500 ps-6">{{ __($mode->hint()) }}</span>
</label>
@endforeach
</div>
</div>
@endif
@endif @endif
@endif @endif
</div> </div>
......
...@@ -743,6 +743,28 @@ class="w-full sm:w-auto px-3 py-2 text-sm border border-gray-300 rounded-lg focu ...@@ -743,6 +743,28 @@ class="w-full sm:w-auto px-3 py-2 text-sm border border-gray-300 rounded-lg focu
<p class="text-xs text-gray-500 mt-1">{{ __('اتركه فارغاً لاستخدام تاريخ اليوم. حدد التاريخ إذا بدأ اللاعب قبل اليوم لحساب الأيام المتبقية بدقة.') }}</p> <p class="text-xs text-gray-500 mt-1">{{ __('اتركه فارغاً لاستخدام تاريخ اليوم. حدد التاريخ إذا بدأ اللاعب قبل اليوم لحساب الأيام المتبقية بدقة.') }}</p>
</div> </div>
{{-- What a mid-month joiner pays for. Only offered when the
academy has proration switched on; otherwise everyone
pays the full month and there is no choice to make. --}}
@if($this->selectedProgramFee > 0 && app(\App\Domain\Shared\Services\ProrationService::class)->isEnabled())
<div class="mt-3 border-t border-gray-200 pt-3">
<p class="text-xs font-medium text-gray-600 mb-2">{{ __('بيدفع إيه عن الشهر ده؟') }}</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
@foreach(\App\Domain\Shared\Enums\ProrationMode::cases() as $mode)
<label class="flex flex-col gap-0.5 p-2.5 border rounded-lg cursor-pointer transition
{{ $proration_mode === $mode->value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50' }}">
<span class="flex items-center gap-2">
<input type="radio" wire:model.live="proration_mode" value="{{ $mode->value }}"
class="w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm font-medium text-gray-800">{{ __($mode->label()) }}</span>
</span>
<span class="text-[11px] text-gray-500 ps-6">{{ __($mode->hint()) }}</span>
</label>
@endforeach
</div>
</div>
@endif
@if($this->selectedProgramFee > 0) @if($this->selectedProgramFee > 0)
<div class="mt-3 space-y-1 text-sm border-t border-gray-200 pt-3"> <div class="mt-3 space-y-1 text-sm border-t border-gray-200 pt-3">
@if($this->proratedProgramFee->applied) @if($this->proratedProgramFee->applied)
......
<?php
namespace Tests\Feature;
use App\Domain\Shared\Enums\ProrationMode;
use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Services\SettingsService;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\SessionCountService;
use Carbon\Carbon;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* What a mid-month joiner is charged.
*
* The old rule counted days on a hardcoded 30-day month: joining on the 22nd
* bought "9 of 30 days", regardless of whether the programme trained nine times
* in those days or twice. A programme meeting Sunday and Tuesday holds nine
* sessions in September 2026 and exactly three of them fall on or after the
* 22nd, so three ninths is what the player owes — not the nine thirtieths the
* calendar arithmetic produced.
*
* September 2026 is the month under test throughout: it opens on a Tuesday and
* carries four Sundays and five Tuesdays.
*/
class SessionProrationTest extends TestCase
{
private const ACADEMY_ID = 1;
private const BRANCH_ID = 1;
private const GROUP_ID = 1;
/** 500 EGP a month, in piasters. */
private const MONTHLY_FEE = 50000;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
// SettingsService reads nothing at all without an academy in the
// container, so proration would read as off and every price assertion
// below would pass for the wrong reason.
app()->instance('current_academy', (object) ['id' => self::ACADEMY_ID]);
$this->createMinimalSchema();
DB::table('training_groups')->insert([
'id' => self::GROUP_ID,
'academy_id' => self::ACADEMY_ID,
'branch_id' => self::BRANCH_ID,
'name_ar' => 'مجموعة الاختبار',
'deleted_at' => null,
]);
// Sunday (0) and Tuesday (2), the whole month through.
$this->addSchedule(dayOfWeek: 0);
$this->addSchedule(dayOfWeek: 2);
}
// ---- counting ---------------------------------------------------------
public function test_a_month_holds_as_many_sessions_as_the_timetable_says(): void
{
$counts = $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-22'));
$this->assertSame(9, $counts['total'], 'Four Sundays and five Tuesdays in September 2026.');
$this->assertSame(3, $counts['remaining'], 'The 22nd, the 27th and the 29th are still to come.');
}
public function test_the_join_day_itself_counts_as_a_remaining_session(): void
{
// The 22nd IS a Tuesday. A player who signs up that afternoon and
// trains that evening has been sold that session.
$counts = $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-22'));
$this->assertSame(3, $counts['remaining']);
$dayAfter = $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-23'));
$this->assertSame(2, $dayAfter['remaining'], 'By Wednesday that session is spent.');
}
public function test_a_holiday_removes_a_session_from_both_figures(): void
{
DB::table('holidays')->insert([
'academy_id' => self::ACADEMY_ID,
'branch_id' => null,
'name' => 'Test holiday',
'name_ar' => 'إجازة',
'date' => '2026-09-27',
'affects' => 'training',
]);
$counts = $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-22'));
$this->assertSame(8, $counts['total']);
$this->assertSame(2, $counts['remaining']);
}
public function test_a_holiday_that_does_not_stop_training_is_ignored(): void
{
DB::table('holidays')->insert([
'academy_id' => self::ACADEMY_ID,
'branch_id' => null,
'name' => 'Office closed',
'name_ar' => 'إجازة إدارية',
'date' => '2026-09-27',
'affects' => 'staff',
]);
$this->assertSame(9, $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-22'))['total']);
}
// ---- what it costs ----------------------------------------------------
public function test_remaining_sessions_is_the_price_of_a_session_times_what_is_left(): void
{
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-22'),
$this->group(),
ProrationMode::RemainingSessions,
);
$this->assertTrue($result->applied);
// 50000 × 3 / 9 = 16666.67, rounded up.
$this->assertSame(16667, $result->proratedAmount);
$this->assertSame(9, $result->totalSessions);
$this->assertSame(3, $result->remainingSessions);
$this->assertStringContainsString('متناسب', $result->description, 'ParticipantBillingService reads this word off the invoice line.');
}
public function test_the_old_day_based_answer_is_no_longer_what_is_charged(): void
{
// 1 + 30 − 22 = 9 days of 30, which the previous rule billed as 15000.
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-22'),
$this->group(),
ProrationMode::RemainingSessions,
);
$this->assertNotSame(15000, $result->proratedAmount);
}
public function test_joining_before_the_first_session_buys_the_whole_month(): void
{
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-01'),
$this->group(),
ProrationMode::RemainingSessions,
);
$this->assertFalse($result->applied);
$this->assertSame(self::MONTHLY_FEE, $result->proratedAmount);
}
public function test_joining_after_the_last_session_owes_nothing_for_this_month(): void
{
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-30'),
$this->group(),
ProrationMode::RemainingSessions,
);
$this->assertSame(0, $result->proratedAmount, 'The last session was the 29th.');
}
public function test_half_month_is_half_the_fee(): void
{
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-22'),
$this->group(),
ProrationMode::HalfMonth,
);
$this->assertTrue($result->applied);
$this->assertSame(25000, $result->proratedAmount);
}
public function test_full_month_charges_the_whole_fee_whenever_it_is_chosen(): void
{
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-22'),
$this->group(),
ProrationMode::FullMonth,
);
$this->assertFalse($result->applied);
$this->assertSame(self::MONTHLY_FEE, $result->proratedAmount);
}
// ---- the guards -------------------------------------------------------
public function test_a_programme_with_no_timetable_is_charged_a_full_month_rather_than_blocked(): void
{
DB::table('training_schedules')->delete();
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-22'),
$this->group(),
ProrationMode::RemainingSessions,
);
$this->assertFalse($result->applied);
$this->assertSame(self::MONTHLY_FEE, $result->proratedAmount);
$this->assertNotSame('', $result->description, 'The desk is told why it was not prorated.');
}
public function test_a_mode_posted_by_hand_cannot_switch_proration_on(): void
{
$this->setSetting('enrollment.allow_proration', '0');
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-22'),
$this->group(),
ProrationMode::HalfMonth,
);
$this->assertFalse($result->applied);
$this->assertSame(self::MONTHLY_FEE, $result->proratedAmount, 'The academy setting is the gate, not the posted mode.');
}
public function test_an_unrecognised_mode_falls_back_to_the_default(): void
{
$result = $this->proration()->calculate(
self::MONTHLY_FEE,
Carbon::parse('2026-09-22'),
$this->group(),
'pay_whatever_you_like',
);
$this->assertSame(ProrationMode::RemainingSessions, $result->mode);
$this->assertSame(16667, $result->proratedAmount);
}
public function test_an_inactive_schedule_row_holds_no_sessions(): void
{
DB::table('training_schedules')->update(['is_active' => false]);
$this->assertSame(0, $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-22'))['total']);
}
public function test_a_schedule_that_has_not_started_yet_holds_no_sessions(): void
{
DB::table('training_schedules')->update(['effective_from' => '2026-10-01']);
$this->assertSame(0, $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-22'))['total']);
}
public function test_two_slots_on_the_same_weekday_are_two_sessions(): void
{
// An early group and a late one, both on Sunday.
$this->addSchedule(dayOfWeek: 0, start: '18:00', end: '19:30');
$counts = $this->counter()->forMonth($this->group(), Carbon::parse('2026-09-01'));
$this->assertSame(13, $counts['total'], 'Five Tuesdays plus four Sundays twice over.');
}
// ---- harness ----------------------------------------------------------
private function counter(): SessionCountService
{
return app(SessionCountService::class);
}
private function proration(): ProrationService
{
return app(ProrationService::class);
}
private function group(): TrainingGroup
{
return TrainingGroup::findOrFail(self::GROUP_ID);
}
private function addSchedule(int $dayOfWeek, string $start = '16:00', string $end = '17:30'): void
{
DB::table('training_schedules')->insert([
'academy_id' => self::ACADEMY_ID,
'training_group_id' => self::GROUP_ID,
'facility_id' => null,
'day_of_week' => $dayOfWeek,
'start_time' => $start,
'end_time' => $end,
'effective_from' => '2026-01-01',
'effective_until' => null,
'is_active' => true,
]);
}
private function setSetting(string $key, string $value): void
{
DB::table('system_settings')->updateOrInsert(
['academy_id' => self::ACADEMY_ID, 'key' => $key],
['value' => $value, 'type' => 'boolean', 'group' => 'enrollment'],
);
// SettingsService memoises; a fresh instance is what a fresh request
// would get.
app()->forgetInstance(SettingsService::class);
}
private function createMinimalSchema(): void
{
Schema::create('training_groups', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('academy_id');
$table->unsignedBigInteger('branch_id')->nullable();
$table->string('name_ar');
$table->timestamp('deleted_at')->nullable();
});
Schema::create('training_schedules', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('academy_id');
$table->unsignedBigInteger('training_group_id');
$table->unsignedBigInteger('facility_id')->nullable();
$table->integer('day_of_week');
$table->string('start_time');
$table->string('end_time');
$table->date('effective_from')->nullable();
$table->date('effective_until')->nullable();
$table->boolean('is_active')->default(true);
});
Schema::create('holidays', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('academy_id');
$table->unsignedBigInteger('branch_id')->nullable();
$table->string('name');
$table->string('name_ar');
$table->date('date');
$table->string('affects')->default('all');
});
Schema::create('system_settings', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('academy_id')->nullable();
$table->string('key');
$table->text('value')->nullable();
$table->string('type')->default('string');
$table->string('group')->default('general');
$table->timestamps();
});
// Proration on, renewing on the first — the configuration the feature
// is being asked about.
$this->setSetting('enrollment.allow_proration', '1');
$this->setSetting('enrollment.renewal_day', '1');
}
}
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