Commit d35573dd authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add platform service fee (3%) on all transactions

Revenue model: 3% fee on every POS transaction, shown as "مصاريف خدمة"
on receipts and invoices. The percentage is controlled ONLY via the
PLATFORM_SERVICE_FEE_PERCENT env var — not editable by any admin.

- PlatformFeeService: calculates fee from env var (default 3%)
- POSService: includes service_fee_amount in transaction + invoice total
- POS terminal UI: shows fee line before grand total
- Receipt print: displays "مصاريف خدمة" row
- Invoice show: displays service fee row
- System settings: read-only banner showing the current fee percentage
- Migration: adds service_fee_amount column to pos_transactions + invoices
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c0f3c428
...@@ -64,4 +64,8 @@ ATTENDANCE_GRACE_MINUTES_TRAINER=10 ...@@ -64,4 +64,8 @@ ATTENDANCE_GRACE_MINUTES_TRAINER=10
CONSECUTIVE_ABSENCE_THRESHOLD=5 CONSECUTIVE_ABSENCE_THRESHOLD=5
ATTENDANCE_MIN_RATE_PERCENT=75 ATTENDANCE_MIN_RATE_PERCENT=75
# Platform Service Fee (percentage charged on all transactions — provider revenue)
# This value is NOT editable from the admin panel — only via this env variable
PLATFORM_SERVICE_FEE_PERCENT=3
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
...@@ -31,6 +31,7 @@ class Invoice extends Model ...@@ -31,6 +31,7 @@ class Invoice extends Model
'subtotal_amount', 'subtotal_amount',
'discount_amount', 'discount_amount',
'tax_amount', 'tax_amount',
'service_fee_amount',
'total_amount', 'total_amount',
'paid_amount', 'paid_amount',
'due_amount', 'due_amount',
...@@ -52,6 +53,7 @@ protected function casts(): array ...@@ -52,6 +53,7 @@ protected function casts(): array
'subtotal_amount' => 'integer', 'subtotal_amount' => 'integer',
'discount_amount' => 'integer', 'discount_amount' => 'integer',
'tax_amount' => 'integer', 'tax_amount' => 'integer',
'service_fee_amount' => 'integer',
'total_amount' => 'integer', 'total_amount' => 'integer',
'paid_amount' => 'integer', 'paid_amount' => 'integer',
'due_amount' => 'integer', 'due_amount' => 'integer',
...@@ -101,7 +103,7 @@ public function approver(): BelongsTo ...@@ -101,7 +103,7 @@ public function approver(): BelongsTo
public function recalculateTotals(): void public function recalculateTotals(): void
{ {
$this->subtotal_amount = $this->items()->sum('total_amount'); $this->subtotal_amount = $this->items()->sum('total_amount');
$this->total_amount = $this->subtotal_amount - $this->discount_amount + $this->tax_amount; $this->total_amount = $this->subtotal_amount - $this->discount_amount + $this->tax_amount + ($this->service_fee_amount ?? 0);
$this->due_amount = $this->total_amount - $this->paid_amount; $this->due_amount = $this->total_amount - $this->paid_amount;
$this->save(); $this->save();
} }
......
...@@ -31,6 +31,7 @@ class POSTransaction extends Model ...@@ -31,6 +31,7 @@ class POSTransaction extends Model
'subtotal', 'subtotal',
'discount_amount', 'discount_amount',
'tax_amount', 'tax_amount',
'service_fee_amount',
'total_amount', 'total_amount',
'payment_method', 'payment_method',
'payment_status', 'payment_status',
...@@ -48,6 +49,7 @@ class POSTransaction extends Model ...@@ -48,6 +49,7 @@ class POSTransaction extends Model
'subtotal' => 'integer', 'subtotal' => 'integer',
'discount_amount' => 'integer', 'discount_amount' => 'integer',
'tax_amount' => 'integer', 'tax_amount' => 'integer',
'service_fee_amount' => 'integer',
'total_amount' => 'integer', 'total_amount' => 'integer',
'payment_method' => POSPaymentMethod::class, 'payment_method' => POSPaymentMethod::class,
'payment_status' => POSPaymentStatus::class, 'payment_status' => POSPaymentStatus::class,
......
...@@ -115,6 +115,7 @@ public static function defaultPosFields(): array ...@@ -115,6 +115,7 @@ public static function defaultPosFields(): array
'subtotal' => true, 'subtotal' => true,
'discount_total' => true, 'discount_total' => true,
'tax_amount' => true, 'tax_amount' => true,
'service_fee' => true,
'grand_total' => true, 'grand_total' => true,
'payment_method' => true, 'payment_method' => true,
'payment_split_details' => true, 'payment_split_details' => true,
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
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\Financial\Services\WalletService; use App\Domain\Financial\Services\WalletService;
use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Enums\POSItemType; use App\Domain\POS\Enums\POSItemType;
use App\Domain\POS\Enums\POSPaymentMethod; use App\Domain\POS\Enums\POSPaymentMethod;
...@@ -26,6 +27,7 @@ public function __construct( ...@@ -26,6 +27,7 @@ public function __construct(
private PaymentService $paymentService, private PaymentService $paymentService,
private WalletService $walletService, private WalletService $walletService,
private CashSessionService $cashSessionService, private CashSessionService $cashSessionService,
private PlatformFeeService $platformFeeService,
) {} ) {}
/** /**
...@@ -85,7 +87,8 @@ public function processTransaction( ...@@ -85,7 +87,8 @@ public function processTransaction(
// Step 5: Calculate totals // Step 5: Calculate totals
$taxAmount = 0; // Tax configuration per academy — to be implemented $taxAmount = 0; // Tax configuration per academy — to be implemented
$totalAmount = $subtotal + $taxAmount; $serviceFeeAmount = $this->platformFeeService->calculate($subtotal);
$totalAmount = $subtotal + $taxAmount + $serviceFeeAmount;
// Step 6-7: Validate payment // Step 6-7: Validate payment
$paymentMethodEnum = POSPaymentMethod::from($paymentMethod); $paymentMethodEnum = POSPaymentMethod::from($paymentMethod);
...@@ -103,6 +106,7 @@ public function processTransaction( ...@@ -103,6 +106,7 @@ public function processTransaction(
'subtotal' => $subtotal, 'subtotal' => $subtotal,
'discount_amount' => $totalDiscount, 'discount_amount' => $totalDiscount,
'tax_amount' => $taxAmount, 'tax_amount' => $taxAmount,
'service_fee_amount' => $serviceFeeAmount,
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'payment_method' => $paymentMethodEnum->value, 'payment_method' => $paymentMethodEnum->value,
'payment_status' => POSPaymentStatus::Completed->value, 'payment_status' => POSPaymentStatus::Completed->value,
...@@ -150,6 +154,7 @@ public function processTransaction( ...@@ -150,6 +154,7 @@ public function processTransaction(
'subtotal' => $subtotal, 'subtotal' => $subtotal,
'discount_amount' => $totalDiscount, 'discount_amount' => $totalDiscount,
'tax_amount' => $taxAmount, 'tax_amount' => $taxAmount,
'service_fee_amount' => $serviceFeeAmount,
'contact_name' => $participant?->person?->name_ar ?? 'عميل عابر', 'contact_name' => $participant?->person?->name_ar ?? 'عميل عابر',
], $this->buildInvoiceItems($cartItems), $cashier); ], $this->buildInvoiceItems($cartItems), $cashier);
......
...@@ -71,6 +71,7 @@ public function buildPosReceiptData(POSTransaction $transaction, ?ReceiptTemplat ...@@ -71,6 +71,7 @@ public function buildPosReceiptData(POSTransaction $transaction, ?ReceiptTemplat
$data['subtotal'] = $transaction->subtotal; $data['subtotal'] = $transaction->subtotal;
$data['discount_amount'] = $transaction->discount_amount; $data['discount_amount'] = $transaction->discount_amount;
$data['tax_amount'] = $transaction->tax_amount; $data['tax_amount'] = $transaction->tax_amount;
$data['service_fee_amount'] = $transaction->service_fee_amount;
$data['total_amount'] = $transaction->total_amount; $data['total_amount'] = $transaction->total_amount;
// Payment // Payment
......
<?php
namespace App\Domain\Shared\Services;
class PlatformFeeService
{
/**
* Get the platform service fee percentage (from env only — not editable by any user).
*/
public function getPercentage(): float
{
return (float) env('PLATFORM_SERVICE_FEE_PERCENT', 3);
}
/**
* Calculate service fee in piasters for a given subtotal (after discount, before fee).
*/
public function calculate(int $amountPiasters): int
{
$percent = $this->getPercentage();
if ($percent <= 0) {
return 0;
}
return (int) round($amountPiasters * $percent / 100);
}
/**
* Whether the platform fee is active.
*/
public function isActive(): bool
{
return $this->getPercentage() > 0;
}
}
...@@ -176,6 +176,16 @@ public function getCartSubtotal(): int ...@@ -176,6 +176,16 @@ public function getCartSubtotal(): int
return collect($this->cart)->sum(fn ($item) => (int) $item['base_price'] * (int) $item['quantity']); return collect($this->cart)->sum(fn ($item) => (int) $item['base_price'] * (int) $item['quantity']);
} }
public function getServiceFee(): int
{
return app(\App\Domain\Shared\Services\PlatformFeeService::class)->calculate($this->getCartTotal());
}
public function getCartGrandTotal(): int
{
return $this->getCartTotal() + $this->getServiceFee();
}
public function addSplitPayment(): void public function addSplitPayment(): void
{ {
if ($this->splitAmount <= 0) { if ($this->splitAmount <= 0) {
...@@ -223,11 +233,11 @@ public function checkout(POSService $posService): void ...@@ -223,11 +233,11 @@ public function checkout(POSService $posService): void
return; return;
} }
// Validate split total // Validate split total (must cover grand total including service fee)
if ($this->paymentMethod === 'split') { if ($this->paymentMethod === 'split') {
$splitTotal = $this->getSplitTotal(); $splitTotal = $this->getSplitTotal();
$cartTotal = $this->getCartTotal(); $grandTotal = $this->getCartGrandTotal();
if ($splitTotal !== $cartTotal) { if ($splitTotal < $grandTotal) {
session()->flash('error', __('المبلغ المدفوع أقل من الإجمالي')); session()->flash('error', __('المبلغ المدفوع أقل من الإجمالي'));
return; return;
} }
...@@ -293,6 +303,8 @@ public function render() ...@@ -293,6 +303,8 @@ public function render()
'cartTotal' => $this->getCartTotal(), 'cartTotal' => $this->getCartTotal(),
'cartDiscount' => $this->getCartDiscount(), 'cartDiscount' => $this->getCartDiscount(),
'cartSubtotal' => $this->getCartSubtotal(), 'cartSubtotal' => $this->getCartSubtotal(),
'serviceFee' => $this->getServiceFee(),
'cartGrandTotal' => $this->getCartGrandTotal(),
'paymentMethods' => POSPaymentMethod::cases(), 'paymentMethods' => POSPaymentMethod::cases(),
]); ]);
} }
......
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Domain\Identity\Models\Branch; use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\POS\Models\ReceiptTemplate; use App\Domain\POS\Models\ReceiptTemplate;
use App\Domain\POS\Services\ReceiptService;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
...@@ -157,6 +155,7 @@ public function render() ...@@ -157,6 +155,7 @@ public function render()
'subtotal' => 'المجموع الفرعي', 'subtotal' => 'المجموع الفرعي',
'discount_total' => 'إجمالي الخصم', 'discount_total' => 'إجمالي الخصم',
'tax_amount' => 'الضريبة', 'tax_amount' => 'الضريبة',
'service_fee' => 'مصاريف خدمة',
'grand_total' => 'الإجمالي النهائي', 'grand_total' => 'الإجمالي النهائي',
'payment_method' => 'طريقة الدفع', 'payment_method' => 'طريقة الدفع',
'payment_split_details' => 'تفاصيل الدفع المقسم', 'payment_split_details' => 'تفاصيل الدفع المقسم',
......
<?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('pos_transactions', function (Blueprint $table) {
$table->bigInteger('service_fee_amount')->default(0)->after('tax_amount');
});
Schema::table('invoices', function (Blueprint $table) {
$table->bigInteger('service_fee_amount')->default(0)->after('tax_amount');
});
}
public function down(): void
{
Schema::table('pos_transactions', function (Blueprint $table) {
$table->dropColumn('service_fee_amount');
});
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn('service_fee_amount');
});
}
};
...@@ -126,6 +126,12 @@ class="text-gray-600 hover:text-gray-800 text-sm font-medium"> ...@@ -126,6 +126,12 @@ class="text-gray-600 hover:text-gray-800 text-sm font-medium">
<td class="px-4 py-2 text-end font-medium" dir="ltr">{{ number_format($invoice->tax_amount / 100, 2) }} {{ __('ج.م') }}</td> <td class="px-4 py-2 text-end font-medium" dir="ltr">{{ number_format($invoice->tax_amount / 100, 2) }} {{ __('ج.م') }}</td>
</tr> </tr>
@endif @endif
@if(($invoice->service_fee_amount ?? 0) > 0)
<tr>
<td colspan="4" class="px-4 py-2 text-end text-sm text-gray-600">{{ __('مصاريف خدمة') }}</td>
<td class="px-4 py-2 text-end font-medium" dir="ltr">{{ number_format($invoice->service_fee_amount / 100, 2) }} {{ __('ج.م') }}</td>
</tr>
@endif
<tr class="border-t border-gray-300"> <tr class="border-t border-gray-300">
<td colspan="4" class="px-4 py-3 text-end text-base font-bold text-gray-800">{{ __('الإجمالي') }}</td> <td colspan="4" class="px-4 py-3 text-end text-base font-bold text-gray-800">{{ __('الإجمالي') }}</td>
<td class="px-4 py-3 text-end text-base font-bold text-blue-600" dir="ltr">{{ number_format($invoice->total_amount / 100, 2) }} {{ __('ج.م') }}</td> <td class="px-4 py-3 text-end text-base font-bold text-blue-600" dir="ltr">{{ number_format($invoice->total_amount / 100, 2) }} {{ __('ج.م') }}</td>
......
...@@ -275,9 +275,15 @@ class="text-gray-400 hover:text-red-500 transition-colors shrink-0"> ...@@ -275,9 +275,15 @@ class="text-gray-400 hover:text-red-500 transition-colors shrink-0">
<span class="text-green-600" dir="ltr">-{{ number_format($cartDiscount / 100, 2) }} {{ __('ج.م') }}</span> <span class="text-green-600" dir="ltr">-{{ number_format($cartDiscount / 100, 2) }} {{ __('ج.م') }}</span>
</div> </div>
@endif @endif
@if($serviceFee > 0)
<div class="flex justify-between text-sm">
<span class="text-amber-600">{{ __('مصاريف خدمة') }}</span>
<span class="text-amber-600" dir="ltr">{{ number_format($serviceFee / 100, 2) }} {{ __('ج.م') }}</span>
</div>
@endif
<div class="flex justify-between text-base font-bold pt-1 border-t border-gray-100"> <div class="flex justify-between text-base font-bold pt-1 border-t border-gray-100">
<span class="text-gray-800">{{ __('الإجمالي') }}</span> <span class="text-gray-800">{{ __('الإجمالي') }}</span>
<span class="text-blue-700" dir="ltr">{{ number_format($cartTotal / 100, 2) }} {{ __('ج.م') }}</span> <span class="text-blue-700" dir="ltr">{{ number_format($cartGrandTotal / 100, 2) }} {{ __('ج.م') }}</span>
</div> </div>
</div> </div>
......
...@@ -11,6 +11,19 @@ ...@@ -11,6 +11,19 @@
</div> </div>
@endif @endif
{{-- Platform Service Fee (read-only, env-controlled) --}}
<div class="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-amber-800">{{ __('مصاريف خدمة المنصة') }}</h3>
<p class="text-xs text-amber-600 mt-1">{{ __('تُضاف تلقائياً على كل إيصال — هذه القيمة محددة من مزود الخدمة ولا يمكن تغييرها') }}</p>
</div>
<div class="text-2xl font-bold text-amber-800" dir="ltr">
{{ env('PLATFORM_SERVICE_FEE_PERCENT', 3) }}%
</div>
</div>
</div>
<!-- Tabs --> <!-- Tabs -->
<div x-data="{ activeGroup: @entangle('activeGroup') }" class="mb-6"> <div x-data="{ activeGroup: @entangle('activeGroup') }" class="mb-6">
<div class="border-b border-gray-200"> <div class="border-b border-gray-200">
......
...@@ -157,6 +157,12 @@ ...@@ -157,6 +157,12 @@
<span dir="ltr">{{ number_format($data['tax_amount'] / 100, 2) }} {{ $currency }}</span> <span dir="ltr">{{ number_format($data['tax_amount'] / 100, 2) }} {{ $currency }}</span>
</div> </div>
@endif @endif
@if(($data['service_fee_amount'] ?? 0) > 0)
<div class="flex">
<span>{{ __('مصاريف خدمة') }}:</span>
<span dir="ltr">{{ number_format($data['service_fee_amount'] / 100, 2) }} {{ $currency }}</span>
</div>
@endif
<div class="flex total-row mt-1 border-top"> <div class="flex total-row mt-1 border-top">
<span>{{ __('الإجمالي') }}:</span> <span>{{ __('الإجمالي') }}:</span>
<span dir="ltr">{{ number_format(($data['total_amount'] ?? 0) / 100, 2) }} {{ $currency }}</span> <span dir="ltr">{{ number_format(($data['total_amount'] ?? 0) / 100, 2) }} {{ $currency }}</span>
......
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