Commit fe016b49 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add per-slot explicit installment amounts to product plans

- Migration: add installment_amounts JSONB (nullable) to product_installment_plans
- ProductInstallmentPlan: new buildSchedule() method — uses explicit piaster amounts per slot where set, auto-calculates remainder evenly for blank slots; downPaymentAmount/regularInstallmentAmount respect slot[0] override
- ProductForm: syncInstallmentSlots() keeps slot array in sync when count changes; plan rows load/save installment_amounts (stored as piasters, shown as pounds); slot inputs appear under collapsible "تحديد مبلغ كل قسط" toggle per plan row
- Wizard confirm(): uses buildSchedule() so each Installment row gets its correct explicit or auto-calculated amount
- Wizard blade breakdown preview: renders individual amounts when slots differ, uniform "N × amount" when all regular slots match
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 7f4dbddd
...@@ -20,6 +20,7 @@ class ProductInstallmentPlan extends Model ...@@ -20,6 +20,7 @@ class ProductInstallmentPlan extends Model
'installments', 'installments',
'frequency', 'frequency',
'down_payment_pct', 'down_payment_pct',
'installment_amounts',
'is_active', 'is_active',
'sort_order', 'sort_order',
]; ];
...@@ -29,6 +30,7 @@ protected function casts(): array ...@@ -29,6 +30,7 @@ protected function casts(): array
return [ return [
'installments' => 'integer', 'installments' => 'integer',
'down_payment_pct' => 'integer', 'down_payment_pct' => 'integer',
'installment_amounts' => 'array',
'is_active' => 'boolean', 'is_active' => 'boolean',
'sort_order' => 'integer', 'sort_order' => 'integer',
]; ];
...@@ -51,9 +53,31 @@ public function scopeForTier(Builder $query, string $tier): Builder ...@@ -51,9 +53,31 @@ public function scopeForTier(Builder $query, string $tier): Builder
); );
} }
/**
* Get the piaster amount for a specific slot (0-based index).
* If an explicit amount was set for that slot, use it; otherwise auto-calculate.
*/
public function amountForSlot(int $slotIndex, int $totalPiasters): int
{
$explicit = $this->installment_amounts[$slotIndex] ?? null;
if ($explicit !== null && $explicit > 0) {
return (int) $explicit;
}
// Auto-calculate based on whether this is the first slot (down payment) or regular
if ($slotIndex === 0) {
return $this->downPaymentAmount($totalPiasters);
}
return $this->regularInstallmentAmount($totalPiasters);
}
/** Calculate down payment amount in piasters */ /** Calculate down payment amount in piasters */
public function downPaymentAmount(int $totalPiasters): int public function downPaymentAmount(int $totalPiasters): int
{ {
$explicit = $this->installment_amounts[0] ?? null;
if ($explicit !== null && $explicit > 0) {
return (int) $explicit;
}
if ($this->down_payment_pct <= 0) { if ($this->down_payment_pct <= 0) {
return (int) ceil($totalPiasters / $this->installments); return (int) ceil($totalPiasters / $this->installments);
} }
...@@ -71,6 +95,36 @@ public function regularInstallmentAmount(int $totalPiasters): int ...@@ -71,6 +95,36 @@ public function regularInstallmentAmount(int $totalPiasters): int
return (int) ceil($afterDown / $remaining); return (int) ceil($afterDown / $remaining);
} }
/**
* Build the full schedule of amounts for all slots.
* Uses explicit amounts where set, auto-calculates the rest.
*/
public function buildSchedule(int $totalPiasters): array
{
$schedule = [];
$explicitSum = 0;
$explicitSlots = [];
// First pass: collect explicit slots
for ($i = 0; $i < $this->installments; $i++) {
$explicit = $this->installment_amounts[$i] ?? null;
if ($explicit !== null && $explicit > 0) {
$explicitSum += (int) $explicit;
$explicitSlots[$i] = (int) $explicit;
}
}
$autoSlots = $this->installments - count($explicitSlots);
$remainingForAuto = max(0, $totalPiasters - $explicitSum);
$autoAmount = $autoSlots > 0 ? (int) ceil($remainingForAuto / $autoSlots) : 0;
for ($i = 0; $i < $this->installments; $i++) {
$schedule[$i] = $explicitSlots[$i] ?? $autoAmount;
}
return $schedule;
}
public function frequencyLabel(): string public function frequencyLabel(): string
{ {
return match ($this->frequency) { return match ($this->frequency) {
......
...@@ -74,13 +74,14 @@ public function mount(?Product $product = null): void ...@@ -74,13 +74,14 @@ public function mount(?Product $product = null): void
->orderBy('sort_order') ->orderBy('sort_order')
->get() ->get()
->map(fn ($p) => [ ->map(fn ($p) => [
'id' => $p->id, 'id' => $p->id,
'tier' => $p->membership_tier, 'tier' => $p->membership_tier,
'label_ar' => $p->label_ar, 'label_ar' => $p->label_ar,
'installments' => (string) $p->installments, 'installments' => (string) $p->installments,
'frequency' => $p->frequency, 'frequency' => $p->frequency,
'down_payment_pct' => (string) $p->down_payment_pct, 'down_payment_pct' => (string) $p->down_payment_pct,
'is_active' => $p->is_active, 'installment_amounts' => $this->buildAmountsArray($p->installment_amounts, $p->installments),
'is_active' => $p->is_active,
]) ])
->toArray(); ->toArray();
} }
...@@ -89,16 +90,35 @@ public function mount(?Product $product = null): void ...@@ -89,16 +90,35 @@ public function mount(?Product $product = null): void
public function addPlanRow(): void public function addPlanRow(): void
{ {
$this->planRows[] = [ $this->planRows[] = [
'id' => null, 'id' => null,
'tier' => 'any', 'tier' => 'any',
'label_ar' => '', 'label_ar' => '',
'installments' => '3', 'installments' => '3',
'frequency' => 'monthly', 'frequency' => 'monthly',
'down_payment_pct' => '0', 'down_payment_pct' => '0',
'is_active' => true, 'installment_amounts' => [], // array indexed by slot: '' means auto
'is_active' => true,
]; ];
} }
public function syncInstallmentSlots(int $rowIndex): void
{
$count = (int) ($this->planRows[$rowIndex]['installments'] ?? 3);
$current = $this->planRows[$rowIndex]['installment_amounts'] ?? [];
$this->planRows[$rowIndex]['installment_amounts'] = $this->buildAmountsArray($current, $count);
}
private function buildAmountsArray(?array $stored, int $count): array
{
$out = [];
for ($i = 0; $i < $count; $i++) {
$v = $stored[$i] ?? null;
// Store as pounds string for the form; '' means auto
$out[$i] = ($v !== null && $v > 0) ? (string) ($v / 100) : '';
}
return $out;
}
public function removePlanRow(int $index): void public function removePlanRow(int $index): void
{ {
array_splice($this->planRows, $index, 1); array_splice($this->planRows, $index, 1);
...@@ -214,16 +234,28 @@ private function syncPlanRows(Product $product): void ...@@ -214,16 +234,28 @@ private function syncPlanRows(Product $product): void
$keepIds = []; $keepIds = [];
foreach ($this->planRows as $i => $row) { foreach ($this->planRows as $i => $row) {
// Convert amounts from pounds strings to piaster integers (null for blanks)
$amounts = [];
foreach ($row['installment_amounts'] ?? [] as $idx => $val) {
$amounts[$idx] = ($val !== '' && $val !== null)
? (int) round((float) $val * 100)
: null;
}
// If all nulls, store null instead of an array
$hasExplicit = array_filter($amounts, fn ($v) => $v !== null);
$installmentAmounts = $hasExplicit ? $amounts : null;
$planData = [ $planData = [
'academy_id' => $product->academy_id, 'academy_id' => $product->academy_id,
'product_id' => $product->id, 'product_id' => $product->id,
'membership_tier' => $row['tier'], 'membership_tier' => $row['tier'],
'label_ar' => $row['label_ar'], 'label_ar' => $row['label_ar'],
'installments' => (int) $row['installments'], 'installments' => (int) $row['installments'],
'frequency' => $row['frequency'], 'frequency' => $row['frequency'],
'down_payment_pct' => (int) $row['down_payment_pct'], 'down_payment_pct' => (int) $row['down_payment_pct'],
'is_active' => $row['is_active'] ?? true, 'installment_amounts' => $installmentAmounts,
'sort_order' => $i, 'is_active' => $row['is_active'] ?? true,
'sort_order' => $i,
]; ];
if (!empty($row['id'])) { if (!empty($row['id'])) {
......
...@@ -590,12 +590,13 @@ public function essentialProducts(): array ...@@ -590,12 +590,13 @@ public function essentialProducts(): array
'non_member_price' => $p->non_member_price, 'non_member_price' => $p->non_member_price,
'selling_price'=> $p->selling_price, 'selling_price'=> $p->selling_price,
'plans' => $p->installmentPlans->map(fn ($pl) => [ 'plans' => $p->installmentPlans->map(fn ($pl) => [
'id' => $pl->id, 'id' => $pl->id,
'label_ar' => $pl->label_ar, 'label_ar' => $pl->label_ar,
'installments' => $pl->installments, 'installments' => $pl->installments,
'frequency' => $pl->frequency, 'frequency' => $pl->frequency,
'frequency_label' => $pl->frequencyLabel(), 'frequency_label' => $pl->frequencyLabel(),
'down_payment_pct' => $pl->down_payment_pct, 'down_payment_pct' => $pl->down_payment_pct,
'installment_amounts' => $pl->installment_amounts ?? [],
])->toArray(), ])->toArray(),
])->toArray(); ])->toArray();
} }
...@@ -628,12 +629,13 @@ public function hotbuyResults(): array ...@@ -628,12 +629,13 @@ public function hotbuyResults(): array
'billing_cycle' => $p->billing_cycle ?? 'one_time', 'billing_cycle' => $p->billing_cycle ?? 'one_time',
'price' => $p->priceForTier($tier), 'price' => $p->priceForTier($tier),
'plans' => $p->installmentPlans->map(fn ($pl) => [ 'plans' => $p->installmentPlans->map(fn ($pl) => [
'id' => $pl->id, 'id' => $pl->id,
'label_ar' => $pl->label_ar, 'label_ar' => $pl->label_ar,
'installments' => $pl->installments, 'installments' => $pl->installments,
'frequency' => $pl->frequency, 'frequency' => $pl->frequency,
'frequency_label' => $pl->frequencyLabel(), 'frequency_label' => $pl->frequencyLabel(),
'down_payment_pct' => $pl->down_payment_pct, 'down_payment_pct' => $pl->down_payment_pct,
'installment_amounts' => $pl->installment_amounts ?? [],
])->toArray(), ])->toArray(),
])->toArray(); ])->toArray();
...@@ -1036,9 +1038,8 @@ public function confirm(): void ...@@ -1036,9 +1038,8 @@ public function confirm(): void
} }
$itemTotal = $cartItem['price'] * $cartItem['quantity']; $itemTotal = $cartItem['price'] * $cartItem['quantity'];
$downAmount = $planTemplate->downPaymentAmount($itemTotal); $schedule = $planTemplate->buildSchedule($itemTotal);
$regularAmount = $planTemplate->regularInstallmentAmount($itemTotal); $regularAmount = count($schedule) > 1 ? ($schedule[1] ?? $schedule[0]) : $schedule[0];
$remaining = $planTemplate->installments - 1;
$paymentPlan = PaymentPlan::create([ $paymentPlan = PaymentPlan::create([
'academy_id' => app('current_academy')->id, 'academy_id' => app('current_academy')->id,
...@@ -1053,10 +1054,10 @@ public function confirm(): void ...@@ -1053,10 +1054,10 @@ public function confirm(): void
'notes' => $planTemplate->label_ar . ' — ' . $cartItem['name_ar'], 'notes' => $planTemplate->label_ar . ' — ' . $cartItem['name_ar'],
]); ]);
// Generate installment schedule // Generate installment schedule using explicit or auto-calculated amounts
$dueDate = now(); $dueDate = now();
for ($seq = 1; $seq <= $planTemplate->installments; $seq++) { foreach ($schedule as $idx => $amount) {
$amount = ($seq === 1) ? $downAmount : $regularAmount; $seq = $idx + 1;
Installment::create([ Installment::create([
'payment_plan_id' => $paymentPlan->id, 'payment_plan_id' => $paymentPlan->id,
'sequence' => $seq, 'sequence' => $seq,
...@@ -1064,7 +1065,6 @@ public function confirm(): void ...@@ -1064,7 +1065,6 @@ public function confirm(): void
'due_date' => $dueDate->toDateString(), 'due_date' => $dueDate->toDateString(),
'status' => 'pending', 'status' => 'pending',
]); ]);
// Advance due date by frequency
$dueDate = match ($planTemplate->frequency) { $dueDate = match ($planTemplate->frequency) {
'weekly' => $dueDate->addWeek(), 'weekly' => $dueDate->addWeek(),
'biweekly' => $dueDate->addWeeks(2), 'biweekly' => $dueDate->addWeeks(2),
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('product_installment_plans') && !Schema::hasColumn('product_installment_plans', 'installment_amounts')) {
Schema::table('product_installment_plans', function (Blueprint $table) {
// Nullable JSON array of piaster amounts per slot index; null slot = auto-calculate
$table->jsonb('installment_amounts')->nullable()->after('down_payment_pct');
});
}
}
public function down(): void
{
if (Schema::hasColumn('product_installment_plans', 'installment_amounts')) {
Schema::table('product_installment_plans', function (Blueprint $table) {
$table->dropColumn('installment_amounts');
});
}
}
};
...@@ -234,12 +234,14 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white tex ...@@ -234,12 +234,14 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white tex
@else @else
<div class="space-y-3"> <div class="space-y-3">
@foreach($planRows as $i => $row) @foreach($planRows as $i => $row)
<div class="p-4 bg-gray-50 border border-gray-200 rounded-xl"> <div x-data="{ showSlots: false }" class="p-4 bg-gray-50 border border-gray-200 rounded-xl">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<span class="text-xs font-semibold text-gray-500 uppercase">{{ __('خطة') }} {{ $i + 1 }}</span> <span class="text-xs font-semibold text-gray-500 uppercase">{{ __('خطة') }} {{ $i + 1 }}</span>
<button type="button" wire:click="removePlanRow({{ $i }})" <button type="button" wire:click="removePlanRow({{ $i }})"
class="text-red-500 hover:text-red-700 text-xs">{{ __('حذف') }}</button> class="text-red-500 hover:text-red-700 text-xs">{{ __('حذف') }}</button>
</div> </div>
{{-- Main plan config --}}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{{-- Label --}} {{-- Label --}}
<div class="col-span-2 sm:col-span-3 lg:col-span-2"> <div class="col-span-2 sm:col-span-3 lg:col-span-2">
...@@ -262,7 +264,10 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -262,7 +264,10 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f
{{-- Installments count --}} {{-- Installments count --}}
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('عدد الأقساط') }}</label> <label class="block text-xs font-medium text-gray-600 mb-1">{{ __('عدد الأقساط') }}</label>
<input type="number" wire:model="planRows.{{ $i }}.installments" min="2" max="24" dir="ltr" <input type="number"
wire:model.live="planRows.{{ $i }}.installments"
wire:change="syncInstallmentSlots({{ $i }})"
min="2" max="24" dir="ltr"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"> class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500">
@error("planRows.{$i}.installments") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror @error("planRows.{$i}.installments") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
...@@ -277,7 +282,7 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -277,7 +282,7 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f
<option value="weekly">{{ __('أسبوعي') }}</option> <option value="weekly">{{ __('أسبوعي') }}</option>
</select> </select>
</div> </div>
{{-- Down payment % --}} {{-- Down payment % — hidden when slots have explicit amounts --}}
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('المقدم (%)') }}</label> <label class="block text-xs font-medium text-gray-600 mb-1">{{ __('المقدم (%)') }}</label>
<input type="number" wire:model="planRows.{{ $i }}.down_payment_pct" min="0" max="100" dir="ltr" <input type="number" wire:model="planRows.{{ $i }}.down_payment_pct" min="0" max="100" dir="ltr"
...@@ -286,6 +291,42 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -286,6 +291,42 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f
<p class="text-xs text-gray-400 mt-0.5">{{ __('0 = موزع بالتساوي') }}</p> <p class="text-xs text-gray-400 mt-0.5">{{ __('0 = موزع بالتساوي') }}</p>
</div> </div>
</div> </div>
{{-- Per-slot custom amounts (optional) --}}
<div class="mt-3 border-t border-gray-200 pt-3">
<button type="button" @click="showSlots = !showSlots"
class="flex items-center gap-1.5 text-xs font-medium text-purple-600 hover:text-purple-800">
<svg class="w-3.5 h-3.5 transition-transform" :class="showSlots ? 'rotate-90' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span x-text="showSlots ? '{{ __('إخفاء مبالغ الأقساط') }}' : '{{ __('تحديد مبلغ كل قسط (اختياري)') }}'"></span>
</button>
<div x-show="showSlots" x-transition class="mt-3">
<p class="text-xs text-gray-500 mb-2">
{{ __('اتركها فارغة للحساب التلقائي. القسط الأول هو المقدم.') }}
</p>
<div class="flex flex-wrap gap-2">
@php $slotCount = (int) ($row['installments'] ?? 3); @endphp
@for($s = 0; $s < $slotCount; $s++)
<div class="flex flex-col items-center gap-1">
<label class="text-xs text-gray-500">
{{ $s === 0 ? __('مقدم') : (__('قسط') . ' ' . $s) }}
</label>
<input type="number"
wire:model="planRows.{{ $i }}.installment_amounts.{{ $s }}"
min="0" step="0.01" dir="ltr"
class="w-24 text-sm px-2 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-center"
placeholder="{{ __('تلقائي') }}">
</div>
@endfor
</div>
<p class="text-xs text-gray-400 mt-2">
{{ __('المبالغ بالجنيه المصري. الخانات الفارغة تُحسب تلقائياً من المبلغ المتبقي.') }}
</p>
</div>
</div>
</div> </div>
@endforeach @endforeach
</div> </div>
......
...@@ -831,20 +831,50 @@ class="px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ...@@ -831,20 +831,50 @@ class="px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors
$selPlan = collect($cartPlans)->firstWhere('id', $cartItem['plan_id']); $selPlan = collect($cartPlans)->firstWhere('id', $cartItem['plan_id']);
@endphp @endphp
@if($selPlan) @if($selPlan)
@php
$tp = $cartItem['price'];
$explicit = $selPlan['installment_amounts'] ?? [];
$n = $selPlan['installments'];
// Build schedule: explicit where set, auto for the rest
$explicitSum = 0;
$explicitCount = 0;
$slots = [];
for ($si = 0; $si < $n; $si++) {
$v = $explicit[$si] ?? null;
if ($v !== null && $v > 0) {
$slots[$si] = (int) $v;
$explicitSum += (int) $v;
$explicitCount++;
} else {
$slots[$si] = null;
}
}
$autoCount = $n - $explicitCount;
$autoAmt = $autoCount > 0 ? (int) ceil(max(0, $tp - $explicitSum) / $autoCount) : 0;
for ($si = 0; $si < $n; $si++) {
if ($slots[$si] === null) $slots[$si] = $autoAmt;
}
// Check if all regular amounts (after slot 0) are the same
$dp = $slots[0];
$regulars = array_slice($slots, 1);
$allSame = count(array_unique($regulars)) <= 1;
@endphp
<div class="mt-2 p-2 bg-white rounded-lg border border-purple-200 text-xs text-purple-800"> <div class="mt-2 p-2 bg-white rounded-lg border border-purple-200 text-xs text-purple-800">
@php
$tp = $cartItem['price'];
$dp = $selPlan['down_payment_pct'] > 0
? (int) ceil($tp * $selPlan['down_payment_pct'] / 100)
: (int) ceil($tp / $selPlan['installments']);
$rem = $selPlan['installments'] - 1;
$regular = $rem > 0 ? (int) ceil(($tp - $dp) / $rem) : 0;
@endphp
<span class="font-semibold">{{ $selPlan['label_ar'] }}</span>: <span class="font-semibold">{{ $selPlan['label_ar'] }}</span>:
{{ __('مقدم') }} <span dir="ltr">{{ number_format($dp / 100, 2) }} {{ __('ج.م') }}</span> {{ __('مقدم') }} <span dir="ltr">{{ number_format($dp / 100, 2) }} {{ __('ج.م') }}</span>
@if($rem > 0) @if(count($regulars) > 0)
+ {{ $rem }} {{ __('قسط') }} × <span dir="ltr">{{ number_format($regular / 100, 2) }} {{ __('ج.م') }}</span> @if($allSame)
({{ $selPlan['frequency_label'] }}) + {{ count($regulars) }} {{ __('قسط') }} × <span dir="ltr">{{ number_format(($regulars[0] ?? 0) / 100, 2) }} {{ __('ج.م') }}</span>
({{ $selPlan['frequency_label'] }})
@else
{{-- Show each installment amount individually --}}
@foreach($regulars as $ri => $ramt)
+ {{ __('قسط') }} {{ $ri + 1 }}: <span dir="ltr">{{ number_format($ramt / 100, 2) }} {{ __('ج.م') }}</span>
@endforeach
({{ $selPlan['frequency_label'] }})
@endif
@endif @endif
</div> </div>
@endif @endif
......
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