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
...@@ -165,16 +165,45 @@ public function test_a_list_customization_with_no_options_is_refused(): void ...@@ -165,16 +165,45 @@ public function test_a_list_customization_with_no_options_is_refused(): void
// --- The desk side --- // --- 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 public function test_the_cart_carries_the_questions_the_product_asks(): void
{ {
$product = $this->aCustomisedProduct(); $product = $this->aCustomisedProduct();
$component = Livewire::actingAs($this->anOwner()) $component = $this->wizardWithOnePlayer()
->test(NewRegistrationWizard::class)
->call('addHotbuyItem', $product->id, 'product'); ->call('addHotbuyItem', $product->id, 'product');
$cart = $component->get('hotbuyCart'); $cart = $component->get('hotbuyCart');
$key = "product_{$product->id}"; $key = "product_{$product->id}_p0";
$this->assertArrayHasKey($key, $cart); $this->assertArrayHasKey($key, $cart);
$this->assertCount(2, $cart[$key]['customizations']); $this->assertCount(2, $cart[$key]['customizations']);
...@@ -186,8 +215,7 @@ public function test_the_cart_draws_the_questions_and_flags_the_unanswered_ones( ...@@ -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 // The blade reads $this->customizationGaps per cart row; a fumbled key
// there throws at render time, not at guard time. // there throws at render time, not at guard time.
Livewire::actingAs($this->anOwner()) $this->wizardWithOnePlayer()
->test(NewRegistrationWizard::class)
->call('addHotbuyItem', $this->aCustomisedProduct()->id, 'product') ->call('addHotbuyItem', $this->aCustomisedProduct()->id, 'product')
->set('currentStep', 5) ->set('currentStep', 5)
->assertOk() ->assertOk()
...@@ -201,8 +229,7 @@ public function test_a_blank_required_customization_blocks_the_step_before_payme ...@@ -201,8 +229,7 @@ public function test_a_blank_required_customization_blocks_the_step_before_payme
{ {
$product = $this->aCustomisedProduct(); $product = $this->aCustomisedProduct();
Livewire::actingAs($this->anOwner()) $this->wizardWithOnePlayer()
->test(NewRegistrationWizard::class)
->call('addHotbuyItem', $product->id, 'product') ->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5) ->set('currentStep', 5)
->call('nextStep') ->call('nextStep')
...@@ -215,11 +242,10 @@ public function test_an_answered_required_customization_lets_the_step_through(): ...@@ -215,11 +242,10 @@ public function test_an_answered_required_customization_lets_the_step_through():
$product = $this->aCustomisedProduct(); $product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id; $sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
Livewire::actingAs($this->anOwner()) $this->wizardWithOnePlayer()
->test(NewRegistrationWizard::class)
->call('addHotbuyItem', $product->id, 'product') ->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5) ->set('currentStep', 5)
->set("hotbuyCustomizations.product_{$product->id}.{$sizeId}", 'ميديم') ->set("hotbuyCustomizations.product_{$product->id}_p0.{$sizeId}", 'ميديم')
->call('nextStep') ->call('nextStep')
->assertHasNoErrors() ->assertHasNoErrors()
->assertSet('currentStep', 6); ->assertSet('currentStep', 6);
...@@ -232,11 +258,10 @@ public function test_an_option_the_product_never_offered_is_refused(): void ...@@ -232,11 +258,10 @@ public function test_an_option_the_product_never_offered_is_refused(): void
$product = $this->aCustomisedProduct(); $product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id; $sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
Livewire::actingAs($this->anOwner()) $this->wizardWithOnePlayer()
->test(NewRegistrationWizard::class)
->call('addHotbuyItem', $product->id, 'product') ->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5) ->set('currentStep', 5)
->set("hotbuyCustomizations.product_{$product->id}.{$sizeId}", 'إكس لارج مجاناً') ->set("hotbuyCustomizations.product_{$product->id}_p0.{$sizeId}", 'إكس لارج مجاناً')
->call('nextStep') ->call('nextStep')
->assertHasErrors('hotbuyCustomizations') ->assertHasErrors('hotbuyCustomizations')
->assertSet('currentStep', 5); ->assertSet('currentStep', 5);
...@@ -247,11 +272,10 @@ public function test_an_optional_customization_left_blank_does_not_block(): void ...@@ -247,11 +272,10 @@ public function test_an_optional_customization_left_blank_does_not_block(): void
$product = $this->aCustomisedProduct(); $product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id; $sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
Livewire::actingAs($this->anOwner()) $this->wizardWithOnePlayer()
->test(NewRegistrationWizard::class)
->call('addHotbuyItem', $product->id, 'product') ->call('addHotbuyItem', $product->id, 'product')
->set('currentStep', 5) ->set('currentStep', 5)
->set("hotbuyCustomizations.product_{$product->id}.{$sizeId}", 'لارج') ->set("hotbuyCustomizations.product_{$product->id}_p0.{$sizeId}", 'لارج')
// 'الاسم المطبوع' deliberately left empty // 'الاسم المطبوع' deliberately left empty
->call('nextStep') ->call('nextStep')
->assertSet('currentStep', 6); ->assertSet('currentStep', 6);
...@@ -261,10 +285,9 @@ public function test_removing_the_item_drops_its_answers(): void ...@@ -261,10 +285,9 @@ public function test_removing_the_item_drops_its_answers(): void
{ {
$product = $this->aCustomisedProduct(); $product = $this->aCustomisedProduct();
$sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id; $sizeId = $product->activeCustomizations->firstWhere('name_ar', 'المقاس')->id;
$key = "product_{$product->id}"; $key = "product_{$product->id}_p0";
$component = Livewire::actingAs($this->anOwner()) $component = $this->wizardWithOnePlayer()
->test(NewRegistrationWizard::class)
->call('addHotbuyItem', $product->id, 'product') ->call('addHotbuyItem', $product->id, 'product')
->set("hotbuyCustomizations.{$key}.{$sizeId}", 'لارج') ->set("hotbuyCustomizations.{$key}.{$sizeId}", 'لارج')
->call('removeHotbuyItem', $key); ->call('removeHotbuyItem', $key);
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment