Commit 50f3a2bd authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(registration): register a whole family in one visit, priced as a family

The reception wizard took exactly one child. That was not merely inconvenient:
the sibling discount is priced off how many children a family has, and a child
registered alone always looked like a family of one. The first child could
never qualify, and by the time the second was entered the first one's invoice
was frozen — invoices are immutable — so it could not be put right afterwards
either. The only way to get the discount was to know to register the younger
child first, which is not a system, it is folklore.

Step 1 is a roster now. The `participant_*` properties stay as the draft form
for the child being typed, so every existing rule, the NID decoder and the
membership-id sibling warning are untouched; `players[]` is the committed list
and the only thing confirm() reads. Each child picks their own programme on
step 4, because siblings are rarely the same age.

resolveSiblingPosition() counts the children already on the books AND the ones
being registered alongside — that one change is what makes the discount fire
for a family arriving together.

The money is written the way the rest of the system writes it: one participant,
one enrolment and one invoice per child, which is also how the renewal cron
finds them next month. What is new is that the desk takes a single sum and it
is allocated across those invoices in roster order, so an underpayment leaves a
visible balance on the last child rather than a shortfall smeared over all of
them. The platform fee and a super-admin override are split by value with the
rounding remainder on the last child, so the invoices always add back up to
exactly what was quoted. Cart lines carry a player index — a kit is issued to
a person — and removing a child takes their lines with them and renumbers the
rest, or the items would silently reattach to whoever slid into that slot.

