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 @@
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\EnrollmentStatus;
......@@ -129,6 +130,15 @@ private function process(
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.
// Adopt them from the CURRENT cycle forward — never retroactively,
// because we have no evidence about months nobody ever billed them for.
......
......@@ -7,6 +7,46 @@
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.
*/
......@@ -20,8 +60,11 @@ public function get(int $branchId, string $key, mixed $default = null): mixed
return $setting->value;
}
// Fall back to academy-level setting
$academy = app('current_academy');
// Fall back to academy-level setting. app()->has() first: outside a
// 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) {
$systemSetting = \DB::table('system_settings')
->where('academy_id', $academy->id)
......@@ -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
{
$academy = app('current_academy');
$academy = app()->has('current_academy') ? app('current_academy') : null;
BranchSetting::updateOrCreate(
['branch_id' => $branchId, 'key' => $key],
......@@ -62,7 +105,7 @@ public function allForBranch(int $branchId): array
->pluck('value', 'key')
->toArray();
$academy = app('current_academy');
$academy = app()->has('current_academy') ? app('current_academy') : null;
$academySettings = [];
if ($academy) {
$academySettings = \DB::table('system_settings')
......
......@@ -2,14 +2,36 @@
namespace App\Domain\Shared\DTOs;
use App\Domain\Shared\Enums\ProrationMode;
readonly class ProrationResult
{
public function __construct(
public bool $applied,
public int $originalAmount,
public int $proratedAmount,
public int $remainingDays,
public ProrationMode $mode,
public int $renewalDay,
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 @@
namespace App\Domain\Shared\Services;
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;
/**
* 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
{
public function __construct(
private readonly SettingsService $settings,
private readonly SessionCountService $sessions,
) {}
public function isEnabled(): bool
......@@ -22,42 +36,109 @@ public function renewalDay(): int
}
/**
* Calculate prorated fee for a mid-month enrollment.
* If today is on or before the renewal day, no proration applies (full price).
* What the desk gets before it chooses. Full month when proration is off —
* 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();
$currentDay = (int) $today->day;
// No proration if enrolling on or before the renewal day
if ($currentDay <= $renewalDay) {
$mode = is_string($mode) ? ProrationMode::tryFrom($mode) : $mode;
$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(
applied: false,
originalAmount: $baseAmount,
proratedAmount: $baseAmount,
remainingDays: 30,
mode: ProrationMode::RemainingSessions,
renewalDay: $renewalDay,
description: '',
totalSessions: $total,
remainingSessions: $remaining,
);
}
// Days remaining until next renewal: renewal_day + 30 - today
$remainingDays = $renewalDay + 30 - $currentDay;
$remainingDays = max(1, $remainingDays);
$proratedAmount = (int) ceil($baseAmount * $remainingDays / 30);
$description = "متناسب: {$remainingDays} من 30 يوم";
$amount = (int) ceil($baseAmount * $remaining / $total);
return new ProrationResult(
applied: true,
originalAmount: $baseAmount,
proratedAmount: $proratedAmount,
remainingDays: $remainingDays,
proratedAmount: $amount,
mode: ProrationMode::RemainingSessions,
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 @@
namespace App\Domain\Training\Services;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
......@@ -31,8 +32,23 @@ public function __construct(
private readonly InvoiceService $invoiceService,
private readonly SettingsService $settings,
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
{
return DB::transaction(function () use ($participant, $group, $actor, $options) {
......@@ -90,6 +106,8 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
throw new DomainException('المشترك والمجموعة في فرعين مختلفين');
}
$waived = $this->billingIsWaived($participant, $group);
$enrollment = Enrollment::create([
// The enrolment belongs to the group's branch, not to whichever
// branch the person doing the enrolling was looking at.
......@@ -100,11 +118,11 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
'enrollment_date' => now()->toDateString(),
'start_date' => $options['start_date'] ?? $group->start_date ?? now()->toDateString(),
'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',
'enrolled_by' => $actor->id,
'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,
]);
......@@ -116,9 +134,15 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
$participant->update(['status' => 'active']);
}
// Auto-create invoice if program has a price (skip if invoice already provided, explicitly skipped, or free player)
if (empty($options['invoice_id']) && empty($options['skip_auto_invoice']) && !$participant->is_free && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) {
$this->createEnrollmentInvoice($enrollment, $participant, $group, $actor);
// 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']) && !$waived && (bool) $this->settings->get('auto_invoice_on_enrollment', false)) {
$this->createEnrollmentInvoice(
$enrollment,
$participant,
$group,
$actor,
isset($options['proration_mode']) ? (string) $options['proration_mode'] : null,
);
}
EnrollmentCreated::dispatch($enrollment, $actor);
......@@ -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.
* 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;
if (!$program) {
......@@ -442,11 +470,29 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
$lineDescription = "اشتراك: {$program->name_ar}";
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;
if ($proration->applied) {
$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([
......
<?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
'coupon_max_uses_default' => ['type' => 'number', 'label' => 'الحد الافتراضي لاستخدام الكوبون', 'step' => '1', 'min' => 1],
],
'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 = أول الشهر)'],
'allow_waitlist' => ['type' => 'boolean', 'label' => 'تفعيل قائمة الانتظار'],
'auto_promote_waitlist' => ['type' => 'boolean', 'label' => 'ترقية تلقائية من قائمة الانتظار'],
......
......@@ -4,6 +4,7 @@
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Services\BranchService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Validation\Rule;
......@@ -31,6 +32,16 @@ class BranchForm extends Component
public bool $is_main = false;
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
public array $operating_hours = [];
......@@ -55,6 +66,8 @@ public function mount(?Branch $branch = null): void
$this->is_main = $branch->is_main;
$this->is_active = $branch->is_active;
$this->operating_hours = $branch->operating_hours ?? [];
$this->billing_handled_externally = app(BranchSettingsService::class)
->billingHandledExternally($branch->id);
} else {
$this->authorize('branches.create');
}
......@@ -89,6 +102,7 @@ public function rules(): array
->whereNull('deleted_at')],
'is_main' => 'boolean',
'is_active' => 'boolean',
'billing_handled_externally' => 'boolean',
];
}
......@@ -138,9 +152,11 @@ public function save(BranchService $branchService): void
try {
if ($this->editing) {
$branchService->update($this->branch, $data, auth()->user());
$this->saveBillingMode($this->branch->id);
session()->flash('success', 'تم تحديث الفرع بنجاح');
} else {
$branchService->create($data, auth()->user());
$branch = $branchService->create($data, auth()->user());
$this->saveBillingMode($branch->id);
session()->flash('success', 'تم إنشاء الفرع بنجاح');
}
......@@ -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()
{
return view('livewire.branches.branch-form', [
......
......@@ -6,6 +6,7 @@
use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\DTOs\ProrationResult;
use App\Domain\Shared\Enums\ProrationMode;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Traits\UsesBranchScope;
......@@ -39,6 +40,12 @@ class EnrollExistingWizard extends Component
// Free player toggle
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
public bool $pay_now = false;
public string $payment_method = 'cash';
......@@ -166,6 +173,10 @@ public function confirm(): void
[
'pay_now' => $this->is_free ? false : $this->pay_now,
'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
}
}
/**
* 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]
public function selectedProgramFee(): int
{
......@@ -224,16 +248,15 @@ public function proratedProgramFee(): ProrationResult
$baseFee = $this->selectedProgramFee;
$service = app(ProrationService::class);
if (!$service->isEnabled() || $baseFee === 0) {
return new ProrationResult(
applied: false,
originalAmount: $baseFee,
proratedAmount: $baseFee,
remainingDays: 30,
renewalDay: $service->renewalDay(),
description: '',
);
return ProrationResult::fullMonth($baseFee, $service->renewalDay());
}
return $service->calculate($baseFee);
return $service->calculate(
$baseFee,
null,
$this->selectedProgram?->defaultGroup(),
$this->proration_mode,
);
}
public function render()
......
......@@ -20,6 +20,8 @@
use App\Domain\Participant\Services\ParticipantService;
use App\Domain\Pricing\Models\BasePrice;
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\Services\PlatformFeeService;
use App\Domain\Shared\Services\ProrationService;
......@@ -85,6 +87,15 @@ class NewRegistrationWizard extends Component
// Step 5: Enrollment options
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
public bool $pay_now = false;
public string $payment_method = 'cash';
......@@ -861,17 +872,22 @@ public function proratedProgramFee(): ProrationResult
$baseFee = $this->selectedProgramFee;
$service = app(ProrationService::class);
if (!$service->isEnabled() || $baseFee === 0) {
return new ProrationResult(
applied: false,
originalAmount: $baseFee,
proratedAmount: $baseFee,
remainingDays: 30,
renewalDay: $service->renewalDay(),
description: '',
);
return ProrationResult::fullMonth($baseFee, $service->renewalDay());
}
$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]
......@@ -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;
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 = [];
// When override is active, compute how much the program fee is adjusted.
......
......@@ -47,6 +47,19 @@
<span class="text-sm text-gray-700">نشط</span>
</label>
</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>
<!-- 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
<span class="font-bold text-green-700 ms-1" dir="ltr">{{ number_format($proration->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
@endif
</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
</div>
......
......@@ -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>
</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)
<div class="mt-3 space-y-1 text-sm border-t border-gray-200 pt-3">
@if($this->proratedProgramFee->applied)
......
This diff is collapsed.
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