Commit 12cedf70 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add partial payment (deposit) option per product in POS

Products can now be configured to accept partial payment (deposit/عربون)
at checkout. When enabled, the cashier can collect a minimum deposit now
and the remaining balance stays on the invoice for later collection.

- Migration adds allows_partial_payment + minimum_deposit_percent to products
- Product form has toggle with configurable minimum % (10-90%)
- POS terminal shows deposit option when cart contains eligible products
- POSService creates full invoice but records only the deposit as payment
- POS transaction marked 'partially_paid' when deposit is used
- Products with deposit enabled show "عربون" badge in POS catalog
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 619abe30
......@@ -36,6 +36,8 @@ class Product extends Model
'track_inventory',
'is_active',
'is_essential',
'allows_partial_payment',
'minimum_deposit_percent',
'billing_cycle',
'member_price',
'non_member_price',
......@@ -61,6 +63,8 @@ protected function casts(): array
'track_inventory' => 'boolean',
'is_active' => 'boolean',
'is_essential' => 'boolean',
'allows_partial_payment' => 'boolean',
'minimum_deposit_percent' => 'integer',
'min_stock_level' => 'integer',
'max_stock_level' => 'integer',
'weight_grams' => 'integer',
......
......@@ -5,6 +5,7 @@
enum POSPaymentStatus: string
{
case Completed = 'completed';
case PartiallyPaid = 'partially_paid';
case Refunded = 'refunded';
case PartiallyRefunded = 'partially_refunded';
}
......@@ -59,6 +59,7 @@ public function processTransaction(
?string $couponCode = null,
?array $splitPayments = null,
?string $notes = null,
?int $depositAmount = null,
): POSTransaction {
// Guard: POS session must be open
$cashSession = $this->cashSessionService->getOpenSession($cashier);
......@@ -82,7 +83,7 @@ public function processTransaction(
}
}
return DB::transaction(function () use ($cartItems, $cashier, $branchId, $paymentMethod, $participant, $couponCode, $splitPayments, $notes, $cashSession) {
return DB::transaction(function () use ($cartItems, $cashier, $branchId, $paymentMethod, $participant, $couponCode, $splitPayments, $notes, $cashSession, $depositAmount) {
// Steps 2-3: Items already priced by caller (pricing engine runs in Livewire/controller layer)
$subtotal = 0;
$totalDiscount = 0;
......@@ -100,10 +101,13 @@ public function processTransaction(
// Step 6-7: Validate payment
$paymentMethodEnum = POSPaymentMethod::from($paymentMethod);
$this->validatePayment($paymentMethodEnum, $totalAmount, $participant, $splitPayments);
$isDeposit = $depositAmount !== null && $depositAmount > 0 && $depositAmount < $totalAmount;
$amountToPayNow = $isDeposit ? $depositAmount : $totalAmount;
$this->validatePayment($paymentMethodEnum, $amountToPayNow, $participant, $splitPayments);
// Step 8: Commit — create POS transaction
$receiptNumber = $this->generateReceiptNumber($branchId);
$paymentStatus = $isDeposit ? POSPaymentStatus::PartiallyPaid : POSPaymentStatus::Completed;
$posTransaction = POSTransaction::create([
'academy_id' => $cashSession->academy_id,
......@@ -117,12 +121,12 @@ public function processTransaction(
'service_fee_amount' => $serviceFeeAmount,
'total_amount' => $totalAmount,
'payment_method' => $paymentMethodEnum->value,
'payment_status' => POSPaymentStatus::Completed->value,
'payment_status' => $paymentStatus->value,
'coupon_code' => $couponCode,
'notes' => $notes,
'notes' => $isDeposit ? ($notes ? $notes . ' | ' : '') . 'دفع مقدم (عربون)' : $notes,
'processed_by' => $cashier->id,
'processed_at' => now(),
'metadata' => [],
'metadata' => $isDeposit ? ['deposit_amount' => $depositAmount, 'remaining' => $totalAmount - $depositAmount] : [],
]);
// Create POS transaction items
......@@ -169,13 +173,13 @@ public function processTransaction(
// Link invoice to POS transaction
$posTransaction->update(['invoice_id' => $invoice->id]);
// Record payment(s) on the invoice
// Record payment(s) on the invoice (deposit pays only the deposit amount)
if ($paymentMethodEnum === POSPaymentMethod::Split && $splitPayments) {
foreach ($splitPayments as $sp) {
$this->recordSinglePayment($invoice, $sp['method'], (int) $sp['amount'], $cashier, $participant);
}
} else {
$this->recordSinglePayment($invoice, $paymentMethod, $totalAmount, $cashier, $participant);
$this->recordSinglePayment($invoice, $paymentMethod, $amountToPayNow, $cashier, $participant);
}
// Handle enrollment for program items + inventory deduction for tracked products
......@@ -208,7 +212,7 @@ public function processTransaction(
$cashSession->increment('transactions_count');
if (in_array($paymentMethod, ['cash', 'split'])) {
$cashAmount = $paymentMethod === 'cash'
? $totalAmount
? $amountToPayNow
: (int) collect($splitPayments)->where('method', 'cash')->sum('amount');
if ($cashAmount > 0) {
......
......@@ -32,6 +32,8 @@ class ProductForm extends Component
public string $tax_rate = '0';
public bool $is_active = true;
public bool $is_essential = false;
public bool $allows_partial_payment = false;
public int $minimum_deposit_percent = 50;
public string $description_ar = '';
// Annual billing
......@@ -65,6 +67,8 @@ public function mount(?Product $product = null): void
$this->tax_rate = (string) ($product->tax_rate ?? 0);
$this->is_active = $product->is_active;
$this->is_essential = $product->is_essential ?? false;
$this->allows_partial_payment = $product->allows_partial_payment ?? false;
$this->minimum_deposit_percent = $product->minimum_deposit_percent ?? 50;
$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) : '';
......@@ -145,6 +149,8 @@ public function rules(): array
'tax_rate' => 'nullable|numeric|min:0|max:100',
'is_active' => 'boolean',
'is_essential' => 'boolean',
'allows_partial_payment' => 'boolean',
'minimum_deposit_percent' => 'required_if:allows_partial_payment,true|integer|min:10|max:90',
'description_ar' => 'nullable|string|max:1000',
'billing_cycle' => 'required|in:one_time,annual',
'member_price' => 'nullable|numeric|min:0',
......@@ -197,6 +203,8 @@ public function save(): void
'tax_rate' => (int) $this->tax_rate,
'is_active' => $this->is_active,
'is_essential' => $this->is_essential,
'allows_partial_payment' => $this->allows_partial_payment,
'minimum_deposit_percent' => $this->allows_partial_payment ? $this->minimum_deposit_percent : 50,
'description_ar' => $this->description_ar ?: null,
'billing_cycle' => $this->billing_cycle,
'member_price' => $this->member_price !== '' ? (int) round((float) $this->member_price * 100) : null,
......
......@@ -37,6 +37,10 @@ class POSTerminal extends Component
public float $manualItemPrice = 0;
public int $manualItemQty = 1;
// Partial payment (deposit)
public bool $useDeposit = false;
public float $depositAmount = 0;
// UI state
public bool $showCheckout = false;
public bool $showReceipt = false;
......@@ -254,6 +258,48 @@ public function getSplitTotal(): int
return collect($this->splitPayments)->sum(fn ($sp) => (int) $sp['amount']);
}
public function cartAllowsDeposit(): bool
{
foreach ($this->cart as $item) {
if (($item['item_type'] ?? '') === POSItemType::Product->value && isset($item['item_id'])) {
$product = Product::find($item['item_id']);
if ($product && $product->allows_partial_payment) {
return true;
}
}
}
return false;
}
public function getMinimumDepositPercent(): int
{
$maxPercent = 10;
foreach ($this->cart as $item) {
if (($item['item_type'] ?? '') === POSItemType::Product->value && isset($item['item_id'])) {
$product = Product::find($item['item_id']);
if ($product && $product->allows_partial_payment) {
$maxPercent = max($maxPercent, $product->minimum_deposit_percent);
}
}
}
return $maxPercent;
}
public function getMinimumDepositAmount(): int
{
$grandTotal = $this->getCartGrandTotal();
return (int) ceil($grandTotal * $this->getMinimumDepositPercent() / 100);
}
public function updatedUseDeposit(): void
{
if ($this->useDeposit) {
$this->depositAmount = round($this->getMinimumDepositAmount() / 100, 2);
} else {
$this->depositAmount = 0;
}
}
public function openCheckout(): void
{
if (empty($this->cart)) {
......@@ -275,8 +321,24 @@ public function checkout(POSService $posService): void
return;
}
// Validate deposit amount if using deposit
if ($this->useDeposit && $this->cartAllowsDeposit()) {
$depositPiasters = (int) round($this->depositAmount * 100);
$minDeposit = $this->getMinimumDepositAmount();
$grandTotal = $this->getCartGrandTotal();
if ($depositPiasters < $minDeposit) {
session()->flash('error', __('مبلغ المقدم أقل من الحد الأدنى'));
return;
}
if ($depositPiasters >= $grandTotal) {
session()->flash('error', __('مبلغ المقدم يجب أن يكون أقل من الإجمالي'));
return;
}
}
// Validate split total (must cover grand total including service fee)
if ($this->paymentMethod === 'split') {
if ($this->paymentMethod === 'split' && !$this->useDeposit) {
$splitTotal = $this->getSplitTotal();
$grandTotal = $this->getCartGrandTotal();
if ($splitTotal < $grandTotal) {
......@@ -288,6 +350,11 @@ public function checkout(POSService $posService): void
try {
$participant = $this->participantId ? Participant::find($this->participantId) : null;
$depositPiasters = null;
if ($this->useDeposit && $this->cartAllowsDeposit()) {
$depositPiasters = (int) round($this->depositAmount * 100);
}
$transaction = $posService->processTransaction(
cartItems: $this->cart,
cashier: auth()->user(),
......@@ -297,6 +364,7 @@ public function checkout(POSService $posService): void
couponCode: $this->couponCode ?: null,
splitPayments: $this->paymentMethod === 'split' ? $this->splitPayments : null,
notes: $this->notes ?: null,
depositAmount: $depositPiasters,
);
$this->lastReceiptNumber = $transaction->receipt_number;
......@@ -309,6 +377,8 @@ public function checkout(POSService $posService): void
$this->couponCode = '';
$this->notes = '';
$this->splitPayments = [];
$this->useDeposit = false;
$this->depositAmount = 0;
$this->showCheckout = false;
session()->flash('success', __('تمت العملية بنجاح'));
......@@ -334,6 +404,8 @@ public function newTransaction(): void
$this->paymentMethod = 'cash';
$this->notes = '';
$this->splitPayments = [];
$this->useDeposit = false;
$this->depositAmount = 0;
$this->showCheckout = false;
$this->showReceipt = false;
$this->lastReceiptNumber = null;
......@@ -360,6 +432,9 @@ public function render()
'serviceFee' => $this->getServiceFee(),
'cartGrandTotal' => $this->getCartGrandTotal(),
'paymentMethods' => POSPaymentMethod::cases(),
'allowsDeposit' => $this->cartAllowsDeposit(),
'minimumDepositAmount' => $this->getMinimumDepositAmount(),
'minimumDepositPercent' => $this->getMinimumDepositPercent(),
]);
}
}
<?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
{
Schema::table('products', function (Blueprint $table) {
$table->boolean('allows_partial_payment')->default(false)->after('is_essential');
$table->integer('minimum_deposit_percent')->default(50)->after('allows_partial_payment');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn(['allows_partial_payment', 'minimum_deposit_percent']);
});
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE pos_transactions DROP CONSTRAINT IF EXISTS pos_transactions_payment_status_check");
DB::statement("ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_payment_status_check CHECK (payment_status IN ('completed', 'partially_paid', 'refunded', 'partially_refunded'))");
}
public function down(): void
{
DB::statement("ALTER TABLE pos_transactions DROP CONSTRAINT IF EXISTS pos_transactions_payment_status_check");
DB::statement("ALTER TABLE pos_transactions ADD CONSTRAINT pos_transactions_payment_status_check CHECK (payment_status IN ('completed', 'refunded', 'partially_refunded'))");
}
};
......@@ -166,6 +166,27 @@ class="mt-0.5 w-4 h-4 rounded border-amber-400 text-amber-600 focus:ring-amber-5
</div>
</label>
</div>
{{-- Allows Partial Payment (Deposit) --}}
<div class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" wire:model.live="allows_partial_payment"
class="mt-0.5 w-4 h-4 rounded border-blue-400 text-blue-600 focus:ring-blue-500">
<div>
<span class="text-sm font-medium text-blue-800">{{ __('يقبل دفع مقدم (عربون)') }}</span>
<p class="text-xs text-blue-600 mt-0.5">{{ __('يسمح ببيع المنتج بدفع جزء الآن والباقي لاحقاً') }}</p>
</div>
</label>
@if($allows_partial_payment)
<div class="mt-3 ms-7">
<label class="block text-xs font-medium text-blue-800 mb-1">{{ __('الحد الأدنى للمقدم (%)') }}</label>
<input type="number" wire:model="minimum_deposit_percent" min="10" max="90" step="5" dir="ltr"
class="w-32 text-sm px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 focus:ring-blue-500 @error('minimum_deposit_percent') border-red-500 @enderror">
@error('minimum_deposit_percent') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
<p class="text-xs text-blue-500 mt-1">{{ __('النسبة الأقل التي يمكن دفعها عند البيع') }}</p>
</div>
@endif
</div>
</div>
{{-- Annual Billing Section --}}
......
......@@ -107,7 +107,10 @@ class="flex-1 px-3 sm:px-4 py-3 min-h-[44px] text-xs sm:text-sm font-medium tran
<div class="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-2 sm:gap-3" wire:loading.class="opacity-50 pointer-events-none" wire:target="addProduct">
@foreach($products as $product)
<button wire:click="addProduct({{ $product->id }})"
class="flex flex-col items-start p-2.5 sm:p-3 min-h-[44px] border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50/50 active:bg-blue-100 active:scale-[0.97] transition-all text-start group">
class="flex flex-col items-start p-2.5 sm:p-3 min-h-[44px] border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50/50 active:bg-blue-100 active:scale-[0.97] transition-all text-start group relative">
@if($product->allows_partial_payment)
<span class="absolute top-1 end-1 px-1.5 py-0.5 text-[10px] font-bold bg-blue-100 text-blue-700 rounded">{{ __('عربون') }}</span>
@endif
<div class="flex items-center gap-2 w-full">
<div class="w-8 h-8 sm:w-9 sm:h-9 rounded-lg bg-emerald-100 text-emerald-600 flex items-center justify-center shrink-0 group-hover:bg-emerald-200 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
......@@ -396,6 +399,33 @@ class="min-w-[44px] min-h-[44px] px-3 bg-blue-600 text-white rounded text-xs hov
</div>
@endif
{{-- Partial Payment (Deposit) Option --}}
@if($allowsDeposit && $paymentMethod !== 'split')
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model.live="useDeposit"
class="w-4 h-4 rounded border-blue-400 text-blue-600 focus:ring-blue-500">
<span class="text-xs sm:text-sm font-medium text-blue-800">{{ __('دفع مقدم (عربون) — الباقي لاحقاً') }}</span>
</label>
@if($useDeposit)
<div class="flex items-center gap-2 ms-6">
<label class="text-xs text-blue-700 shrink-0">{{ __('المبلغ المدفوع الآن (ج.م):') }}</label>
<input type="number"
wire:model.live="depositAmount"
min="{{ number_format($minimumDepositAmount / 100, 2, '.', '') }}"
max="{{ number_format(($cartGrandTotal - 1) / 100, 2, '.', '') }}"
step="0.01"
dir="ltr"
class="w-28 px-2 py-1.5 min-h-[40px] border border-blue-300 rounded-lg text-xs focus:ring-2 focus:ring-blue-500">
</div>
<div class="ms-6 text-xs text-blue-600 space-y-0.5">
<p>{{ __('الحد الأدنى:') }} <span class="font-bold" dir="ltr">{{ number_format($minimumDepositAmount / 100, 2) }} {{ __('ج.م') }}</span> ({{ $minimumDepositPercent }}%)</p>
<p>{{ __('المتبقي للدفع لاحقاً:') }} <span class="font-bold text-amber-700" dir="ltr">{{ number_format(($cartGrandTotal - (int) round($depositAmount * 100)) / 100, 2) }} {{ __('ج.م') }}</span></p>
</div>
@endif
</div>
@endif
{{-- Notes --}}
<div>
<input type="text"
......
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