Commit e64e6951 authored by Administrator's avatar Administrator

Update 21 files via Son of Anton

parent 446d6aca
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Payments\Models\Payment;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Payments\Services\BalanceCalculator;
class PaymentController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'payment_type' => $request->get('payment_type', ''),
'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);
return $this->view('Payments.Views.history', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function processForm(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('العضو غير موجود');
$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");
return $this->view('Payments.Views.process', [
'member' => $member,
'outstandingItems' => $outstandingItems,
'summary' => $summary,
'paymentMethods' => $paymentMethods,
]);
}
public function processStore(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']);
$paymentType = trim($data['payment_type'] ?? '');
$amount = trim($data['amount'] ?? '0');
$method = trim($data['payment_method'] ?? 'cash');
$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 (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
return $this->redirect("/payments/process/{$memberId}");
}
$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);
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
}
}
}
return $this->redirect("/receipts/{$result['receipt_id']}/print")
->withSuccess('تم تسجيل الدفعة بنجاح — إيصال رقم: ' . $result['receipt_number']);
}
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
FROM payments p
JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
LEFT JOIN employees e ON e.id = p.received_by_employee_id
LEFT JOIN employees ve ON ve.id = p.voided_by
WHERE p.id = ?",
[(int) $id]
);
if (!$payment) return $this->redirect('/payments')->withError('الدفعة غير موجودة');
$canVoid = PaymentService::canVoid((int) $id);
return $this->view('Payments.Views.history', [
'payment' => $payment,
'canVoid' => $canVoid,
'singleView' => true,
'rows' => [], 'pagination' => ['last_page' => 1, 'current_page' => 1], 'filters' => [],
]);
}
public function voidPayment(Request $request, string $id): Response
{
$reason = trim((string) $request->post('void_reason', ''));
if ($reason === '') {
return $this->redirect("/payments/{$id}")->withError('يجب إدخال سبب الإلغاء');
}
$result = PaymentService::voidPayment((int) $id, $reason);
if (!$result['success']) {
return $this->redirect("/payments/{$id}")->withError($result['error']);
}
return $this->redirect('/payments')->withSuccess('تم إلغاء الدفعة والإيصال بنجاح');
}
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('العضو غير موجود');
$payments = Payment::getForMember((int) $memberId, true);
$summary = BalanceCalculator::getMemberSummary((int) $memberId);
return $this->view('Payments.Views.history', [
'member' => $member,
'rows' => $payments,
'summary' => $summary,
'memberView' => true,
'pagination' => ['last_page' => 1, 'current_page' => 1],
'filters' => [],
]);
}
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;
$report = PaymentService::getDailyReport($date, $branchId);
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Payments.Views.daily-report', [
'report' => $report,
'date' => $date,
'branchId' => $branchId,
'branches' => $branches,
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Payment extends Model
{
protected static string $table = 'payments';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $dispatchEvents = true;
protected static array $fillable = [
'member_id', 'receipt_id', 'payment_type', 'amount', 'currency',
'payment_method', 'check_number', 'check_bank', 'check_date', 'check_status',
'visa_reference', 'transfer_reference', 'transfer_bank',
'related_entity_type', 'related_entity_id', 'notes',
'payment_date', 'received_by_employee_id',
'is_voided', 'voided_at', 'voided_by', 'void_reason',
];
public static function getForMember(int $memberId, bool $includeVoided = false): array
{
$db = App::getInstance()->db();
$where = $includeVoided ? '' : ' AND p.is_voided = 0';
return $db->select(
"SELECT p.*, r.receipt_number, e.full_name_ar as received_by_name
FROM payments p
LEFT JOIN receipts r ON r.id = p.receipt_id
LEFT JOIN employees e ON e.id = p.received_by_employee_id
WHERE p.member_id = ?{$where}
ORDER BY p.payment_date DESC, p.id DESC",
[$memberId]
);
}
public static function getTotalPaidForMember(int $memberId): string
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE member_id = ? AND is_voided = 0",
[$memberId]
);
return $row['total'] ?? '0.00';
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['member_id'])) {
$where .= ' AND p.member_id = ?';
$params[] = (int) $filters['member_id'];
}
if (!empty($filters['payment_type'])) {
$where .= ' AND p.payment_type = ?';
$params[] = $filters['payment_type'];
}
if (!empty($filters['payment_method'])) {
$where .= ' AND p.payment_method = ?';
$params[] = $filters['payment_method'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND p.payment_date >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND p.payment_date <= ?';
$params[] = $filters['date_to'];
}
if (isset($filters['is_voided']) && $filters['is_voided'] !== '') {
$where .= ' AND p.is_voided = ?';
$params[] = (int) $filters['is_voided'];
} else {
$where .= ' AND p.is_voided = 0';
}
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR m.membership_number LIKE ? OR r.receipt_number LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s;
$params[] = $s;
$params[] = $s;
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM payments p JOIN members m ON m.id = p.member_id LEFT JOIN receipts r ON r.id = p.receipt_id WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT p.*, m.full_name_ar as member_name, m.membership_number, r.receipt_number, e.full_name_ar as received_by_name
FROM payments p
JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id
LEFT JOIN employees e ON e.id = p.received_by_employee_id
WHERE {$where}
ORDER BY p.payment_date DESC, p.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function getDailyTotals(string $date, ?int $branchId = null): array
{
$db = App::getInstance()->db();
$where = 'p.payment_date = ? AND p.is_voided = 0';
$params = [$date];
if ($branchId) {
$where .= ' AND m.branch_id = ?';
$params[] = $branchId;
}
$byType = $db->select(
"SELECT p.payment_type, COUNT(*) as count, SUM(p.amount) as total
FROM payments p JOIN members m ON m.id = p.member_id
WHERE {$where} GROUP BY p.payment_type ORDER BY total DESC",
$params
);
$byMethod = $db->select(
"SELECT p.payment_method, COUNT(*) as count, SUM(p.amount) as total
FROM payments p JOIN members m ON m.id = p.member_id
WHERE {$where} GROUP BY p.payment_method ORDER BY total DESC",
$params
);
$grandTotal = $db->selectOne(
"SELECT COUNT(*) as count, COALESCE(SUM(p.amount), 0) as total
FROM payments p JOIN members m ON m.id = p.member_id
WHERE {$where}",
$params
);
return [
'by_type' => $byType,
'by_method' => $byMethod,
'grand_total' => $grandTotal,
];
}
public static function getPaymentTypeLabel(string $type): string
{
return match ($type) {
'membership_fee' => 'رسوم عضوية',
'form_fee' => 'رسوم استمارة',
'addition_fee' => 'رسوم إضافة',
'annual_subscription'=> 'اشتراك سنوي',
'fine' => 'غرامة',
'penalty' => 'عقوبة مالية',
'installment' => 'قسط',
'down_payment' => 'مقدم',
'development_fee' => 'رسوم تنمية',
'carnet_replacement' => 'بدل فاقد كارنيه',
'other' => 'أخرى',
default => $type,
};
}
public static function getPaymentMethodLabel(string $method): string
{
return match ($method) {
'cash' => 'نقدي',
'check' => 'شيك',
'visa' => 'فيزا / بطاقة',
'bank_transfer' => 'تحويل بنكي',
default => $method,
};
}
}
\ No newline at end of file
<?php
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'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Services;
use App\Core\App;
final class BalanceCalculator
{
/**
* Get complete financial summary for a member.
*/
public static function getMemberSummary(int $memberId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$memberId]);
if (!$member) return ['error' => 'Member not found'];
$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);
}
}
$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]
);
return [
'membership_value' => $membershipValue,
'total_paid' => $totalPaid['total'] ?? '0.00',
'total_voided' => $totalVoided['total'] ?? '0.00',
'membership_paid' => $membershipPaid,
'membership_outstanding' => $membershipOutstanding,
'payments_by_type' => $byType,
'last_payment' => $lastPayment,
'payment_count' => array_sum(array_column($byType, 'count')),
];
}
/**
* Get list of outstanding items a member owes.
*/
public static function getOutstandingItems(int $memberId): array
{
$db = App::getInstance()->db();
$items = [];
$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,
];
}
// 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'],
];
}
}
// 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'],
];
}
}
return $items;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
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;
final class PaymentService
{
/**
* Process a payment: create payment record + receipt, fire events.
*/
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;
if ($memberId <= 0 || $paymentType === '' || bccomp($amount, '0.00', 2) <= 0) {
return ['success' => false, 'error' => 'بيانات غير مكتملة'];
}
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) {
return ['success' => false, 'error' => 'العضو غير موجود'];
}
$db->beginTransaction();
try {
// Create payment record
$paymentId = $db->insert('payments', [
'member_id' => $memberId,
'payment_type' => $paymentType,
'amount' => $amount,
'currency' => 'EGP',
'payment_method' => $method,
'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,
'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'),
'received_by_employee_id' => $employee ? (int) $employee->id : null,
'is_voided' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
// Generate receipt
$receiptNumber = Receipt::generateNumber();
$amountWords = self::amountToArabicWords($amount);
if ($description === '') {
$description = Payment::getPaymentTypeLabel($paymentType) . ' — ' . ($member['full_name_ar'] ?? '');
}
$receiptId = $db->insert('receipts', [
'receipt_number' => $receiptNumber,
'member_id' => $memberId,
'payment_id' => $paymentId,
'receipt_type' => 'payment',
'amount' => $amount,
'amount_in_words_ar' => $amountWords,
'description_ar' => $description,
'issued_by_employee_id' => $employee ? (int) $employee->id : null,
'issued_at' => date('Y-m-d H:i:s'),
'is_voided' => 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->commit();
// Fire event AFTER commit
EventBus::dispatch('payment.completed', [
'payment_id' => $paymentId,
'receipt_id' => $receiptId,
'receipt_number'=> $receiptNumber,
'member_id' => $memberId,
'payment_type' => $paymentType,
'amount' => $amount,
'method' => $method,
]);
Logger::info("Payment processed: #{$paymentId}, Receipt: {$receiptNumber}", [
'member_id' => $memberId, 'type' => $paymentType, 'amount' => $amount,
]);
return [
'success' => true,
'payment_id' => $paymentId,
'receipt_id' => $receiptId,
'receipt_number' => $receiptNumber,
];
} catch (\Throwable $e) {
$db->rollBack();
Logger::error("Payment processing failed: " . $e->getMessage());
return ['success' => false, 'error' => 'فشل في معالجة الدفع: ' . $e->getMessage()];
}
}
/**
* Void a payment and its receipt.
*/
public static function voidPayment(int $paymentId, string $reason): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$payment = $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]);
if (!$payment) {
return ['success' => false, 'error' => 'الدفعة غير موجودة'];
}
if ($payment['is_voided']) {
return ['success' => false, 'error' => 'الدفعة ملغاة بالفعل'];
}
// Check 24h rule
$canVoid = self::canVoid($paymentId);
if (!$canVoid['allowed']) {
return ['success' => false, 'error' => $canVoid['reason']];
}
$now = date('Y-m-d H:i:s');
$empId = $employee ? (int) $employee->id : null;
$db->beginTransaction();
try {
// Void payment
$db->update('payments', [
'is_voided' => 1,
'voided_at' => $now,
'voided_by' => $empId,
'void_reason'=> $reason,
'updated_at' => $now,
], '`id` = ?', [$paymentId]);
// Void linked receipt
if ($payment['receipt_id']) {
$db->update('receipts', [
'is_voided' => 1,
'voided_at' => $now,
'voided_by' => $empId,
'void_reason'=> $reason,
], '`id` = ?', [(int) $payment['receipt_id']]);
}
$db->commit();
EventBus::dispatch('payment.voided', [
'payment_id' => $paymentId,
'member_id' => (int) $payment['member_id'],
'amount' => $payment['amount'],
'reason' => $reason,
]);
Logger::info("Payment #{$paymentId} voided", ['reason' => $reason]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل في إلغاء الدفعة'];
}
}
/**
* Check if a payment can be voided (24h rule).
*/
public static function canVoid(int $paymentId): array
{
$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' => 'ملغاة بالفعل'];
// 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' => ''];
}
// 24h rule
$createdAt = strtotime($payment['created_at']);
$hoursElapsed = (time() - $createdAt) / 3600;
if ($hoursElapsed > 24) {
return ['allowed' => false, 'reason' => 'مضى أكثر من 24 ساعة. يلزم صلاحية المدير العام للإلغاء'];
}
return ['allowed' => true, 'reason' => ''];
}
/**
* Get daily report data.
*/
public static function getDailyReport(string $date, ?int $branchId = null): array
{
$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);
$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]
);
return [
'date' => $date,
'branch_id' => $branchId,
'payments' => $payments,
'totals' => $totals,
'voided' => $voidedCount,
];
}
/**
* Convert numeric amount to Arabic words for receipt.
*/
public static function amountToArabicWords(string $amount): 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 = 'صفر';
} 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);
}
$result .= ' جنيه مصري';
if ($decPart > 0) {
$result .= ' و' . $convertGroup($decPart) . ' قرش';
}
return $result . ' فقط لا غير';
}
}
\ No newline at end of file
<?php $summary = $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>
</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>
<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>
<div style="background:#FFF7ED;padding:15px;border-radius:8px;text-align:center;">
<div style="font-size:20px;font-weight:700;color:#D97706;"><?= money($summary['total_voided'] ?? '0') ?></div>
<div style="color:#6B7280;font-size:12px;">ملغاة</div>
</div>
</div>
<?php if (!empty($summary['last_payment'])): ?>
<div class="card" style="margin-bottom:20px;padding:15px;font-size:13px;color:#6B7280;">
آخر دفعة: <?= e($summary['last_payment']['payment_date'] ?? '') ?> — إيصال: <?= e($summary['last_payment']['receipt_number'] ?? '—') ?><?= money($summary['last_payment']['amount'] ?? '0') ?>
</div>
<?php endif; ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>التقرير اليومي — <?= e($date) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<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; ?>
</select>
</div>
<button type="submit" class="btn btn-outline">عرض</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>
</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>
</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>
</div>
</div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">تفاصيل العمليات</h3></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): ?>
<tr>
<td><?= $i + 1 ?></td>
<td style="font-size:12px;direction:ltr;text-align:right;"><?= 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\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; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= !empty($memberView) ? 'سجل مدفوعات: ' . e($member['full_name_ar'] ?? '') : (!empty($singleView) ? 'تفاصيل الدفعة #' . (int) ($payment['id'] ?? 0) : 'كل المدفوعات') ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php if (!empty($singleView) && !empty($payment)): ?>
<!-- Single Payment View -->
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h3 style="margin:0;color:#0D7377;">تفاصيل الدفعة #<?= (int) $payment['id'] ?></h3>
<?php if ($payment['is_voided']): ?>
<span style="background:#FEF2F2;color:#DC2626;padding:6px 16px;border-radius:20px;font-weight:700;">ملغاة</span>
<?php else: ?>
<span style="background:#F0FDF4;color:#059669;padding:6px 16px;border-radius:20px;font-weight:700;">سارية</span>
<?php endif; ?>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<table style="width:100%;font-size:14px;">
<tr><td style="padding:6px 0;color:#6B7280;width:40%;">العضو</td><td style="padding:6px 0;"><a href="/members/<?= (int) $payment['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($payment['member_name']) ?></a></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">رقم العضوية</td><td style="padding:6px 0;"><?= e($payment['membership_number'] ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">نوع الدفعة</td><td style="padding:6px 0;font-weight:600;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentTypeLabel($payment['payment_type'])) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">المبلغ</td><td style="padding:6px 0;font-size:20px;font-weight:700;color:#0D7377;"><?= money($payment['amount']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">طريقة الدفع</td><td style="padding:6px 0;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentMethodLabel($payment['payment_method'])) ?></td></tr>
</table>
<table style="width:100%;font-size:14px;">
<tr><td style="padding:6px 0;color:#6B7280;width:40%;">رقم الإيصال</td><td style="padding:6px 0;font-weight:600;direction:ltr;text-align:right;"><?= e($payment['receipt_number'] ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">تاريخ الدفع</td><td style="padding:6px 0;"><?= e($payment['payment_date']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">استلمها</td><td style="padding:6px 0;"><?= e($payment['received_by_name'] ?? '—') ?></td></tr>
<?php if ($payment['is_voided']): ?>
<tr><td style="padding:6px 0;color:#6B7280;">ألغاها</td><td style="padding:6px 0;color:#DC2626;"><?= e($payment['voided_by_name'] ?? '—') ?><?= e($payment['voided_at'] ?? '') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">سبب الإلغاء</td><td style="padding:6px 0;"><?= e($payment['void_reason'] ?? '—') ?></td></tr>
<?php endif; ?>
<?php if ($payment['notes']): ?>
<tr><td style="padding:6px 0;color:#6B7280;">ملاحظات</td><td style="padding:6px 0;"><?= e($payment['notes']) ?></td></tr>
<?php endif; ?>
</table>
</div>
<?php if (!$payment['is_voided'] && ($canVoid['allowed'] ?? false)): ?>
<div style="margin-top:20px;padding:15px;background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;">
<form method="POST" action="/payments/<?= (int) $payment['id'] ?>/void" onsubmit="return confirm('هل أنت متأكد من إلغاء هذه الدفعة والإيصال المرتبط بها؟');">
<?= csrf_field() ?>
<label class="form-label" style="color:#DC2626;font-weight:700;">إلغاء الدفعة</label>
<div style="display:flex;gap:10px;margin-top:8px;">
<input type="text" name="void_reason" class="form-input" placeholder="سبب الإلغاء (مطلوب)" required style="flex:1;">
<button type="submit" class="btn" style="background:#DC2626;color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;">إلغاء الدفعة</button>
</div>
</form>
</div>
<?php endif; ?>
<div style="margin-top:15px;display:flex;gap:10px;">
<?php if ($payment['receipt_id']): ?><a href="/receipts/<?= (int) $payment['receipt_id'] ?>/print" class="btn btn-outline" target="_blank">طباعة الإيصال</a><?php endif; ?>
<a href="/payments" class="btn btn-outline">← العودة</a>
</div>
</div>
<?php else: ?>
<!-- List View -->
<?php if (empty($memberView)): ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/payments" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم، رقم عضوية، إيصال..." class="form-input" style="min-width:200px;"></div>
<div><label class="form-label" style="font-size:12px;">النوع</label>
<select name="payment_type" class="form-select"><option value="">الكل</option>
<option value="membership_fee" <?= ($filters['payment_type'] ?? '') === 'membership_fee' ? 'selected' : '' ?>>رسوم عضوية</option>
<option value="form_fee" <?= ($filters['payment_type'] ?? '') === 'form_fee' ? 'selected' : '' ?>>رسوم استمارة</option>
<option value="addition_fee" <?= ($filters['payment_type'] ?? '') === 'addition_fee' ? 'selected' : '' ?>>رسوم إضافة</option>
<option value="annual_subscription" <?= ($filters['payment_type'] ?? '') === 'annual_subscription' ? 'selected' : '' ?>>اشتراك سنوي</option>
<option value="down_payment" <?= ($filters['payment_type'] ?? '') === 'down_payment' ? 'selected' : '' ?>>مقدم</option>
</select>
</div>
<div><label class="form-label" style="font-size:12px;">من</label><input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input"></div>
<div><label class="form-label" style="font-size:12px;">إلى</label><input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input"></div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/payments" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<?php endif; ?>
<?php if (!empty($memberView) && !empty($summary)): ?>
<?php $__template->include('Payments.Views._partials.payment-summary', ['summary' => $summary]); ?>
<?php endif; ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead><tr>
<th>التاريخ</th>
<?php if (empty($memberView)): ?><th>العضو</th><?php endif; ?>
<th>النوع</th><th>المبلغ</th><th>الطريقة</th><th>الإيصال</th><th>الحالة</th><th>الإجراءات</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr style="<?= ($r['is_voided'] ?? false) ? 'opacity:0.5;text-decoration:line-through;' : '' ?>">
<td style="font-size:12px;white-space:nowrap;"><?= e($r['payment_date'] ?? '') ?></td>
<?php if (empty($memberView)): ?><td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;"><?= e($r['member_name'] ?? $r['full_name_ar'] ?? '—') ?></a></td><?php endif; ?>
<td style="font-size:13px;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentTypeLabel($r['payment_type'])) ?></td>
<td style="font-weight:700;direction:ltr;text-align:right;"><?= money($r['amount']) ?></td>
<td style="font-size:13px;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentMethodLabel($r['payment_method'])) ?></td>
<td style="font-size:12px;direction:ltr;text-align:right;"><?= e($r['receipt_number'] ?? '—') ?></td>
<td><?php if ($r['is_voided'] ?? false): ?><span style="color:#DC2626;font-weight:600;">ملغاة</span><?php else: ?><span style="color:#059669;font-weight:600;">سارية</span><?php endif; ?></td>
<td><a href="/payments/<?= (int) $r['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="<?= empty($memberView) ? 8 : 7 ?>" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مدفوعات</td></tr><?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?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('content'); ?>
<div class="card" style="margin-bottom:15px;padding:15px;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']) ?>
</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>
</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>
<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>
<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>
</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;">
<?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>
<div style="font-weight:700;color:#DC2626;font-size:16px;"><?= money($item['outstanding']) ?></div>
</label>
<?php endforeach; ?>
</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>
</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>
</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">
<div class="form-group" style="margin-top:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"></textarea>
</div>
</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();
// 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
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Members\Services\MemberNumberGenerator;
MenuRegistry::register('payments', [
'label_ar' => 'الخزينة والمدفوعات',
'label_en' => 'Treasury & Payments',
'icon' => 'wallet',
'route' => '/payments',
'permission' => 'payment.view',
'parent' => null,
'order' => 500,
'children' => [
['label_ar' => 'كل المدفوعات', 'label_en' => 'All Payments', 'route' => '/payments', 'permission' => 'payment.view', 'order' => 1],
['label_ar' => 'التقرير اليومي', 'label_en' => 'Daily Report', 'route' => '/payments/daily-report', 'permission' => 'payment.view', 'order' => 2],
['label_ar' => 'الإيصالات', 'label_en' => 'Receipts', 'route' => '/receipts', 'permission' => 'payment.view', 'order' => 3],
['label_ar' => 'إيصالات ملغاة', 'label_en' => 'Voided', 'route' => '/receipts/voided', 'permission' => 'payment.void_receipt', 'order' => 4],
],
]);
PermissionRegistry::register('payments', [
'payment.process_cash' => ['ar' => 'معالجة دفع نقدي', 'en' => 'Process Cash Payment'],
'payment.process_check' => ['ar' => 'معالجة دفع بشيك', 'en' => 'Process Check Payment'],
'payment.process_visa' => ['ar' => 'معالجة دفع فيزا', 'en' => 'Process Visa Payment'],
'payment.view' => ['ar' => 'عرض المدفوعات', 'en' => 'View Payments'],
'payment.void_receipt' => ['ar' => 'إلغاء إيصال', 'en' => 'Void Receipt'],
'payment.refund' => ['ar' => 'استرداد', 'en' => 'Refund Payment'],
]);
// When payment.completed fires: activate membership, assign number, transition workflow
EventBus::listen('payment.completed', function (array $data) {
try {
$db = App::getInstance()->db();
$paymentType = $data['payment_type'] ?? '';
$memberId = (int) ($data['member_id'] ?? 0);
if ($memberId <= 0) return;
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) return;
// Membership fee payment → activate member + assign number
if (in_array($paymentType, ['membership_fee', 'down_payment'])) {
if (in_array($member['status'], ['potential', 'payment_pending', 'accepted'])) {
// Assign membership number if not yet assigned
if (empty($member['membership_number'])) {
try {
$number = MemberNumberGenerator::assign($memberId);
Logger::info("Membership number {$number} assigned to member #{$memberId} after payment");
} catch (\Throwable $e) {
Logger::warning("Failed to assign membership number for member #{$memberId}: " . $e->getMessage());
}
}
// Update member status to active
$db->update('members', [
'status' => 'active',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$memberId]);
// Try workflow transition
if ($member['workflow_instance_id']) {
try {
$instance = $db->selectOne(
"SELECT * FROM workflow_instances WHERE id = ? AND is_completed = 0",
[(int) $member['workflow_instance_id']]
);
if ($instance && $instance['current_state'] === 'payment_pending') {
$db->update('workflow_instances', [
'current_state' => 'active',
'state_entered_at' => date('Y-m-d H:i:s'),
'is_completed' => 1,
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $instance['id']]);
$employee = App::getInstance()->currentEmployee();
$db->insert('workflow_transition_log', [
'workflow_instance_id' => (int) $instance['id'],
'from_state' => 'payment_pending',
'to_state' => 'active',
'transition_name' => 'payment_completed',
'triggered_by_employee_id' => $employee ? (int) $employee->id : null,
'trigger_type' => 'system',
'notes' => 'Auto-transitioned by payment completion',
'created_at' => date('Y-m-d H:i:s'),
]);
}
} catch (\Throwable $e) {
Logger::warning("Workflow transition failed for member #{$memberId}: " . $e->getMessage());
}
}
Logger::info("Member #{$memberId} activated after payment", $data);
}
}
} catch (\Throwable $e) {
Logger::error("payment.completed handler error: " . $e->getMessage(), $data);
}
}, 100);
// Enrich member profile with payment data
EventBus::listen('member.profile_data', function (array &$data) {
try {
$db = App::getInstance()->db();
$memberId = (int) ($data['member']->id ?? 0);
if ($memberId <= 0) return;
if ($db->tableExists('payments')) {
$data['payments'] = $db->select(
"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 50",
[$memberId]
);
}
} catch (\Throwable $e) {
// Table may not exist yet
}
}, 50);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Receipts\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Receipts\Models\Receipt;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Payments\Models\Payment;
class ReceiptController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'is_voided' => '0',
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Receipt::search($filters, 25, $page);
return $this->view('Receipts.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'title' => 'الإيصالات',
]);
}
public function voided(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'is_voided' => '1',
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Receipt::search($filters, 25, $page);
return $this->view('Receipts.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'title' => 'الإيصالات الملغاة',
'showVoided' => true,
]);
}
public function printReceipt(Request $request, string $id): Response
{
$receipt = Receipt::findWithDetails((int) $id);
if (!$receipt) return $this->redirect('/receipts')->withError('الإيصال غير موجود');
// Increment print count
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$db->query("UPDATE receipts SET print_count = print_count + 1, last_printed_at = ?, last_printed_by = ? WHERE id = ?", [
date('Y-m-d H:i:s'), $employee ? (int) $employee->id : null, (int) $id
]);
return $this->view('Receipts.Views.print', ['receipt' => $receipt]);
}
public function voidReceipt(Request $request, string $id): Response
{
$receipt = Receipt::find((int) $id);
if (!$receipt) return $this->redirect('/receipts')->withError('الإيصال غير موجود');
$reason = trim((string) $request->post('void_reason', ''));
if ($reason === '') return $this->redirect('/receipts')->withError('يجب إدخال سبب الإلغاء');
if ($receipt['payment_id']) {
$result = PaymentService::voidPayment((int) $receipt['payment_id'], $reason);
if (!$result['success']) {
return $this->redirect('/receipts')->withError($result['error']);
}
} else {
$employee = App::getInstance()->currentEmployee();
$db = App::getInstance()->db();
$db->update('receipts', [
'is_voided' => 1,
'voided_at' => date('Y-m-d H:i:s'),
'voided_by' => $employee ? (int) $employee->id : null,
'void_reason'=> $reason,
], '`id` = ?', [(int) $id]);
}
return $this->redirect('/receipts')->withSuccess('تم إلغاء الإيصال بنجاح');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Receipts\Models;
use App\Core\App;
use App\Core\Pagination;
class Receipt
{
public static function find(int $id): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM receipts WHERE id = ?", [$id]);
}
public static function findWithDetails(int $id): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT r.*, m.full_name_ar as member_name, m.membership_number, m.phone_mobile,
e.full_name_ar as issued_by_name, ve.full_name_ar as voided_by_name,
p.payment_type, p.payment_method
FROM receipts r
JOIN members m ON m.id = r.member_id
LEFT JOIN employees e ON e.id = r.issued_by_employee_id
LEFT JOIN employees ve ON ve.id = r.voided_by
LEFT JOIN payments p ON p.id = r.payment_id
WHERE r.id = ?",
[$id]
);
}
public static function generateNumber(): string
{
$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 {
$seq = 1;
}
return $prefix . str_pad((string) $seq, 6, '0', STR_PAD_LEFT);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (isset($filters['is_voided']) && $filters['is_voided'] !== '') {
$where .= ' AND r.is_voided = ?';
$params[] = (int) $filters['is_voided'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND DATE(r.issued_at) >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND DATE(r.issued_at) <= ?';
$params[] = $filters['date_to'];
}
if (!empty($filters['search'])) {
$where .= ' AND (r.receipt_number LIKE ? OR m.full_name_ar LIKE ? OR m.membership_number LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM receipts r JOIN members m ON m.id = r.member_id WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT r.*, m.full_name_ar as member_name, m.membership_number, e.full_name_ar as issued_by_name
FROM receipts r
JOIN members m ON m.id = r.member_id
LEFT JOIN employees e ON e.id = r.issued_by_employee_id
WHERE {$where}
ORDER BY r.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function incrementPrintCount(int $id): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$db->update('receipts', [
'print_count' => new \stdClass(), // hack: we'll use raw SQL below
'last_printed_at' => date('Y-m-d H:i:s'),
'last_printed_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$id]);
// Use raw query for increment
$db->query("UPDATE receipts SET print_count = print_count + 1 WHERE id = ?", [$id]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/receipts', 'Receipts\Controllers\ReceiptController@index', ['auth'], 'payment.view'],
['GET', '/receipts/voided', 'Receipts\Controllers\ReceiptController@voided', ['auth'], 'payment.void_receipt'],
['GET', '/receipts/{id}/print', 'Receipts\Controllers\ReceiptController@printReceipt', ['auth'], 'payment.view'],
['POST', '/receipts/{id}/void', 'Receipts\Controllers\ReceiptController@voidReceipt', ['auth'], 'payment.void_receipt'],
];
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= e($title ?? 'الإيصالات') ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="<?= !empty($showVoided) ? '/receipts/voided' : '/receipts' ?>" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" placeholder="رقم إيصال، اسم عضو..." class="form-input" style="min-width:200px;"></div>
<div><label class="form-label" style="font-size:12px;">من</label><input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input"></div>
<div><label class="form-label" style="font-size:12px;">إلى</label><input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input"></div>
<button type="submit" class="btn btn-outline">بحث</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>رقم الإيصال</th><th>العضو</th><th>المبلغ</th><th>التاريخ</th><th>بواسطة</th><th>الحالة</th><th>الإجراءات</th></tr></thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr style="<?= $r['is_voided'] ? 'opacity:0.6;' : '' ?>">
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($r['receipt_number']) ?></td>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;"><?= e($r['member_name']) ?></a></td>
<td style="font-weight:700;direction:ltr;text-align:right;"><?= money($r['amount']) ?></td>
<td style="font-size:12px;"><?= e(substr($r['issued_at'], 0, 10)) ?></td>
<td style="font-size:13px;"><?= e($r['issued_by_name'] ?? '—') ?></td>
<td><?php if ($r['is_voided']): ?><span style="color:#DC2626;font-weight:600;">ملغى</span><?php else: ?><span style="color:#059669;font-weight:600;">ساري</span><?php endif; ?></td>
<td>
<div style="display:flex;gap:5px;">
<?php if (!$r['is_voided']): ?>
<a href="/receipts/<?= (int) $r['id'] ?>/print" class="btn btn-sm btn-outline" target="_blank">طباعة</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا توجد إيصالات</td></tr><?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.print'); ?>
<?php $__template->section('title'); ?>إيصال رقم <?= e($receipt['receipt_number']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="max-width:700px;margin:0 auto;font-family:'Cairo',sans-serif;direction:rtl;">
<?php if ($receipt['is_voided']): ?>
<div style="text-align:center;color:#DC2626;font-size:24px;font-weight:700;border:3px solid #DC2626;padding:10px;margin-bottom:20px;">ملغى</div>
<?php endif; ?>
<div style="text-align:center;margin-bottom:30px;">
<h2 style="margin:0;color:#0D7377;">نادي النادي شيراتون</h2>
<p style="margin:5px 0;color:#6B7280;">THE CLUB Sheraton</p>
<h3 style="margin:15px 0 5px;color:#1A1A2E;">إيصال تحصيل</h3>
</div>
<table style="width:100%;border-collapse:collapse;margin-bottom:20px;font-size:14px;">
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;width:30%;">رقم الإيصال</td>
<td style="padding:8px;border:1px solid #E5E7EB;direction:ltr;text-align:right;font-weight:700;font-size:16px;"><?= e($receipt['receipt_number']) ?></td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">التاريخ</td>
<td style="padding:8px;border:1px solid #E5E7EB;"><?= e(substr($receipt['issued_at'], 0, 10)) ?></td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">اسم العضو</td>
<td style="padding:8px;border:1px solid #E5E7EB;font-weight:600;"><?= e($receipt['member_name']) ?></td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">رقم العضوية</td>
<td style="padding:8px;border:1px solid #E5E7EB;"><?= e($receipt['membership_number'] ?? '—') ?></td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">البيان</td>
<td style="padding:8px;border:1px solid #E5E7EB;"><?= e($receipt['description_ar'] ?? '—') ?></td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">المبلغ (رقماً)</td>
<td style="padding:8px;border:1px solid #E5E7EB;font-size:22px;font-weight:700;color:#0D7377;direction:ltr;text-align:right;"><?= money($receipt['amount']) ?></td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">المبلغ (كتابةً)</td>
<td style="padding:8px;border:1px solid #E5E7EB;font-size:13px;"><?= e($receipt['amount_in_words_ar'] ?? '') ?></td>
</tr>
<?php if ($receipt['payment_type'] ?? ''): ?>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">نوع الدفعة</td>
<td style="padding:8px;border:1px solid #E5E7EB;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentTypeLabel($receipt['payment_type'])) ?></td>
</tr>
<?php endif; ?>
<?php if ($receipt['payment_method'] ?? ''): ?>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;font-weight:600;">طريقة الدفع</td>
<td style="padding:8px;border:1px solid #E5E7EB;"><?= e(\App\Modules\Payments\Models\Payment::getPaymentMethodLabel($receipt['payment_method'])) ?></td>
</tr>
<?php endif; ?>
</table>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:30px;margin-top:50px;text-align:center;font-size:13px;">
<div style="border-top:1px solid #000;padding-top:10px;">توقيع المستلم</div>
<div style="border-top:1px solid #000;padding-top:10px;">أمين الخزينة<br><small><?= e($receipt['issued_by_name'] ?? '') ?></small></div>
<div style="border-top:1px solid #000;padding-top:10px;">المدير المالي</div>
</div>
<div style="margin-top:30px;text-align:center;font-size:11px;color:#9CA3AF;">
طبع بتاريخ: <?= date('Y-m-d H:i:s') ?> — عدد مرات الطباعة: <?= (int) ($receipt['print_count'] ?? 0) + 1 ?>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
// This is handled by index.php with showVoided flag
// The ReceiptController::voided() method uses the same index view
// This file exists as a redirect/alias
$__template->layout('Layout.main');
$__template->section('title'); ?>الإيصالات الملغاة<?php $__template->endSection();
$__template->section('content'); ?>
<p>يتم العرض من خلال صفحة الإيصالات الرئيسية.</p>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('receipts', [
'receipt.view' => ['ar' => 'عرض الإيصالات', 'en' => 'View Receipts'],
'receipt.print' => ['ar' => 'طباعة إيصال', 'en' => 'Print Receipt'],
'receipt.void' => ['ar' => 'إلغاء إيصال', 'en' => 'Void Receipt'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `payment_methods` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(30) NOT NULL,
`name_ar` VARCHAR(100) NOT NULL,
`name_en` VARCHAR(100) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` INT UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY `uq_payment_methods_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `payment_methods`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `payments` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`receipt_id` BIGINT UNSIGNED NULL,
`payment_type` VARCHAR(50) NOT NULL,
`amount` DECIMAL(15,2) NOT NULL,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`payment_method` VARCHAR(30) NOT NULL DEFAULT 'cash',
`check_number` VARCHAR(50) NULL,
`check_bank` VARCHAR(200) NULL,
`check_date` DATE NULL,
`check_status` VARCHAR(30) NULL,
`visa_reference` VARCHAR(100) NULL,
`transfer_reference` VARCHAR(100) NULL,
`transfer_bank` VARCHAR(200) NULL,
`related_entity_type` VARCHAR(100) NULL,
`related_entity_id` BIGINT UNSIGNED NULL,
`notes` TEXT NULL,
`payment_date` DATE NOT NULL,
`received_by_employee_id` BIGINT UNSIGNED NULL,
`is_voided` TINYINT(1) NOT NULL DEFAULT 0,
`voided_at` TIMESTAMP NULL DEFAULT NULL,
`voided_by` BIGINT UNSIGNED NULL,
`void_reason` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
INDEX `idx_payments_member` (`member_id`),
INDEX `idx_payments_receipt` (`receipt_id`),
INDEX `idx_payments_type` (`payment_type`),
INDEX `idx_payments_method` (`payment_method`),
INDEX `idx_payments_date` (`payment_date`),
INDEX `idx_payments_voided` (`is_voided`),
INDEX `idx_payments_related` (`related_entity_type`, `related_entity_id`),
INDEX `idx_payments_check_status` (`check_status`),
CONSTRAINT `fk_payments_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `chk_payments_amount` CHECK (`amount` >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `payments`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `receipts` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`receipt_number` VARCHAR(50) NOT NULL,
`member_id` BIGINT UNSIGNED NOT NULL,
`payment_id` BIGINT UNSIGNED NULL,
`receipt_type` VARCHAR(30) NOT NULL DEFAULT 'payment',
`amount` DECIMAL(15,2) NOT NULL,
`amount_in_words_ar` TEXT NULL,
`description_ar` TEXT NULL,
`issued_by_employee_id` BIGINT UNSIGNED NULL,
`issued_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`is_voided` TINYINT(1) NOT NULL DEFAULT 0,
`voided_at` TIMESTAMP NULL DEFAULT NULL,
`voided_by` BIGINT UNSIGNED NULL,
`void_reason` TEXT NULL,
`print_count` INT UNSIGNED NOT NULL DEFAULT 0,
`last_printed_at` TIMESTAMP NULL DEFAULT NULL,
`last_printed_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_receipts_number` (`receipt_number`),
INDEX `idx_receipts_member` (`member_id`),
INDEX `idx_receipts_payment` (`payment_id`),
INDEX `idx_receipts_type` (`receipt_type`),
INDEX `idx_receipts_voided` (`is_voided`),
INDEX `idx_receipts_date` (`issued_at`),
CONSTRAINT `fk_receipts_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `fk_receipts_payment` FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`) ON DELETE SET NULL,
CONSTRAINT `chk_receipts_amount` CHECK (`amount` >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `payments`
ADD CONSTRAINT `fk_payments_receipt` FOREIGN KEY (`receipt_id`) REFERENCES `receipts`(`id`) ON DELETE SET NULL;
",
'down' => "
ALTER TABLE `payments` DROP FOREIGN KEY IF EXISTS `fk_payments_receipt`;
DROP TABLE IF EXISTS `receipts`;
",
];
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$methods = [
['code' => 'cash', 'name_ar' => 'نقدي', 'name_en' => 'Cash', 'sort_order' => 1],
['code' => 'check', 'name_ar' => 'شيك', 'name_en' => 'Check', 'sort_order' => 2],
['code' => 'visa', 'name_ar' => 'فيزا / بطاقة', 'name_en' => 'Visa / Card', 'sort_order' => 3],
['code' => 'bank_transfer', 'name_ar' => 'تحويل بنكي', 'name_en' => 'Bank Transfer', 'sort_order' => 4],
];
foreach ($methods as $m) {
$existing = $db->selectOne("SELECT id FROM payment_methods WHERE code = ?", [$m['code']]);
if ($existing) continue;
$db->insert('payment_methods', [
'code' => $m['code'],
'name_ar' => $m['name_ar'],
'name_en' => $m['name_en'],
'is_active' => 1,
'sort_order' => $m['sort_order'],
]);
}
};
\ 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