Commit a8cb5217 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(billing): let the desk date a subscription invoice to the month it settles

A branch going onto the system mid-season has players who have been training —
and paying — since before anyone typed them in. Registration could only ever
raise an invoice dated today, so the first invoice for those players said
September when the money it settled was August, and nothing reconciled.

Both reception wizards now ask, on the payment step, whether the invoice is for
today or for an earlier month, and offer the last twelve. The month is bounded
at both ends: a future month is refused outright, in validation and again in the
service, because an invoice dated ahead of today is one the renewal run raises a
second time when that month arrives and one no "what did we bill this month"
report shows. Inside the current month the invoice still reads as raised today —
backdating it to the 1st only makes it look overdue the day it is created.

The dating is the smaller half. GenerateRenewalInvoices skips a month only when
it finds BOTH metadata->month and metadata->renewal_enrollment_id on a
non-cancelled invoice, and neither wizard wrote either — so every invoice they
raised was invisible to that check and the nightly run billed the same month
again. Two invoices, one player, one month, nothing on either saying so. Both
now stamp both keys, and both advance next_billing_date past the month just
billed — forward only, so a backdated invoice can never drag the anchor back
behind where it already sits and have the run bill the months in between.

The guard resolving the month runs before the auto-invoice branch, so a bad
month is refused identically whether or not an academy has
auto_invoice_on_enrollment switched on.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 27766de3
...@@ -106,6 +106,18 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act ...@@ -106,6 +106,18 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
throw new DomainException('المشترك والمجموعة في فرعين مختلفين'); throw new DomainException('المشترك والمجموعة في فرعين مختلفين');
} }
// Resolved up front, not inside the invoice branch: a bad or future
// month must be refused whether or not this academy auto-invoices,
// otherwise the desk gets a silent no-op on some tenants and an
// error on others for the same input.
$billingMonth = isset($options['billing_month'])
? (string) $options['billing_month']
: null;
if ($billingMonth !== null) {
$this->resolveBillingMonth($billingMonth);
}
$waived = $this->billingIsWaived($participant, $group); $waived = $this->billingIsWaived($participant, $group);
$enrollment = Enrollment::create([ $enrollment = Enrollment::create([
...@@ -152,6 +164,7 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act ...@@ -152,6 +164,7 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
$group, $group,
$actor, $actor,
isset($options['proration_mode']) ? (string) $options['proration_mode'] : null, isset($options['proration_mode']) ? (string) $options['proration_mode'] : null,
$billingMonth,
); );
} }
...@@ -516,7 +529,13 @@ public function processWaitlist(TrainingGroup $group): void ...@@ -516,7 +529,13 @@ public function processWaitlist(TrainingGroup $group): void
* @param string|null $prorationMode what the desk chose the joiner pays * @param string|null $prorationMode what the desk chose the joiner pays
* for this month; null takes the default * for this month; null takes the default
*/ */
private function createEnrollmentInvoice(Enrollment $enrollment, Participant $participant, TrainingGroup $group, User $actor, ?string $prorationMode = null): void /**
* @param string|null $billingMonth 'YYYY-MM' to date the invoice to that
* month instead of today. A desk putting a player onto the system in the
* middle of a month they have already been training — and paying — for
* needs the invoice to say that month, not the day the typing happened.
*/
private function createEnrollmentInvoice(Enrollment $enrollment, Participant $participant, TrainingGroup $group, User $actor, ?string $prorationMode = null, ?string $billingMonth = null): void
{ {
$program = $group->program; $program = $group->program;
if (!$program) { if (!$program) {
...@@ -570,6 +589,14 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -570,6 +589,14 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
} }
} }
// Which month this invoice settles. Today unless the desk said otherwise.
$issueDate = $this->resolveBillingMonth($billingMonth);
$monthKey = $issueDate->format('Y-m');
if ($billingMonth !== null) {
$lineDescription .= ' — ' . $monthKey;
}
$invoice = $this->invoiceService->create([ $invoice = $this->invoiceService->create([
'academy_id' => $enrollment->academy_id ?? $group->academy_id, 'academy_id' => $enrollment->academy_id ?? $group->academy_id,
'branch_id' => $group->branch_id, 'branch_id' => $group->branch_id,
...@@ -580,8 +607,19 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -580,8 +607,19 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
'subtotal_amount' => $priceResult->baseAmount, 'subtotal_amount' => $priceResult->baseAmount,
'discount_amount' => $priceResult->totalDiscount, 'discount_amount' => $priceResult->totalDiscount,
'tax_amount' => 0, 'tax_amount' => 0,
'due_date' => now()->addDays(7)->toDateString(), 'issue_date' => $issueDate->toDateString(),
'due_date' => $issueDate->copy()->addDays(7)->toDateString(),
'contact_name' => $participant->person?->name_ar ?? $participant->person?->name, 'contact_name' => $participant->person?->name_ar ?? $participant->person?->name,
// GenerateRenewalInvoices skips a month only when it finds BOTH of
// these on a non-cancelled invoice. Without them this invoice was
// invisible to that check, so the nightly run billed the same month
// a second time — the enrolment invoice and the renewal invoice
// sitting side by side on one player.
'metadata' => [
'month' => $monthKey,
'renewal_enrollment_id' => (string) $enrollment->id,
'source' => 'enrollment',
],
], [ ], [
[ [
'description' => $lineDescription, 'description' => $lineDescription,
...@@ -592,11 +630,59 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa ...@@ -592,11 +630,59 @@ private function createEnrollmentInvoice(Enrollment $enrollment, Participant $pa
], ],
], $actor); ], $actor);
// Link invoice to enrollment // Link invoice to enrollment, and move the renewal anchor past the month
$enrollment->update([ // just billed. Leaving it on the joining month would have the cron bill
// that month again the moment the dedupe above ever failed to match.
$update = [
'invoice_id' => $invoice->id, 'invoice_id' => $invoice->id,
'payment_status' => 'pending', 'payment_status' => 'pending',
]); ];
$nextCycle = BillingCycle::next(
$issueDate,
$group->program?->billing_cycle,
$group->program?->program_duration_weeks,
)->toDateString();
// Only ever forward. A backdated invoice must not drag the anchor back
// behind where it already sits, or the cron re-bills the months between.
if ($enrollment->next_billing_date
&& $nextCycle > $enrollment->next_billing_date->toDateString()) {
$update['next_billing_date'] = $nextCycle;
}
$enrollment->update($update);
}
/**
* Turn a 'YYYY-MM' from the desk into the date the invoice carries.
*
* Refuses a future month outright: an invoice dated ahead of today is one
* the renewal run will raise again when that month arrives, and one no
* report of "what was billed this month" will show.
*/
private function resolveBillingMonth(?string $billingMonth): \Carbon\Carbon
{
if ($billingMonth === null) {
return \Carbon\Carbon::today();
}
try {
$month = \Carbon\Carbon::createFromFormat('Y-m', $billingMonth)->startOfMonth();
} catch (\Throwable) {
throw new DomainException('شهر الفوترة غير صالح');
}
if ($month->greaterThan(\Carbon\Carbon::today()->startOfMonth())) {
throw new DomainException('لا يمكن إصدار فاتورة بشهر في المستقبل');
}
// The current month bills on today's date, not the 1st — the invoice
// should read as raised now, and dating it backwards inside its own
// month only makes it look overdue on the day it is created.
return $month->isSameMonth(\Carbon\Carbon::today())
? \Carbon\Carbon::today()
: $month;
} }
/** /**
......
...@@ -50,6 +50,21 @@ class EnrollExistingWizard extends Component ...@@ -50,6 +50,21 @@ class EnrollExistingWizard extends Component
public bool $pay_now = false; public bool $pay_now = false;
public string $payment_method = 'cash'; public string $payment_method = 'cash';
/**
* Which month the subscription invoice settles.
*
* 'today' raises it dated now. 'backdate' dates it to `billing_month`, for
* a player who has been training and paying since before the branch was put
* on the system — the invoice has to say the month it covers, or the desk
* cannot reconcile what was collected against what was billed.
*
* Browser-settable, and nothing downstream trusts it: the month is
* revalidated in rules() and again inside EnrollmentService, which refuses
* a future month outright.
*/
public string $invoice_timing = 'today';
public string $billing_month = '';
// Result // Result
public bool $completed = false; public bool $completed = false;
public ?string $created_participant_uuid = null; public ?string $created_participant_uuid = null;
...@@ -68,10 +83,19 @@ public function mount(): void ...@@ -68,10 +83,19 @@ public function mount(): void
} }
public function rules(): array public function rules(): array
{
return $this->rulesForStep($this->currentStep);
}
/**
* Split out from rules() so confirm() can re-check step 3 without depending
* on currentStep, which is itself a public property the browser sets.
*/
private function rulesForStep(int $step): array
{ {
$branchId = $this->getActiveBranchIdOrFail(); $branchId = $this->getActiveBranchIdOrFail();
return match ($this->currentStep) { return match ($step) {
1 => [ 1 => [
// `exists:` is a raw table query — no Eloquent global scope // `exists:` is a raw table query — no Eloquent global scope
// reaches it — and the id it checks comes from a public // reaches it — and the id it checks comes from a public
...@@ -95,6 +119,18 @@ public function rules(): array ...@@ -95,6 +119,18 @@ public function rules(): array
], ],
3 => [ 3 => [
'payment_method' => 'required_if:pay_now,true|in:cash,card,wallet', 'payment_method' => 'required_if:pay_now,true|in:cash,card,wallet',
'invoice_timing' => 'required|in:today,backdate',
// Bounded on both ends: no future month, and nothing older than
// a year, because a desk reaching further back than that is
// reconstructing history the renewal run should be asked to
// rebuild rather than typing it one player at a time.
'billing_month' => [
'required_if:invoice_timing,backdate',
'nullable',
'date_format:Y-m',
'after_or_equal:' . now()->subMonths(12)->format('Y-m'),
'before_or_equal:' . now()->format('Y-m'),
],
], ],
default => [], default => [],
}; };
...@@ -111,9 +147,34 @@ public function messages(): array ...@@ -111,9 +147,34 @@ public function messages(): array
'selected_program_id.exists' => 'البرنامج المختار غير موجود', 'selected_program_id.exists' => 'البرنامج المختار غير موجود',
'payment_method.required_if' => 'يرجى اختيار طريقة الدفع', 'payment_method.required_if' => 'يرجى اختيار طريقة الدفع',
'payment_method.in' => 'طريقة الدفع غير صالحة', 'payment_method.in' => 'طريقة الدفع غير صالحة',
'invoice_timing.required' => 'يرجى اختيار تاريخ الفاتورة',
'invoice_timing.in' => 'تاريخ الفاتورة غير صالح',
'billing_month.required_if' => 'يرجى اختيار الشهر',
'billing_month.date_format' => 'صيغة الشهر غير صحيحة',
'billing_month.after_or_equal' => 'لا يمكن الرجوع لأكثر من 12 شهراً',
'billing_month.before_or_equal' => 'لا يمكن إصدار فاتورة بشهر في المستقبل',
]; ];
} }
/**
* The months the desk may bill for — this month back twelve.
*
* @return array<string, string>
*/
#[Computed]
public function billableMonths(): array
{
$months = [];
$cursor = now()->startOfMonth();
for ($i = 0; $i < 13; $i++) {
$months[$cursor->format('Y-m')] = $cursor->translatedFormat('F Y');
$cursor->subMonthNoOverflow();
}
return $months;
}
public function selectParticipant(int $id, string $name): void public function selectParticipant(int $id, string $name): void
{ {
// Both arguments come from the browser. Resolve the row instead of // Both arguments come from the browser. Resolve the row instead of
...@@ -157,6 +218,11 @@ public function updatedSelectedActivityId(): void ...@@ -157,6 +218,11 @@ public function updatedSelectedActivityId(): void
public function confirm(): void public function confirm(): void
{ {
try { try {
// Re-validate rather than trust what step 3 left on the component:
// both properties are public, so the browser can post any month it
// likes straight into confirm() without ever passing nextStep().
$this->validate($this->rulesForStep(3));
$enrollmentService = app(\App\Domain\Training\Services\EnrollmentService::class); $enrollmentService = app(\App\Domain\Training\Services\EnrollmentService::class);
$participant = Participant::findOrFail($this->selected_participant_id); $participant = Participant::findOrFail($this->selected_participant_id);
...@@ -177,6 +243,12 @@ public function confirm(): void ...@@ -177,6 +243,12 @@ public function confirm(): void
// Without this the service would fall back to its default // Without this the service would fall back to its default
// and quietly bill a different figure from the one quoted. // and quietly bill a different figure from the one quoted.
'proration_mode' => $this->proration_mode, 'proration_mode' => $this->proration_mode,
// null means today. The service stamps metadata.month from
// whichever month this resolves to, which is what keeps the
// nightly renewal run from billing the same month twice.
'billing_month' => $this->invoice_timing === 'backdate'
? $this->billing_month
: null,
] ]
); );
......
...@@ -24,6 +24,8 @@ ...@@ -24,6 +24,8 @@
use App\Domain\Shared\Enums\ProrationMode; use App\Domain\Shared\Enums\ProrationMode;
use App\Domain\Training\Models\TrainingGroup; use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Support\BillingCycle;
use Carbon\Carbon;
use App\Domain\Shared\Services\PlatformFeeService; use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Shared\Services\ProrationService; use App\Domain\Shared\Services\ProrationService;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
...@@ -128,6 +130,20 @@ class NewRegistrationWizard extends Component ...@@ -128,6 +130,20 @@ class NewRegistrationWizard extends Component
*/ */
public string $proration_mode = ProrationMode::RemainingSessions->value; public string $proration_mode = ProrationMode::RemainingSessions->value;
/**
* Which month the subscription invoice settles.
*
* 'today' raises it dated now — what registration has always done. 'backdate'
* dates it to `billing_month`, for a player being put on the system in the
* middle of a month they have already been training for. The invoice has to
* carry the month it covers, or nothing reconciles what was collected
* against what was billed.
*
* Browser-settable, and re-validated in confirm() before it is used.
*/
public string $invoice_timing = 'today';
public string $billing_month = '';
// 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';
...@@ -730,6 +746,12 @@ public function messages(): array ...@@ -730,6 +746,12 @@ public function messages(): array
'players.*.program_id.exists' => 'البرنامج المختار غير موجود', 'players.*.program_id.exists' => 'البرنامج المختار غير موجود',
'payment_method.required_if' => 'يرجى اختيار طريقة الدفع', 'payment_method.required_if' => 'يرجى اختيار طريقة الدفع',
'payment_method.in' => 'طريقة الدفع غير صالحة', 'payment_method.in' => 'طريقة الدفع غير صالحة',
'invoice_timing.required' => 'يرجى اختيار تاريخ الفاتورة',
'invoice_timing.in' => 'تاريخ الفاتورة غير صالح',
'billing_month.required_if' => 'يرجى اختيار الشهر',
'billing_month.date_format' => 'صيغة الشهر غير صحيحة',
'billing_month.after_or_equal' => 'لا يمكن الرجوع لأكثر من 12 شهراً',
'billing_month.before_or_equal' => 'لا يمكن إصدار فاتورة بشهر في المستقبل',
'payment_transaction_ref.required_if' => 'رقم المرجع / رقم العملية مطلوب لهذه الطريقة', 'payment_transaction_ref.required_if' => 'رقم المرجع / رقم العملية مطلوب لهذه الطريقة',
'payment_cheque_number.required_if' => 'رقم الشيك مطلوب', 'payment_cheque_number.required_if' => 'رقم الشيك مطلوب',
'partial_amount_input.required_if' => 'المبلغ المدفوع مطلوب', 'partial_amount_input.required_if' => 'المبلغ المدفوع مطلوب',
...@@ -744,10 +766,23 @@ public function messages(): array ...@@ -744,10 +766,23 @@ public function messages(): array
private function paymentStepRules(): array private function paymentStepRules(): array
{ {
if (!$this->pay_now) return []; // The invoice date is asked whether or not money changes hands now —
// an unpaid invoice still has to say which month it covers.
$rules = [
'invoice_timing' => 'required|in:today,backdate',
'billing_month' => [
'required_if:invoice_timing,backdate',
'nullable',
'date_format:Y-m',
'after_or_equal:' . now()->subMonths(12)->format('Y-m'),
'before_or_equal:' . now()->format('Y-m'),
],
];
if (!$this->pay_now) return $rules;
$methodsRequiringRef = ['card', 'bank_transfer', 'online']; $methodsRequiringRef = ['card', 'bank_transfer', 'online'];
$rules = [ $rules += [
'payment_method' => 'required|in:cash,card,bank_transfer,wallet,online,cheque,other', 'payment_method' => 'required|in:cash,card,bank_transfer,wallet,online,cheque,other',
]; ];
...@@ -1763,9 +1798,27 @@ private function registerPlayer( ...@@ -1763,9 +1798,27 @@ private function registerPlayer(
$customizationAnswers, $customizationAnswers,
$invoiceService, $invoiceService,
$actor, $actor,
$enrollment,
); );
$enrollment->update(['invoice_id' => $invoice->id]); $update = ['invoice_id' => $invoice->id];
// A backdated invoice settles an earlier month, so the renewal
// anchor has to clear that month too — otherwise the nightly run
// bills everything from the backdated month forward. Only ever
// forward: never drag the anchor behind where it already sits.
$nextCycle = BillingCycle::next(
$this->resolvedIssueDate(),
$program->billing_cycle,
$program->program_duration_weeks,
)->toDateString();
if ($enrollment->next_billing_date
&& $nextCycle > $enrollment->next_billing_date->toDateString()) {
$update['next_billing_date'] = $nextCycle;
}
$enrollment->update($update);
$this->createInstallmentPlansFor($index, $invoice); $this->createInstallmentPlansFor($index, $invoice);
} }
...@@ -1778,6 +1831,53 @@ private function registerPlayer( ...@@ -1778,6 +1831,53 @@ private function registerPlayer(
]; ];
} }
/**
* The date every invoice in this registration carries.
*
* A future month is refused rather than clamped: an invoice dated ahead of
* today is one the renewal run raises again when that month arrives, and one
* that no "what did we bill this month" report will show.
*/
private function resolvedIssueDate(): Carbon
{
if ($this->invoice_timing !== 'backdate' || $this->billing_month === '') {
return Carbon::today();
}
try {
$month = Carbon::createFromFormat('Y-m', $this->billing_month)->startOfMonth();
} catch (\Throwable) {
throw new DomainException('شهر الفوترة غير صالح');
}
if ($month->greaterThan(Carbon::today()->startOfMonth())) {
throw new DomainException('لا يمكن إصدار فاتورة بشهر في المستقبل');
}
// Inside the current month the invoice reads as raised today; dating it
// back to the 1st only makes it look overdue on the day it is created.
return $month->isSameMonth(Carbon::today()) ? Carbon::today() : $month;
}
/**
* The months the desk may bill for — this month back twelve.
*
* @return array<string, string>
*/
#[Computed]
public function billableMonths(): array
{
$months = [];
$cursor = Carbon::now()->startOfMonth();
for ($i = 0; $i < 13; $i++) {
$months[$cursor->format('Y-m')] = $cursor->translatedFormat('F Y');
$cursor->subMonthNoOverflow();
}
return $months;
}
/** /**
* The invoice for one child: their subscription line, their kit lines, and * The invoice for one child: their subscription line, their kit lines, and
* their share of the platform fee. * their share of the platform fee.
...@@ -1795,8 +1895,10 @@ private function createPlayerInvoice( ...@@ -1795,8 +1895,10 @@ private function createPlayerInvoice(
?int $overrideShare, ?int $overrideShare,
array $customizationAnswers, array $customizationAnswers,
InvoiceService $invoiceService, InvoiceService $invoiceService,
User $actor User $actor,
?\App\Domain\Training\Models\Enrollment $enrollment = null
): Invoice { ): Invoice {
$issueDate = $this->resolvedIssueDate();
$invoiceItems = []; $invoiceItems = [];
// The override moves the whole order, and the kit lines are sold at // The override moves the whole order, and the kit lines are sold at
...@@ -1879,9 +1981,18 @@ private function createPlayerInvoice( ...@@ -1879,9 +1981,18 @@ private function createPlayerInvoice(
'service_fee_amount' => $serviceFee, 'service_fee_amount' => $serviceFee,
'total_amount' => $total, 'total_amount' => $total,
'currency' => 'EGP', 'currency' => 'EGP',
'issue_date' => now()->toDateString(), 'issue_date' => $issueDate->toDateString(),
'due_date' => now()->addDays(7)->toDateString(), 'due_date' => $issueDate->copy()->addDays(7)->toDateString(),
'notes' => 'اشتراك: ' . $program->name_ar, 'notes' => 'اشتراك: ' . $program->name_ar,
// GenerateRenewalInvoices skips a month only when it finds both of
// these on a non-cancelled invoice, so without them the nightly run
// bills the same month again — a registration invoice and a renewal
// invoice side by side on a player who joined days earlier.
'metadata' => array_filter([
'month' => $issueDate->format('Y-m'),
'renewal_enrollment_id' => $enrollment ? (string) $enrollment->id : null,
'source' => 'registration',
]),
], $invoiceItems, $actor); ], $invoiceItems, $actor);
$invoice->update(['status' => 'sent']); $invoice->update(['status' => 'sent']);
......
...@@ -345,6 +345,47 @@ class="w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500"> ...@@ -345,6 +345,47 @@ class="w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500">
</div> </div>
</div> </div>
{{-- Which month the invoice settles --}}
@if($program && $this->selectedProgramFee > 0)
<div class="border-t border-gray-200 pt-6">
<h4 class="text-base font-medium text-gray-700 mb-3">{{ __('تاريخ الفاتورة') }}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="relative flex items-start gap-3 p-4 border rounded-lg cursor-pointer hover:bg-gray-50 has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
<input type="radio" wire:model.live="invoice_timing" value="today" class="mt-1">
<span>
<span class="block text-sm font-medium text-gray-800">{{ __('النهاردة') }}</span>
<span class="block text-xs text-gray-500">{{ __('فاتورة بتاريخ اليوم') }}</span>
</span>
</label>
<label class="relative flex items-start gap-3 p-4 border rounded-lg cursor-pointer hover:bg-gray-50 has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
<input type="radio" wire:model.live="invoice_timing" value="backdate" class="mt-1">
<span>
<span class="block text-sm font-medium text-gray-800">{{ __('بأثر رجعي') }}</span>
<span class="block text-xs text-gray-500">{{ __('فاتورة عن شهر سابق') }}</span>
</span>
</label>
</div>
@if($invoice_timing === 'backdate')
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الشهر') }}</label>
<select wire:model.live="billing_month"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('— اختر الشهر —') }}</option>
@foreach($this->billableMonths as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
@error('billing_month') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
<p class="mt-1 text-xs text-gray-500">
{{ __('الفاتورة هتتسجل على الشهر ده، والتجديد الشهري هيكمل من الشهر اللي بعده.') }}
</p>
</div>
@endif
@error('invoice_timing') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
{{-- Payment Option --}} {{-- Payment Option --}}
@if($program && $this->selectedProgramFee > 0) @if($program && $this->selectedProgramFee > 0)
@php $proration = $this->proratedProgramFee; @endphp @php $proration = $this->proratedProgramFee; @endphp
......
...@@ -1266,6 +1266,47 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-6 py ...@@ -1266,6 +1266,47 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-6 py
<div class="space-y-5"> <div class="space-y-5">
{{-- Which month the invoice settles --}}
@if($this->effectiveTotal > 0)
<div class="p-4 bg-white border border-gray-200 rounded-xl">
<h4 class="text-base font-medium text-gray-700 mb-3">{{ __('تاريخ الفاتورة') }}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-4 border rounded-lg cursor-pointer hover:bg-gray-50 has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
<input type="radio" wire:model.live="invoice_timing" value="today" class="mt-1">
<span>
<span class="block text-sm font-medium text-gray-800">{{ __('النهاردة') }}</span>
<span class="block text-xs text-gray-500">{{ __('فاتورة بتاريخ اليوم') }}</span>
</span>
</label>
<label class="flex items-start gap-3 p-4 border rounded-lg cursor-pointer hover:bg-gray-50 has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
<input type="radio" wire:model.live="invoice_timing" value="backdate" class="mt-1">
<span>
<span class="block text-sm font-medium text-gray-800">{{ __('بأثر رجعي') }}</span>
<span class="block text-xs text-gray-500">{{ __('فاتورة عن شهر سابق') }}</span>
</span>
</label>
</div>
@if($invoice_timing === 'backdate')
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الشهر') }}</label>
<select wire:model.live="billing_month"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('— اختر الشهر —') }}</option>
@foreach($this->billableMonths as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
@error('billing_month') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
<p class="mt-1 text-xs text-gray-500">
{{ __('الفاتورة هتتسجل على الشهر ده، والتجديد الشهري هيكمل من الشهر اللي بعده.') }}
</p>
</div>
@endif
@error('invoice_timing') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
{{-- Pay Now Toggle --}} {{-- Pay Now Toggle --}}
@if($this->effectiveTotal > 0) @if($this->effectiveTotal > 0)
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Shared\Models\Academy;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\EnrollmentService;
use App\Models\User;
use App\Domain\Participant\Models\Participant;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Dating an enrolment invoice, and not billing the same month twice.
*
* A branch put onto the system mid-season has players who have been training —
* and paying — since before anyone typed them in. Their first invoice has to
* carry the month it covers, not the day the typing happened, or nothing
* reconciles what was collected against what was billed.
*
* The second half matters more than the first. GenerateRenewalInvoices skips a
* month only when it finds BOTH `metadata->month` and
* `metadata->renewal_enrollment_id` on a non-cancelled invoice. The enrolment
* invoice carried neither, so it was invisible to that check and the nightly
* run billed the same month again — two invoices on one player for one month,
* with nothing in either to say they were duplicates.
*
* DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_DATABASE=oc_sport_test \
* ./vendor/bin/phpunit --filter BackdatedEnrolmentInvoiceTest
*/
class BackdatedEnrolmentInvoiceTest extends TestCase
{
private ?Academy $academy = null;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant.');
}
if (! $this->academy = Academy::first()) {
$this->markTestSkipped('No academy in the restored tenant.');
}
app()->instance('current_academy', $this->academy);
DB::beginTransaction();
// The invoice path is the point of these tests, so turn it on inside the
// transaction rather than skipping wherever a tenant happens to have it
// off. Rolled back with everything else.
DB::table('system_settings')->updateOrInsert(
['academy_id' => $this->academy->id, 'key' => 'auto_invoice_on_enrollment'],
['value' => '1', 'group' => 'billing', 'type' => 'boolean', 'updated_at' => now(), 'created_at' => now()],
);
app()->forgetInstance(\App\Domain\Shared\Services\SettingsService::class);
}
protected function tearDown(): void
{
DB::rollBack();
Carbon::setTestNow();
parent::tearDown();
}
public function test_a_backdated_invoice_carries_the_month_it_settles(): void
{
[$participant, $group, $actor] = $this->fixture();
$lastMonth = Carbon::today()->subMonthNoOverflow()->format('Y-m');
$enrollment = app(EnrollmentService::class)->enroll($participant, $group, $actor, [
'billing_month' => $lastMonth,
]);
$invoice = $this->invoiceFor($enrollment);
if (! $invoice) {
$this->markTestSkipped('auto_invoice_on_enrollment is off, or the programme has no price.');
}
$this->assertSame($lastMonth, $invoice->issue_date->format('Y-m'), 'the invoice must be dated in the month it settles');
$this->assertSame($lastMonth, $invoice->metadata['month'] ?? null);
$this->assertSame((string) $enrollment->id, $invoice->metadata['renewal_enrollment_id'] ?? null);
}
public function test_the_renewal_run_will_not_bill_that_month_again(): void
{
[$participant, $group, $actor] = $this->fixture();
$lastMonth = Carbon::today()->subMonthNoOverflow()->format('Y-m');
$enrollment = app(EnrollmentService::class)->enroll($participant, $group, $actor, [
'billing_month' => $lastMonth,
]);
if (! $this->invoiceFor($enrollment)) {
$this->markTestSkipped('auto_invoice_on_enrollment is off, or the programme has no price.');
}
// Exactly the predicate GenerateRenewalInvoices::alreadyInvoiced() uses.
$seen = Invoice::query()
->where('billable_type', $participant->getMorphClass())
->where('billable_id', $participant->id)
->where('status', '!=', 'cancelled')
->where('metadata->month', $lastMonth)
->where('metadata->renewal_enrollment_id', (string) $enrollment->id)
->exists();
$this->assertTrue($seen, 'the renewal run must recognise this month as already billed');
// And the anchor has to have cleared the billed month, or the run bills
// every cycle from it forward.
$this->assertNotNull($enrollment->fresh()->next_billing_date);
$this->assertGreaterThan(
$lastMonth,
$enrollment->fresh()->next_billing_date->format('Y-m'),
);
}
public function test_a_future_month_is_refused(): void
{
[$participant, $group, $actor] = $this->fixture();
$this->expectExceptionMessage('لا يمكن إصدار فاتورة بشهر في المستقبل');
app(EnrollmentService::class)->enroll($participant, $group, $actor, [
'billing_month' => Carbon::today()->addMonthNoOverflow()->format('Y-m'),
]);
}
/** @return array{0: Participant, 1: TrainingGroup, 2: User} */
private function fixture(): array
{
$group = TrainingGroup::withoutGlobalScopes()
->whereIn('status', ['active', 'forming'])
->whereNotNull('branch_id')
->whereHas('program')
->first();
$actor = User::first();
$participant = $group
? Participant::withoutGlobalScopes()
->where('branch_id', $group->branch_id)
->whereDoesntHave('enrollments', fn ($q) => $q
->where('training_program_id', $group->training_program_id)
->whereIn('status', ['pending', 'active']))
->first()
: null;
if (! $group || ! $actor || ! $participant) {
$this->markTestSkipped('Tenant has no group/participant pair free to enrol.');
}
return [$participant, $group, $actor];
}
private function invoiceFor(Enrollment $enrollment): ?Invoice
{
return $enrollment->fresh()->invoice_id
? Invoice::withoutGlobalScopes()->find($enrollment->fresh()->invoice_id)
: null;
}
}
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