SiblingRegistrationTest pins the parts that can lose money: two children get
two invoices summing to the quoted total, one payment lands on them in full,
and a part payment settles the first child and leaves one visible balance.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent b3c232ab
......@@ -50,6 +50,32 @@ class NewRegistrationWizard extends Component
public int $currentStep = 1;
public int $totalSteps = 7;
/**
* The children being registered in this visit.
*
* The desk registers a family in one sitting, not a child at a time. That
* is not only convenience: the sibling discount is priced off how many
* children the family has, and registering them one by one meant the
* engine saw a family of one every time — the first child could never get
* the discount, and the invoice froze before the second child existed.
*
* Each entry is the step-1 form plus that child's own programme choice.
* The `participant_*` properties below are the DRAFT for the child being
* typed right now; this array is the committed roster and the only thing
* confirm() reads.
*
* @var array<int, array<string, mixed>>
*/
public array $players = [];
/**
* Which roster entry the draft form is currently editing, or null when the
* draft is a new child. Locked: it indexes an array this component owns,
* and a value off the wire must not reach it.
*/
#[Locked]
public ?int $editingPlayerIndex = null;
// Step 1: Participant (the actual player/child) — FIRST now
public string $participant_name_ar = '';
public string $participant_name = '';
......@@ -81,9 +107,6 @@ class NewRegistrationWizard extends Component
public string $parentPassword = '';
public bool $sendParentCredentials = true;
// Step 4: Program selection
public ?int $selected_activity_id = null;
public ?int $selected_program_id = null;
// Step 5: Enrollment options
public ?string $enrollment_start_date = null;
......@@ -138,6 +161,15 @@ class NewRegistrationWizard extends Component
public ?string $participant_number = null;
public ?string $participant_uuid = null;
public ?string $enrollment_summary = null;
/**
* What was actually created, one row per child, for the confirmation
* screen. The scalars below stay pointed at the first child so the
* existing receipt links keep working.
*
* @var array<int, array<string, mixed>>
*/
public array $registeredPlayers = [];
public ?int $invoice_amount = null;
public ?string $invoice_number = null;
// Locked: this id is what invoiceForPrint() renders, line items included.
......@@ -162,6 +194,16 @@ class NewRegistrationWizard extends Component
#[Locked]
public array $hotbuyCart = [];
/**
* Which child on the roster the kit/product cart is currently adding to.
*
* Deliberately NOT locked — the desk picks this on step 5, so it has to be
* settable from the browser. It is only ever an index into $players, and
* addHotbuyItem() refuses an index that names no child, so the worst a
* forged value does is nothing.
*/
public int $cartPlayerIndex = 0;
#[Locked]
public array $hotbuyInstallmentsToPay = []; // key => number of installments to pay now
......@@ -312,30 +354,219 @@ public function updatedMembershipId(): void
// --- Step navigation ---
public function nextStep(): void
/**
* The draft form as a roster entry.
*
* @return array<string, mixed>
*/
private function draftPlayer(): array
{
$rules = $this->rulesForStep($this->currentStep);
return [
'name_ar' => $this->participant_name_ar,
'name' => $this->participant_name,
'date_of_birth' => $this->participant_date_of_birth,
'gender' => $this->participant_gender,
'phone' => $this->participant_phone,
'national_id' => $this->participant_national_id,
'medical_notes' => $this->participant_medical_notes,
'membership_type' => $this->membership_type,
'membership_id' => $this->membership_id,
'governorate' => $this->participant_governorate,
'is_foreign' => $this->participant_is_foreign,
'nid_decoded' => $this->participant_nid_decoded,
'is_free' => $this->is_free,
// Chosen on step 4, one programme per child.
'activity_id' => null,
'program_id' => null,
];
}
if (!empty($rules)) {
$this->validate($rules, $this->messages());
/** Put a roster entry back into the draft form for editing. */
private function loadDraft(array $player): void
{
$this->participant_name_ar = (string) ($player['name_ar'] ?? '');
$this->participant_name = (string) ($player['name'] ?? '');
$this->participant_date_of_birth = $player['date_of_birth'] ?? null;
$this->participant_gender = (string) ($player['gender'] ?? 'male');
$this->participant_phone = (string) ($player['phone'] ?? '');
$this->participant_national_id = (string) ($player['national_id'] ?? '');
$this->participant_medical_notes = (string) ($player['medical_notes'] ?? '');
$this->membership_type = (string) ($player['membership_type'] ?? 'non_member');
$this->membership_id = (string) ($player['membership_id'] ?? '');
$this->participant_governorate = (string) ($player['governorate'] ?? '');
$this->participant_is_foreign = (bool) ($player['is_foreign'] ?? false);
$this->participant_nid_decoded = (bool) ($player['nid_decoded'] ?? false);
$this->is_free = (bool) ($player['is_free'] ?? false);
}
/** Clear the draft form so the next child starts from an empty sheet. */
private function resetDraft(): void
{
$this->participant_name_ar = '';
$this->participant_name = '';
$this->participant_date_of_birth = null;
$this->participant_gender = 'male';
$this->participant_phone = '';
$this->participant_national_id = '';
$this->participant_medical_notes = '';
$this->membership_type = 'non_member';
$this->membership_id = '';
$this->participant_governorate = '';
$this->participant_is_foreign = false;
$this->participant_nid_decoded = false;
$this->is_free = false;
$this->membershipIdSiblingConfirmed = false;
$this->membershipIdSiblings = [];
$this->editingPlayerIndex = null;
}
/** True when the desk has started typing a child but not yet added them. */
private function draftIsStarted(): bool
{
return $this->participant_name_ar !== ''
|| $this->participant_national_id !== ''
|| $this->participant_date_of_birth !== null;
}
/**
* Move the draft onto the roster.
*
* Returns false when validation failed or the membership-id sibling
* warning is still unanswered, so callers know not to advance.
*/
private function commitDraft(): bool
{
$this->validate($this->rulesForStep(1), $this->messages());
if (! $this->membershipSiblingCheckPassed()) {
return false;
}
$entry = $this->draftPlayer();
if ($this->editingPlayerIndex !== null && isset($this->players[$this->editingPlayerIndex])) {
// Keep the programme this child was already given on step 4 —
// editing a name must not silently un-choose their programme.
$entry['activity_id'] = $this->players[$this->editingPlayerIndex]['activity_id'] ?? null;
$entry['program_id'] = $this->players[$this->editingPlayerIndex]['program_id'] ?? null;
$this->players[$this->editingPlayerIndex] = $entry;
} else {
$this->players[] = $entry;
}
// Membership ID sibling check on step 1
if ($this->currentStep === 1 && $this->membership_type === 'member' && $this->membership_id && !$this->membershipIdSiblingConfirmed) {
// Participant carries BranchScope; the branch predicate this query
// used to repeat is applied for it.
$existingSiblings = Participant::where('membership_id', $this->membership_id)
->whereNull('deleted_at')
->with('person')
->get();
if ($existingSiblings->isNotEmpty()) {
$this->membershipIdSiblings = $existingSiblings->map(fn ($p) => [
'id' => $p->id,
'name' => $p->person?->name_ar ?? '-',
])->toArray();
$this->resetDraft();
return true;
}
/**
* The membership-id warning, lifted out of nextStep() so adding a second
* child runs the same check as the first.
*
* Returns false when the desk still has to confirm.
*/
private function membershipSiblingCheckPassed(): bool
{
if ($this->membership_type !== 'member' || ! $this->membership_id || $this->membershipIdSiblingConfirmed) {
return true;
}
// Participant carries BranchScope; the branch predicate this query
// used to repeat is applied for it.
$existingSiblings = Participant::where('membership_id', $this->membership_id)
->whereNull('deleted_at')
->with('person')
->get();
if ($existingSiblings->isEmpty()) {
return true;
}
$this->membershipIdSiblings = $existingSiblings->map(fn ($p) => [
'id' => $p->id,
'name' => $p->person?->name_ar ?? '-',
])->toArray();
return false;
}
/** Add the child in the draft form and leave the sheet ready for another. */
public function addPlayer(): void
{
$this->commitDraft();
}
public function editPlayer(int $index): void
{
if (! isset($this->players[$index])) {
return;
}
// A half-typed child would be lost by loading another over it.
if ($this->draftIsStarted() && $this->editingPlayerIndex === null) {
$this->addError('participant_name_ar', __('أضف اللاعب الحالي أو امسح بياناته قبل تعديل لاعب آخر'));
return;
}
$this->loadDraft($this->players[$index]);
$this->editingPlayerIndex = $index;
}
public function removePlayer(int $index): void
{
if (! isset($this->players[$index])) {
return;
}
// Cart lines are pinned to a player by index, so removing a child has
// to take their items with it and renumber what is left — otherwise a
// kit silently reattaches to whoever slid into that slot.
$this->dropCartItemsForPlayer($index);
unset($this->players[$index]);
$this->players = array_values($this->players);
if ($this->editingPlayerIndex === $index) {
$this->resetDraft();
} elseif ($this->editingPlayerIndex !== null && $this->editingPlayerIndex > $index) {
$this->editingPlayerIndex--;
}
}
public function clearDraft(): void
{
$this->resetDraft();
}
public function nextStep(): void
{
// Step 1 is a roster now, not a single form. The draft is committed on
// the way out so a desk that typed one child and pressed Next never
// loses them — but an empty draft on top of an existing roster is just
// somebody who already finished adding, not an error.
if ($this->currentStep === 1) {
if ($this->draftIsStarted() || empty($this->players)) {
if (! $this->commitDraft()) {
return;
}
}
if (empty($this->players)) {
$this->addError('participant_name_ar', __('أضف لاعباً واحداً على الأقل'));
return;
}
$this->currentStep = 2;
return;
}
$rules = $this->rulesForStep($this->currentStep);
if (!empty($rules)) {
$this->validate($rules, $this->messages());
}
// Duplicate check on step 2 (guardian) — if duplicates found, stay on step 2
......@@ -354,12 +585,25 @@ public function nextStep(): void
}
}
// Price guard on step 4 — block if no base price for selected program (skip for free players)
if ($this->currentStep === 4 && $this->selected_program_id && !$this->is_free) {
$program = TrainingProgram::find($this->selected_program_id);
if ($program && $this->resolveProgramBasePrice($program) === 0) {
$this->addError('selected_program_id', 'لا يوجد سعر محدد لهذا البرنامج — تواصل مع المسؤول');
return;
// Price guard on step 4 — every child, not just one. An unpriceable
// programme is a hard stop by design: guessing a price here is how a
// family gets billed a number nobody chose.
if ($this->currentStep === 4) {
foreach ($this->players as $i => $player) {
if (! empty($player['is_free']) || empty($player['program_id'])) {
continue;
}
$program = TrainingProgram::find($player['program_id']);
if ($program && $this->resolveProgramBasePrice($program, (string) $player['membership_type']) === 0) {
$this->addError(
"players.{$i}.program_id",
($player['name_ar'] ?? '') . ': ' . 'لا يوجد سعر محدد لهذا البرنامج — تواصل مع المسؤول'
);
return;
}
}
}
......@@ -412,18 +656,16 @@ private function rulesForStep(int $step): array
] : [],
4 => [
// activities are academy-level: they carry no branch.
'selected_activity_id' => 'required|exists:activities,id',
'players.*.activity_id' => 'required|exists:activities,id',
// `exists:` is a raw table query that no Eloquent global scope
// reaches, and selected_program_id is a public property. The
// dropdown is filtered; without this rule the dropdown was the
// only thing standing between a foreign programme and an
// enrolment. A branch runs its own programmes, so only this
// branch's pass.
'selected_program_id' => [
// reaches, and the roster is a public property. The dropdown is
// filtered; without this rule the dropdown was the only thing
// standing between a foreign programme and an enrolment. A
// branch runs its own programmes, so only this branch's pass.
'players.*.program_id' => [
'required',
Rule::exists('training_programs', 'id')->where(
fn ($q) => $q->where('branch_id', $this->getActiveBranchIdOrFail())
),
],
],
......@@ -452,10 +694,10 @@ public function messages(): array
'parentEmail.unique' => 'البريد الإلكتروني مستخدم بالفعل',
'parentPassword.required' => 'كلمة المرور مطلوبة',
'parentPassword.min' => 'كلمة المرور يجب أن تكون 6 أحرف على الأقل',
'selected_activity_id.required' => 'يرجى اختيار النشاط',
'selected_activity_id.exists' => 'النشاط المختار غير موجود',
'selected_program_id.required' => 'يرجى اختيار البرنامج',
'selected_program_id.exists' => 'البرنامج المختار غير موجود',
'players.*.activity_id.required' => 'يرجى اختيار النشاط لكل لاعب',
'players.*.activity_id.exists' => 'النشاط المختار غير موجود',
'players.*.program_id.required' => 'يرجى اختيار البرنامج لكل لاعب',
'players.*.program_id.exists' => 'البرنامج المختار غير موجود',
'payment_method.required_if' => 'يرجى اختيار طريقة الدفع',
'payment_method.in' => 'طريقة الدفع غير صالحة',
'payment_transaction_ref.required_if' => 'رقم المرجع / رقم العملية مطلوب لهذه الطريقة',
......@@ -660,9 +902,34 @@ public function generateParentPassword(): void
$this->parentPassword = Str::random(8);
}
public function updatedSelectedActivityId(): void
/**
* Changing a child's activity un-picks the programme they had, because the
* programme list they picked it from no longer applies to them.
*
* Keyed by the wire property path (`players.2.activity_id`), which is how
* Livewire reports a nested update.
*/
public function updatedPlayers(mixed $value, ?string $key = null): void
{
$this->selected_program_id = null;
if (!$key || !str_ends_with($key, '.activity_id')) {
return;
}
$index = (int) explode('.', $key)[0];
if (isset($this->players[$index])) {
$this->players[$index]['program_id'] = null;
}
}
/** Choose a programme for one child. */
public function selectProgramFor(int $index, int $programId): void
{
if (!isset($this->players[$index])) {
return;
}
$this->players[$index]['program_id'] = $programId;
}
// --- Price override (super admin only) ---
......@@ -688,10 +955,29 @@ public function effectiveTotal(): int
// --- Hot-buy methods ---
/**
* The child the desk is currently shopping for.
*
* Kit and product lines belong to a specific player: a shirt is issued to
* a person and lands on that person's invoice. With a family on the roster
* there is no "the participant" to default to any more, so the desk says
* who each item is for.
*/
private function cartPlayer(): array
{
return $this->players[$this->cartPlayerIndex] ?? [];
}
/** The price tier the cart prices against — the shopping child's own. */
private function cartTier(): string
{
return (string) ($this->cartPlayer()['membership_type'] ?? $this->membership_type);
}
#[Computed]
public function essentialProducts(): array
{
$tier = $this->membership_type;
$tier = $this->cartTier();
return Product::where('is_active', true)
->where('is_essential', true)
......@@ -731,7 +1017,7 @@ public function hotbuyResults(): array
$search = $this->hotbuy_search;
$tier = $this->membership_type;
$tier = $this->cartTier();
$products = Product::where('is_active', true)
->where(fn ($q) => $q->where('name_ar', 'ilike', "%{$search}%")
......@@ -780,7 +1066,16 @@ public function hotbuyResults(): array
public function addHotbuyItem(int $id, string $type): void
{
$key = "{$type}_{$id}";
// Same product for two children is two cart lines, not a quantity of
// two: they go on different invoices and are issued to different
// people, so the player index is part of the identity of the line.
$playerIndex = $this->cartPlayerIndex;
if (! isset($this->players[$playerIndex])) {
return;
}
$key = "{$type}_{$id}_p{$playerIndex}";
if (isset($this->hotbuyCart[$key])) {
$this->hotbuyCart[$key]['quantity']++;
......@@ -794,12 +1089,12 @@ public function addHotbuyItem(int $id, string $type): void
}
$price = ($type === 'product')
? $item->priceForTier($this->membership_type)
? $item->priceForTier($this->cartTier())
: $item->selling_price;
$plans = [];
if ($type === 'product' && ($item->billing_cycle ?? 'one_time') === 'annual') {
$tier = $this->membership_type;
$tier = $this->cartTier();
$plans = $item->installmentPlans()
->active()
->forTier($tier)
......@@ -833,6 +1128,7 @@ public function addHotbuyItem(int $id, string $type): void
$this->hotbuyCart[$key] = [
'id' => $item->id,
'type' => $type,
'player_index' => $playerIndex,
'name_ar' => $item->name_ar,
'price' => $price,
'billing_cycle' => $type === 'product' ? ($item->billing_cycle ?? 'one_time') : 'one_time',
......@@ -984,49 +1280,132 @@ public function hotbuyDueNow(): int
return $total;
}
// --- Proration ---
#[Computed]
public function proratedProgramFee(): ProrationResult
/**
* What this child's cart items come to right now, honouring an instalment
* plan's "pay N up front" selection the same way hotbuyDueNow does for the
* order as a whole.
*/
private function hotbuyDueNowForPlayer(int $playerIndex): int
{
$baseFee = $this->selectedProgramFee;
$service = app(ProrationService::class);
if (!$service->isEnabled() || $baseFee === 0) {
return ProrationResult::fullMonth($baseFee, $service->renewalDay());
$total = 0;
foreach ($this->hotbuyCart as $key => $item) {
if ((int) ($item['player_index'] ?? 0) !== $playerIndex) {
continue;
}
$itemTotal = $item['price'] * $item['quantity'];
if (!empty($item['plan_id']) && isset($this->hotbuyInstallmentsToPay[$key])) {
$plan = ProductInstallmentPlan::find($item['plan_id']);
if ($plan) {
$schedule = $plan->buildSchedule($itemTotal);
$toPay = min($this->hotbuyInstallmentsToPay[$key], count($schedule));
$total += array_sum(array_slice($schedule, 0, $toPay));
continue;
}
}
$total += $itemTotal;
}
$startDate = $this->enrollment_start_date ? \Carbon\Carbon::parse($this->enrollment_start_date) : null;
return $service->calculate($baseFee, $startDate, $this->prorationGroup(), $this->proration_mode);
return $total;
}
/**
* The group whose timetable prices "باقي تمرينات الشهر".
* Drop a removed child's cart lines and renumber the ones above them.
*
* The desk picks a programme, not a group, so this is the programme's
* default group — the same one ProgramForm writes the timetable onto.
* Without the renumber, removing the first of three children leaves lines
* pinned to index 1 and 2 while the remaining children are 0 and 1 — every
* item silently shifts onto the wrong child's invoice.
*/
private function prorationGroup(): ?TrainingGroup
private function dropCartItemsForPlayer(int $playerIndex): void
{
return $this->selectedProgram?->defaultGroup();
$rebuilt = [];
$rebuiltInstallments = [];
$rebuiltCustomizations = [];
foreach ($this->hotbuyCart as $key => $item) {
$owner = (int) ($item['player_index'] ?? 0);
if ($owner === $playerIndex) {
continue;
}
$newOwner = $owner > $playerIndex ? $owner - 1 : $owner;
$item['player_index'] = $newOwner;
$newKey = "{$item['type']}_{$item['id']}_p{$newOwner}";
$rebuilt[$newKey] = $item;
if (isset($this->hotbuyInstallmentsToPay[$key])) {
$rebuiltInstallments[$newKey] = $this->hotbuyInstallmentsToPay[$key];
}
if (isset($this->hotbuyCustomizations[$key])) {
$rebuiltCustomizations[$newKey] = $this->hotbuyCustomizations[$key];
}
}
$this->hotbuyCart = $rebuilt;
$this->hotbuyInstallmentsToPay = $rebuiltInstallments;
$this->hotbuyCustomizations = $rebuiltCustomizations;
$this->cartPlayerIndex = 0;
}
// --- Proration ---
/**
* One priced line per child on the roster.
*
* Everything downstream — the review screen, the totals, the invoices —
* reads this, so the number quoted at the desk and the number written to
* the invoice come from one calculation rather than two that can drift.
*
* @return array<int, array{program: ?TrainingProgram, fee: int, proration: ProrationResult, due: int, kit: int}>
*/
#[Computed]
public function selectedProgram(): ?TrainingProgram
public function playerLines(): array
{
if (!$this->selected_program_id) {
return null;
$service = app(ProrationService::class);
$startDate = $this->enrollment_start_date ? \Carbon\Carbon::parse($this->enrollment_start_date) : null;
$lines = [];
foreach ($this->players as $i => $player) {
$program = empty($player['program_id'])
? null
: TrainingProgram::with('activity')->find($player['program_id']);
$fee = ($program && empty($player['is_free']))
? $this->resolveProgramFee($program, $player, $i)
: 0;
$proration = (!$service->isEnabled() || $fee === 0)
? ProrationResult::fullMonth($fee, $service->renewalDay())
: $service->calculate($fee, $startDate, $program?->defaultGroup(), $this->proration_mode);
$kit = $this->hotbuyDueNowForPlayer($i);
$lines[$i] = [
'program' => $program,
'fee' => $fee,
'proration' => $proration,
'due' => $proration->proratedAmount + $kit,
'kit' => $kit,
];
}
return TrainingProgram::with('activity')->find($this->selected_program_id);
return $lines;
}
/** What the subscriptions come to across the whole family. */
#[Computed]
public function selectedProgramFee(): int
public function programsSubtotal(): int
{
if (!$this->selected_program_id) {
return 0;
}
$program = TrainingProgram::find($this->selected_program_id);
return $program ? $this->resolveProgramFee($program) : 0;
return array_sum(array_map(
fn (array $line) => $line['proration']->proratedAmount,
$this->playerLines
));
}
#[Computed]
......@@ -1036,14 +1415,14 @@ public function platformFee(): int
if (!$service->customerPays()) {
return 0;
}
$subtotal = $this->proratedProgramFee->proratedAmount + $this->hotbuyDueNow;
$subtotal = $this->programsSubtotal + $this->hotbuyDueNow;
return $service->calculate($subtotal);
}
#[Computed]
public function totalWithFee(): int
{
return $this->proratedProgramFee->proratedAmount + $this->hotbuyDueNow + $this->platformFee;
return $this->programsSubtotal + $this->hotbuyDueNow + $this->platformFee;
}
#[Computed]
......@@ -1076,402 +1455,670 @@ public function confirm(): void
return;
}
if (empty($this->players)) {
session()->flash('error', __('أضف لاعباً واحداً على الأقل'));
$this->currentStep = 1;
return;
}
try {
DB::transaction(function () use ($customizationAnswers) {
$actor = auth()->user();
$personService = app(PersonService::class);
$participantService = app(ParticipantService::class);
$enrollmentService = app(EnrollmentService::class);
$invoiceService = app(InvoiceService::class);
$paymentService = app(PaymentService::class);
// 1. Create or reuse guardian's Person record
if ($this->useExistingPersonId) {
$guardianPerson = Person::findOrFail($this->useExistingPersonId);
} else {
$guardianPerson = Person::where('phone', $this->guardian_phone)->first();
if (!$guardianPerson) {
$guardianPerson = $personService->create([
'name_ar' => $this->guardian_name_ar,
'name' => $this->guardian_name ?: $this->guardian_name_ar,
'phone' => $this->guardian_phone,
], $actor);
}
}
// 2. Create or find the Guardian record
$guardian = Guardian::firstOrCreate(
['person_id' => $guardianPerson->id],
[
'academy_id' => app('current_academy')->id,
'relationship_type' => $this->guardian_relation,
'occupation' => $this->guardian_occupation ?: null,
'workplace' => $this->guardian_workplace ?: null,
'is_emergency_contact' => true,
'is_financial_responsible' => true,
'can_pickup' => true,
]
);
// Update occupation/workplace if guardian already existed
if (!$guardian->wasRecentlyCreated && ($this->guardian_occupation || $this->guardian_workplace)) {
$guardian->update([
'occupation' => $this->guardian_occupation ?: $guardian->occupation,
'workplace' => $this->guardian_workplace ?: $guardian->workplace,
]);
// The family is written once — guardian, guardian record and
// the parent login — and every child hangs off it. Doing this
// per child would create a second Person for the same parent
// on the second pass.
[$guardianPerson, $guardian] = $this->resolveGuardian($actor);
$this->createParentAccountIfRequested($guardianPerson, $guardian, $actor);
$lines = $this->playerLines;
$overrideShares = $this->overrideShares($lines, $actor);
$results = [];
foreach ($this->players as $index => $player) {
$results[$index] = $this->registerPlayer(
$index,
$player,
$lines[$index] ?? null,
$guardian,
$overrideShares[$index] ?? null,
$customizationAnswers,
$actor,
);
}
// 2b. Create parent User account if requested
if ($this->createParentAccount && $this->parentEmail) {
$parentRole = Role::where('slug', 'parent')
->where('academy_id', app('current_academy')->id)
->first();
if (!$guardian->user_id) {
$parentUser = User::create([
'academy_id' => app('current_academy')->id,
'name' => $guardianPerson->name ?: $guardianPerson->name_ar,
'name_ar' => $guardianPerson->name_ar,
'email' => $this->parentEmail,
'phone' => $guardianPerson->phone,
'password' => Hash::make($this->parentPassword),
'person_id' => $guardianPerson->id,
'role_id' => $parentRole?->id,
'status' => 'active',
]);
$guardian->update(['user_id' => $parentUser->id]);
$guardianPerson->update(['user_id' => $parentUser->id]);
if ($parentRole) {
$parentUser->roles()->attach($parentRole->id, [
'assigned_by' => $actor->id,
'created_at' => now(),
]);
}
}
// One sum of money at the desk, spread across the children's
// invoices. Kept outside the loop on purpose: the family pays
// once, and allocating inside the loop would mean deciding how
// much each child got before knowing what the others cost.
if ($this->pay_now) {
$this->collectPayment($results, $actor);
}
// 3. Create participant's Person record
$participantPerson = $personService->create([
'name_ar' => $this->participant_name_ar,
'name' => $this->participant_name ?: $this->participant_name_ar,
'date_of_birth' => $this->participant_date_of_birth,
'gender' => $this->participant_gender,
'phone' => $this->participant_phone ?: null,
'national_id' => $this->participant_national_id ?: null,
'governorate' => $this->participant_governorate ?: null,
'medical_notes' => $this->participant_medical_notes ?: null,
], $actor);
$this->publishResults($results);
$this->currentStep = 7;
});
session()->flash('success', __('تم التسجيل بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
} catch (\Throwable $e) {
session()->flash('error', 'حدث خطأ: ' . $e->getMessage());
}
}
/**
* The guardian's Person and Guardian rows, created or reused.
*
* @return array{0: Person, 1: Guardian}
*/
private function resolveGuardian(User $actor): array
{
$personService = app(PersonService::class);
// 4. Create Participant record via service
$participant = $participantService->register([
'person_id' => $participantPerson->id,
'branch_id' => $this->getActiveBranchIdOrFail(),
'registration_source' => 'walk_in',
'primary_guardian_id' => $guardian->id,
'primary_activity_id' => $this->selected_activity_id,
'membership_type' => $this->membership_type,
'membership_id' => $this->membership_type === 'member' ? $this->membership_id : null,
'is_free' => $this->is_free,
if ($this->useExistingPersonId) {
$guardianPerson = Person::findOrFail($this->useExistingPersonId);
} else {
$guardianPerson = Person::where('phone', $this->guardian_phone)->first();
if (!$guardianPerson) {
$guardianPerson = $personService->create([
'name_ar' => $this->guardian_name_ar,
'name' => $this->guardian_name ?: $this->guardian_name_ar,
'phone' => $this->guardian_phone,
], $actor);
}
}
// 5. Link guardian to participant via pivot
$participant->guardians()->attach($guardian->id, [
'relationship_type' => $this->guardian_relation,
'is_primary' => true,
'is_emergency_contact' => true,
'can_pickup' => true,
'receives_notifications' => true,
'can_authorize_payment' => true,
]);
$guardian = Guardian::firstOrCreate(
['person_id' => $guardianPerson->id],
[
'academy_id' => app('current_academy')->id,
'relationship_type' => $this->guardian_relation,
'occupation' => $this->guardian_occupation ?: null,
'workplace' => $this->guardian_workplace ?: null,
'is_emergency_contact' => true,
'is_financial_responsible' => true,
'can_pickup' => true,
]
);
// 6. Enroll in program
$program = TrainingProgram::findOrFail($this->selected_program_id);
$enrollment = $enrollmentService->enrollInProgram(
$participant,
$program,
$actor
);
// Update occupation/workplace if guardian already existed
if (!$guardian->wasRecentlyCreated && ($this->guardian_occupation || $this->guardian_workplace)) {
$guardian->update([
'occupation' => $this->guardian_occupation ?: $guardian->occupation,
'workplace' => $this->guardian_workplace ?: $guardian->workplace,
]);
}
return [$guardianPerson, $guardian];
}
private function createParentAccountIfRequested(Person $guardianPerson, Guardian $guardian, User $actor): void
{
if (!$this->createParentAccount || !$this->parentEmail || $guardian->user_id) {
return;
}
// 7. Resolve program fee with optional proration, hot-buy, platform fee
$prorationResult = $this->proratedProgramFee;
$programFee = $prorationResult->proratedAmount;
$hotbuyTotal = $this->hotbuyTotal;
$subtotal = $programFee + $hotbuyTotal;
$platformFeeService = app(PlatformFeeService::class);
$customerPays = $platformFeeService->customerPays();
$serviceFee = $customerPays ? $platformFeeService->calculate($subtotal) : 0;
$computedTotal = $subtotal + $serviceFee;
// Super-admin override
$finalTotal = $computedTotal;
if ($this->priceOverrideEnabled && $this->priceOverrideInput !== '' && $actor->is_super_admin) {
$overrideAmount = (int) round((float) $this->priceOverrideInput * 100);
$finalTotal = max(0, $overrideAmount);
// Audit the override
\Log::channel('audit')->info('super_admin_price_override', [
'actor_id' => $actor->id,
'actor_name' => $actor->name,
'participant_name' => $this->participant_name_ar,
'program' => $program->name_ar,
'original_piasters' => $computedTotal,
'override_piasters' => $finalTotal,
'reason' => $this->priceOverrideReason,
]);
$parentRole = Role::where('slug', 'parent')
->where('academy_id', app('current_academy')->id)
->first();
$parentUser = User::create([
'academy_id' => app('current_academy')->id,
'name' => $guardianPerson->name ?: $guardianPerson->name_ar,
'name_ar' => $guardianPerson->name_ar,
'email' => $this->parentEmail,
'phone' => $guardianPerson->phone,
'password' => Hash::make($this->parentPassword),
'person_id' => $guardianPerson->id,
'role_id' => $parentRole?->id,
'status' => 'active',
]);
$guardian->update(['user_id' => $parentUser->id]);
$guardianPerson->update(['user_id' => $parentUser->id]);
if ($parentRole) {
$parentUser->roles()->attach($parentRole->id, [
'assigned_by' => $actor->id,
'created_at' => now(),
]);
}
}
/**
* How a super-admin's order-level override is divided between the children.
*
* The override names one figure for the whole family, but the money is
* written as one invoice per child, so the difference has to be split.
* Split in proportion to what each child's subscription costs, integer
* piasters only, and the rounding remainder lands on the last child —
* the same rule every other split in the system follows, so the invoices
* always add back up to exactly what the desk quoted.
*
* @param array<int, array<string, mixed>> $lines
* @return array<int, int>|array{} per-player override delta, empty when no override is active
*/
private function overrideShares(array $lines, User $actor): array
{
if (!$this->priceOverrideEnabled || $this->priceOverrideInput === '' || !$actor->is_super_admin) {
return [];
}
$computedTotal = $this->totalWithFee;
$finalTotal = max(0, (int) round((float) $this->priceOverrideInput * 100));
$delta = $finalTotal - $computedTotal;
if ($delta === 0) {
return [];
}
$feeTotal = array_sum(array_map(fn ($l) => $l['proration']->proratedAmount, $lines));
if ($feeTotal <= 0) {
return [];
}
$shares = [];
$assigned = 0;
$indexes = array_keys($lines);
$last = end($indexes);
foreach ($lines as $i => $line) {
if ($i === $last) {
$shares[$i] = $delta - $assigned;
continue;
}
// intdiv-style truncation toward zero keeps a discount a discount
// and a surcharge a surcharge; the remainder goes to the last child.
$share = (int) ($delta * $line['proration']->proratedAmount / $feeTotal);
$shares[$i] = $share;
$assigned += $share;
}
\Log::channel('audit')->info('super_admin_price_override', [
'actor_id' => $actor->id,
'actor_name' => $actor->name,
'players' => array_map(fn ($p) => $p['name_ar'] ?? '', $this->players),
'original_piasters' => $computedTotal,
'override_piasters' => $finalTotal,
'reason' => $this->priceOverrideReason,
'shares' => $shares,
]);
return $shares;
}
/**
* Everything that belongs to one child: their person, participant,
* enrolment and invoice.
*
* @param array<string, mixed> $player
* @param array<string, mixed>|null $line the priced line from playerLines
* @return array{participant: Participant, enrollment: \App\Domain\Training\Models\Enrollment, invoice: ?Invoice, program: TrainingProgram}
*/
private function registerPlayer(
int $index,
array $player,
?array $line,
Guardian $guardian,
?int $overrideShare,
array $customizationAnswers,
User $actor
): array {
$personService = app(PersonService::class);
$participantService = app(ParticipantService::class);
$enrollmentService = app(EnrollmentService::class);
$invoiceService = app(InvoiceService::class);
$participantPerson = $personService->create([
'name_ar' => $player['name_ar'],
'name' => ($player['name'] ?? '') ?: $player['name_ar'],
'date_of_birth' => $player['date_of_birth'] ?? null,
'gender' => $player['gender'] ?? 'male',
'phone' => ($player['phone'] ?? '') ?: null,
'national_id' => ($player['national_id'] ?? '') ?: null,
'governorate' => ($player['governorate'] ?? '') ?: null,
'medical_notes' => ($player['medical_notes'] ?? '') ?: null,
], $actor);
$membershipType = (string) ($player['membership_type'] ?? 'non_member');
$isFree = (bool) ($player['is_free'] ?? false);
$participant = $participantService->register([
'person_id' => $participantPerson->id,
'branch_id' => $this->getActiveBranchIdOrFail(),
'registration_source' => 'walk_in',
'primary_guardian_id' => $guardian->id,
'primary_activity_id' => $player['activity_id'] ?? null,
'membership_type' => $membershipType,
'membership_id' => $membershipType === 'member' ? ($player['membership_id'] ?: null) : null,
'is_free' => $isFree,
], $actor);
$participant->guardians()->attach($guardian->id, [
'relationship_type' => $this->guardian_relation,
'is_primary' => true,
'is_emergency_contact' => true,
'can_pickup' => true,
'receives_notifications' => true,
'can_authorize_payment' => true,
]);
$program = TrainingProgram::findOrFail($player['program_id']);
$enrollment = $enrollmentService->enrollInProgram($participant, $program, $actor);
$prorationResult = $line['proration'] ?? null;
$programFee = $prorationResult?->proratedAmount ?? 0;
$kitTotal = $line['kit'] ?? 0;
$subtotal = $programFee + $kitTotal;
$invoice = null;
$billsNothing = app(\App\Domain\Identity\Services\BranchSettingsService::class)
->billingHandledExternally($this->getActiveBranchId());
if (!$isFree && !$billsNothing && ($subtotal > 0 || $overrideShare !== null)) {
$invoice = $this->createPlayerInvoice(
$index,
$player,
$participant,
$program,
$prorationResult,
$programFee,
$subtotal,
$overrideShare,
$customizationAnswers,
$invoiceService,
$actor,
);
$enrollment->update(['invoice_id' => $invoice->id]);
$this->createInstallmentPlansFor($index, $invoice);
}
return [
'participant' => $participant,
'enrollment' => $enrollment,
'invoice' => $invoice,
'program' => $program,
];
}
/**
* The invoice for one child: their subscription line, their kit lines, and
* their share of the platform fee.
*
* @param array<string, mixed> $player
*/
private function createPlayerInvoice(
int $index,
array $player,
Participant $participant,
TrainingProgram $program,
?ProrationResult $prorationResult,
int $programFee,
int $subtotal,
?int $overrideShare,
array $customizationAnswers,
InvoiceService $invoiceService,
User $actor
): Invoice {
$invoiceItems = [];
// The override moves the whole order, and the kit lines are sold at
// their listed price — so the difference comes off the subscription,
// exactly as it did when there was only ever one child.
$effectiveProgramFee = $programFee;
$programItemMeta = [];
if ($overrideShare !== null && $overrideShare !== 0 && $programFee > 0) {
$effectiveProgramFee = max(0, $programFee + $overrideShare);
$programItemMeta = [
'original_price' => $programFee,
'overridden_price' => $effectiveProgramFee,
'override_reason' => $this->priceOverrideReason,
'overridden_by' => $actor->name,
];
}
if ($programFee > 0) {
$description = $program->name_ar;
if ($prorationResult && $prorationResult->applied) {
$description .= ' (' . $prorationResult->description . ')';
}
$invoiceItems[] = [
'description' => $description,
'quantity' => 1,
'unit_price' => $effectiveProgramFee,
'discount_amount' => 0,
'tax_amount' => 0,
'metadata' => $programItemMeta ?: [],
];
}
foreach ($this->cartItemsFor($index) as $cartKey => $cartItem) {
$itemEntry = [
'description' => $cartItem['name_ar'],
'quantity' => $cartItem['quantity'],
'unit_price' => $cartItem['price'],
'discount_amount' => 0,
'tax_amount' => 0,
];
// Frozen onto the line, deliberately: the answers must survive the
// admin later renaming an option or deleting the question.
if (!empty($customizationAnswers[$cartKey])) {
$itemEntry['metadata'] = ['customizations' => $customizationAnswers[$cartKey]];
$itemEntry['description'] .= ' (' . implode(
'، ',
array_map(
fn ($a) => $a['name'] . ': ' . $a['value'],
$customizationAnswers[$cartKey]
)
) . ')';
}
if ($cartItem['type'] === 'product') {
$itemEntry['itemable_type'] = Product::class;
$itemEntry['itemable_id'] = $cartItem['id'];
} elseif ($cartItem['type'] === 'kit') {
$itemEntry['itemable_type'] = Kit::class;
$itemEntry['itemable_id'] = $cartItem['id'];
}
$invoiceItems[] = $itemEntry;
}
$serviceFee = $this->platformFeeShare($index);
$total = $effectiveProgramFee + ($subtotal - $programFee) + $serviceFee;
$invoice = $invoiceService->create([
'academy_id' => app('current_academy')->id,
'number' => $invoiceService->generateNumber(app('current_academy')->id),
'type' => 'standard',
'billable_type' => Participant::class,
'billable_id' => $participant->id,
'contact_name' => $participant->person?->name_ar ?? $this->guardian_name_ar,
'contact_phone' => $participant->person?->phone ?? $this->guardian_phone,
'subtotal_amount' => $subtotal,
// No separate discount line: the subscription item already carries
// the overridden price, so recalculateTotals() sums to $total.
'discount_amount' => 0,
'tax_amount' => 0,
'service_fee_amount' => $serviceFee,
'total_amount' => $total,
'currency' => 'EGP',
'issue_date' => now()->toDateString(),
'due_date' => now()->addDays(7)->toDateString(),
'notes' => 'اشتراك: ' . $program->name_ar,
], $invoiceItems, $actor);
$invoice->update(['status' => 'sent']);
return $invoice;
}
/**
* This child's cart lines.
*
* @return array<string, array<string, mixed>>
*/
private function cartItemsFor(int $playerIndex): array
{
return array_filter(
$this->hotbuyCart,
fn ($item) => (int) ($item['player_index'] ?? 0) === $playerIndex
);
}
/**
* This child's share of the order's platform fee.
*
* The fee is charged on the order, so it is split the same way the
* override is — by value, with the remainder on the last child — rather
* than recalculated per invoice, which would round N times and no longer
* add up to what the desk quoted.
*/
private function platformFeeShare(int $playerIndex): int
{
$fee = $this->platformFee;
if ($fee <= 0) {
return 0;
}
$lines = $this->playerLines;
$dueTotal = array_sum(array_map(fn ($l) => $l['due'], $lines));
if ($dueTotal <= 0) {
return 0;
}
$indexes = array_keys($lines);
$last = end($indexes);
if ($playerIndex === $last) {
$assigned = 0;
foreach ($lines as $i => $line) {
if ($i === $last) {
continue;
}
$assigned += (int) ($fee * $line['due'] / $dueTotal);
}
return $fee - $assigned;
}
return (int) ($fee * ($lines[$playerIndex]['due'] ?? 0) / $dueTotal);
}
/** Instalment plans for the annual products in this child's cart. */
private function createInstallmentPlansFor(int $playerIndex, Invoice $invoice): void
{
foreach ($this->cartItemsFor($playerIndex) as $cartKey => $cartItem) {
if (empty($cartItem['plan_id'])) {
continue;
}
$planTemplate = ProductInstallmentPlan::find($cartItem['plan_id']);
if (!$planTemplate) {
continue;
}
// 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;
$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.
// The override replaces the entire order total, so the delta comes off the program item.
$effectiveProgramFee = $programFee;
$programItemMeta = [];
if ($this->priceOverrideEnabled && $actor->is_super_admin && $finalTotal !== $computedTotal && $programFee > 0) {
// Override delta = difference between computed and final totals (hotbuy items are untouched)
$overrideDelta = $finalTotal - $computedTotal; // negative = discount, positive = surcharge
$effectiveProgramFee = max(0, $programFee + $overrideDelta);
$programItemMeta = [
'original_price' => $programFee, // per-item original, not order total
'overridden_price' => $effectiveProgramFee,
'override_reason' => $this->priceOverrideReason,
'overridden_by' => $actor->name,
];
}
if ($programFee > 0) {
$description = $program->name_ar;
if ($prorationResult->applied) {
$description .= ' (' . $prorationResult->description . ')';
}
$invoiceItems[] = [
'description' => $description,
'quantity' => 1,
'unit_price' => $effectiveProgramFee, // ← overridden value, not original
'discount_amount' => 0,
'tax_amount' => 0,
'metadata' => $programItemMeta ?: [],
];
}
foreach ($this->hotbuyCart as $cartKey => $cartItem) {
$itemEntry = [
'description' => $cartItem['name_ar'],
'quantity' => $cartItem['quantity'],
'unit_price' => $cartItem['price'],
'discount_amount' => 0,
'tax_amount' => 0,
];
// Frozen onto the line, deliberately: the answers must
// survive the admin later renaming an option or
// deleting the question altogether.
if (!empty($customizationAnswers[$cartKey])) {
$itemEntry['metadata'] = ['customizations' => $customizationAnswers[$cartKey]];
$itemEntry['description'] .= ' (' . implode(
'، ',
array_map(
fn ($a) => $a['name'] . ': ' . $a['value'],
$customizationAnswers[$cartKey]
)
) . ')';
}
if ($cartItem['type'] === 'product') {
$itemEntry['itemable_type'] = Product::class;
$itemEntry['itemable_id'] = $cartItem['id'];
} elseif ($cartItem['type'] === 'kit') {
$itemEntry['itemable_type'] = Kit::class;
$itemEntry['itemable_id'] = $cartItem['id'];
}
$invoiceItems[] = $itemEntry;
}
// No separate discount line needed — the program item already carries the overridden price,
// so recalculateTotals() will sum to $finalTotal correctly.
$discountAmount = 0;
$invoice = $invoiceService->create([
'academy_id' => app('current_academy')->id,
'number' => $invoiceService->generateNumber(app('current_academy')->id),
'type' => 'standard',
'billable_type' => Participant::class,
'billable_id' => $participant->id,
'contact_name' => $participant->person?->name_ar ?? $this->guardian_name_ar,
'contact_phone' => $participant->person?->phone ?? $this->guardian_phone,
'subtotal_amount' => $subtotal,
'discount_amount' => $discountAmount,
'tax_amount' => 0,
'service_fee_amount' => $serviceFee,
'total_amount' => $finalTotal,
'currency' => 'EGP',
'issue_date' => now()->toDateString(),
'due_date' => now()->addDays(7)->toDateString(),
'notes' => 'اشتراك: ' . $program->name_ar,
], $invoiceItems, $actor);
$invoice->update(['status' => 'sent']);
$enrollment->update(['invoice_id' => $invoice->id]);
$this->invoice_amount = $invoice->total_amount;
$this->invoice_number = $invoice->number;
$this->invoiceId = $invoice->id;
// 8b. Create installment payment plans for annual products
foreach ($this->hotbuyCart as $cartKey => $cartItem) {
if (empty($cartItem['plan_id'])) {
continue;
}
$planTemplate = ProductInstallmentPlan::find($cartItem['plan_id']);
if (!$planTemplate) {
continue;
}
$itemTotal = $cartItem['price'] * $cartItem['quantity'];
$schedule = $planTemplate->buildSchedule($itemTotal);
$regularAmount = count($schedule) > 1 ? ($schedule[1] ?? $schedule[0]) : $schedule[0];
$installmentsToPay = $this->hotbuyInstallmentsToPay[$cartKey] ?? 1;
$paymentPlan = PaymentPlan::create([
'academy_id' => app('current_academy')->id,
'invoice_id' => $invoice->id,
'status' => 'active',
'total_installments' => $planTemplate->installments,
'paid_installments' => $this->pay_now ? $installmentsToPay : 0,
'installment_amount' => $regularAmount,
'frequency' => $planTemplate->frequency,
'start_date' => now()->toDateString(),
'next_due_date' => now()->toDateString(),
'notes' => $planTemplate->label_ar . ' — ' . $cartItem['name_ar'],
]);
// Generate installment schedule using explicit or auto-calculated amounts
$dueDate = now();
foreach ($schedule as $idx => $amount) {
$seq = $idx + 1;
$isPaidNow = $this->pay_now && $seq <= $installmentsToPay;
Installment::create([
'payment_plan_id' => $paymentPlan->id,
'sequence' => $seq,
'amount' => $amount,
'due_date' => $dueDate->toDateString(),
'status' => $isPaidNow ? 'paid' : 'pending',
'paid_at' => $isPaidNow ? now() : null,
]);
$dueDate = match ($planTemplate->frequency) {
'weekly' => $dueDate->addWeek(),
'biweekly' => $dueDate->addWeeks(2),
'quarterly' => $dueDate->addMonths(3),
default => $dueDate->addMonth(),
};
}
// Update next_due_date to the first unpaid installment
if ($this->pay_now && $installmentsToPay > 0) {
$nextDue = $paymentPlan->installments()
->where('status', 'pending')
->orderBy('sequence')
->value('due_date');
if ($nextDue) {
$paymentPlan->update(['next_due_date' => $nextDue]);
}
}
}
// 9. Record payment(s) if paying now
if ($this->pay_now) {
$basePaymentData = [
'academy_id' => app('current_academy')->id,
'branch_id' => $this->getActiveBranchIdOrFail(),
'invoice_id' => $invoice->id,
'direction' => 'inbound',
'payer_type' => Participant::class,
'payer_id' => $participant->id,
'currency' => 'EGP',
'payment_date' => now()->toDateString(),
];
if ($this->split_payment) {
// Two payments: amount1 + remainder of what's due now
$amount1 = (int) round((float) $this->split_amount1_input * 100);
$amount2 = $this->effectiveTotal - $amount1;
if ($amount1 > 0) {
$paymentService->recordPayment(array_merge($basePaymentData, [
'reference' => 'PAY-' . now()->format('YmdHis') . 'A-' . $participant->id,
'method' => $this->payment_method,
'amount' => $amount1,
'notes' => $this->buildPaymentNotes($this->payment_method, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name, $program->name_ar),
'gateway_data' => $this->buildGatewayData($this->payment_method, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name),
]), $actor);
}
if ($amount2 > 0) {
$paymentService->recordPayment(array_merge($basePaymentData, [
'reference' => 'PAY-' . now()->format('YmdHis') . 'B-' . $participant->id,
'method' => $this->split_method2,
'amount' => $amount2,
'notes' => $this->buildPaymentNotes($this->split_method2, $this->split_transaction_ref2, $this->split_cheque_number2, $this->split_bank_name2, $program->name_ar),
'gateway_data' => $this->buildGatewayData($this->split_method2, $this->split_transaction_ref2, $this->split_cheque_number2, $this->split_bank_name2),
]), $actor);
}
} elseif ($this->partial_payment) {
// One partial payment — balance stays on invoice
$paidAmount = (int) round((float) $this->partial_amount_input * 100);
if ($paidAmount > 0) {
$paymentService->recordPayment(array_merge($basePaymentData, [
'reference' => 'PAY-' . now()->format('YmdHis') . '-' . $participant->id,
'method' => $this->payment_method,
'amount' => $paidAmount,
'notes' => $this->buildPaymentNotes($this->payment_method, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name, $program->name_ar),
'gateway_data' => $this->buildGatewayData($this->payment_method, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name),
]), $actor);
}
} else {
// Full payment (= effectiveTotal which accounts for installment selections)
$fullPayAmount = $this->effectiveTotal;
$paymentService->recordPayment(array_merge($basePaymentData, [
'reference' => 'PAY-' . now()->format('YmdHis') . '-' . $participant->id,
'method' => $this->payment_method,
'amount' => $fullPayAmount,
'notes' => $this->buildPaymentNotes($this->payment_method, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name, $program->name_ar),
'gateway_data' => $this->buildGatewayData($this->payment_method, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name),
]), $actor);
}
$this->payment_recorded = true;
// Only mark enrollment paid if full amount covered
$paidSoFar = $invoice->fresh()->paid_amount ?? 0;
if ($paidSoFar >= $invoice->total_amount) {
$enrollment->update(['payment_status' => 'paid']);
}
}
$itemTotal = $cartItem['price'] * $cartItem['quantity'];
$schedule = $planTemplate->buildSchedule($itemTotal);
$regularAmount = count($schedule) > 1 ? ($schedule[1] ?? $schedule[0]) : $schedule[0];
$installmentsToPay = $this->hotbuyInstallmentsToPay[$cartKey] ?? 1;
$paymentPlan = PaymentPlan::create([
'academy_id' => app('current_academy')->id,
'invoice_id' => $invoice->id,
'status' => 'active',
'total_installments' => $planTemplate->installments,
'paid_installments' => $this->pay_now ? $installmentsToPay : 0,
'installment_amount' => $regularAmount,
'frequency' => $planTemplate->frequency,
'start_date' => now()->toDateString(),
'next_due_date' => now()->toDateString(),
'notes' => $planTemplate->label_ar . ' — ' . $cartItem['name_ar'],
]);
$dueDate = now();
foreach ($schedule as $idx => $amount) {
$seq = $idx + 1;
$isPaidNow = $this->pay_now && $seq <= $installmentsToPay;
Installment::create([
'payment_plan_id' => $paymentPlan->id,
'sequence' => $seq,
'amount' => $amount,
'due_date' => $dueDate->toDateString(),
'status' => $isPaidNow ? 'paid' : 'pending',
'paid_at' => $isPaidNow ? now() : null,
]);
$dueDate = match ($planTemplate->frequency) {
'weekly' => $dueDate->addWeek(),
'biweekly' => $dueDate->addWeeks(2),
'quarterly' => $dueDate->addMonths(3),
default => $dueDate->addMonth(),
};
}
if ($this->pay_now && $installmentsToPay > 0) {
$nextDue = $paymentPlan->installments()
->where('status', 'pending')
->orderBy('sequence')
->value('due_date');
if ($nextDue) {
$paymentPlan->update(['next_due_date' => $nextDue]);
}
}
}
}
/**
* Take the family's money and spread it over the children's invoices.
*
* The desk collects one sum — in one method, split across two, or as a
* part payment. Each invoice is filled in roster order until the money
* runs out, so an underpayment leaves a visible balance on the last child
* rather than a mysterious shortfall spread over all of them. Every
* payment row still names the invoice it settled, which is what keeps the
* ledger reconcilable.
*
* @param array<int, array<string, mixed>> $results
*/
private function collectPayment(array $results, User $actor): void
{
$paymentService = app(PaymentService::class);
$invoices = [];
foreach ($results as $index => $result) {
if ($result['invoice'] instanceof Invoice) {
$invoices[$index] = $result;
}
}
if (empty($invoices)) {
return;
}
// Each tranche is (method, amount, reference suffix, notes/gateway).
$tranches = [];
if ($this->split_payment) {
$amount1 = (int) round((float) $this->split_amount1_input * 100);
$amount2 = $this->effectiveTotal - $amount1;
if ($amount1 > 0) {
$tranches[] = ['A', $this->payment_method, $amount1, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name];
}
if ($amount2 > 0) {
$tranches[] = ['B', $this->split_method2, $amount2, $this->split_transaction_ref2, $this->split_cheque_number2, $this->split_bank_name2];
}
} elseif ($this->partial_payment) {
$paidAmount = (int) round((float) $this->partial_amount_input * 100);
if ($paidAmount > 0) {
$tranches[] = ['', $this->payment_method, $paidAmount, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name];
}
} else {
$tranches[] = ['', $this->payment_method, $this->effectiveTotal, $this->payment_transaction_ref, $this->payment_cheque_number, $this->payment_bank_name];
}
$this->completed = true;
$this->participant_number = $participant->participant_number;
$this->participant_uuid = $participant->uuid;
$this->enrollment_summary = $program->name_ar . ' — ' . ($enrollment->group?->name_ar ?? '');
if ($invoice) {
$this->invoice_uuid = $invoice->uuid;
// How much each invoice still wants, in roster order.
$outstanding = [];
foreach ($invoices as $index => $result) {
$outstanding[$index] = (int) $result['invoice']->total_amount;
}
foreach ($tranches as [$suffix, $method, $amount, $ref, $cheque, $bank]) {
foreach ($outstanding as $index => $owed) {
if ($amount <= 0) {
break;
}
if ($owed <= 0) {
continue;
}
$this->currentStep = 7;
});
session()->flash('success', __('تم التسجيل بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
} catch (\Throwable $e) {
session()->flash('error', 'حدث خطأ: ' . $e->getMessage());
$slice = min($amount, $owed);
$result = $invoices[$index];
$paymentService->recordPayment([
'academy_id' => app('current_academy')->id,
'branch_id' => $this->getActiveBranchIdOrFail(),
'invoice_id' => $result['invoice']->id,
'direction' => 'inbound',
'payer_type' => Participant::class,
'payer_id' => $result['participant']->id,
'currency' => 'EGP',
'payment_date' => now()->toDateString(),
'reference' => 'PAY-' . now()->format('YmdHis') . $suffix . '-' . $result['participant']->id,
'method' => $method,
'amount' => $slice,
'notes' => $this->buildPaymentNotes($method, $ref, $cheque, $bank, $result['program']->name_ar),
'gateway_data' => $this->buildGatewayData($method, $ref, $cheque, $bank),
], $actor);
$outstanding[$index] -= $slice;
$amount -= $slice;
}
}
$this->payment_recorded = true;
foreach ($invoices as $result) {
$paidSoFar = $result['invoice']->fresh()->paid_amount ?? 0;
if ($paidSoFar >= $result['invoice']->total_amount) {
$result['enrollment']->update(['payment_status' => 'paid']);
}
}
}
/**
* Fill in what step 7 prints.
*
* The single-player scalars still point at the first child so the existing
* receipt and "open the player" links keep working; registeredPlayers is
* what the confirmation screen lists.
*
* @param array<int, array<string, mixed>> $results
*/
private function publishResults(array $results): void
{
$this->registeredPlayers = [];
foreach ($results as $result) {
/** @var Participant $participant */
$participant = $result['participant'];
/** @var Invoice|null $invoice */
$invoice = $result['invoice'];
$this->registeredPlayers[] = [
'name_ar' => $participant->person?->name_ar ?? '',
'participant_number' => $participant->participant_number,
'participant_uuid' => $participant->uuid,
'program' => $result['program']->name_ar,
'group' => $result['enrollment']->group?->name_ar ?? '',
'invoice_number' => $invoice?->number,
'invoice_uuid' => $invoice?->uuid,
'invoice_amount' => $invoice?->total_amount ?? 0,
];
}
$first = $results[array_key_first($results)];
$this->completed = true;
$this->participant_number = $first['participant']->participant_number;
$this->participant_uuid = $first['participant']->uuid;
$this->enrollment_summary = $first['program']->name_ar . ' — ' . ($first['enrollment']->group?->name_ar ?? '');
if ($first['invoice'] instanceof Invoice) {
$this->invoice_uuid = $first['invoice']->uuid;
$this->invoice_number = $first['invoice']->number;
$this->invoiceId = $first['invoice']->id;
}
// The receipt total is what the family owes, not what one child owes.
$this->invoice_amount = array_sum(array_column($this->registeredPlayers, 'invoice_amount'));
}
private function buildPaymentNotes(string $method, string $ref, string $cheque, string $bank, string $programName): string
......@@ -1494,14 +2141,17 @@ private function buildGatewayData(string $method, string $ref, string $cheque, s
}
/**
* The price actually charged — base price WITH pricing rules applied.
* The price actually charged for one child — base price WITH pricing rules
* applied.
*
* This flow used to read BasePrice directly, so no sibling discount, early
* bird, coupon or promotion could ever apply at new registration. The
* participant row does not exist yet, so the engine is given a provisional
* context built from the form.
* context built from the roster.
*
* @param array<string, mixed> $player
*/
private function resolveProgramFee(TrainingProgram $program): int
private function resolveProgramFee(TrainingProgram $program, array $player, int $index): int
{
try {
return app(\App\Domain\Pricing\Services\PricingService::class)->calculate(
......@@ -1509,7 +2159,7 @@ private function resolveProgramFee(TrainingProgram $program): int
participant: null,
branchId: $this->getActiveBranchIdOrFail(),
date: null,
contextOverride: $this->provisionalContext(),
contextOverride: $this->provisionalContext($player, $index),
)->finalAmount;
} catch (\App\Domain\Shared\Exceptions\DomainException $e) {
// No active base price — the step-4 guard reports this properly.
......@@ -1518,21 +2168,25 @@ private function resolveProgramFee(TrainingProgram $program): int
}
/**
* Participant context derived from the form, for pricing before the row exists.
* Context for one child on the roster, for pricing before any row exists.
*
* @param array<string, mixed> $player
*/
private function provisionalContext(): array
private function provisionalContext(array $player, int $index): array
{
$age = $this->participant_date_of_birth
? (int) \Carbon\Carbon::parse($this->participant_date_of_birth)->diffInYears(now())
$dob = $player['date_of_birth'] ?? null;
$age = $dob
? (int) \Carbon\Carbon::parse($dob)->diffInYears(now())
: null;
[$familySize, $siblingOrder] = $this->resolveSiblingPosition();
[$familySize, $siblingOrder] = $this->resolveSiblingPosition($index);
return [
'age' => $age,
'gender' => $this->participant_gender ?: null,
'gender' => ($player['gender'] ?? '') ?: null,
'classification' => 'regular',
'membership_type' => $this->membership_type,
'membership_type' => (string) ($player['membership_type'] ?? 'non_member'),
'membership_duration_months' => 0,
'family_size' => $familySize,
'sibling_order' => $siblingOrder,
......@@ -1542,32 +2196,48 @@ private function provisionalContext(): array
}
/**
* Siblings are found through the guardian's phone — the only identity we
* have before the participant is saved.
* Where this child sits in the family, counting the ones already on the
* books AND the ones being registered alongside them right now.
*
* The second half is the whole point of the multi-player roster. Before it,
* each child was priced on its own and the family always looked like a
* family of one: the sibling rule could not fire for the first child, and
* by the time the second was registered the first one's invoice was frozen.
* Counting the roster means a family arriving together is priced as the
* family it is.
*
* @return array{0:int,1:int} [family size, this child's order]
*/
private function resolveSiblingPosition(): array
private function resolveSiblingPosition(int $index): array
{
if (! $this->guardian_phone) {
return [1, 1];
}
$guardian = Guardian::whereHas('person', fn ($q) => $q->where('phone', $this->guardian_phone))->first();
$existing = 0;
if (! $guardian) {
return [1, 1];
if ($this->guardian_phone) {
$guardian = Guardian::whereHas('person', fn ($q) => $q->where('phone', $this->guardian_phone))->first();
$existing = $guardian ? $guardian->participants()->count() : 0;
}
// The child being registered is the next one in the family.
$existing = $guardian->participants()->count();
$familySize = max(1, $existing + count($this->players));
// Order within the family: everyone already enrolled, then this
// child's position in today's roster.
$siblingOrder = $existing + $index + 1;
return [$existing + 1, $existing + 1];
return [$familySize, max(1, $siblingOrder)];
}
/** Base price only — used by the step-4 guard, which must not see discounts. */
private function resolveProgramBasePrice(TrainingProgram $program): int
/**
* Base price only — used by the step-4 guard, which must not see discounts.
*
* The tier is passed rather than read off the draft form: on step 4 the
* draft is empty and each child on the roster carries their own tier, so
* reading $this->membership_type here priced every child as whatever the
* last one typed happened to be.
*/
private function resolveProgramBasePrice(TrainingProgram $program, ?string $membershipType = null): int
{
$membershipType ??= $this->membership_type;
$query = BasePrice::where('priceable_type', TrainingProgram::class)
->where('priceable_id', $program->id)
->where('is_active', true)
......@@ -1580,7 +2250,7 @@ private function resolveProgramBasePrice(TrainingProgram $program): int
->where(fn ($q) => $q->whereNull('effective_to')->orWhere('effective_to', '>=', now()));
$specificPrice = (clone $query)
->whereJsonContains('metadata->membership_type', $this->membership_type)
->whereJsonContains('metadata->membership_type', $membershipType)
->orderByDesc('priority')
->first();
......@@ -1603,21 +2273,31 @@ private function resolveProgramBasePrice(TrainingProgram $program): int
public function render()
{
$activities = Activity::where('is_active', true)->orderBy('name_ar')->get();
$programs = collect();
if ($this->selected_activity_id) {
// One programme list per activity any child on the roster has picked —
// fetched once for the whole page rather than once per child, which on
// a family of four was four identical queries.
$chosenActivities = array_values(array_unique(array_filter(
array_column($this->players, 'activity_id')
)));
$programsByActivity = [];
if ($chosenActivities) {
// Same as the preflight check: TrainingGroup's own scope supplies
// the branch, so this asks only for a group that is running.
$programs = TrainingProgram::where('activity_id', $this->selected_activity_id)
$programsByActivity = TrainingProgram::whereIn('activity_id', $chosenActivities)
->where('status', 'active')
->whereHas('groups', fn ($q) => $q->whereIn('status', ['active', 'forming']))
->orderBy('name_ar')
->get();
->get()
->groupBy('activity_id')
->all();
}
return view('livewire.receptionist.new-registration-wizard', [
'activities' => $activities,
'programs' => $programs,
'programsByActivity' => $programsByActivity,
'isSuperAdmin' => auth()->user()?->is_super_admin ?? false,
'relationOptions' => [
'father' => 'أب',
......
......@@ -116,7 +116,40 @@ class="flex items-center gap-2 {{ $num < $currentStep ? 'cursor-pointer' : 'curs
{{-- ===================== STEP 1: PLAYER DATA ===================== --}}
@if($currentStep === 1)
<div>
<h2 class="text-lg font-semibold text-gray-800 mb-1">{{ __('بيانات اللاعب') }}</h2>
<h2 class="text-lg font-semibold text-gray-800 mb-1">{{ __('بيانات اللاعبين') }}</h2>
<p class="text-sm text-gray-500 mb-5">{{ __('أضف كل الإخوة معاً ليُحتسب خصم الإخوة تلقائياً') }}</p>
{{-- The roster: children already added in this visit --}}
@if(count($players))
<div class="mb-6 rounded-xl border border-emerald-200 bg-emerald-50/60 divide-y divide-emerald-100">
@foreach($players as $i => $player)
<div class="flex items-center gap-3 px-4 py-3" wire:key="roster-{{ $i }}">
<span class="w-7 h-7 shrink-0 rounded-full bg-emerald-600 text-white text-xs font-bold flex items-center justify-center" dir="ltr">{{ $i + 1 }}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-emerald-900 truncate">{{ $player['name_ar'] }}</p>
<p class="text-xs text-emerald-700">
{{ $player['date_of_birth'] ?? '—' }}
&middot; {{ $player['membership_type'] === 'member' ? term('member') : term('non_member') }}
</p>
</div>
<button type="button" wire:click="editPlayer({{ $i }})"
class="shrink-0 text-xs text-blue-600 hover:text-blue-800 px-2 py-1">{{ __('تعديل') }}</button>
<button type="button" wire:click="removePlayer({{ $i }})"
class="shrink-0 text-xs text-red-600 hover:text-red-800 px-2 py-1">{{ __('حذف') }}</button>
</div>
@endforeach
</div>
@endif
<h3 class="text-sm font-semibold text-gray-700 mb-3">
@if($editingPlayerIndex !== null)
{{ __('تعديل بيانات اللاعب') }} <span dir="ltr">#{{ $editingPlayerIndex + 1 }}</span>
@elseif(count($players))
{{ __('إضافة لاعب آخر') }}
@else
{{ __('بيانات اللاعب') }}
@endif
</h3>
<p class="text-sm text-gray-500 mb-5">{{ __('أدخل الرقم القومي لملء البيانات تلقائياً') }}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
......@@ -341,8 +374,18 @@ class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:rin
</div>
</div>
<div class="fixed bottom-0 start-0 end-0 bg-white border-t border-gray-200 p-4 safe-bottom sm:static sm:border-0 sm:p-0 sm:mt-8 flex justify-end z-30">
<button wire:click="nextStep" wire:loading.attr="disabled"
<div class="fixed bottom-0 start-0 end-0 bg-white border-t border-gray-200 p-4 safe-bottom sm:static sm:border-0 sm:p-0 sm:mt-8 flex flex-col sm:flex-row justify-end gap-3 z-30">
<button wire:click="addPlayer" wire:loading.attr="disabled" wire:target="addPlayer"
class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-5 py-3.5 sm:py-3 border border-emerald-600 text-emerald-700 bg-white rounded-xl sm:rounded-lg hover:bg-emerald-50 text-base font-medium transition-colors disabled:opacity-50">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
<span wire:loading.remove wire:target="addPlayer">
{{ $editingPlayerIndex !== null ? __('حفظ التعديل') : __('إضافة أخ / أخت') }}
</span>
<span wire:loading wire:target="addPlayer">{{ __('جارٍ...') }}</span>
</button>
<button wire:click="nextStep" wire:loading.attr="disabled" wire:target="nextStep"
class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 sm:py-3 bg-blue-600 text-white rounded-xl sm:rounded-lg hover:bg-blue-700 text-base font-medium transition-colors disabled:opacity-50">
<span wire:loading.remove wire:target="nextStep">{{ __('التالي') }}</span>
<span wire:loading wire:target="nextStep">{{ __('جارٍ...') }}</span>
......@@ -619,50 +662,63 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-6 py
{{-- ===================== STEP 4: PROGRAM SELECTION ===================== --}}
@if($currentStep === 4)
<div>
<h2 class="text-lg font-semibold text-gray-800 mb-6">{{ __('اختيار البرنامج') }}</h2>
<h2 class="text-lg font-semibold text-gray-800 mb-1">{{ __('اختيار البرنامج') }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ __('لكل لاعب برنامجه — يمكن أن يختلف الإخوة في البرنامج') }}</p>
<div class="space-y-8">
@foreach($players as $i => $player)
<div class="rounded-xl border border-gray-200 p-4 sm:p-5" wire:key="prog-player-{{ $i }}">
<div class="flex items-center gap-2 mb-4">
<span class="w-7 h-7 shrink-0 rounded-full bg-blue-600 text-white text-xs font-bold flex items-center justify-center" dir="ltr">{{ $i + 1 }}</span>
<h3 class="font-semibold text-gray-800">{{ $player['name_ar'] }}</h3>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-3">{{ __('اختر النشاط') }}</label>
<div class="flex gap-2.5 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0 sm:flex-wrap sm:overflow-visible scrollbar-hide">
@foreach($activities as $activity)
<label class="relative cursor-pointer shrink-0 sm:shrink">
<input type="radio" wire:model.live="selected_activity_id" value="{{ $activity->id }}" class="peer sr-only">
<div class="px-5 py-3 sm:p-4 sm:min-h-[52px] flex items-center justify-center border border-gray-300 rounded-full sm:rounded-xl text-center font-medium transition-all whitespace-nowrap
peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 peer-checked:shadow-sm hover:border-gray-400">
{{ $activity->name_ar }}
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-3">{{ __('اختر النشاط') }}</label>
<div class="flex gap-2.5 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0 sm:flex-wrap sm:overflow-visible scrollbar-hide">
@foreach($activities as $activity)
<label class="relative cursor-pointer shrink-0 sm:shrink">
<input type="radio" wire:model.live="players.{{ $i }}.activity_id" value="{{ $activity->id }}" class="peer sr-only">
<div class="px-5 py-3 sm:p-4 sm:min-h-[52px] flex items-center justify-center border border-gray-300 rounded-full sm:rounded-xl text-center font-medium transition-all whitespace-nowrap
peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 peer-checked:shadow-sm hover:border-gray-400">
{{ $activity->name_ar }}
</div>
</label>
@endforeach
</div>
</label>
@endforeach
</div>
@error('selected_activity_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@error("players.{$i}.activity_id") <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@if($selected_activity_id)
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">{{ __('اختر البرنامج') }}</label>
@if($programs->isEmpty())
<div class="text-center py-8 text-gray-500">
<p>{{ __('لا يوجد برامج متاحة لهذا النشاط') }}</p>
</div>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@foreach($programs as $program)
<label class="relative cursor-pointer">
<input type="radio" wire:model="selected_program_id" value="{{ $program->id }}" class="peer sr-only">
<div class="p-5 border border-gray-300 rounded-xl transition-all
peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:shadow-sm hover:border-gray-400">
<h4 class="font-bold text-gray-800">{{ $program->name_ar }}</h4>
@if($program->description)
<p class="text-sm text-gray-600 mt-1">{{ Str::limit($program->description, 80) }}</p>
@endif
@if(!empty($player['activity_id']))
@php $available = $programsByActivity[$player['activity_id']] ?? collect(); @endphp
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">{{ __('اختر البرنامج') }}</label>
@if($available->isEmpty())
<div class="text-center py-8 text-gray-500">
<p>{{ __('لا يوجد برامج متاحة لهذا النشاط') }}</p>
</div>
</label>
@endforeach
@else
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@foreach($available as $program)
<label class="relative cursor-pointer">
<input type="radio" wire:model="players.{{ $i }}.program_id" value="{{ $program->id }}" class="peer sr-only">
<div class="p-5 border border-gray-300 rounded-xl transition-all
peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:shadow-sm hover:border-gray-400">
<h4 class="font-bold text-gray-800">{{ $program->name_ar }}</h4>
@if($program->description)
<p class="text-sm text-gray-600 mt-1">{{ Str::limit($program->description, 80) }}</p>
@endif
</div>
</label>
@endforeach
</div>
@endif
@error("players.{$i}.program_id") <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
</div>
@endif
@error('selected_program_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@endforeach
</div>
@endif
<div class="fixed bottom-0 start-0 end-0 bg-white border-t border-gray-200 p-4 safe-bottom sm:static sm:border-0 sm:p-0 sm:mt-8 flex justify-between gap-3 z-30">
<button wire:click="previousStep"
......@@ -691,25 +747,36 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-6 py
<div class="space-y-4">
{{-- Player Summary --}}
{{-- Players --}}
<div class="p-4 bg-gray-50 rounded-xl border border-gray-200">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-700">{{ __('اللاعب') }}</h3>
<h3 class="font-semibold text-gray-700">
{{ trans_choice('{1}اللاعب|[2,*]اللاعبون', count($players)) }}
@if(count($players) > 1)
<span class="text-xs font-normal text-gray-500" dir="ltr">({{ count($players) }})</span>
@endif
</h3>
<button wire:click="goToStep(1)" class="text-sm text-blue-600 hover:text-blue-800">{{ __('تعديل') }}</button>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-500">{{ __('الاسم') }}:</span> <span class="font-medium text-gray-800 ms-1">{{ $participant_name_ar }}</span></div>
<div><span class="text-gray-500">{{ __('تاريخ الميلاد') }}:</span> <span class="font-medium text-gray-800 ms-1" dir="ltr">{{ $participant_date_of_birth }}</span></div>
<div><span class="text-gray-500">{{ __('الجنس') }}:</span> <span class="font-medium text-gray-800 ms-1">{{ $participant_gender === 'male' ? __('ذكر') : __('أنثى') }}</span></div>
@if($participant_governorate)
<div><span class="text-gray-500">{{ __('المحافظة') }}:</span> <span class="font-medium text-gray-800 ms-1">{{ $participant_governorate }}</span></div>
@endif
<div>
<span class="text-gray-500">{{ term('membership') }}:</span>
<span class="font-medium ms-1 {{ $membership_type === 'member' ? 'text-green-700' : 'text-amber-700' }}">
{{ membership_label($membership_type) }}
</span>
<div class="space-y-3">
@foreach($players as $i => $player)
<div class="grid grid-cols-2 gap-3 text-sm {{ !$loop->first ? 'pt-3 border-t border-gray-200' : '' }}">
<div class="col-span-2 font-semibold text-gray-800">
<span dir="ltr">{{ $i + 1 }}.</span> {{ $player['name_ar'] }}
</div>
<div><span class="text-gray-500">{{ __('تاريخ الميلاد') }}:</span> <span class="font-medium text-gray-800 ms-1" dir="ltr">{{ $player['date_of_birth'] }}</span></div>
<div><span class="text-gray-500">{{ __('الجنس') }}:</span> <span class="font-medium text-gray-800 ms-1">{{ ($player['gender'] ?? 'male') === 'male' ? __('ذكر') : __('أنثى') }}</span></div>
@if(!empty($player['governorate']))
<div><span class="text-gray-500">{{ __('المحافظة') }}:</span> <span class="font-medium text-gray-800 ms-1">{{ $player['governorate'] }}</span></div>
@endif
<div>
<span class="text-gray-500">{{ term('membership') }}:</span>
<span class="font-medium ms-1 {{ $player['membership_type'] === 'member' ? 'text-green-700' : 'text-amber-700' }}">
{{ membership_label($player['membership_type']) }}
</span>
</div>
</div>
@endforeach
</div>
</div>
......@@ -726,32 +793,31 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-6 py
</div>
</div>
{{-- Program Summary + Fee --}}
{{-- Programme + fee, one block per child --}}
<div class="p-4 bg-gray-50 rounded-xl border border-gray-200">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-700">{{ __('البرنامج') }}</h3>
<h3 class="font-semibold text-gray-700">{{ __('البرامج والرسوم') }}</h3>
<button wire:click="goToStep(4)" class="text-sm text-blue-600 hover:text-blue-800">{{ __('تعديل') }}</button>
</div>
@if($this->selectedProgram)
<p class="text-sm font-medium text-gray-800">{{ $this->selectedProgram->name_ar }}</p>
@if($this->selectedProgram->activity)
<p class="text-xs text-gray-500 mt-1">{{ $this->selectedProgram->activity->name_ar }}</p>
@endif
{{-- Enrollment Start Date --}}
<div class="mt-3 pt-3 border-t border-gray-200">
{{-- Start date and proration are decided once for the visit:
the family walks in on one day, and asking per child
would be four answers to the same question. --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تاريخ بداية اللعب') }}</label>
<input type="date" wire:model.live="enrollment_start_date" dir="ltr"
class="w-full sm:w-auto px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-500 mt-1">{{ __('اتركه فارغاً لاستخدام تاريخ اليوم. حدد التاريخ إذا بدأ اللاعب قبل اليوم لحساب الأيام المتبقية بدقة.') }}</p>
</div>
@php $lines = $this->playerLines; @endphp
{{-- 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>
@if($this->programsSubtotal > 0 && app(\App\Domain\Shared\Services\ProrationService::class)->isEnabled())
<div class="mb-4 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
......@@ -768,25 +834,40 @@ class="w-4 h-4 border-gray-300 text-blue-600 focus:ring-blue-500">
</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)
<div class="flex items-center justify-between">
<span class="text-gray-500">{{ __('السعر الأصلي') }}:</span>
<span class="text-gray-400 line-through" dir="ltr">{{ number_format($this->proratedProgramFee->originalAmount / 100, 2) }} {{ __('ج.م') }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-blue-600 font-medium">{{ __('رسوم البرنامج') }} <span class="text-xs font-normal">({{ $this->proratedProgramFee->description }})</span>:</span>
<span class="font-medium text-blue-700" dir="ltr">{{ number_format($this->proratedProgramFee->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@else
<div class="flex items-center justify-between">
<span class="text-gray-500">{{ __('رسوم البرنامج') }}:</span>
<span class="font-medium text-gray-800" dir="ltr">{{ number_format($this->selectedProgramFee / 100, 2) }} {{ __('ج.م') }}</span>
<div class="space-y-3">
@foreach($players as $i => $player)
@php $line = $lines[$i] ?? null; @endphp
<div class="{{ !$loop->first ? 'pt-3 border-t border-gray-200' : '' }}">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-800 truncate">{{ $player['name_ar'] }}</p>
<p class="text-sm text-gray-700">{{ $line['program']?->name_ar ?? __('لم يتم اختيار برنامج') }}</p>
@if($line && $line['program']?->activity)
<p class="text-xs text-gray-500">{{ $line['program']->activity->name_ar }}</p>
@endif
</div>
<div class="text-end shrink-0">
@if(!empty($player['is_free']))
<span class="text-sm font-medium text-green-700">{{ __('مجاني') }}</span>
@elseif($line && $line['proration']->applied)
<span class="block text-xs text-gray-400 line-through" dir="ltr">{{ number_format($line['proration']->originalAmount / 100, 2) }}</span>
<span class="block text-sm font-medium text-blue-700" dir="ltr">{{ number_format($line['proration']->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
<span class="block text-[11px] text-gray-500">{{ $line['proration']->description }}</span>
@elseif($line)
<span class="block text-sm font-medium text-gray-800" dir="ltr">{{ number_format($line['proration']->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
@endif
</div>
</div>
</div>
@endif
@endforeach
</div>
@endif
@if(count($players) > 1)
<div class="mt-3 pt-3 border-t border-gray-200 flex items-center justify-between text-sm">
<span class="font-medium text-gray-700">{{ __('إجمالي الاشتراكات') }}:</span>
<span class="font-bold text-gray-900" dir="ltr">{{ number_format($this->programsSubtotal / 100, 2) }} {{ __('ج.م') }}</span>
</div>
<p class="mt-1 text-[11px] text-gray-500">{{ __('خصم الإخوة — إن وُجد — محسوب بالفعل في الأسعار أعلاه') }}</p>
@endif
</div>
......@@ -801,6 +882,24 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium">
</button>
</div>
{{-- Who is this for? A kit is issued to a person and lands
on that person's invoice, so with a family on the roster
the desk has to say which child each item belongs to. --}}
@if(count($players) > 1)
<div class="mb-3">
<p class="text-xs font-medium text-amber-700 mb-2">{{ __('الإضافة لحساب') }}</p>
<div class="flex flex-wrap gap-2">
@foreach($players as $i => $player)
<button type="button" wire:click="$set('cartPlayerIndex', {{ $i }})"
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{{ $cartPlayerIndex === $i ? 'bg-amber-600 text-white border-amber-700' : 'bg-white text-amber-800 border-amber-300 hover:bg-amber-100' }}">
{{ $player['name_ar'] }}
</button>
@endforeach
</div>
</div>
@endif
{{-- Essential product quick-buttons --}}
@php $essentials = $this->essentialProducts; @endphp
@if(count($essentials) > 0)
......@@ -808,7 +907,7 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium">
<p class="text-xs font-medium text-amber-700 mb-2">{{ __('منتجات أساسية') }}</p>
<div class="flex flex-wrap gap-2">
@foreach($essentials as $ep)
@php $epKey = "product_{$ep['id']}"; @endphp
@php $epKey = "product_{$ep['id']}_p{$cartPlayerIndex}"; @endphp
<button type="button" wire:click="addHotbuyItem({{ $ep['id'] }}, 'product')"
class="flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{{ isset($hotbuyCart[$epKey]) ? 'bg-amber-600 text-white border-amber-700' : 'bg-white text-amber-800 border-amber-300 hover:bg-amber-100' }}">
......@@ -854,11 +953,18 @@ class="w-full flex items-center justify-between px-4 py-3 text-start hover:bg-am
<div class="bg-white rounded-xl border {{ $isAnnual ? 'border-purple-200' : 'border-gray-200' }} overflow-hidden">
<div class="flex items-center justify-between p-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
<p class="text-sm font-medium text-gray-800">{{ $cartItem['name_ar'] }}</p>
@if($isAnnual)
<span class="px-1.5 py-0.5 text-xs rounded bg-purple-100 text-purple-700 font-medium">{{ __('سنوي') }}</span>
@endif
{{-- Whose item this is. Only worth the space
when there is more than one child. --}}
@if(count($players) > 1)
<span class="px-1.5 py-0.5 text-xs rounded bg-blue-100 text-blue-700 font-medium">
{{ $players[$cartItem['player_index'] ?? 0]['name_ar'] ?? '—' }}
</span>
@endif
</div>
<p class="text-xs text-gray-500 mt-0.5" dir="ltr">{{ number_format($cartItem['price'] / 100, 2) }} {{ __('ج.م') }}
@if($isAnnual) × 1 {{ __('سنة') }} @endif
......@@ -1045,10 +1151,10 @@ class="px-2.5 py-1 text-xs font-medium rounded-md border transition-colors
@if($this->totalWithFee > 0)
<div class="p-4 bg-green-50 rounded-xl border border-green-200">
<div class="space-y-1.5 text-sm">
@if($this->proratedProgramFee->proratedAmount > 0)
@if($this->programsSubtotal > 0)
<div class="flex items-center justify-between">
<span class="text-gray-600">{{ __('رسوم البرنامج') }}</span>
<span class="font-medium text-gray-800" dir="ltr">{{ number_format($this->proratedProgramFee->proratedAmount / 100, 2) }} {{ __('ج.م') }}</span>
<span class="text-gray-600">{{ trans_choice('{1}رسوم البرنامج|[2,*]رسوم الاشتراكات', count($players)) }}</span>
<span class="font-medium text-gray-800" dir="ltr">{{ number_format($this->programsSubtotal / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@endif
@if($this->hotbuyTotal > 0)
......@@ -1409,12 +1515,51 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-8 py
</svg>
</div>
<h2 class="text-xl font-bold text-green-700 mb-1">{{ __('تم التسجيل بنجاح!') }}</h2>
@if(count($registeredPlayers) <= 1)
<p class="text-gray-500 text-sm">{{ __('رقم المشترك') }}: <strong dir="ltr">{{ $participant_number }}</strong></p>
@if($enrollment_summary)
<p class="text-gray-500 text-sm mt-1">{{ $enrollment_summary }}</p>
@endif
@endif
</div>
{{-- Every child registered in this visit, each with their own
participant number and invoice — the desk hands the parent one
line per child. --}}
@if(count($registeredPlayers) > 1)
<div class="mb-6 rounded-xl border border-gray-200 divide-y divide-gray-100 overflow-hidden">
@foreach($registeredPlayers as $done)
<div class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-semibold text-gray-800 truncate">{{ $done['name_ar'] }}</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ __('رقم المشترك') }}: <strong dir="ltr">{{ $done['participant_number'] }}</strong>
</p>
<p class="text-xs text-gray-500">{{ $done['program'] }}@if($done['group']) — {{ $done['group'] }}@endif</p>
</div>
<div class="text-end shrink-0">
@if($done['invoice_number'])
<p class="text-xs text-gray-500" dir="ltr">{{ $done['invoice_number'] }}</p>
<p class="text-sm font-bold text-gray-800" dir="ltr">{{ number_format($done['invoice_amount'] / 100, 2) }} {{ __('ج.م') }}</p>
@else
<span class="text-xs text-green-700">{{ __('بدون فاتورة') }}</span>
@endif
</div>
</div>
<div class="mt-2 flex flex-wrap gap-3 text-xs">
<a href="{{ route('participants.show', $done['participant_uuid']) }}"
class="text-blue-600 hover:text-blue-800">{{ __('ملف اللاعب') }}</a>
@if($done['invoice_uuid'])
<a href="{{ route('invoices.show', $done['invoice_uuid']) }}"
class="text-blue-600 hover:text-blue-800">{{ __('الفاتورة') }}</a>
@endif
</div>
</div>
@endforeach
</div>
@endif
@if($this->invoiceForPrint)
@php
$inv = $this->invoiceForPrint;
......
......@@ -165,16 +165,45 @@ public function test_a_list_customization_with_no_options_is_refused(): void
// --- The desk side ---
/**
* A wizard with one child on the roster.
*
* Cart lines belong to a player now — a kit is issued to a person and
* lands on that person's invoice — so there is nothing to add an item to
* until at least one child has been entered.
*/
private function wizardWithOnePlayer(): \Livewire\Features\SupportTesting\Testable
{
return Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('players', [[
'name_ar' => 'أدم محمد حسن على',
'name' => '',
'date_of_birth' => '2015-04-01',
'gender' => 'male',
'phone' => '',
'national_id' => '',
'medical_notes' => '',
'membership_type' => 'non_member',
'membership_id' => '',
'governorate' => '',
'is_foreign' => false,
'nid_decoded' => false,
'is_free' => false,
'activity_id' => null,
'program_id' => null,
]]);
}
public function test_the_cart_carries_the_questions_the_product_asks(): void
{
$product = $this->aCustomisedProduct();
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
$component = $this->wizardWithOnePlayer()
->call('addHotbuyItem', $product->id, 'product');
$cart = $component->get('hotbuyCart');
$key = "product_{$product->id}";
$key = "product_{$product->id}_p0";
$this->assertArrayHasKey($key, $cart);
$this->assertCount(2, $cart[$key]['customizations']);
......@@ -186,8 +215,7 @@ public function test_the_cart_draws_the_questions_and_flags_the_unanswered_ones(
{
// The blade reads $this->customizationGaps per cart row; a fumbled key
// there throws at render time, not at guard time.
Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
$this->wizardWithOnePlayer()
->call('addHotbuyItem', $this->aCustomisedProduct()->id, 'product')
->set('currentStep', 5)
->assertOk()
......@@ -201,8 +229,7 @@ public function test_a_blank_required_customization_blocks_the_step_before_payme
{
$product = $this->aCustomisedProduct();
Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
$this->wizardWithOnePlayer()
->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5)
->call('nextStep')
......@@ -215,11 +242,10 @@ public function test_an_answered_required_customization_lets_the_step_through():
$product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
$this->wizardWithOnePlayer()
->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5)
->set("hotbuyCustomizations.product_{$product->id}.{$sizeId}", 'ميديم')
->set("hotbuyCustomizations.product_{$product->id}_p0.{$sizeId}", 'ميديم')
->call('nextStep')
->assertHasNoErrors()
->assertSet('currentStep', 6);
......@@ -232,11 +258,10 @@ public function test_an_option_the_product_never_offered_is_refused(): void
$product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
$this->wizardWithOnePlayer()
->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5)
->set("hotbuyCustomizations.product_{$product->id}.{$sizeId}", 'إكس لارج مجاناً')
->set("hotbuyCustomizations.product_{$product->id}_p0.{$sizeId}", 'إكس لارج مجاناً')
->call('nextStep')
->assertHasErrors('hotbuyCustomizations')
->assertSet('currentStep', 5);
......@@ -247,11 +272,10 @@ public function test_an_optional_customization_left_blank_does_not_block(): void
$product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
$this->wizardWithOnePlayer()
->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5)
->set("hotbuyCustomizations.product_{$product->id}.{$sizeId}", 'لارج')
->set("hotbuyCustomizations.product_{$product->id}_p0.{$sizeId}", 'لارج')
// 'الاسم المطبوع' deliberately left empty
->call('nextStep')
->assertSet('currentStep', 6);
......@@ -261,10 +285,9 @@ public function test_removing_the_item_drops_its_answers(): void
{
$product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
$key = "product_{$product->id}";
$key = "product_{$product->id}_p0";
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
$component = $this->wizardWithOnePlayer()
->call('addHotbuyItem', $product->id, 'product')
->set("hotbuyCustomizations.{$key}.{$sizeId}", 'لارج')
->call('removeHotbuyItem', $key);
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Models\Person;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingProgram;
use App\Livewire\Receptionist\NewRegistrationWizard;
use App\Models\User;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Tests\TestCase;
/**
* A family registered in one visit at the desk.
*
* The wizard used to take exactly one child, and that was not merely
* inconvenient: the sibling discount is priced off how big the family is, and
* a child registered on their own always looked like a family of one. The
* first child could never qualify, and by the time the second was entered the
* first child's invoice was frozen — invoices are immutable — so nobody could
* put it right afterwards either.
*
* What these tests hold down:
*
* - both children are priced knowing the whole family, not just themselves;
* - each child gets their own participant, enrolment and invoice, because
* that is how every other invoice in the system is filed and how the
* renewal cron finds them next month;
* - one sum of money at the desk lands on those invoices adding up to
* exactly what was quoted — no piaster invented, none lost to rounding.
*
* Runs against a restored Postgres tenant; skips on the default SQLite suite:
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter SiblingRegistrationTest
*/
class SiblingRegistrationTest extends TestCase
{
/** @var array<int, Participant> created by a test, torn down after it */
private array $created = [];
private ?Guardian $guardian = null;
private string $phone = '';
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
$academy = \App\Domain\Shared\Models\Academy::query()->first();
if (! $academy) {
$this->markTestSkipped('No academy in the restored tenant.');
}
app()->instance('current_academy', $academy);
// A phone nobody else in the tenant owns, so the sibling lookup finds
// this test's family and only this test's family.
$this->phone = '0109' . random_int(1000000, 9999999);
}
protected function tearDown(): void
{
foreach ($this->created as $participant) {
$invoiceIds = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->pluck('id');
Payment::withoutGlobalScopes()->whereIn('invoice_id', $invoiceIds)->forceDelete();
\App\Domain\Financial\Models\Transaction::withoutGlobalScopes()
->whereIn('reference_id', $invoiceIds)
->where('reference_type', Invoice::class)
->delete();
\DB::table('invoice_items')->whereIn('invoice_id', $invoiceIds)->delete();
// Enrolments carry invoice_id, so they go before the invoices they
// point at or Postgres refuses the delete.
Enrollment::withoutGlobalScopes()->where('participant_id', $participant->id)->forceDelete();
Invoice::withoutGlobalScopes()->whereIn('id', $invoiceIds)->forceDelete();
\DB::table('guardian_participant')->where('participant_id', $participant->id)->delete();
$personId = $participant->person_id;
Participant::withoutGlobalScopes()->whereKey($participant->id)->forceDelete();
Person::withoutGlobalScopes()->whereKey($personId)->forceDelete();
}
if ($this->guardian) {
$personId = $this->guardian->person_id;
Guardian::withoutGlobalScopes()->whereKey($this->guardian->id)->forceDelete();
Person::withoutGlobalScopes()->whereKey($personId)->forceDelete();
}
$this->created = [];
$this->guardian = null;
parent::tearDown();
}
private function anOwner(): User
{
$user = User::withoutGlobalScopes()
->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
->first();
if (! $user) {
$this->markTestSkipped('No academy_owner in the restored tenant.');
}
return $user;
}
/** A programme with a live group and an active price, so the sale can complete. */
private function aSellableProgram(): TrainingProgram
{
$program = TrainingProgram::withoutBranchScope()
->where('status', 'active')
->whereHas('groups', fn ($q) => $q->whereIn('status', ['active', 'forming'])
->whereColumn('current_count', '<', 'max_capacity'))
->whereIn('id', BasePrice::query()
->where('priceable_type', TrainingProgram::class)
->where('is_active', true)
->select('priceable_id'))
->first();
if (! $program) {
$this->markTestSkipped('No priced programme with a live group in the restored tenant.');
}
return $program;
}
/**
* @return array<string, mixed>
*/
private function player(string $name, string $dob, ?int $programId = null, ?int $activityId = null): array
{
return [
'name_ar' => $name,
'name' => '',
'date_of_birth' => $dob,
'gender' => 'male',
'phone' => '',
'national_id' => '',
'medical_notes' => '',
'membership_type' => 'non_member',
'membership_id' => '',
'governorate' => '',
'is_foreign' => false,
'nid_decoded' => false,
'is_free' => false,
'activity_id' => $activityId,
'program_id' => $programId,
];
}
// ---- the roster ------------------------------------------------------
public function test_the_draft_form_becomes_a_roster_entry(): void
{
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('participant_name_ar', 'أدم محمد حسن على')
->set('participant_date_of_birth', '2015-04-01')
->set('participant_gender', 'male')
->call('addPlayer');
$component->assertHasNoErrors();
$players = $component->get('players');
$this->assertCount(1, $players);
$this->assertSame('أدم محمد حسن على', $players[0]['name_ar']);
// The sheet is clear for the next child rather than still holding the
// last one, which is what made "add another" feel like an edit.
$component->assertSet('participant_name_ar', '');
}
public function test_a_half_typed_child_is_not_lost_when_the_desk_presses_next(): void
{
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('participant_name_ar', 'مازن عمرو أحمد عبدالرازق')
->set('participant_date_of_birth', '2016-02-11')
->call('nextStep');
$component->assertSet('currentStep', 2);
$this->assertCount(1, $component->get('players'));
}
public function test_removing_a_child_takes_their_cart_lines_with_them(): void
{
$product = \App\Domain\Inventory\Models\Product::withoutBranchScope()
->where('is_active', true)
->first();
if (! $product) {
$this->markTestSkipped('No active product in the restored tenant.');
}
// hotbuyCart is #[Locked] on purpose — confirm() writes its prices
// straight onto invoice lines — so the cart is built the way the desk
// builds it rather than set from the test.
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('players', [
$this->player('أدم محمد حسن على', '2015-04-01'),
$this->player('مازن محمد حسن على', '2017-06-02'),
])
->set('cartPlayerIndex', 0)
->call('addHotbuyItem', $product->id, 'product')
->set('cartPlayerIndex', 1)
->call('addHotbuyItem', $product->id, 'product');
// The same product for two children is two lines, not a quantity of
// two: they go on different invoices and are issued to different people.
$this->assertCount(2, $component->get('hotbuyCart'));
$component->call('removePlayer', 0);
$cart = $component->get('hotbuyCart');
// The surviving child's line follows them down to index 0. Leaving it
// at index 1 would silently hand their shirt to nobody.
$this->assertCount(1, $cart);
$this->assertArrayHasKey("product_{$product->id}_p0", $cart);
$this->assertSame(0, $cart["product_{$product->id}_p0"]['player_index']);
}
// ---- the sibling discount -------------------------------------------
public function test_two_children_registered_together_are_priced_as_a_family_of_two(): void
{
$program = $this->aSellableProgram();
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('guardian_phone', $this->phone)
->set('players', [
$this->player('أدم محمد حسن على', '2015-04-01', $program->id, $program->activity_id),
$this->player('مازن محمد حسن على', '2017-06-02', $program->id, $program->activity_id),
]);
$lines = $component->instance()->playerLines;
$this->assertCount(2, $lines);
// Both children price against a real base price, so neither line is
// the silent zero an unpriceable programme would produce.
$this->assertGreaterThan(0, $lines[0]['fee']);
$this->assertGreaterThan(0, $lines[1]['fee']);
// The family total is what the review screen and the invoices both
// read, so it has to be the sum of the lines and nothing else.
$this->assertSame(
$lines[0]['proration']->proratedAmount + $lines[1]['proration']->proratedAmount,
$component->instance()->programsSubtotal
);
}
public function test_the_second_child_on_the_roster_is_the_second_sibling(): void
{
// Reaching into the private method is the point: sibling_order is what
// the pricing engine matches a sibling rule against, and a rule of
// "from the second child" is invisible from the outside until a rule
// happens to be configured. This asserts the input, not the discount.
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('guardian_phone', $this->phone)
->set('players', [
$this->player('أدم محمد حسن على', '2015-04-01'),
$this->player('مازن محمد حسن على', '2017-06-02'),
$this->player('يوسف محمد حسن على', '2019-01-20'),
]);
$resolve = new \ReflectionMethod(NewRegistrationWizard::class, 'resolveSiblingPosition');
$resolve->setAccessible(true);
$instance = $component->instance();
$this->assertSame([3, 1], $resolve->invoke($instance, 0));
$this->assertSame([3, 2], $resolve->invoke($instance, 1));
$this->assertSame([3, 3], $resolve->invoke($instance, 2));
}
// ---- the money -------------------------------------------------------
public function test_a_family_gets_one_invoice_each_and_one_payment_split_across_them(): void
{
$program = $this->aSellableProgram();
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('players', [
$this->player('أدم ' . Str::random(6) . ' حسن على', '2015-04-01', $program->id, $program->activity_id),
$this->player('مازن ' . Str::random(6) . ' حسن على', '2017-06-02', $program->id, $program->activity_id),
])
->set('guardian_name_ar', 'محمد حسن على ابراهيم')
->set('guardian_phone', $this->phone)
->set('guardian_relation', 'father')
->set('createParentAccount', false)
->set('pay_now', true)
->set('payment_method', 'cash')
->set('currentStep', 6);
$quoted = $component->instance()->effectiveTotal;
$component->call('confirm')->assertHasNoErrors();
$registered = $component->get('registeredPlayers');
$this->assertCount(2, $registered, 'both children should be registered');
$this->guardian = Guardian::withoutGlobalScopes()
->whereHas('person', fn ($q) => $q->where('phone', $this->phone))
->first();
$invoices = [];
foreach ($registered as $row) {
$participant = Participant::withoutGlobalScopes()->where('uuid', $row['participant_uuid'])->first();
$this->assertNotNull($participant, 'each child gets their own participant row');
$this->created[] = $participant;
// Each child is enrolled in their own right — this is what the
// renewal cron reads next month, and a child with no enrolment row
// is a child nothing ever bills.
$this->assertTrue(
Enrollment::withoutGlobalScopes()->where('participant_id', $participant->id)->exists(),
'each child gets their own enrolment'
);
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->first();
$this->assertNotNull($invoice, 'each child gets their own invoice');
$invoices[] = $invoice;
}
// The invoices add up to exactly what the desk quoted. This is the
// property that a proportional split can break by a piaster and
// nobody notices until a month-end reconciliation.
$this->assertSame(
$quoted,
array_sum(array_map(fn (Invoice $i) => (int) $i->total_amount, $invoices)),
'the children\'s invoices must sum to the quoted family total'
);
// And the single sum collected at the desk lands on them, in full.
$paid = Payment::withoutGlobalScopes()
->whereIn('invoice_id', array_map(fn (Invoice $i) => $i->id, $invoices))
->sum('amount');
$this->assertSame($quoted, (int) $paid, 'the money taken must equal the money quoted');
foreach ($invoices as $invoice) {
$this->assertSame(
(int) $invoice->total_amount,
(int) $invoice->fresh()->paid_amount,
'a fully paid family leaves no invoice part-paid'
);
}
}
public function test_a_part_payment_settles_the_children_in_order_and_leaves_one_balance(): void
{
$program = $this->aSellableProgram();
$component = Livewire::actingAs($this->anOwner())
->test(NewRegistrationWizard::class)
->set('players', [
$this->player('أدم ' . Str::random(6) . ' حسن على', '2015-04-01', $program->id, $program->activity_id),
$this->player('مازن ' . Str::random(6) . ' حسن على', '2017-06-02', $program->id, $program->activity_id),
])
->set('guardian_name_ar', 'محمد حسن على ابراهيم')
->set('guardian_phone', $this->phone)
->set('guardian_relation', 'father')
->set('createParentAccount', false)
->set('pay_now', true)
->set('payment_method', 'cash')
->set('currentStep', 6);
$lines = $component->instance()->playerLines;
$firstChildOwes = $lines[0]['proration']->proratedAmount;
// Pay exactly the first child's subscription and not a piaster more.
$component
->set('partial_payment', true)
->set('partial_amount_input', number_format($firstChildOwes / 100, 2, '.', ''))
->call('confirm')
->assertHasNoErrors();
$registered = $component->get('registeredPlayers');
$this->assertCount(2, $registered);
$this->guardian = Guardian::withoutGlobalScopes()
->whereHas('person', fn ($q) => $q->where('phone', $this->phone))
->first();
$balances = [];
foreach ($registered as $row) {
$participant = Participant::withoutGlobalScopes()->where('uuid', $row['participant_uuid'])->first();
$this->created[] = $participant;
$invoice = Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->where('billable_id', $participant->id)
->first();
$balances[] = (int) $invoice->total_amount - (int) $invoice->paid_amount;
}
// Filled in roster order: the first child is settled, the shortfall is
// visible on one invoice rather than smeared across both.
$this->assertSame(0, $balances[0], 'the first child should be settled in full');
$this->assertGreaterThan(0, $balances[1], 'the shortfall belongs to the last child');
$this->assertSame($firstChildOwes, (int) Payment::withoutGlobalScopes()
->whereIn('invoice_id', Invoice::withoutGlobalScopes()
->where('billable_type', Participant::class)
->whereIn('billable_id', array_map(fn ($p) => $p->id, $this->created))
->pluck('id'))
->sum('amount'));
}
}
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