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
CONSECUTIVE_ABSENCE_THRESHOLD=5
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}"
......@@ -31,6 +31,7 @@ class Invoice extends Model
'subtotal_amount',
'discount_amount',
'tax_amount',
'service_fee_amount',
'total_amount',
'paid_amount',
'due_amount',
......@@ -52,6 +53,7 @@ protected function casts(): array
'subtotal_amount' => 'integer',
'discount_amount' => 'integer',
'tax_amount' => 'integer',
'service_fee_amount' => 'integer',
'total_amount' => 'integer',
'paid_amount' => 'integer',
'due_amount' => 'integer',
......@@ -101,7 +103,7 @@ public function approver(): BelongsTo
public function recalculateTotals(): void
{
$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->save();
}
......
......@@ -31,6 +31,7 @@ class POSTransaction extends Model
'subtotal',
'discount_amount',
'tax_amount',
'service_fee_amount',
'total_amount',
'payment_method',
'payment_status',
......@@ -48,6 +49,7 @@ class POSTransaction extends Model
'subtotal' => 'integer',
'discount_amount' => 'integer',
'tax_amount' => 'integer',
'service_fee_amount' => 'integer',
'total_amount' => 'integer',
'payment_method' => POSPaymentMethod::class,
'payment_status' => POSPaymentStatus::class,
......
......@@ -115,6 +115,7 @@ public static function defaultPosFields(): array
'subtotal' => true,
'discount_total' => true,
'tax_amount' => true,
'service_fee' => true,
'grand_total' => true,
'payment_method' => true,
'payment_split_details' => true,
......
......@@ -7,6 +7,7 @@
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\WalletService;
use App\Domain\Shared\Services\PlatformFeeService;
use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Enums\POSItemType;
use App\Domain\POS\Enums\POSPaymentMethod;
......@@ -26,6 +27,7 @@ public function __construct(
private PaymentService $paymentService,
private WalletService $walletService,
private CashSessionService $cashSessionService,
private PlatformFeeService $platformFeeService,
) {}
/**
......@@ -85,7 +87,8 @@ public function processTransaction(
// Step 5: Calculate totals
$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
$paymentMethodEnum = POSPaymentMethod::from($paymentMethod);
......@@ -103,6 +106,7 @@ public function processTransaction(
'subtotal' => $subtotal,
'discount_amount' => $totalDiscount,
'tax_amount' => $taxAmount,
'service_fee_amount' => $serviceFeeAmount,
'total_amount' => $totalAmount,
'payment_method' => $paymentMethodEnum->value,
'payment_status' => POSPaymentStatus::Completed->value,
......@@ -150,6 +154,7 @@ public function processTransaction(
'subtotal' => $subtotal,
'discount_amount' => $totalDiscount,
'tax_amount' => $taxAmount,
'service_fee_amount' => $serviceFeeAmount,
'contact_name' => $participant?->person?->name_ar ?? 'عميل عابر',
], $this->buildInvoiceItems($cartItems), $cashier);
......
......@@ -71,6 +71,7 @@ public function buildPosReceiptData(POSTransaction $transaction, ?ReceiptTemplat
$data['subtotal'] = $transaction->subtotal;
$data['discount_amount'] = $transaction->discount_amount;
$data['tax_amount'] = $transaction->tax_amount;
$data['service_fee_amount'] = $transaction->service_fee_amount;
$data['total_amount'] = $transaction->total_amount;
// 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
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
{
if ($this->splitAmount <= 0) {
......@@ -223,11 +233,11 @@ public function checkout(POSService $posService): void
return;
}
// Validate split total
// Validate split total (must cover grand total including service fee)
if ($this->paymentMethod === 'split') {
$splitTotal = $this->getSplitTotal();
$cartTotal = $this->getCartTotal();
if ($splitTotal !== $cartTotal) {
$grandTotal = $this->getCartGrandTotal();
if ($splitTotal < $grandTotal) {
session()->flash('error', __('المبلغ المدفوع أقل من الإجمالي'));
return;
}
......@@ -293,6 +303,8 @@ public function render()
'cartTotal' => $this->getCartTotal(),
'cartDiscount' => $this->getCartDiscount(),
'cartSubtotal' => $this->getCartSubtotal(),
'serviceFee' => $this->getServiceFee(),
'cartGrandTotal' => $this->getCartGrandTotal(),
'paymentMethods' => POSPaymentMethod::cases(),
]);
}
......
......@@ -3,9 +3,7 @@
namespace App\Livewire\Settings;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\POS\Models\ReceiptTemplate;
use App\Domain\POS\Services\ReceiptService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -157,6 +155,7 @@ public function render()
'subtotal' => 'المجموع الفرعي',
'discount_total' => 'إجمالي الخصم',
'tax_amount' => 'الضريبة',
'service_fee' => 'مصاريف خدمة',
'grand_total' => 'الإجمالي النهائي',
'payment_method' => 'طريقة الدفع',
'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">
<td class="px-4 py-2 text-end font-medium" dir="ltr">{{ number_format($invoice->tax_amount / 100, 2) }} {{ __('ج.م') }}</td>
</tr>
@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">
<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>
......
......@@ -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>
</div>
@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">
<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>
......
......@@ -11,6 +11,19 @@
</div>
@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 -->
<div x-data="{ activeGroup: @entangle('activeGroup') }" class="mb-6">
<div class="border-b border-gray-200">
......
......@@ -157,6 +157,12 @@
<span dir="ltr">{{ number_format($data['tax_amount'] / 100, 2) }} {{ $currency }}</span>
</div>
@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">
<span>{{ __('الإجمالي') }}:</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