Commit c4a7701a authored by Administrator's avatar Administrator

Update 6 files via Son of Anton

parent 6140d228
......@@ -7,12 +7,17 @@ use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Payments\Models\Payment;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Payments\Services\BalanceCalculator;
use App\Modules\Members\Services\MemberNumberGenerator;
class PaymentController extends Controller
{
/**
* List all payments with filters.
*/
public function index(Request $request): Response
{
$filters = [
......@@ -21,7 +26,6 @@ class PaymentController extends Controller
'payment_method' => $request->get('payment_method', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'is_voided' => $request->get('is_voided', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Payment::search($filters, 25, $page);
......@@ -33,90 +37,272 @@ class PaymentController extends Controller
]);
}
public function processForm(Request $request, string $memberId): Response
/**
* Show the payment form for a member.
* Lists all available payment types and outstanding amounts.
*/
public function process(Request $request, string $memberId): Response
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود');
}
$summary = BalanceCalculator::getSummary((int) $memberId);
// Build available payment options
$paymentOptions = [];
// Form fee
if (!$summary['form_fee_paid']) {
$paymentOptions[] = [
'type' => 'form_fee',
'label' => 'رسوم استمارة',
'amount' => $summary['form_fee'],
'description' => 'رسوم استمارة رقم ' . ($member['form_number'] ?? ''),
'required' => true,
];
}
// Membership value (full cash or down payment)
if ($member['status'] === 'accepted' && !$summary['membership_fee_paid']) {
$paymentOptions[] = [
'type' => 'membership_fee',
'label' => 'قيمة العضوية (كاش كامل)',
'amount' => $member['membership_value'] ?? '0.00',
'description' => 'قيمة العضوية كاملة',
'required' => false,
];
// Down payment for installment
$minDown = bcdiv(bcmul($member['membership_value'] ?? '0', '25', 2), '100', 2);
$paymentOptions[] = [
'type' => 'down_payment',
'label' => 'مقدم تقسيط (25% على الأقل)',
'amount' => $minDown,
'description' => 'مقدم تقسيط — الحد الأدنى ' . money($minDown),
'required' => false,
'is_variable' => true,
'min_amount' => $minDown,
];
}
// Installment payment
if (($summary['installments']['next_due'] ?? null) !== null) {
$paymentOptions[] = [
'type' => 'installment',
'label' => 'قسط شهري',
'amount' => $summary['installments']['next_due']['amount'] ?? '0.00',
'description' => 'قسط مستحق ' . ($summary['installments']['next_due']['due_date'] ?? ''),
'required' => false,
];
}
// Annual subscription
if (bccomp($summary['unpaid_subscriptions'], '0', 2) > 0) {
$paymentOptions[] = [
'type' => 'annual_subscription',
'label' => 'اشتراك سنوي',
'amount' => $summary['unpaid_subscriptions'],
'description' => 'اشتراكات سنوية غير مدفوعة (' . $summary['unpaid_subs_count'] . ' اشتراك)',
'required' => false,
];
}
// Fines
if (bccomp($summary['unpaid_fines'], '0', 2) > 0) {
$paymentOptions[] = [
'type' => 'fine',
'label' => 'غرامات',
'amount' => $summary['unpaid_fines'],
'description' => 'غرامات مستحقة',
'required' => false,
];
}
$outstandingItems = BalanceCalculator::getOutstandingItems((int) $memberId);
$summary = BalanceCalculator::getMemberSummary((int) $memberId);
$paymentMethods = $db->select("SELECT * FROM payment_methods WHERE is_active = 1 ORDER BY sort_order");
// Generic custom payment
$paymentOptions[] = [
'type' => 'other',
'label' => 'دفعة أخرى',
'amount' => '0.00',
'description' => '',
'required' => false,
'is_variable' => true,
];
return $this->view('Payments.Views.process', [
'member' => $member,
'outstandingItems' => $outstandingItems,
'summary' => $summary,
'paymentMethods' => $paymentMethods,
'member' => $member,
'summary' => $summary,
'paymentOptions' => $paymentOptions,
]);
}
public function processStore(Request $request, string $memberId): Response
/**
* Handle payment submission.
*/
public function store(Request $request, string $memberId): Response
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$data = $request->all();
unset($data['_csrf_token']);
if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود');
}
$paymentType = trim($data['payment_type'] ?? '');
$amount = trim($data['amount'] ?? '0');
$method = trim($data['payment_method'] ?? 'cash');
$paymentType = trim((string) $request->post('payment_type', ''));
$amount = trim((string) $request->post('amount', '0'));
$paymentMethod = trim((string) $request->post('payment_method', 'cash'));
$notes = trim((string) $request->post('notes', ''));
$errors = [];
if ($paymentType === '') $errors[] = 'نوع الدفع مطلوب';
if (!is_numeric($amount) || bccomp($amount, '0', 2) <= 0) $errors[] = 'المبلغ غير صالح';
if ($method === '') $errors[] = 'طريقة الدفع مطلوبة';
if ($method === 'check') {
if (empty(trim($data['check_number'] ?? ''))) $errors[] = 'رقم الشيك مطلوب';
if (empty(trim($data['check_bank'] ?? ''))) $errors[] = 'البنك مطلوب';
if ($paymentType === '' || bccomp($amount, '0.01', 2) < 0) {
return $this->redirect("/payments/process/{$memberId}")->withError('بيانات الدفع غير صالحة');
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
return $this->redirect("/payments/process/{$memberId}");
$description = PaymentService::getPaymentTypeLabel($paymentType);
if ($member['form_number']) {
$description .= ' — استمارة ' . $member['form_number'];
}
$data['member_id'] = (int) $memberId;
$data['amount'] = $amount;
$data['payment_method'] = $method;
$data['description'] = Payment::getPaymentTypeLabel($paymentType) . ' — ' . $member['full_name_ar'];
$result = PaymentService::processPayment($data);
$result = PaymentService::processPayment([
'member_id' => (int) $memberId,
'amount' => $amount,
'payment_type' => $paymentType,
'payment_method' => $paymentMethod,
'related_entity_type' => 'members',
'related_entity_id' => (int) $memberId,
'description' => $description,
'notes' => $notes,
'check_number' => $request->post('check_number'),
'check_bank' => $request->post('check_bank'),
'check_date' => $request->post('check_date'),
'visa_reference' => $request->post('visa_reference'),
'transfer_reference' => $request->post('transfer_reference'),
'transfer_bank' => $request->post('transfer_bank'),
]);
if (!$result['success']) {
return $this->redirect("/payments/process/{$memberId}")->withError($result['error']);
}
// If addition fee, update the receipt number on the related entity
if ($paymentType === 'addition_fee' && !empty($data['related_entity_type']) && !empty($data['related_entity_id'])) {
$entityTable = $data['related_entity_type'];
$entityId = (int) $data['related_entity_id'];
$allowedTables = ['spouses', 'children', 'temporary_members'];
if (in_array($entityTable, $allowedTables)) {
try {
$db->update($entityTable, [
'fee_receipt_number' => $result['receipt_number'],
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]);
} catch (\Throwable $e) {
// Log but don't block
// ── Post-payment actions based on type ──
// Membership fee paid → assign membership number + activate
if (in_array($paymentType, ['membership_fee', 'down_payment'])) {
$this->handleMembershipPayment((int) $memberId, $paymentType, $amount);
}
return $this->redirect("/members/{$memberId}")
->withSuccess('تم تسجيل الدفع — ' . money($amount) . ' — إيصال: ' . $result['receipt_number']);
}
/**
* Handle membership value payment — assign number + activate.
*/
private function handleMembershipPayment(int $memberId, string $type, string $amount): void
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$memberId]);
if (!$member) return;
// If full cash payment → activate immediately
if ($type === 'membership_fee') {
$number = MemberNumberGenerator::assign($memberId);
$db->update('members', [
'status' => 'active',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$memberId]);
EventBus::dispatch('member.activated', [
'member_id' => $memberId,
'membership_number' => $number,
'payment_method' => 'cash_full',
]);
}
// If down payment → create installment plan + activate
if ($type === 'down_payment') {
$membershipValue = $member['membership_value'] ?? '0.00';
$remaining = bcsub($membershipValue, $amount, 2);
if (bccomp($remaining, '0', 2) > 0) {
// Create installment plan
$interestRate = '22.00';
$months = 30; // Max default, can be overridden
$totalInterest = bcdiv(bcmul($remaining, $interestRate, 4), '100', 2);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
$planId = $db->insert('installment_plans', [
'member_id' => $memberId,
'related_entity_type' => 'members',
'related_entity_id' => $memberId,
'total_amount' => $membershipValue,
'down_payment' => $amount,
'remaining_balance' => $remaining,
'interest_rate' => $interestRate,
'total_interest' => $totalInterest,
'total_with_interest' => $totalWithInterest,
'number_of_months' => $months,
'monthly_payment' => $monthlyPayment,
'start_date' => date('Y-m-d'),
'status' => 'active',
'notes' => 'خطة تقسيط — قيمة العضوية',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
// Generate schedule
$remainingAfter = $totalWithInterest;
for ($i = 1; $i <= $months; $i++) {
$principal = bcdiv($remaining, (string) $months, 2);
$interest = bcdiv($totalInterest, (string) $months, 2);
$installmentAmount = bcadd($principal, $interest, 2);
$remainingAfter = bcsub($remainingAfter, $installmentAmount, 2);
if (bccomp($remainingAfter, '0', 2) < 0) $remainingAfter = '0.00';
$dueDate = date('Y-m-d', strtotime("+{$i} months"));
$db->insert('installment_schedule', [
'installment_plan_id' => $planId,
'installment_number' => $i,
'due_date' => $dueDate,
'amount' => $installmentAmount,
'principal' => $principal,
'interest' => $interest,
'remaining_after' => $remainingAfter,
'paid_amount' => '0.00',
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
return $this->redirect("/receipts/{$result['receipt_id']}/print")
->withSuccess('تم تسجيل الدفعة بنجاح — إيصال رقم: ' . $result['receipt_number']);
// Assign number and activate
$number = MemberNumberGenerator::assign($memberId);
$db->update('members', [
'status' => 'active',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$memberId]);
EventBus::dispatch('member.activated', [
'member_id' => $memberId,
'membership_number' => $number,
'payment_method' => 'installment',
]);
}
}
/**
* Show single payment details.
*/
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$payment = $db->selectOne(
"SELECT p.*, m.full_name_ar as member_name, m.membership_number, r.receipt_number,
e.full_name_ar as received_by_name, ve.full_name_ar as voided_by_name
"SELECT p.*, m.full_name_ar as member_name, m.membership_number,
r.receipt_number, e.full_name_ar as received_by_name,
ve.full_name_ar as voided_by_name
FROM payments p
JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
......@@ -125,23 +311,45 @@ class PaymentController extends Controller
WHERE p.id = ?",
[(int) $id]
);
if (!$payment) return $this->redirect('/payments')->withError('الدفعة غير موجودة');
if (!$payment) {
return $this->redirect('/payments')->withError('الدفعة غير موجودة');
}
$canVoid = PaymentService::canVoid((int) $id);
// Check if void is allowed
$canVoid = ['allowed' => false];
if (!$payment['is_voided']) {
$hoursSince = (time() - strtotime($payment['created_at'])) / 3600;
$canVoid['allowed'] = ($hoursSince <= 24);
// Super admin can always void
$employee = App::getInstance()->currentEmployee();
if ($employee) {
$sa = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id
WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1 LIMIT 1",
[(int) $employee->id]
);
if ($sa) $canVoid['allowed'] = true;
}
}
return $this->view('Payments.Views.history', [
'payment' => $payment,
'canVoid' => $canVoid,
'payment' => $payment,
'canVoid' => $canVoid,
'singleView' => true,
'rows' => [], 'pagination' => ['last_page' => 1, 'current_page' => 1], 'filters' => [],
'rows' => [],
'pagination' => ['last_page' => 1, 'current_page' => 1],
'filters' => [],
]);
}
public function voidPayment(Request $request, string $id): Response
/**
* Void a payment.
*/
public function void(Request $request, string $id): Response
{
$reason = trim((string) $request->post('void_reason', ''));
if ($reason === '') {
return $this->redirect("/payments/{$id}")->withError('يجب إدخال سبب الإلغاء');
return $this->redirect("/payments/{$id}")->withError('سبب الإلغاء مطلوب');
}
$result = PaymentService::voidPayment((int) $id, $reason);
......@@ -149,21 +357,26 @@ class PaymentController extends Controller
return $this->redirect("/payments/{$id}")->withError($result['error']);
}
return $this->redirect('/payments')->withSuccess('تم إلغاء الدفعة والإيصال بنجاح');
return $this->redirect("/payments/{$id}")->withSuccess('تم إلغاء الدفعة والإيصال');
}
/**
* Member payment history.
*/
public function memberHistory(Request $request, string $memberId): Response
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود');
}
$payments = Payment::getForMember((int) $memberId, true);
$summary = BalanceCalculator::getMemberSummary((int) $memberId);
$summary = BalanceCalculator::getSummary((int) $memberId);
return $this->view('Payments.Views.history', [
'member' => $member,
'rows' => $payments,
'member' => $member,
'summary' => $summary,
'memberView' => true,
'pagination' => ['last_page' => 1, 'current_page' => 1],
......@@ -171,20 +384,38 @@ class PaymentController extends Controller
]);
}
/**
* Daily cash report.
*/
public function dailyReport(Request $request): Response
{
$date = $request->get('date', date('Y-m-d'));
$branchId = $request->get('branch_id', '') !== '' ? (int) $request->get('branch_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$report = PaymentService::getDailyReport($date, $branchId);
$report = Payment::getDailyTotals($date, $branchId);
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
// Get individual payments for the day
$where = 'p.payment_date = ? AND p.is_voided = 0';
$params = [$date];
if ($branchId) {
$where .= ' AND m.branch_id = ?';
$params[] = $branchId;
}
$payments = $db->select(
"SELECT p.*, m.full_name_ar as member_name, m.membership_number, r.receipt_number
FROM payments p JOIN members m ON m.id = p.member_id LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where} ORDER BY p.id ASC",
$params
);
return $this->view('Payments.Views.daily-report', [
'report' => $report,
'date' => $date,
'branchId' => $branchId,
'report' => $report,
'payments' => $payments,
'branches' => $branches,
]);
}
......
......@@ -2,11 +2,11 @@
declare(strict_types=1);
return [
['GET', '/payments', 'Payments\Controllers\PaymentController@index', ['auth'], 'payment.view'],
['GET', '/payments/daily-report', 'Payments\Controllers\PaymentController@dailyReport', ['auth'], 'payment.view'],
['GET', '/payments/process/{memberId}', 'Payments\Controllers\PaymentController@processForm', ['auth'], 'payment.process_cash'],
['POST', '/payments/process/{memberId}', 'Payments\Controllers\PaymentController@processStore', ['auth'], 'payment.process_cash'],
['GET', '/payments/member/{memberId}', 'Payments\Controllers\PaymentController@memberHistory',['auth'], 'payment.view'],
['GET', '/payments/{id}', 'Payments\Controllers\PaymentController@show', ['auth'], 'payment.view'],
['POST', '/payments/{id}/void', 'Payments\Controllers\PaymentController@voidPayment', ['auth'], 'payment.void_receipt'],
['GET', '/payments', 'Payments\Controllers\PaymentController@index', ['auth'], 'payment.view'],
['GET', '/payments/daily-report', 'Payments\Controllers\PaymentController@dailyReport', ['auth'], 'payment.view'],
['GET', '/payments/process/{memberId}', 'Payments\Controllers\PaymentController@process', ['auth'], 'payment.create'],
['POST', '/payments/process/{memberId}', 'Payments\Controllers\PaymentController@store', ['auth'], 'payment.create'],
['GET', '/payments/member/{memberId}', 'Payments\Controllers\PaymentController@memberHistory', ['auth'], 'payment.view'],
['GET', '/payments/{id}', 'Payments\Controllers\PaymentController@show', ['auth'], 'payment.view'],
['POST', '/payments/{id}/void', 'Payments\Controllers\PaymentController@void', ['auth'], 'payment.void'],
];
\ No newline at end of file
......@@ -5,156 +5,135 @@ namespace App\Modules\Payments\Services;
use App\Core\App;
/**
* Calculates all financial obligations and balances for a member.
*/
final class BalanceCalculator
{
/**
* Get complete financial summary for a member.
*/
public static function getMemberSummary(int $memberId): array
public static function getSummary(int $memberId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$memberId]);
if (!$member) return ['error' => 'Member not found'];
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) return [];
$membershipValue = $member['membership_value'] ?? '0.00';
// Total paid (non-voided)
$totalPaid = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE member_id = ? AND is_voided = 0",
[$memberId]
);
// Total voided
$totalVoided = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE member_id = ? AND is_voided = 1",
[$memberId]
);
// Breakdown by type
$byType = $db->select(
"SELECT payment_type, SUM(amount) as total, COUNT(*) as count FROM payments WHERE member_id = ? AND is_voided = 0 GROUP BY payment_type",
[$memberId]
);
$membershipPaid = '0.00';
foreach ($byType as $t) {
if (in_array($t['payment_type'], ['membership_fee', 'down_payment'])) {
$membershipPaid = bcadd($membershipPaid, $t['total'], 2);
// ── Total Paid ──
$totalPaid = PaymentService::totalPaid($memberId);
// ── Form Fee ──
$formFeePaid = PaymentService::hasPaid($memberId, 'form_fee');
$formFee = '505.00';
// ── Membership Fee ──
$membershipFeePaid = PaymentService::hasPaid($memberId, 'membership_fee');
$membershipFeeAmount = PaymentService::totalPaid($memberId, 'membership_fee');
$downPaymentAmount = PaymentService::totalPaid($memberId, 'down_payment');
$membershipPaid = bcadd($membershipFeeAmount, $downPaymentAmount, 2);
$membershipRemaining = bcsub($membershipValue, $membershipPaid, 2);
if (bccomp($membershipRemaining, '0', 2) < 0) $membershipRemaining = '0.00';
// ── Installments ──
$installmentData = ['total' => '0.00', 'paid' => '0.00', 'remaining' => '0.00', 'overdue_count' => 0, 'next_due' => null];
try {
$activePlan = $db->selectOne(
"SELECT * FROM installment_plans WHERE member_id = ? AND status = 'active' LIMIT 1",
[$memberId]
);
if ($activePlan) {
$installmentData['total'] = $activePlan['total_with_interest'];
$paidInstallments = $db->selectOne(
"SELECT COALESCE(SUM(paid_amount), 0) as paid FROM installment_schedule WHERE installment_plan_id = ? AND status = 'paid'",
[(int) $activePlan['id']]
);
$installmentData['paid'] = $paidInstallments['paid'] ?? '0.00';
$installmentData['remaining'] = bcsub($activePlan['total_with_interest'], bcadd($activePlan['down_payment'], $installmentData['paid'], 2), 2);
if (bccomp($installmentData['remaining'], '0', 2) < 0) $installmentData['remaining'] = '0.00';
$overdueRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM installment_schedule WHERE installment_plan_id = ? AND status = 'pending' AND due_date < CURDATE()",
[(int) $activePlan['id']]
);
$installmentData['overdue_count'] = (int) ($overdueRow['cnt'] ?? 0);
$nextDue = $db->selectOne(
"SELECT due_date, amount FROM installment_schedule WHERE installment_plan_id = ? AND status = 'pending' ORDER BY due_date ASC LIMIT 1",
[(int) $activePlan['id']]
);
$installmentData['next_due'] = $nextDue;
}
}
$membershipOutstanding = bcsub($membershipValue, $membershipPaid, 2);
if (bccomp($membershipOutstanding, '0', 2) < 0) $membershipOutstanding = '0.00';
// Last payment info
$lastPayment = $db->selectOne(
"SELECT p.*, r.receipt_number FROM payments p LEFT JOIN receipts r ON r.id = p.receipt_id WHERE p.member_id = ? AND p.is_voided = 0 ORDER BY p.payment_date DESC, p.id DESC LIMIT 1",
[$memberId]
} catch (\Throwable $e) {}
// ── Unpaid Subscriptions ──
$unpaidSubscriptions = '0.00';
$unpaidSubsCount = 0;
try {
$row = $db->selectOne(
"SELECT COUNT(*) as cnt, COALESCE(SUM(total_amount - paid_amount + fine_amount), 0) as total
FROM subscriptions WHERE member_id = ? AND status IN ('pending', 'overdue')",
[$memberId]
);
$unpaidSubscriptions = $row['total'] ?? '0.00';
$unpaidSubsCount = (int) ($row['cnt'] ?? 0);
} catch (\Throwable $e) {}
// ── Unpaid Fines ──
$unpaidFines = '0.00';
try {
$row = $db->selectOne(
"SELECT COALESCE(SUM(amount - paid_amount), 0) as total FROM fines WHERE member_id = ? AND status IN ('imposed', 'appeal_upheld')",
[$memberId]
);
$unpaidFines = $row['total'] ?? '0.00';
} catch (\Throwable $e) {}
// ── Total Outstanding ──
$totalOutstanding = bcadd(
bcadd($membershipRemaining, $unpaidSubscriptions, 2),
bcadd($unpaidFines, $installmentData['remaining'], 2),
2
);
return [
'member_id' => $memberId,
'membership_value' => $membershipValue,
'total_paid' => $totalPaid['total'] ?? '0.00',
'total_voided' => $totalVoided['total'] ?? '0.00',
'total_paid' => $totalPaid,
'form_fee_paid' => $formFeePaid,
'form_fee' => $formFee,
'membership_fee_paid' => $membershipFeePaid || bccomp($membershipPaid, '0', 2) > 0,
'membership_paid' => $membershipPaid,
'membership_outstanding' => $membershipOutstanding,
'payments_by_type' => $byType,
'last_payment' => $lastPayment,
'payment_count' => array_sum(array_column($byType, 'count')),
'membership_remaining' => $membershipRemaining,
'installments' => $installmentData,
'unpaid_subscriptions' => $unpaidSubscriptions,
'unpaid_subs_count' => $unpaidSubsCount,
'unpaid_fines' => $unpaidFines,
'total_outstanding' => $totalOutstanding,
];
}
/**
* Get list of outstanding items a member owes.
* Check if member can print carnet.
* Blocked if: unpaid subscriptions, overdue installments, active suspension.
*/
public static function getOutstandingItems(int $memberId): array
public static function canPrintCarnet(int $memberId): array
{
$db = App::getInstance()->db();
$items = [];
$summary = self::getSummary($memberId);
$blocks = [];
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) return $items;
$membershipValue = $member['membership_value'] ?? '0.00';
// Check membership fee outstanding
$membershipPaid = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE member_id = ? AND is_voided = 0 AND payment_type IN ('membership_fee', 'down_payment')",
[$memberId]
);
$mPaid = $membershipPaid['total'] ?? '0.00';
$mRemaining = bcsub($membershipValue, $mPaid, 2);
if (bccomp($mRemaining, '0', 2) > 0) {
$items[] = [
'type' => 'membership_fee',
'label' => 'رسوم العضوية',
'total' => $membershipValue,
'paid' => $mPaid,
'outstanding' => $mRemaining,
'entity_type' => 'members',
'entity_id' => $memberId,
];
if (bccomp($summary['unpaid_subscriptions'], '0', 2) > 0) {
$blocks[] = 'اشتراكات غير مدفوعة: ' . money($summary['unpaid_subscriptions']);
}
// Check unpaid spouse addition fees
if ($db->tableExists('spouses')) {
$unpaidSpouses = $db->select(
"SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 AND addition_fee > 0 AND (fee_receipt_number IS NULL OR fee_receipt_number = '')",
[$memberId]
);
foreach ($unpaidSpouses as $s) {
$items[] = [
'type' => 'addition_fee',
'label' => 'رسوم إضافة زوجة: ' . $s['full_name_ar'],
'total' => $s['addition_fee'],
'paid' => '0.00',
'outstanding' => $s['addition_fee'],
'entity_type' => 'spouses',
'entity_id' => (int) $s['id'],
];
}
if (($summary['installments']['overdue_count'] ?? 0) > 0) {
$blocks[] = 'أقساط متأخرة: ' . $summary['installments']['overdue_count'] . ' قسط';
}
// Check unpaid children addition fees
if ($db->tableExists('children')) {
$unpaidChildren = $db->select(
"SELECT * FROM children WHERE member_id = ? AND is_archived = 0 AND addition_fee > 0 AND (fee_receipt_number IS NULL OR fee_receipt_number = '')",
[$memberId]
);
foreach ($unpaidChildren as $c) {
$items[] = [
'type' => 'addition_fee',
'label' => 'رسوم إضافة ابن/ابنة: ' . $c['full_name_ar'],
'total' => $c['addition_fee'],
'paid' => '0.00',
'outstanding' => $c['addition_fee'],
'entity_type' => 'children',
'entity_id' => (int) $c['id'],
];
}
}
// Check unpaid temporary member fees
if ($db->tableExists('temporary_members')) {
$unpaidTemp = $db->select(
"SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND addition_fee > 0 AND (fee_receipt_number IS NULL OR fee_receipt_number = '')",
[$memberId]
);
foreach ($unpaidTemp as $t) {
$items[] = [
'type' => 'addition_fee',
'label' => 'رسوم عضو مؤقت: ' . $t['full_name_ar'],
'total' => $t['addition_fee'],
'paid' => '0.00',
'outstanding' => $t['addition_fee'],
'entity_type' => 'temporary_members',
'entity_id' => (int) $t['id'],
];
}
if (bccomp($summary['unpaid_fines'], '0', 2) > 0) {
$blocks[] = 'غرامات غير مدفوعة: ' . money($summary['unpaid_fines']);
}
return $items;
return ['allowed' => empty($blocks), 'blocks' => $blocks];
}
}
\ No newline at end of file
......@@ -6,55 +6,86 @@ namespace App\Modules\Payments\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Payments\Models\Payment;
use App\Modules\Receipts\Models\Receipt;
/**
* Central payment processor — EVERY payment in the system goes through here.
* Creates payment + receipt in a single transaction.
*
* Payment Types:
* form_fee رسوم استمارة 505 ج.م
* membership_fee قيمة العضوية 150,000 / 225,000 / 300,000
* addition_fee رسوم إضافة (زوجة/ابن/مؤقت)
* annual_subscription اشتراك سنوي 492 + 35 تنمية = 527
* development_fee رسوم تنمية 35 ج.م
* down_payment مقدم تقسيط 25% minimum
* installment قسط شهري
* fine غرامة مخالفة 1,000 - 10,000
* separation_fee رسوم فصل 30%/20%/15%/10%/5%/2.5%
* divorce_fee رسوم طلاق 10% or 50%
* death_fee رسوم وفاة (استمارة + اشتراك)
* waiver_fee رسوم تنازل 30%
* carnet_replacement بدل فاقد كارنيه
* seasonal_fee رسوم عضوية موسمية
* sports_conversion رسوم تحويل رياضي 50%
* other أخرى
*/
final class PaymentService
{
/**
* Process a payment: create payment record + receipt, fire events.
* Process a payment and create receipt.
* Returns ['success' => bool, 'payment_id' => int, 'receipt_id' => int, 'receipt_number' => string]
*/
public static function processPayment(array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$memberId = (int) ($data['member_id'] ?? 0);
$paymentType = trim($data['payment_type'] ?? '');
$amount = $data['amount'] ?? '0.00';
$method = $data['payment_method'] ?? 'cash';
$description = $data['description'] ?? '';
$notes = $data['notes'] ?? null;
// Validate required fields
$memberId = (int) ($data['member_id'] ?? 0);
$amount = $data['amount'] ?? '0.00';
$paymentType = $data['payment_type'] ?? '';
$paymentMethod = $data['payment_method'] ?? 'cash';
$description = $data['description'] ?? '';
if ($memberId <= 0 || $paymentType === '' || bccomp($amount, '0.00', 2) <= 0) {
return ['success' => false, 'error' => 'بيانات غير مكتملة'];
if ($memberId <= 0) {
return ['success' => false, 'error' => 'العضو غير محدد'];
}
if (bccomp((string) $amount, '0.01', 2) < 0) {
return ['success' => false, 'error' => 'المبلغ يجب أن يكون أكبر من صفر'];
}
if ($paymentType === '') {
return ['success' => false, 'error' => 'نوع الدفعة مطلوب'];
}
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
// Verify member exists
$member = $db->selectOne("SELECT id, full_name_ar, form_number FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) {
return ['success' => false, 'error' => 'العضو غير موجود'];
}
$db->beginTransaction();
try {
// Generate receipt number
$receiptNumber = self::generateReceiptNumber();
// Create payment record
$paymentId = $db->insert('payments', [
'member_id' => $memberId,
'payment_type' => $paymentType,
'amount' => $amount,
'currency' => 'EGP',
'payment_method' => $method,
'currency' => $data['currency'] ?? 'EGP',
'payment_method' => $paymentMethod,
'check_number' => $data['check_number'] ?? null,
'check_bank' => $data['check_bank'] ?? null,
'check_date' => !empty($data['check_date']) ? $data['check_date'] : null,
'check_status' => $method === 'check' ? 'pending' : null,
'check_date' => $data['check_date'] ?? null,
'check_status' => $paymentMethod === 'check' ? 'pending' : null,
'visa_reference' => $data['visa_reference'] ?? null,
'transfer_reference' => $data['transfer_reference'] ?? null,
'transfer_bank' => $data['transfer_bank'] ?? null,
'related_entity_type' => $data['related_entity_type'] ?? null,
'related_entity_id' => !empty($data['related_entity_id']) ? (int) $data['related_entity_id'] : null,
'notes' => $notes,
'payment_date' => date('Y-m-d'),
'related_entity_id' => $data['related_entity_id'] ?? null,
'notes' => $data['notes'] ?? $description,
'payment_date' => $data['payment_date'] ?? date('Y-m-d'),
'received_by_employee_id' => $employee ? (int) $employee->id : null,
'is_voided' => 0,
'created_at' => date('Y-m-d H:i:s'),
......@@ -62,45 +93,51 @@ final class PaymentService
'created_by' => $employee ? (int) $employee->id : null,
]);
// Generate receipt
$receiptNumber = Receipt::generateNumber();
$amountWords = self::amountToArabicWords($amount);
// Auto-generate description if empty
if ($description === '') {
$description = Payment::getPaymentTypeLabel($paymentType) . ' — ' . ($member['full_name_ar'] ?? '');
$description = self::getPaymentTypeLabel($paymentType);
if ($member['form_number']) {
$description .= ' — استمارة ' . $member['form_number'];
}
}
// Create receipt
$receiptId = $db->insert('receipts', [
'receipt_number' => $receiptNumber,
'member_id' => $memberId,
'payment_id' => $paymentId,
'receipt_type' => 'payment',
'amount' => $amount,
'amount_in_words_ar' => $amountWords,
'amount_in_words_ar' => number_to_arabic_words((float) $amount),
'description_ar' => $description,
'issued_by_employee_id' => $employee ? (int) $employee->id : null,
'issued_at' => date('Y-m-d H:i:s'),
'is_voided' => 0,
'print_count' => 0,
'created_at' => date('Y-m-d H:i:s'),
]);
// Link receipt to payment
$db->update('payments', ['receipt_id' => $receiptId, 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$paymentId]);
$db->update('payments', ['receipt_id' => $receiptId], '`id` = ?', [$paymentId]);
$db->commit();
// Fire event AFTER commit
// Fire event
EventBus::dispatch('payment.completed', [
'payment_id' => $paymentId,
'receipt_id' => $receiptId,
'receipt_number'=> $receiptNumber,
'member_id' => $memberId,
'payment_type' => $paymentType,
'amount' => $amount,
'method' => $method,
'payment_id' => $paymentId,
'receipt_id' => $receiptId,
'receipt_number' => $receiptNumber,
'member_id' => $memberId,
'type' => $paymentType,
'amount' => $amount,
'method' => $paymentMethod,
]);
Logger::info("Payment processed: #{$paymentId}, Receipt: {$receiptNumber}", [
'member_id' => $memberId, 'type' => $paymentType, 'amount' => $amount,
Logger::info("Payment processed", [
'payment_id' => $paymentId,
'member_id' => $memberId,
'type' => $paymentType,
'amount' => $amount,
]);
return [
......@@ -108,12 +145,17 @@ final class PaymentService
'payment_id' => $paymentId,
'receipt_id' => $receiptId,
'receipt_number' => $receiptNumber,
'amount' => $amount,
];
} catch (\Throwable $e) {
$db->rollBack();
Logger::error("Payment processing failed: " . $e->getMessage());
return ['success' => false, 'error' => 'فشل في معالجة الدفع: ' . $e->getMessage()];
Logger::error("Payment failed: " . $e->getMessage(), [
'member_id' => $memberId,
'type' => $paymentType,
'amount' => $amount,
]);
return ['success' => false, 'error' => 'فشل تسجيل الدفع: ' . $e->getMessage()];
}
}
......@@ -125,41 +167,47 @@ final class PaymentService
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$payment = $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]);
$payment = $db->selectOne("SELECT * FROM payments WHERE id = ? AND is_voided = 0", [$paymentId]);
if (!$payment) {
return ['success' => false, 'error' => 'الدفعة غير موجودة'];
}
if ($payment['is_voided']) {
return ['success' => false, 'error' => 'الدفعة ملغاة بالفعل'];
return ['success' => false, 'error' => 'الدفعة غير موجودة أو ملغاة بالفعل'];
}
// Check 24h rule
$canVoid = self::canVoid($paymentId);
if (!$canVoid['allowed']) {
return ['success' => false, 'error' => $canVoid['reason']];
// Check 24-hour void window (unless super admin)
$hoursSince = (time() - strtotime($payment['created_at'])) / 3600;
$isSuperAdmin = false;
if ($employee) {
try {
$sa = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id
WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1 LIMIT 1",
[(int) $employee->id]
);
$isSuperAdmin = ($sa !== null);
} catch (\Throwable $e) {}
}
$now = date('Y-m-d H:i:s');
$empId = $employee ? (int) $employee->id : null;
if ($hoursSince > 24 && !$isSuperAdmin) {
return ['success' => false, 'error' => 'لا يمكن إلغاء الدفعة بعد 24 ساعة — يتطلب صلاحية مدير النظام'];
}
$db->beginTransaction();
try {
// Void payment
// Void the payment
$db->update('payments', [
'is_voided' => 1,
'voided_at' => $now,
'voided_by' => $empId,
'void_reason'=> $reason,
'updated_at' => $now,
'voided_at' => date('Y-m-d H:i:s'),
'voided_by' => $employee ? (int) $employee->id : null,
'void_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$paymentId]);
// Void linked receipt
// Void the receipt
if ($payment['receipt_id']) {
$db->update('receipts', [
'is_voided' => 1,
'voided_at' => $now,
'voided_by' => $empId,
'void_reason'=> $reason,
'voided_at' => date('Y-m-d H:i:s'),
'voided_by' => $employee ? (int) $employee->id : null,
'void_reason' => $reason,
], '`id` = ?', [(int) $payment['receipt_id']]);
}
......@@ -168,158 +216,103 @@ final class PaymentService
EventBus::dispatch('payment.voided', [
'payment_id' => $paymentId,
'member_id' => (int) $payment['member_id'],
'type' => $payment['payment_type'],
'amount' => $payment['amount'],
'reason' => $reason,
]);
Logger::info("Payment #{$paymentId} voided", ['reason' => $reason]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل في إلغاء الدفعة'];
return ['success' => false, 'error' => 'فشل الإلغاء: ' . $e->getMessage()];
}
}
/**
* Check if a payment can be voided (24h rule).
* Check if a specific payment type has been paid for a member.
*/
public static function canVoid(int $paymentId): array
public static function hasPaid(int $memberId, string $paymentType, ?string $relatedEntityType = null, ?int $relatedEntityId = null): bool
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$payment = $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]);
if (!$payment) return ['allowed' => false, 'reason' => 'الدفعة غير موجودة'];
if ($payment['is_voided']) return ['allowed' => false, 'reason' => 'ملغاة بالفعل'];
$where = 'member_id = ? AND payment_type = ? AND is_voided = 0';
$params = [$memberId, $paymentType];
// Super admin can always void
if ($employee) {
$isSuperAdmin = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1",
[(int) $employee->id]
);
if ($isSuperAdmin) return ['allowed' => true, 'reason' => ''];
if ($relatedEntityType !== null) {
$where .= ' AND related_entity_type = ?';
$params[] = $relatedEntityType;
}
// 24h rule
$createdAt = strtotime($payment['created_at']);
$hoursElapsed = (time() - $createdAt) / 3600;
if ($hoursElapsed > 24) {
return ['allowed' => false, 'reason' => 'مضى أكثر من 24 ساعة. يلزم صلاحية المدير العام للإلغاء'];
if ($relatedEntityId !== null) {
$where .= ' AND related_entity_id = ?';
$params[] = $relatedEntityId;
}
return ['allowed' => true, 'reason' => ''];
$row = $db->selectOne("SELECT COUNT(*) as cnt FROM payments WHERE {$where}", $params);
return (int) ($row['cnt'] ?? 0) > 0;
}
/**
* Get daily report data.
* Get total paid for a member of a specific type.
*/
public static function getDailyReport(string $date, ?int $branchId = null): array
public static function totalPaid(int $memberId, ?string $paymentType = null): string
{
$db = App::getInstance()->db();
$where = 'p.payment_date = ? AND p.is_voided = 0';
$params = [$date];
if ($branchId) {
$where .= ' AND m.branch_id = ?';
$params[] = $branchId;
}
$payments = $db->select(
"SELECT p.*, m.full_name_ar as member_name, m.membership_number, r.receipt_number
FROM payments p
JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
WHERE {$where}
ORDER BY p.id ASC",
$params
);
$totals = Payment::getDailyTotals($date, $branchId);
$where = 'member_id = ? AND is_voided = 0';
$params = [$memberId];
$voidedCount = $db->selectOne(
"SELECT COUNT(*) as cnt, COALESCE(SUM(p.amount),0) as total
FROM payments p JOIN members m ON m.id = p.member_id
WHERE p.payment_date = ? AND p.is_voided = 1" . ($branchId ? ' AND m.branch_id = ?' : ''),
$branchId ? [$date, $branchId] : [$date]
);
if ($paymentType !== null) {
$where .= ' AND payment_type = ?';
$params[] = $paymentType;
}
return [
'date' => $date,
'branch_id' => $branchId,
'payments' => $payments,
'totals' => $totals,
'voided' => $voidedCount,
];
$row = $db->selectOne("SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE {$where}", $params);
return $row['total'] ?? '0.00';
}
/**
* Convert numeric amount to Arabic words for receipt.
* Generate unique receipt number.
*/
public static function amountToArabicWords(string $amount): string
public static function generateReceiptNumber(): string
{
$amount = (float) $amount;
$intPart = (int) floor($amount);
$decPart = (int) round(($amount - $intPart) * 100);
$ones = ['', 'واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة',
'عشرة', 'أحد عشر', 'اثنا عشر', 'ثلاثة عشر', 'أربعة عشر', 'خمسة عشر',
'ستة عشر', 'سبعة عشر', 'ثمانية عشر', 'تسعة عشر'];
$tens = ['', '', 'عشرون', 'ثلاثون', 'أربعون', 'خمسون', 'ستون', 'سبعون', 'ثمانون', 'تسعون'];
$hundreds = ['', 'مائة', 'مائتان', 'ثلاثمائة', 'أربعمائة', 'خمسمائة', 'ستمائة', 'سبعمائة', 'ثمانمائة', 'تسعمائة'];
$convertGroup = function (int $n) use ($ones, $tens, $hundreds): string {
if ($n === 0) return '';
$parts = [];
$h = (int) floor($n / 100);
$remainder = $n % 100;
if ($h > 0) $parts[] = $hundreds[$h];
if ($remainder > 0 && $remainder < 20) {
$parts[] = $ones[$remainder];
} elseif ($remainder >= 20) {
$t = (int) floor($remainder / 10);
$o = $remainder % 10;
if ($o > 0) {
$parts[] = $ones[$o] . ' و' . $tens[$t];
} else {
$parts[] = $tens[$t];
}
}
return implode(' و', $parts);
};
$result = '';
if ($intPart === 0) {
$result = 'صفر';
$db = App::getInstance()->db();
$year = date('Y');
$prefix = 'REC-' . $year . '-';
$last = $db->selectOne(
"SELECT receipt_number FROM receipts WHERE receipt_number LIKE ? ORDER BY id DESC LIMIT 1",
[$prefix . '%']
);
if ($last) {
$parts = explode('-', $last['receipt_number']);
$seq = (int) end($parts) + 1;
} else {
$groups = [];
$millions = (int) floor($intPart / 1000000);
$thousands = (int) floor(($intPart % 1000000) / 1000);
$remainder = $intPart % 1000;
if ($millions > 0) {
if ($millions === 1) $groups[] = 'مليون';
elseif ($millions === 2) $groups[] = 'مليونان';
elseif ($millions <= 10) $groups[] = $convertGroup($millions) . ' ملايين';
else $groups[] = $convertGroup($millions) . ' مليون';
}
if ($thousands > 0) {
if ($thousands === 1) $groups[] = 'ألف';
elseif ($thousands === 2) $groups[] = 'ألفان';
elseif ($thousands <= 10) $groups[] = $convertGroup($thousands) . ' آلاف';
else $groups[] = $convertGroup($thousands) . ' ألف';
}
if ($remainder > 0) {
$groups[] = $convertGroup($remainder);
}
$result = implode(' و', $groups);
$seq = 1;
}
return $prefix . str_pad((string) $seq, 6, '0', STR_PAD_LEFT);
}
$result .= ' جنيه مصري';
if ($decPart > 0) {
$result .= ' و' . $convertGroup($decPart) . ' قرش';
}
return $result . ' فقط لا غير';
/**
* Get Arabic label for payment type.
*/
public static function getPaymentTypeLabel(string $type): string
{
return match ($type) {
'form_fee' => 'رسوم استمارة',
'membership_fee' => 'قيمة العضوية',
'addition_fee' => 'رسوم إضافة',
'annual_subscription' => 'اشتراك سنوي',
'development_fee' => 'رسوم تنمية',
'down_payment' => 'مقدم تقسيط',
'installment' => 'قسط شهري',
'fine' => 'غرامة',
'separation_fee' => 'رسوم فصل',
'divorce_fee' => 'رسوم طلاق',
'death_fee' => 'رسوم نقل وفاة',
'waiver_fee' => 'رسوم تنازل',
'carnet_replacement' => 'بدل فاقد كارنيه',
'seasonal_fee' => 'رسوم عضوية موسمية',
'sports_conversion' => 'رسوم تحويل رياضي',
'other' => 'أخرى',
default => $type,
};
}
}
\ No newline at end of file
......@@ -2,77 +2,86 @@
<?php $__template->section('title'); ?>التقرير اليومي — <?= e($date) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Date Filter -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/payments/daily-report" style="display:flex;gap:10px;align-items:end;">
<div><label class="form-label" style="font-size:12px;">التاريخ</label><input type="date" name="date" value="<?= e($date) ?>" class="form-input" required></div>
<div><label class="form-label" style="font-size:12px;">الفرع</label>
<select name="branch_id" class="form-select"><option value="">جميع الفروع</option>
<?php foreach ($branches as $b): ?><option value="<?= (int) $b['id'] ?>" <?= $branchId == $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option><?php endforeach; ?>
<div class="form-group">
<label class="form-label" style="font-size:12px;">التاريخ</label>
<input type="date" name="date" value="<?= e($date) ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label" style="font-size:12px;">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">كل الفروع</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>" <?= $branchId == $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline">عرض</button>
<button type="submit" class="btn btn-primary">عرض التقرير</button>
</form>
</div>
<?php $totals = $report['totals'] ?? []; ?>
<?php $grand = $totals['grand_total'] ?? ['count' => 0, 'total' => '0.00']; ?>
<?php $voided = $report['voided'] ?? ['cnt' => 0, 'total' => '0.00']; ?>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-bottom:20px;">
<div style="background:#F0FDF4;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#059669;"><?= money($grand['total'] ?? '0') ?></div>
<div style="color:#6B7280;">إجمالي التحصيل (<?= (int) ($grand['count'] ?? 0) ?> عملية)</div>
</div>
<div style="background:#FEF2F2;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#DC2626;"><?= money($voided['total'] ?? '0') ?></div>
<div style="color:#6B7280;">إيصالات ملغاة (<?= (int) ($voided['cnt'] ?? 0) ?>)</div>
<!-- Summary Cards -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:20px;">
<div class="card" style="padding:20px;text-align:center;background:#F0FDF4;border:1px solid #BBF7D0;">
<div style="font-size:32px;font-weight:700;color:#059669;"><?= money($report['grand_total']['total'] ?? '0') ?></div>
<div style="color:#6B7280;font-size:14px;">إجمالي التحصيل</div>
<div style="color:#9CA3AF;font-size:12px;margin-top:5px;"><?= (int) ($report['grand_total']['count'] ?? 0) ?> دفعة</div>
</div>
<div style="background:#EFF6FF;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#0284C7;"><?= money(bcsub($grand['total'] ?? '0', $voided['total'] ?? '0', 2)) ?></div>
<div style="color:#6B7280;">الصافي</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;">
<div class="card" style="padding:20px;">
<h4 style="color:#0D7377;margin-bottom:15px;">حسب النوع</h4>
<table style="width:100%;font-size:14px;">
<?php foreach ($totals['by_type'] ?? [] as $t): ?>
<tr><td style="padding:6px 0;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentTypeLabel($t['payment_type'])) ?></td><td style="padding:6px 0;text-align:left;"><?= (int) $t['count'] ?> عملية</td><td style="padding:6px 0;font-weight:700;direction:ltr;text-align:left;"><?= money($t['total']) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($totals['by_type'])): ?><tr><td colspan="3" style="text-align:center;color:#6B7280;padding:15px;">لا توجد بيانات</td></tr><?php endif; ?>
</table>
<h4 style="color:#0D7377;margin:0 0 10px;font-size:14px;">حسب النوع</h4>
<?php foreach ($report['by_type'] as $t): ?>
<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:13px;">
<span><?= e(\App\Modules\Payments\Services\PaymentService::getPaymentTypeLabel($t['payment_type'])) ?></span>
<strong><?= money($t['total']) ?> <small style="color:#9CA3AF;">(<?= (int) $t['count'] ?>)</small></strong>
</div>
<?php endforeach; ?>
</div>
<div class="card" style="padding:20px;">
<h4 style="color:#0D7377;margin-bottom:15px;">حسب طريقة الدفع</h4>
<table style="width:100%;font-size:14px;">
<?php foreach ($totals['by_method'] ?? [] as $m): ?>
<tr><td style="padding:6px 0;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentMethodLabel($m['payment_method'])) ?></td><td style="padding:6px 0;text-align:left;"><?= (int) $m['count'] ?> عملية</td><td style="padding:6px 0;font-weight:700;direction:ltr;text-align:left;"><?= money($m['total']) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($totals['by_method'])): ?><tr><td colspan="3" style="text-align:center;color:#6B7280;padding:15px;">لا توجد بيانات</td></tr><?php endif; ?>
</table>
<h4 style="color:#0D7377;margin:0 0 10px;font-size:14px;">حسب طريقة الدفع</h4>
<?php foreach ($report['by_method'] as $m): ?>
<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:13px;">
<span><?= e(\App\Modules\Payments\Models\Payment::getPaymentMethodLabel($m['payment_method'])) ?></span>
<strong><?= money($m['total']) ?> <small style="color:#9CA3AF;">(<?= (int) $m['count'] ?>)</small></strong>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Individual Payments Table -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">تفاصيل العمليات</h3></div>
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#0D7377;">تفاصيل الدفعات</h3>
<button onclick="window.print()" class="btn btn-sm btn-outline">🖨️ طباعة</button>
</div>
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>#</th><th>الإيصال</th><th>العضو</th><th>النوع</th><th>الطريقة</th><th>المبلغ</th></tr></thead>
<tbody>
<?php foreach ($report['payments'] ?? [] as $i => $p): ?>
<?php $grandTotal = '0.00'; ?>
<?php foreach ($payments as $i => $p): ?>
<?php $grandTotal = bcadd($grandTotal, $p['amount'], 2); ?>
<tr>
<td><?= $i + 1 ?></td>
<td style="font-size:12px;direction:ltr;text-align:right;"><?= e($p['receipt_number'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($p['receipt_number'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $p['member_id'] ?>" style="color:#0D7377;"><?= e($p['member_name']) ?></a></td>
<td style="font-size:13px;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentTypeLabel($p['payment_type'])) ?></td>
<td style="font-size:13px;"><?= e(\App\Modules\Payments\Services\PaymentService::getPaymentTypeLabel($p['payment_type'])) ?></td>
<td style="font-size:13px;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentMethodLabel($p['payment_method'])) ?></td>
<td style="font-weight:700;direction:ltr;text-align:right;"><?= money($p['amount']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($report['payments'])): ?><tr><td colspan="6" style="text-align:center;padding:40px;color:#6B7280;">لا توجد عمليات في هذا اليوم</td></tr><?php endif; ?>
<?php endforeach; ?>
<?php if (empty($payments)): ?>
<tr><td colspan="6" style="text-align:center;padding:40px;color:#6B7280;">لا توجد دفعات في هذا التاريخ</td></tr>
<?php else: ?>
<tr style="background:#F0FDF4;font-weight:700;">
<td colspan="5" style="padding:12px 15px;">الإجمالي</td>
<td style="padding:12px 15px;direction:ltr;text-align:right;font-size:18px;color:#059669;"><?= money($grandTotal) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تحصيل دفعة — <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('title'); ?>تسجيل دفعة — <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:15px;display:flex;justify-content:space-between;align-items:center;">
<!-- Member Info Banner -->
<div class="card" style="margin-bottom:20px;padding:20px;display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>العضو:</strong> <?= e($member['full_name_ar']) ?>
&nbsp;|&nbsp; <strong>رقم العضوية:</strong> <?= e($member['membership_number'] ?? 'لم يُحدد') ?>
&nbsp;|&nbsp; <strong>قيمة العضوية:</strong> <?= money($member['membership_value'] ?? '0') ?>
&nbsp;|&nbsp; <strong>الحالة:</strong> <?= e($member['status']) ?>
<strong style="font-size:18px;"><?= e($member['full_name_ar']) ?></strong>
<div style="font-size:13px;color:#6B7280;margin-top:5px;">
📋 استمارة: <?= e($member['form_number'] ?? '—') ?>
<?php if ($member['membership_number']): ?>
&nbsp;|&nbsp; 🪪 عضوية: <?= e($member['membership_number']) ?>
<?php endif; ?>
&nbsp;|&nbsp; الحالة: <strong style="color:<?= match($member['status']) { 'active' => '#059669', 'accepted' => '#059669', default => '#D97706' } ?>;"><?= e($member['status']) ?></strong>
</div>
</div>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">← العودة للعضو</a>
</div>
<?php if (!empty($summary)): ?>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:20px;">
<div style="background:#F0FDF4;padding:15px;border-radius:8px;text-align:center;">
<div style="font-size:20px;font-weight:700;color:#059669;"><?= money($summary['total_paid'] ?? '0') ?></div>
<div style="color:#6B7280;font-size:12px;">إجمالي المدفوع</div>
<!-- Financial Summary -->
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:15px;margin-bottom:20px;">
<div class="card" style="padding:15px;text-align:center;border-right:4px solid #0D7377;">
<div style="font-size:20px;font-weight:700;color:#0D7377;"><?= money($summary['membership_value'] ?? '0') ?></div>
<div style="font-size:11px;color:#6B7280;">قيمة العضوية</div>
</div>
<div style="background:#FEF2F2;padding:15px;border-radius:8px;text-align:center;">
<div style="font-size:20px;font-weight:700;color:#DC2626;"><?= money($summary['membership_outstanding'] ?? '0') ?></div>
<div style="color:#6B7280;font-size:12px;">المتبقي من العضوية</div>
<div class="card" style="padding:15px;text-align:center;border-right:4px solid #059669;">
<div style="font-size:20px;font-weight:700;color:#059669;"><?= money($summary['total_paid'] ?? '0') ?></div>
<div style="font-size:11px;color:#6B7280;">إجمالي المدفوع</div>
</div>
<div style="background:#EFF6FF;padding:15px;border-radius:8px;text-align:center;">
<div style="font-size:20px;font-weight:700;color:#0284C7;"><?= (int) ($summary['payment_count'] ?? 0) ?></div>
<div style="color:#6B7280;font-size:12px;">عدد المدفوعات</div>
<div class="card" style="padding:15px;text-align:center;border-right:4px solid #DC2626;">
<div style="font-size:20px;font-weight:700;color:#DC2626;"><?= money($summary['total_outstanding'] ?? '0') ?></div>
<div style="font-size:11px;color:#6B7280;">إجمالي المتبقي</div>
</div>
<div style="background:#FFF7ED;padding:15px;border-radius:8px;text-align:center;">
<div style="font-size:20px;font-weight:700;color:#D97706;"><?= count($outstandingItems) ?></div>
<div style="color:#6B7280;font-size:12px;">بنود مستحقة</div>
<div class="card" style="padding:15px;text-align:center;border-right:4px solid <?= $summary['form_fee_paid'] ? '#059669' : '#DC2626' ?>;">
<div style="font-size:20px;font-weight:700;color:<?= $summary['form_fee_paid'] ? '#059669' : '#DC2626' ?>;">
<?= $summary['form_fee_paid'] ? '✅' : '❌' ?>
</div>
<div style="font-size:11px;color:#6B7280;">رسوم الاستمارة</div>
</div>
</div>
<?php endif; ?>
<form method="POST" action="/payments/process/<?= (int) $member['id'] ?>" id="payment-form">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">تفاصيل الدفعة</h3></div>
<div style="padding:20px;">
<!-- Payment Options -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">💰 اختر نوع الدفعة</h3>
</div>
<div style="padding:20px;">
<?php foreach ($paymentOptions as $i => $opt): ?>
<div style="border:2px solid #E5E7EB;border-radius:10px;padding:20px;margin-bottom:15px;<?= $opt['required'] ?? false ? 'border-color:#DC2626;background:#FEF2F2;' : '' ?>">
<form method="POST" action="/payments/process/<?= (int) $member['id'] ?>">
<?= csrf_field() ?>
<input type="hidden" name="payment_type" value="<?= e($opt['type']) ?>">
<?php if (!empty($outstandingItems)): ?>
<div style="margin-bottom:20px;">
<label class="form-label" style="font-weight:700;">البنود المستحقة (اختر للدفع)</label>
<div style="display:grid;gap:10px;margin-top:10px;">
<?php foreach ($outstandingItems as $i => $item): ?>
<label style="display:flex;align-items:center;gap:12px;padding:12px;border:2px solid #E5E7EB;border-radius:8px;cursor:pointer;" class="outstanding-item" data-amount="<?= e($item['outstanding']) ?>" data-type="<?= e($item['type']) ?>" data-entity-type="<?= e($item['entity_type']) ?>" data-entity-id="<?= (int) $item['entity_id'] ?>">
<input type="radio" name="selected_item" value="<?= $i ?>" style="flex-shrink:0;">
<div style="flex:1;">
<strong><?= e($item['label']) ?></strong>
<div style="font-size:12px;color:#6B7280;">إجمالي: <?= money($item['total']) ?> — مدفوع: <?= money($item['paid']) ?></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">
<div>
<h4 style="margin:0;color:#1A1A2E;"><?= e($opt['label']) ?></h4>
<p style="margin:3px 0 0;font-size:13px;color:#6B7280;"><?= e($opt['description']) ?></p>
<?php if ($opt['required'] ?? false): ?>
<span style="color:#DC2626;font-size:12px;font-weight:600;">⚠ مطلوب</span>
<?php endif; ?>
</div>
<div style="text-align:left;">
<div style="font-size:24px;font-weight:700;color:#0D7377;">
<?= money($opt['amount']) ?>
</div>
<div style="font-weight:700;color:#DC2626;font-size:16px;"><?= money($item['outstanding']) ?></div>
</label>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">نوع الدفعة <span style="color:#DC2626;">*</span></label>
<select name="payment_type" id="payment_type" class="form-select" required>
<option value="">-- اختر --</option>
<option value="membership_fee">رسوم عضوية</option>
<option value="down_payment">مقدم (تقسيط)</option>
<option value="form_fee">رسوم استمارة</option>
<option value="addition_fee">رسوم إضافة</option>
<option value="development_fee">رسوم تنمية</option>
<option value="annual_subscription">اشتراك سنوي</option>
<option value="fine">غرامة</option>
<option value="carnet_replacement">بدل فاقد كارنيه</option>
<option value="other">أخرى</option>
</select>
</div>
<div class="form-group">
<label class="form-label">المبلغ (ج.م) <span style="color:#DC2626;">*</span></label>
<input type="number" name="amount" id="payment_amount" class="form-input" step="0.01" min="0.01" required style="direction:ltr;text-align:left;font-size:18px;font-weight:700;">
<small style="color:#6B7280;">المبلغ من الكتالوج — لا يُدخل يدوياً</small>
</div>
<div class="form-group">
<label class="form-label">طريقة الدفع <span style="color:#DC2626;">*</span></label>
<select name="payment_method" id="payment_method" class="form-select" required>
<?php foreach ($paymentMethods as $pm): ?>
<option value="<?= e($pm['code']) ?>"><?= e($pm['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" id="check-fields" style="display:none;grid-column:1/-1;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;padding:15px;background:#F9FAFB;border-radius:8px;">
<div class="form-group"><label class="form-label">رقم الشيك</label><input type="text" name="check_number" class="form-input" style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">البنك</label><input type="text" name="check_bank" class="form-input"></div>
<div class="form-group"><label class="form-label">تاريخ الشيك</label><input type="date" name="check_date" class="form-input"></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:10px;align-items:end;">
<!-- Amount -->
<div class="form-group">
<label class="form-label">المبلغ</label>
<?php if (!empty($opt['is_variable'])): ?>
<input type="number" name="amount" value="<?= e($opt['amount']) ?>" class="form-input" step="0.01" min="<?= e($opt['min_amount'] ?? '0.01') ?>" required style="direction:ltr;text-align:left;font-weight:700;">
<?php else: ?>
<input type="number" name="amount" value="<?= e($opt['amount']) ?>" class="form-input" step="0.01" readonly style="direction:ltr;text-align:left;font-weight:700;background:#F3F4F6;">
<?php endif; ?>
</div>
</div>
<div class="form-group" id="visa-fields" style="display:none;">
<label class="form-label">مرجع العملية (فيزا)</label>
<input type="text" name="visa_reference" class="form-input" style="direction:ltr;text-align:left;">
</div>
<div class="form-group" id="transfer-fields" style="display:none;grid-column:1/-1;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group"><label class="form-label">مرجع التحويل</label><input type="text" name="transfer_reference" class="form-input" style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">البنك</label><input type="text" name="transfer_bank" class="form-input"></div>
<!-- Payment Method -->
<div class="form-group">
<label class="form-label">طريقة الدفع</label>
<select name="payment_method" class="form-select">
<option value="cash">نقدي 💵</option>
<option value="visa">فيزا 💳</option>
<option value="bank_transfer">تحويل بنكي 🏦</option>
<option value="check">شيك 📄</option>
</select>
</div>
</div>
</div>
<input type="hidden" name="related_entity_type" id="related_entity_type">
<input type="hidden" name="related_entity_id" id="related_entity_id">
<!-- Notes -->
<div class="form-group">
<label class="form-label">ملاحظات</label>
<input type="text" name="notes" class="form-input" placeholder="اختياري">
</div>
<div class="form-group" style="margin-top:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"></textarea>
</div>
<!-- Submit -->
<button type="submit" class="btn btn-primary" style="padding:9px 20px;white-space:nowrap;" onclick="return confirm('تأكيد دفع <?= e(money($opt['amount'])) ?>؟')">
💰 سجّل الدفع
</button>
</div>
</form>
</div>
<?php endforeach; ?>
</div>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary" style="font-size:16px;padding:12px 30px;">تأكيد الدفع وإصدار الإيصال</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
</div>
</form>
<?php $__template->endSection(); ?>
<?php $__template->section('scripts'); ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
var methodSelect = document.getElementById('payment_method');
function toggleMethodFields() {
document.getElementById('check-fields').style.display = methodSelect.value === 'check' ? 'block' : 'none';
document.getElementById('visa-fields').style.display = methodSelect.value === 'visa' ? 'block' : 'none';
document.getElementById('transfer-fields').style.display = methodSelect.value === 'bank_transfer' ? 'block' : 'none';
}
methodSelect.addEventListener('change', toggleMethodFields);
toggleMethodFields();
<!-- Quick links -->
<div style="display:flex;gap:10px;">
<a href="/payments/member/<?= (int) $member['id'] ?>" class="btn btn-outline">📜 سجل المدفوعات</a>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">← العضو</a>
</div>
// When outstanding item selected, fill amount and type
document.querySelectorAll('.outstanding-item input[type="radio"]').forEach(function(radio) {
radio.addEventListener('change', function() {
var item = this.closest('.outstanding-item');
document.getElementById('payment_amount').value = item.dataset.amount;
document.getElementById('payment_type').value = item.dataset.type;
document.getElementById('related_entity_type').value = item.dataset.entityType;
document.getElementById('related_entity_id').value = item.dataset.entityId;
item.style.borderColor = '#0D7377';
document.querySelectorAll('.outstanding-item').forEach(function(el) {
if (el !== item) el.style.borderColor = '#E5E7EB';
});
});
});
});
</script>
<?php $__template->endSection(); ?>
\ No newline at end of file
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