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
'installments',
'frequency',
'down_payment_pct',
'installment_amounts',
'is_active',
'sort_order',
];
......@@ -29,6 +30,7 @@ protected function casts(): array
return [
'installments' => 'integer',
'down_payment_pct' => 'integer',
'installment_amounts' => 'array',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
......@@ -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 */
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) {
return (int) ceil($totalPiasters / $this->installments);
}
......@@ -71,6 +95,36 @@ public function regularInstallmentAmount(int $totalPiasters): int
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
{
return match ($this->frequency) {
......
......@@ -74,13 +74,14 @@ public function mount(?Product $product = null): void
->orderBy('sort_order')
->get()
->map(fn ($p) => [
'id' => $p->id,
'tier' => $p->membership_tier,
'label_ar' => $p->label_ar,
'installments' => (string) $p->installments,
'frequency' => $p->frequency,
'down_payment_pct' => (string) $p->down_payment_pct,
'is_active' => $p->is_active,
'id' => $p->id,
'tier' => $p->membership_tier,
'label_ar' => $p->label_ar,
'installments' => (string) $p->installments,
'frequency' => $p->frequency,
'down_payment_pct' => (string) $p->down_payment_pct,
'installment_amounts' => $this->buildAmountsArray($p->installment_amounts, $p->installments),
'is_active' => $p->is_active,
])
->toArray();
}
......@@ -89,16 +90,35 @@ public function mount(?Product $product = null): void
public function addPlanRow(): void
{
$this->planRows[] = [
'id' => null,
'tier' => 'any',
'label_ar' => '',
'installments' => '3',
'frequency' => 'monthly',
'down_payment_pct' => '0',
'is_active' => true,
'id' => null,
'tier' => 'any',
'label_ar' => '',
'installments' => '3',
'frequency' => 'monthly',
'down_payment_pct' => '0',
'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
{
array_splice($this->planRows, $index, 1);
......@@ -214,16 +234,28 @@ private function syncPlanRows(Product $product): void
$keepIds = [];
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 = [
'academy_id' => $product->academy_id,
'product_id' => $product->id,
'membership_tier' => $row['tier'],
'label_ar' => $row['label_ar'],
'installments' => (int) $row['installments'],
'frequency' => $row['frequency'],
'down_payment_pct' => (int) $row['down_payment_pct'],
'is_active' => $row['is_active'] ?? true,
'sort_order' => $i,
'academy_id' => $product->academy_id,
'product_id' => $product->id,
'membership_tier' => $row['tier'],
'label_ar' => $row['label_ar'],
'installments' => (int) $row['installments'],
'frequency' => $row['frequency'],
'down_payment_pct' => (int) $row['down_payment_pct'],
'installment_amounts' => $installmentAmounts,
'is_active' => $row['is_active'] ?? true,
'sort_order' => $i,
];
if (!empty($row['id'])) {
......
......@@ -590,12 +590,13 @@ public function essentialProducts(): array
'non_member_price' => $p->non_member_price,
'selling_price'=> $p->selling_price,
'plans' => $p->installmentPlans->map(fn ($pl) => [
'id' => $pl->id,
'label_ar' => $pl->label_ar,
'installments' => $pl->installments,
'frequency' => $pl->frequency,
'frequency_label' => $pl->frequencyLabel(),
'down_payment_pct' => $pl->down_payment_pct,
'id' => $pl->id,
'label_ar' => $pl->label_ar,
'installments' => $pl->installments,
'frequency' => $pl->frequency,
'frequency_label' => $pl->frequencyLabel(),
'down_payment_pct' => $pl->down_payment_pct,
'installment_amounts' => $pl->installment_amounts ?? [],
])->toArray(),
])->toArray();
}
......@@ -628,12 +629,13 @@ public function hotbuyResults(): array
'billing_cycle' => $p->billing_cycle ?? 'one_time',
'price' => $p->priceForTier($tier),
'plans' => $p->installmentPlans->map(fn ($pl) => [
'id' => $pl->id,
'label_ar' => $pl->label_ar,
'installments' => $pl->installments,
'frequency' => $pl->frequency,
'frequency_label' => $pl->frequencyLabel(),
'down_payment_pct' => $pl->down_payment_pct,
'id' => $pl->id,
'label_ar' => $pl->label_ar,
'installments' => $pl->installments,
'frequency' => $pl->frequency,
'frequency_label' => $pl->frequencyLabel(),
'down_payment_pct' => $pl->down_payment_pct,
'installment_amounts' => $pl->installment_amounts ?? [],
])->toArray(),
])->toArray();
......@@ -1036,9 +1038,8 @@ public function confirm(): void
}
$itemTotal = $cartItem['price'] * $cartItem['quantity'];
$downAmount = $planTemplate->downPaymentAmount($itemTotal);
$regularAmount = $planTemplate->regularInstallmentAmount($itemTotal);
$remaining = $planTemplate->installments - 1;
$schedule = $planTemplate->buildSchedule($itemTotal);
$regularAmount = count($schedule) > 1 ? ($schedule[1] ?? $schedule[0]) : $schedule[0];
$paymentPlan = PaymentPlan::create([
'academy_id' => app('current_academy')->id,
......@@ -1053,10 +1054,10 @@ public function confirm(): void
'notes' => $planTemplate->label_ar . ' — ' . $cartItem['name_ar'],
]);
// Generate installment schedule
// Generate installment schedule using explicit or auto-calculated amounts
$dueDate = now();
for ($seq = 1; $seq <= $planTemplate->installments; $seq++) {
$amount = ($seq === 1) ? $downAmount : $regularAmount;
foreach ($schedule as $idx => $amount) {
$seq = $idx + 1;
Installment::create([
'payment_plan_id' => $paymentPlan->id,
'sequence' => $seq,
......@@ -1064,7 +1065,6 @@ public function confirm(): void
'due_date' => $dueDate->toDateString(),
'status' => 'pending',
]);
// Advance due date by frequency
$dueDate = match ($planTemplate->frequency) {
'weekly' => $dueDate->addWeek(),
'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
@else
<div class="space-y-3">
@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">
<span class="text-xs font-semibold text-gray-500 uppercase">{{ __('خطة') }} {{ $i + 1 }}</span>
<button type="button" wire:click="removePlanRow({{ $i }})"
class="text-red-500 hover:text-red-700 text-xs">{{ __('حذف') }}</button>
</div>
{{-- Main plan config --}}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{{-- Label --}}
<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
{{-- Installments count --}}
<div>
<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">
@error("planRows.{$i}.installments") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
......@@ -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>
</select>
</div>
{{-- Down payment % --}}
{{-- Down payment % — hidden when slots have explicit amounts --}}
<div>
<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"
......@@ -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>
</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>
@endforeach
</div>
......
......@@ -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']);
@endphp
@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">
@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 dir="ltr">{{ number_format($dp / 100, 2) }} {{ __('ج.م') }}</span>
@if($rem > 0)
+ {{ $rem }} {{ __('قسط') }} × <span dir="ltr">{{ number_format($regular / 100, 2) }} {{ __('ج.م') }}</span>
({{ $selPlan['frequency_label'] }})
@if(count($regulars) > 0)
@if($allSame)
+ {{ 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
</div>
@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