Commit 7f4dbddd authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add annual products with dual pricing and installment plans

- New migration: billing_cycle (one_time/annual), member_price, non_member_price on products; product_installment_plans table (per-product, per-tier plan templates)
- ProductInstallmentPlan model with scopeActive/forTier, downPaymentAmount/regularInstallmentAmount helpers
- Product model: priceForTier(), isAnnual(), installmentPlans() relationship
- ProductForm: annual billing section with member/non-member dual prices and installment plan builder (add/remove rows, upsert on save)
- Registration wizard: essentialProducts() and hotbuyResults() now return billing_cycle and plans[]; addHotbuyItem() carries plans into cart; setHotbuyPlan() selects a plan per cart item; confirm() generates PaymentPlan + Installment rows for annual items with a selected plan
- Wizard blade: annual cart items show purple "سنوي" badge, plan pills (full payment + per-plan buttons), and installment breakdown preview on selection
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent b9239f68
...@@ -36,6 +36,9 @@ class Product extends Model ...@@ -36,6 +36,9 @@ class Product extends Model
'track_inventory', 'track_inventory',
'is_active', 'is_active',
'is_essential', 'is_essential',
'billing_cycle',
'member_price',
'non_member_price',
'min_stock_level', 'min_stock_level',
'max_stock_level', 'max_stock_level',
'weight_grams', 'weight_grams',
...@@ -53,6 +56,8 @@ protected function casts(): array ...@@ -53,6 +56,8 @@ protected function casts(): array
'type' => ProductType::class, 'type' => ProductType::class,
'selling_price' => 'integer', 'selling_price' => 'integer',
'cost_price' => 'integer', 'cost_price' => 'integer',
'member_price' => 'integer',
'non_member_price' => 'integer',
'track_inventory' => 'boolean', 'track_inventory' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'is_essential' => 'boolean', 'is_essential' => 'boolean',
...@@ -66,6 +71,22 @@ protected function casts(): array ...@@ -66,6 +71,22 @@ protected function casts(): array
]; ];
} }
public function priceForTier(string $membershipType): int
{
if ($membershipType === 'member' && $this->member_price !== null) {
return $this->member_price;
}
if ($membershipType === 'non_member' && $this->non_member_price !== null) {
return $this->non_member_price;
}
return $this->selling_price;
}
public function isAnnual(): bool
{
return $this->billing_cycle === 'annual';
}
public function category(): BelongsTo public function category(): BelongsTo
{ {
return $this->belongsTo(ProductCategory::class, 'category_id'); return $this->belongsTo(ProductCategory::class, 'category_id');
...@@ -86,6 +107,11 @@ public function inventoryLevels(): HasMany ...@@ -86,6 +107,11 @@ public function inventoryLevels(): HasMany
return $this->hasMany(InventoryLevel::class); return $this->hasMany(InventoryLevel::class);
} }
public function installmentPlans(): HasMany
{
return $this->hasMany(ProductInstallmentPlan::class);
}
public function scopeActive(Builder $query): Builder public function scopeActive(Builder $query): Builder
{ {
return $query->where('is_active', true); return $query->where('is_active', true);
......
<?php
namespace App\Domain\Inventory\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductInstallmentPlan extends Model
{
use BelongsToAcademy, HasUuid;
protected $fillable = [
'academy_id',
'product_id',
'membership_tier',
'label_ar',
'installments',
'frequency',
'down_payment_pct',
'is_active',
'sort_order',
];
protected function casts(): array
{
return [
'installments' => 'integer',
'down_payment_pct' => 'integer',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function scopeForTier(Builder $query, string $tier): Builder
{
return $query->where(fn ($q) =>
$q->where('membership_tier', $tier)->orWhere('membership_tier', 'any')
);
}
/** Calculate down payment amount in piasters */
public function downPaymentAmount(int $totalPiasters): int
{
if ($this->down_payment_pct <= 0) {
return (int) ceil($totalPiasters / $this->installments);
}
return (int) ceil($totalPiasters * $this->down_payment_pct / 100);
}
/** Calculate per-installment amount (after down payment) in piasters */
public function regularInstallmentAmount(int $totalPiasters): int
{
$remaining = $this->installments - 1;
if ($remaining <= 0) {
return 0;
}
$afterDown = $totalPiasters - $this->downPaymentAmount($totalPiasters);
return (int) ceil($afterDown / $remaining);
}
public function frequencyLabel(): string
{
return match ($this->frequency) {
'weekly' => 'أسبوعي',
'biweekly' => 'نصف شهري',
'monthly' => 'شهري',
'quarterly' => 'ربع سنوي',
default => $this->frequency,
};
}
public function tierLabel(): string
{
return match ($this->membership_tier) {
'member' => 'أعضاء',
'non_member' => 'غير أعضاء',
default => 'الجميع',
};
}
}
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
use App\Domain\Inventory\Enums\ProductType; use App\Domain\Inventory\Enums\ProductType;
use App\Domain\Inventory\Models\Product; use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductCategory; use App\Domain\Inventory\Models\ProductCategory;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
...@@ -33,6 +34,14 @@ class ProductForm extends Component ...@@ -33,6 +34,14 @@ class ProductForm extends Component
public bool $is_essential = false; public bool $is_essential = false;
public string $description_ar = ''; public string $description_ar = '';
// Annual billing
public string $billing_cycle = 'one_time';
public string $member_price = '';
public string $non_member_price = '';
// Installment plan rows [['tier','label_ar','installments','frequency','down_payment_pct']]
public array $planRows = [];
public function mount(?Product $product = null): void public function mount(?Product $product = null): void
{ {
$this->authorize('inventory.create'); $this->authorize('inventory.create');
...@@ -57,7 +66,42 @@ public function mount(?Product $product = null): void ...@@ -57,7 +66,42 @@ public function mount(?Product $product = null): void
$this->is_active = $product->is_active; $this->is_active = $product->is_active;
$this->is_essential = $product->is_essential ?? false; $this->is_essential = $product->is_essential ?? false;
$this->description_ar = $product->description_ar ?? ''; $this->description_ar = $product->description_ar ?? '';
$this->billing_cycle = $product->billing_cycle ?? 'one_time';
$this->member_price = $product->member_price ? (string) ($product->member_price / 100) : '';
$this->non_member_price = $product->non_member_price ? (string) ($product->non_member_price / 100) : '';
$this->planRows = $product->installmentPlans()
->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,
])
->toArray();
}
} }
public function addPlanRow(): void
{
$this->planRows[] = [
'id' => null,
'tier' => 'any',
'label_ar' => '',
'installments' => '3',
'frequency' => 'monthly',
'down_payment_pct' => '0',
'is_active' => true,
];
}
public function removePlanRow(int $index): void
{
array_splice($this->planRows, $index, 1);
} }
public function rules(): array public function rules(): array
...@@ -66,7 +110,7 @@ public function rules(): array ...@@ -66,7 +110,7 @@ public function rules(): array
? 'unique:products,sku,' . $this->product->id ? 'unique:products,sku,' . $this->product->id
: 'unique:products,sku'; : 'unique:products,sku';
return [ $rules = [
'name_ar' => 'required|string|max:255', 'name_ar' => 'required|string|max:255',
'name' => 'nullable|string|max:255', 'name' => 'nullable|string|max:255',
'sku' => ['nullable', 'string', 'max:50', $uniqueSku], 'sku' => ['nullable', 'string', 'max:50', $uniqueSku],
...@@ -82,29 +126,34 @@ public function rules(): array ...@@ -82,29 +126,34 @@ public function rules(): array
'is_active' => 'boolean', 'is_active' => 'boolean',
'is_essential' => 'boolean', 'is_essential' => 'boolean',
'description_ar' => 'nullable|string|max:1000', 'description_ar' => 'nullable|string|max:1000',
'billing_cycle' => 'required|in:one_time,annual',
'member_price' => 'nullable|numeric|min:0',
'non_member_price' => 'nullable|numeric|min:0',
]; ];
foreach ($this->planRows as $i => $row) {
$rules["planRows.{$i}.tier"] = 'required|in:member,non_member,any';
$rules["planRows.{$i}.label_ar"] = 'required|string|max:100';
$rules["planRows.{$i}.installments"] = 'required|integer|min:2|max:24';
$rules["planRows.{$i}.frequency"] = 'required|in:weekly,biweekly,monthly,quarterly';
$rules["planRows.{$i}.down_payment_pct"] = 'required|integer|min:0|max:100';
}
return $rules;
} }
public function messages(): array public function messages(): array
{ {
return [ return [
'name_ar.required' => 'اسم المنتج بالعربية مطلوب', 'name_ar.required' => 'اسم المنتج بالعربية مطلوب',
'name_ar.max' => 'اسم المنتج يجب ألا يتجاوز 255 حرف',
'sku.required' => 'رمز المنتج (SKU) مطلوب',
'sku.unique' => 'رمز المنتج مستخدم بالفعل', 'sku.unique' => 'رمز المنتج مستخدم بالفعل',
'sku.max' => 'رمز المنتج يجب ألا يتجاوز 50 حرف',
'type.required' => 'نوع المنتج مطلوب', 'type.required' => 'نوع المنتج مطلوب',
'type.in' => 'نوع المنتج غير صالح', 'selling_price.required' => 'السعر الأساسي مطلوب',
'selling_price.required' => 'سعر البيع مطلوب', 'selling_price.min' => 'السعر يجب ألا يكون سالبًا',
'selling_price.numeric' => 'سعر البيع يجب أن يكون رقمًا', 'billing_cycle.required' => 'دورة الفوترة مطلوبة',
'selling_price.min' => 'سعر البيع يجب ألا يكون سالبًا', 'planRows.*.label_ar.required' => 'اسم الخطة مطلوب',
'cost_price.numeric' => 'سعر التكلفة يجب أن يكون رقمًا', 'planRows.*.installments.required' => 'عدد الأقساط مطلوب',
'cost_price.min' => 'سعر التكلفة يجب ألا يكون سالبًا', 'planRows.*.installments.min' => 'يجب أن يكون عدد الأقساط 2 على الأقل',
'min_stock_level.integer' => 'الحد الأدنى يجب أن يكون عددًا صحيحًا',
'max_stock_level.integer' => 'الحد الأقصى يجب أن يكون عددًا صحيحًا',
'tax_rate.numeric' => 'نسبة الضريبة يجب أن تكون رقمًا',
'tax_rate.max' => 'نسبة الضريبة يجب ألا تتجاوز 100%',
'category_id.exists' => 'التصنيف غير موجود',
]; ];
} }
...@@ -129,18 +178,25 @@ public function save(): void ...@@ -129,18 +178,25 @@ public function save(): void
'is_active' => $this->is_active, 'is_active' => $this->is_active,
'is_essential' => $this->is_essential, 'is_essential' => $this->is_essential,
'description_ar' => $this->description_ar ?: null, 'description_ar' => $this->description_ar ?: null,
'billing_cycle' => $this->billing_cycle,
'member_price' => $this->member_price !== '' ? (int) round((float) $this->member_price * 100) : null,
'non_member_price' => $this->non_member_price !== '' ? (int) round((float) $this->non_member_price * 100) : null,
]; ];
if ($this->editing) { if ($this->editing) {
$this->product->update($data); $this->product->update($data);
$product = $this->product;
session()->flash('success', __('تم تحديث المنتج بنجاح')); session()->flash('success', __('تم تحديث المنتج بنجاح'));
} else { } else {
$data['created_by'] = auth()->id(); $data['created_by'] = auth()->id();
$data['branch_id'] = session('active_branch_id'); $data['branch_id'] = session('active_branch_id');
Product::create($data); $product = Product::create($data);
session()->flash('success', __('تم إنشاء المنتج بنجاح')); session()->flash('success', __('تم إنشاء المنتج بنجاح'));
} }
// Sync installment plan rows
$this->syncPlanRows($product);
$this->redirect(route('inventory.products'), navigate: true); $this->redirect(route('inventory.products'), navigate: true);
} catch (DomainException $e) { } catch (DomainException $e) {
session()->flash('error', $e->getMessage()); session()->flash('error', $e->getMessage());
...@@ -153,6 +209,41 @@ public function save(): void ...@@ -153,6 +209,41 @@ public function save(): void
} }
} }
private function syncPlanRows(Product $product): void
{
$keepIds = [];
foreach ($this->planRows as $i => $row) {
$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,
];
if (!empty($row['id'])) {
$plan = ProductInstallmentPlan::find($row['id']);
if ($plan) {
$plan->update($planData);
$keepIds[] = $plan->id;
}
} else {
$plan = ProductInstallmentPlan::create($planData);
$keepIds[] = $plan->id;
}
}
// Delete removed rows
$product->installmentPlans()
->when(!empty($keepIds), fn ($q) => $q->whereNotIn('id', $keepIds))
->delete();
}
private function generateSku(): string private function generateSku(): string
{ {
$prefix = match ($this->type) { $prefix = match ($this->type) {
......
...@@ -2,9 +2,12 @@ ...@@ -2,9 +2,12 @@
namespace App\Livewire\Receptionist; namespace App\Livewire\Receptionist;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Financial\Services\InvoiceService; use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService; use App\Domain\Financial\Services\PaymentService;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Identity\Models\Branch; use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Models\Guardian; use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Models\Person; use App\Domain\Identity\Models\Person;
...@@ -567,8 +570,11 @@ public function effectiveTotal(): int ...@@ -567,8 +570,11 @@ public function effectiveTotal(): int
#[Computed] #[Computed]
public function essentialProducts(): array public function essentialProducts(): array
{ {
$tier = $this->membership_type;
return Product::where('is_active', true) return Product::where('is_active', true)
->where('is_essential', true) ->where('is_essential', true)
->with(['installmentPlans' => fn ($q) => $q->active()->forTier($tier)->orderBy('sort_order')])
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('name_ar') ->orderBy('name_ar')
->get() ->get()
...@@ -578,7 +584,19 @@ public function essentialProducts(): array ...@@ -578,7 +584,19 @@ public function essentialProducts(): array
'name_ar' => $p->name_ar, 'name_ar' => $p->name_ar,
'name' => $p->name, 'name' => $p->name,
'sku' => $p->sku, 'sku' => $p->sku,
'price' => $p->selling_price, 'billing_cycle'=> $p->billing_cycle,
'price' => $p->priceForTier($tier),
'member_price' => $p->member_price,
'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,
])->toArray(),
])->toArray(); ])->toArray();
} }
...@@ -591,11 +609,14 @@ public function hotbuyResults(): array ...@@ -591,11 +609,14 @@ public function hotbuyResults(): array
$search = $this->hotbuy_search; $search = $this->hotbuy_search;
$tier = $this->membership_type;
$products = Product::where('is_active', true) $products = Product::where('is_active', true)
->where(fn ($q) => $q->where('name_ar', 'ilike', "%{$search}%") ->where(fn ($q) => $q->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%") ->orWhere('name', 'ilike', "%{$search}%")
->orWhere('sku', 'ilike', "%{$search}%") ->orWhere('sku', 'ilike', "%{$search}%")
->orWhere('barcode', $search)) ->orWhere('barcode', $search))
->with(['installmentPlans' => fn ($q) => $q->active()->forTier($tier)->orderBy('sort_order')])
->limit(5) ->limit(5)
->get() ->get()
->map(fn ($p) => [ ->map(fn ($p) => [
...@@ -604,7 +625,16 @@ public function hotbuyResults(): array ...@@ -604,7 +625,16 @@ public function hotbuyResults(): array
'name_ar' => $p->name_ar, 'name_ar' => $p->name_ar,
'name' => $p->name, 'name' => $p->name,
'sku' => $p->sku, 'sku' => $p->sku,
'price' => $p->selling_price, '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,
])->toArray(),
])->toArray(); ])->toArray();
$kits = Kit::where('is_active', true) $kits = Kit::where('is_active', true)
...@@ -640,18 +670,50 @@ public function addHotbuyItem(int $id, string $type): void ...@@ -640,18 +670,50 @@ public function addHotbuyItem(int $id, string $type): void
return; return;
} }
$price = ($type === 'product')
? $item->priceForTier($this->membership_type)
: $item->selling_price;
$plans = [];
if ($type === 'product' && ($item->billing_cycle ?? 'one_time') === 'annual') {
$tier = $this->membership_type;
$plans = $item->installmentPlans()
->active()
->forTier($tier)
->orderBy('sort_order')
->get()
->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,
])->toArray();
}
$this->hotbuyCart[$key] = [ $this->hotbuyCart[$key] = [
'id' => $item->id, 'id' => $item->id,
'type' => $type, 'type' => $type,
'name_ar' => $item->name_ar, 'name_ar' => $item->name_ar,
'price' => $item->selling_price, 'price' => $price,
'billing_cycle' => $type === 'product' ? ($item->billing_cycle ?? 'one_time') : 'one_time',
'quantity' => 1, 'quantity' => 1,
'plan_id' => null,
'plans' => $plans,
]; ];
} }
$this->hotbuy_search = ''; $this->hotbuy_search = '';
} }
public function setHotbuyPlan(string $key, ?int $planId): void
{
if (isset($this->hotbuyCart[$key])) {
$this->hotbuyCart[$key]['plan_id'] = $planId;
}
}
public function removeHotbuyItem(string $key): void public function removeHotbuyItem(string $key): void
{ {
unset($this->hotbuyCart[$key]); unset($this->hotbuyCart[$key]);
...@@ -963,6 +1025,55 @@ public function confirm(): void ...@@ -963,6 +1025,55 @@ public function confirm(): void
$this->invoice_number = $invoice->number; $this->invoice_number = $invoice->number;
$this->invoiceId = $invoice->id; $this->invoiceId = $invoice->id;
// 8b. Create installment payment plans for annual products
foreach ($this->hotbuyCart as $cartItem) {
if (empty($cartItem['plan_id'])) {
continue;
}
$planTemplate = ProductInstallmentPlan::find($cartItem['plan_id']);
if (!$planTemplate) {
continue;
}
$itemTotal = $cartItem['price'] * $cartItem['quantity'];
$downAmount = $planTemplate->downPaymentAmount($itemTotal);
$regularAmount = $planTemplate->regularInstallmentAmount($itemTotal);
$remaining = $planTemplate->installments - 1;
$paymentPlan = PaymentPlan::create([
'academy_id' => app('current_academy')->id,
'invoice_id' => $invoice->id,
'status' => 'active',
'total_installments' => $planTemplate->installments,
'paid_installments' => 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
$dueDate = now();
for ($seq = 1; $seq <= $planTemplate->installments; $seq++) {
$amount = ($seq === 1) ? $downAmount : $regularAmount;
Installment::create([
'payment_plan_id' => $paymentPlan->id,
'sequence' => $seq,
'amount' => $amount,
'due_date' => $dueDate->toDateString(),
'status' => 'pending',
]);
// Advance due date by frequency
$dueDate = match ($planTemplate->frequency) {
'weekly' => $dueDate->addWeek(),
'biweekly' => $dueDate->addWeeks(2),
'quarterly' => $dueDate->addMonths(3),
default => $dueDate->addMonth(),
};
}
}
// 9. Record payment(s) if paying now // 9. Record payment(s) if paying now
if ($this->pay_now) { if ($this->pay_now) {
$basePaymentData = [ $basePaymentData = [
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('products', 'billing_cycle')) {
Schema::table('products', function (Blueprint $table) {
$table->string('billing_cycle', 20)->default('one_time')->after('is_essential');
$table->bigInteger('member_price')->nullable()->after('billing_cycle');
$table->bigInteger('non_member_price')->nullable()->after('member_price');
});
DB::statement("ALTER TABLE products ADD CONSTRAINT products_billing_cycle_check CHECK (billing_cycle IN ('one_time', 'annual'))");
}
if (!Schema::hasTable('product_installment_plans')) {
Schema::create('product_installment_plans', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('membership_tier', 20)->default('any');
$table->string('label_ar');
$table->unsignedSmallInteger('installments');
$table->string('frequency', 20)->default('monthly');
$table->unsignedTinyInteger('down_payment_pct')->default(0);
$table->boolean('is_active')->default(true);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['product_id', 'membership_tier', 'is_active']);
$table->index('academy_id');
});
DB::statement("ALTER TABLE product_installment_plans ADD CONSTRAINT product_installment_plans_tier_check CHECK (membership_tier IN ('member', 'non_member', 'any'))");
DB::statement("ALTER TABLE product_installment_plans ADD CONSTRAINT product_installment_plans_frequency_check CHECK (frequency IN ('weekly', 'biweekly', 'monthly', 'quarterly'))");
}
}
public function down(): void
{
Schema::dropIfExists('product_installment_plans');
if (Schema::hasColumn('products', 'billing_cycle')) {
DB::statement('ALTER TABLE products DROP CONSTRAINT IF EXISTS products_billing_cycle_check');
Schema::table('products', function (Blueprint $table) {
$table->dropColumn(['billing_cycle', 'member_price', 'non_member_price']);
});
}
}
};
...@@ -168,6 +168,132 @@ class="mt-0.5 w-4 h-4 rounded border-amber-400 text-amber-600 focus:ring-amber-5 ...@@ -168,6 +168,132 @@ class="mt-0.5 w-4 h-4 rounded border-amber-400 text-amber-600 focus:ring-amber-5
</div> </div>
</div> </div>
{{-- Annual Billing Section --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6" x-data>
<div class="flex items-center justify-between mb-4">
<h2 class="text-base sm:text-lg font-semibold text-gray-700">{{ __('الفوترة السنوية والأقساط') }}</h2>
<span class="text-xs text-gray-400">{{ __('للخدمات السنوية كالتأمين والزي وما شابه') }}</span>
</div>
{{-- Billing Cycle Toggle --}}
<div class="flex gap-3 mb-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" wire:model.live="billing_cycle" value="one_time" class="text-blue-600">
<span class="text-sm font-medium text-gray-700">{{ __('دفعة واحدة') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" wire:model.live="billing_cycle" value="annual" class="text-purple-600">
<span class="text-sm font-medium text-purple-700">{{ __('سنوي (مع دعم الأقساط)') }}</span>
</label>
</div>
@if($billing_cycle === 'annual')
{{-- Dual pricing --}}
<div class="p-4 bg-purple-50 border border-purple-200 rounded-xl mb-6">
<h3 class="text-sm font-semibold text-purple-800 mb-3">{{ __('الأسعار حسب نوع العضوية') }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('السعر الأساسي (ج.م)') }} *</label>
<input type="number" wire:model="selling_price" step="0.01" min="0" 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('selling_price') border-red-500 @enderror"
placeholder="{{ __('افتراضي') }}">
<p class="text-xs text-gray-400 mt-0.5">{{ __('يُستخدم إذا لم يحدد سعر خاص') }}</p>
</div>
<div>
<label class="block text-xs font-medium text-green-700 mb-1">{{ __('سعر الأعضاء (ج.م)') }}</label>
<input type="number" wire:model="member_price" step="0.01" min="0" dir="ltr"
class="w-full text-sm px-3 py-2 border border-green-300 rounded-lg focus:ring-2 focus:ring-green-500 @error('member_price') border-red-500 @enderror"
placeholder="{{ __('اتركه فارغاً للسعر الأساسي') }}">
@error('member_price') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-orange-700 mb-1">{{ __('سعر غير الأعضاء (ج.م)') }}</label>
<input type="number" wire:model="non_member_price" step="0.01" min="0" dir="ltr"
class="w-full text-sm px-3 py-2 border border-orange-300 rounded-lg focus:ring-2 focus:ring-orange-500 @error('non_member_price') border-red-500 @enderror"
placeholder="{{ __('اتركه فارغاً للسعر الأساسي') }}">
@error('non_member_price') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
</div>
</div>
{{-- Installment Plan Builder --}}
<div>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700">{{ __('خطط التقسيط') }}</h3>
<button type="button" wire:click="addPlanRow"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white text-xs font-medium rounded-lg hover:bg-purple-700">
<svg class="w-3.5 h-3.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>
{{ __('إضافة خطة') }}
</button>
</div>
@if(count($planRows) === 0)
<div class="py-6 text-center border-2 border-dashed border-gray-200 rounded-xl">
<p class="text-sm text-gray-400">{{ __('لا توجد خطط تقسيط — اضغط "إضافة خطة" لإنشاء واحدة') }}</p>
</div>
@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 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>
<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">
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('اسم الخطة') }} *</label>
<input type="text" wire:model="planRows.{{ $i }}.label_ar"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="{{ __('مثال: 3 أقساط شهرية للأعضاء') }}">
@error("planRows.{$i}.label_ar") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Tier --}}
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('للعضوية') }}</label>
<select wire:model="planRows.{{ $i }}.tier"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500">
<option value="any">{{ __('الجميع') }}</option>
<option value="member">{{ __('أعضاء فقط') }}</option>
<option value="non_member">{{ __('غير أعضاء') }}</option>
</select>
</div>
{{-- 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"
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>
{{-- Frequency --}}
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('الدورية') }}</label>
<select wire:model="planRows.{{ $i }}.frequency"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500">
<option value="monthly">{{ __('شهري') }}</option>
<option value="quarterly">{{ __('ربع سنوي') }}</option>
<option value="biweekly">{{ __('نصف شهري') }}</option>
<option value="weekly">{{ __('أسبوعي') }}</option>
</select>
</div>
{{-- Down payment % --}}
<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"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="0">
<p class="text-xs text-gray-400 mt-0.5">{{ __('0 = موزع بالتساوي') }}</p>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endif
</div>
{{-- Actions --}} {{-- Actions --}}
<div class="flex flex-col-reverse sm:flex-row items-stretch sm:items-center justify-end gap-3"> <div class="flex flex-col-reverse sm:flex-row items-stretch sm:items-center justify-end gap-3">
<a href="{{ route('inventory.products') }}" wire:navigate <a href="{{ route('inventory.products') }}" wire:navigate
......
...@@ -756,27 +756,102 @@ class="w-full flex items-center justify-between px-4 py-3 text-start hover:bg-am ...@@ -756,27 +756,102 @@ class="w-full flex items-center justify-between px-4 py-3 text-start hover:bg-am
@endif @endif
</div> </div>
@if(count($hotbuyCart) > 0) @if(count($hotbuyCart) > 0)
<div class="space-y-2"> <div class="space-y-3">
@foreach($hotbuyCart as $key => $cartItem) @foreach($hotbuyCart as $key => $cartItem)
<div class="flex items-center justify-between bg-white p-3 rounded-lg border border-gray-200"> @php
$isAnnual = ($cartItem['billing_cycle'] ?? 'one_time') === 'annual';
$cartPlans = ($isAnnual && $cartItem['type'] === 'product') ? ($cartItem['plans'] ?? []) : [];
@endphp
<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-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-gray-800">{{ $cartItem['name_ar'] }}</p> <p class="text-sm font-medium text-gray-800">{{ $cartItem['name_ar'] }}</p>
<p class="text-xs text-gray-500" dir="ltr">{{ number_format($cartItem['price'] / 100, 2) }} {{ __('ج.م') }}</p> @if($isAnnual)
<span class="px-1.5 py-0.5 text-xs rounded bg-purple-100 text-purple-700 font-medium">{{ __('سنوي') }}</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
</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if(!$isAnnual)
<button type="button" wire:click="updateHotbuyQuantity('{{ $key }}', {{ $cartItem['quantity'] - 1 }})" <button type="button" wire:click="updateHotbuyQuantity('{{ $key }}', {{ $cartItem['quantity'] - 1 }})"
class="w-8 h-8 rounded-full bg-gray-200 text-gray-700 flex items-center justify-center hover:bg-gray-300 text-lg font-bold"></button> class="w-8 h-8 rounded-full bg-gray-200 text-gray-700 flex items-center justify-center hover:bg-gray-300 text-lg font-bold"></button>
<span class="w-8 text-center text-sm font-bold">{{ $cartItem['quantity'] }}</span> <span class="w-8 text-center text-sm font-bold">{{ $cartItem['quantity'] }}</span>
<button type="button" wire:click="updateHotbuyQuantity('{{ $key }}', {{ $cartItem['quantity'] + 1 }})" <button type="button" wire:click="updateHotbuyQuantity('{{ $key }}', {{ $cartItem['quantity'] + 1 }})"
class="w-8 h-8 rounded-full bg-gray-200 text-gray-700 flex items-center justify-center hover:bg-gray-300 text-lg font-bold">+</button> class="w-8 h-8 rounded-full bg-gray-200 text-gray-700 flex items-center justify-center hover:bg-gray-300 text-lg font-bold">+</button>
@endif
<button type="button" wire:click="removeHotbuyItem('{{ $key }}')" <button type="button" wire:click="removeHotbuyItem('{{ $key }}')"
class="w-8 h-8 rounded-full bg-red-100 text-red-600 flex items-center justify-center hover:bg-red-200 ms-2"> class="w-8 h-8 rounded-full bg-red-100 text-red-600 flex items-center justify-center hover:bg-red-200 {{ $isAnnual ? '' : 'ms-2' }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
{{-- Installment plan picker for annual products --}}
@if($isAnnual)
<div class="px-3 pb-3 border-t border-purple-100 pt-2 bg-purple-50">
<p class="text-xs font-medium text-purple-700 mb-2">{{ __('طريقة الدفع') }}</p>
<div class="flex flex-wrap gap-2">
{{-- Full payment option --}}
<button type="button" wire:click="setHotbuyPlan('{{ $key }}', null)"
class="px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors
{{ is_null($cartItem['plan_id']) ? 'bg-purple-600 text-white border-purple-700' : 'bg-white text-gray-700 border-gray-300 hover:border-purple-400' }}">
{{ __('دفع كامل') }}
<span class="opacity-75 ms-1" dir="ltr">{{ number_format($cartItem['price'] / 100, 2) }}</span>
</button>
@foreach($cartPlans as $plan)
@php
$planTotal = $cartItem['price'];
$downPct = $plan['down_payment_pct'];
$downAmt = $downPct > 0
? (int) ceil($planTotal * $downPct / 100)
: (int) ceil($planTotal / $plan['installments']);
@endphp
<button type="button" wire:click="setHotbuyPlan('{{ $key }}', {{ $plan['id'] }})"
class="px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors
{{ $cartItem['plan_id'] === $plan['id'] ? 'bg-purple-600 text-white border-purple-700' : 'bg-white text-gray-700 border-gray-300 hover:border-purple-400' }}">
{{ $plan['label_ar'] }}
<span class="opacity-75 ms-1" dir="ltr">{{ number_format($downAmt / 100, 2) }} {{ __('مقدم') }}</span>
</button>
@endforeach
@if(count($cartPlans) === 0)
<span class="text-xs text-gray-400 italic">{{ __('لا توجد خطط تقسيط محددة') }}</span>
@endif
</div>
@if(!is_null($cartItem['plan_id']))
@php
$selPlan = collect($cartPlans)->firstWhere('id', $cartItem['plan_id']);
@endphp
@if($selPlan)
<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'] }})
@endif
</div>
@endif
@endif
</div>
@endif
</div>
@endforeach @endforeach
</div> </div>
@else @else
......
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