Commit 4b579bab authored by Administrator's avatar Administrator

Update 33 files via Son of Anton

parent e64e6951
<?php
declare(strict_types=1);
namespace App\Modules\Fines\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Fines\Models\Fine;
use App\Modules\Fines\Models\Violation;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Payments\Services\PaymentService;
class FineController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'status' => $request->get('status', ''),
'penalty_type' => $request->get('penalty_type', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Fine::search($filters, 25, $page);
return $this->view('Fines.Views.fines.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function impose(Request $request, string $violationId): Response
{
$db = App::getInstance()->db();
$violation = $db->selectOne("SELECT * FROM violations WHERE id = ?", [(int) $violationId]);
if (!$violation) return $this->redirect('/violations')->withError('المخالفة غير موجودة');
$penaltyType = trim($request->post('penalty_type', ''));
$amount = trim($request->post('amount', '0'));
$suspensionFrom = $request->post('suspension_from') ?: null;
$suspensionTo = $request->post('suspension_to') ?: null;
$notes = trim($request->post('notes', ''));
$errors = [];
$validTypes = ['warning', 'caution', 'fine', 'suspension', 'ban', 'termination'];
if (!in_array($penaltyType, $validTypes)) $errors[] = 'نوع العقوبة غير صالح';
if ($penaltyType === 'fine') {
$minData = RuleEngine::get('VIOLATION_FINE_MIN');
$maxData = RuleEngine::get('VIOLATION_FINE_MAX');
$min = $minData['amount'] ?? '1000.00';
$max = $maxData['amount'] ?? '10000.00';
if (bccomp($amount, $min, 2) < 0 || bccomp($amount, $max, 2) > 0) {
$errors[] = "مبلغ الغرامة يجب أن يكون بين {$min} و {$max} ج.م";
}
}
if (in_array($penaltyType, ['suspension', 'ban']) && (!$suspensionFrom || !$suspensionTo)) {
$errors[] = 'تاريخ البداية والنهاية مطلوبان للإيقاف/المنع';
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
return $this->redirect('/violations');
}
$employee = App::getInstance()->currentEmployee();
$fineAmount = in_array($penaltyType, ['fine']) ? $amount : '0.00';
$fine = Fine::create([
'member_id' => (int) $violation['member_id'],
'violation_id' => (int) $violationId,
'fine_type' => 'violation_fine',
'amount' => $fineAmount,
'penalty_type' => $penaltyType,
'suspension_from' => $suspensionFrom,
'suspension_to' => $suspensionTo,
'status' => 'imposed',
'notes' => $notes ?: null,
]);
$db->update('violations', ['status' => 'penalty_imposed', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $violationId]);
EventBus::dispatch('fine.imposed', [
'fine_id' => (int) $fine->id,
'member_id' => (int) $violation['member_id'],
'penalty_type' => $penaltyType,
'amount' => $fineAmount,
]);
return $this->redirect('/fines')->withSuccess('تم فرض العقوبة: ' . Fine::getPenaltyTypeLabel($penaltyType));
}
public function pay(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ?", [(int) $id]);
if (!$fine) return $this->redirect('/fines')->withError('الغرامة غير موجودة');
if ($fine['penalty_type'] !== 'fine') return $this->redirect('/fines')->withError('هذه العقوبة ليست غرامة مالية');
$remaining = bcsub($fine['amount'], $fine['paid_amount'], 2);
if (bccomp($remaining, '0', 2) <= 0) return $this->redirect('/fines')->withError('الغرامة مسددة بالكامل');
$data = $request->all();
$data['member_id'] = (int) $fine['member_id'];
$data['amount'] = $remaining;
$data['payment_type'] = 'fine';
$data['payment_method'] = $data['payment_method'] ?? 'cash';
$data['related_entity_type'] = 'fines';
$data['related_entity_id'] = (int) $id;
$data['description'] = 'غرامة مخالفة #' . ($fine['violation_id'] ?? $id);
$result = PaymentService::processPayment($data);
if (!$result['success']) return $this->redirect('/fines')->withError($result['error']);
$db->update('fines', [
'paid_amount' => $fine['amount'],
'payment_id' => $result['payment_id'],
'paid_at' => date('Y-m-d H:i:s'),
'status' => 'paid',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('fine.paid', ['fine_id' => (int) $id, 'member_id' => (int) $fine['member_id'], 'amount' => $remaining]);
return $this->redirect('/fines')->withSuccess('تم تسجيل دفع الغرامة — إيصال: ' . $result['receipt_number']);
}
public function submitAppeal(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ?", [(int) $id]);
if (!$fine) return $this->redirect('/fines')->withError('الغرامة غير موجودة');
$appealWindowData = RuleEngine::get('APPEAL_WINDOW');
$appealDays = $appealWindowData['days'] ?? 15;
$imposedAt = strtotime($fine['created_at']);
$daysSinceImposed = (time() - $imposedAt) / 86400;
if ($daysSinceImposed > $appealDays) {
return $this->redirect('/fines')->withError("انتهت مهلة التظلم ({$appealDays} يوم)");
}
$appealNotes = trim((string) $request->post('appeal_notes', ''));
if ($appealNotes === '') return $this->redirect('/fines')->withError('يجب إدخال أسباب التظلم');
$db->update('fines', [
'appeal_submitted' => 1,
'appeal_date' => date('Y-m-d'),
'appeal_notes' => $appealNotes,
'status' => 'appealed',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('fine.appealed', ['fine_id' => (int) $id, 'member_id' => (int) $fine['member_id']]);
return $this->redirect('/fines')->withSuccess('تم تقديم التظلم');
}
public function decideAppeal(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ? AND status = 'appealed'", [(int) $id]);
if (!$fine) return $this->redirect('/fines')->withError('التظلم غير موجود');
$decision = trim($request->post('appeal_decision', ''));
$validDecisions = ['upheld', 'modified', 'cancelled'];
if (!in_array($decision, $validDecisions)) return $this->redirect('/fines')->withError('قرار غير صالح');
$employee = App::getInstance()->currentEmployee();
$newStatus = match ($decision) {
'upheld' => 'appeal_upheld',
'modified' => 'appeal_modified',
'cancelled' => 'cancelled',
};
$updateData = [
'appeal_decision' => $decision,
'appeal_decided_by' => $employee ? (int) $employee->id : null,
'appeal_decided_at' => date('Y-m-d H:i:s'),
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($decision === 'modified') {
$newAmount = trim($request->post('new_amount', $fine['amount']));
if (is_numeric($newAmount)) $updateData['amount'] = $newAmount;
}
$db->update('fines', $updateData, '`id` = ?', [(int) $id]);
return $this->redirect('/fines')->withSuccess('تم البت في التظلم: ' . match($decision) { 'upheld' => 'تأييد العقوبة', 'modified' => 'تعديل العقوبة', 'cancelled' => 'إلغاء العقوبة' });
}
public function waive(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ?", [(int) $id]);
if (!$fine) return $this->redirect('/fines')->withError('الغرامة غير موجودة');
$reason = trim((string) $request->post('waive_reason', ''));
if ($reason === '') return $this->redirect('/fines')->withError('يجب إدخال سبب الإعفاء');
$db->update('fines', [
'status' => 'waived',
'notes' => ($fine['notes'] ? $fine['notes'] . "\n" : '') . 'إعفاء: ' . $reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/fines')->withSuccess('تم الإعفاء من الغرامة');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Fines\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Fines\Models\Violation;
class ViolationController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'status' => $request->get('status', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Violation::search($filters, 25, $page);
return $this->view('Fines.Views.violations.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(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('العضو غير موجود');
return $this->view('Fines.Views.violations.create', ['member' => $member]);
}
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('العضو غير موجود');
$description = trim((string) $request->post('description', ''));
$violationDate = $request->post('violation_date', date('Y-m-d'));
$evidence = trim((string) $request->post('evidence_notes', ''));
if ($description === '') return $this->redirect("/violations/create/{$memberId}")->withError('وصف المخالفة مطلوب');
$employee = App::getInstance()->currentEmployee();
$violation = Violation::create([
'member_id' => (int) $memberId,
'violation_date' => $violationDate,
'description' => $description,
'reported_by' => $employee ? (int) $employee->id : null,
'evidence_notes' => $evidence ?: null,
'status' => 'reported',
]);
EventBus::dispatch('violation.reported', ['violation_id' => (int) $violation->id, 'member_id' => (int) $memberId]);
return $this->redirect('/violations')->withSuccess('تم تسجيل المخالفة');
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Fines\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Fine extends Model
{
protected static string $table = 'fines';
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', 'violation_id', 'fine_type', 'amount', 'penalty_type',
'suspension_from', 'suspension_to', 'appeal_submitted', 'appeal_date',
'appeal_notes', 'appeal_decision', 'appeal_decided_by', 'appeal_decided_at',
'status', 'paid_amount', 'payment_id', 'paid_at', 'notes',
];
public static function getForMember(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT f.*, v.description as violation_desc FROM fines f LEFT JOIN violations v ON v.id = f.violation_id WHERE f.member_id = ? ORDER BY f.created_at DESC",
[$memberId]
);
}
public static function getUnpaidForMember(int $memberId): string
{
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT COALESCE(SUM(amount - paid_amount), 0) as total FROM fines WHERE member_id = ? AND status IN ('imposed','appeal_upheld')", [$memberId]);
return $row['total'] ?? '0.00';
}
public static function isSuspended(int $memberId): bool
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COUNT(*) as cnt FROM fines WHERE member_id = ? AND penalty_type IN ('suspension','ban') AND status IN ('imposed','appeal_upheld') AND suspension_from <= CURDATE() AND (suspension_to IS NULL OR suspension_to >= CURDATE())",
[$memberId]
);
return (int) ($row['cnt'] ?? 0) > 0;
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) { $where .= ' AND f.status = ?'; $params[] = $filters['status']; }
if (!empty($filters['penalty_type'])) { $where .= ' AND f.penalty_type = ?'; $params[] = $filters['penalty_type']; }
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR m.membership_number LIKE ?)';
$s = '%' . $filters['search'] . '%'; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM fines f JOIN members m ON m.id = f.member_id WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT f.*, m.full_name_ar as member_name, m.membership_number, v.description as violation_desc
FROM fines f JOIN members m ON m.id = f.member_id LEFT JOIN violations v ON v.id = f.violation_id
WHERE {$where} ORDER BY f.created_at DESC LIMIT {$perPage} OFFSET {$offset}", $params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function getPenaltyTypeLabel(string $type): string
{
return match ($type) {
'warning' => 'لفت نظر',
'caution' => 'إنذار',
'fine' => 'غرامة مالية',
'suspension' => 'إيقاف نشاط',
'ban' => 'منع دخول',
'termination' => 'إنهاء عضوية',
default => $type,
};
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Fines\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Violation extends Model
{
protected static string $table = 'violations';
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', 'violation_date', 'description', 'reported_by', 'evidence_notes', 'status',
];
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) { $where .= ' AND v.status = ?'; $params[] = $filters['status']; }
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR v.description LIKE ?)';
$s = '%' . $filters['search'] . '%'; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM violations v JOIN members m ON m.id = v.member_id WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT v.*, m.full_name_ar as member_name, m.membership_number, e.full_name_ar as reported_by_name
FROM violations v JOIN members m ON m.id = v.member_id LEFT JOIN employees e ON e.id = v.reported_by
WHERE {$where} ORDER BY v.violation_date DESC LIMIT {$perPage} OFFSET {$offset}", $params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/violations', 'Fines\Controllers\ViolationController@index', ['auth'], 'fine.view'],
['GET', '/violations/create/{memberId}', 'Fines\Controllers\ViolationController@create', ['auth'], 'fine.impose'],
['POST', '/violations/store/{memberId}', 'Fines\Controllers\ViolationController@store', ['auth'], 'fine.impose'],
['GET', '/fines', 'Fines\Controllers\FineController@index', ['auth'], 'fine.view'],
['POST', '/fines/impose/{violationId}', 'Fines\Controllers\FineController@impose', ['auth'], 'fine.impose'],
['POST', '/fines/{id}/pay', 'Fines\Controllers\FineController@pay', ['auth'], 'fine.collect'],
['POST', '/fines/{id}/appeal', 'Fines\Controllers\FineController@submitAppeal', ['auth'], 'fine.view'],
['POST', '/fines/{id}/appeal-decide', 'Fines\Controllers\FineController@decideAppeal', ['auth'], 'fine.impose'],
['POST', '/fines/{id}/waive', 'Fines\Controllers\FineController@waive', ['auth'], 'fine.waive'],
];
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقديم تظلم<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:20px;">
<p>صفحة التظلم — يتم التعامل معها عبر النماذج المضمنة في القائمة</p>
<a href="/fines" class="btn btn-outline">← العودة للغرامات</a>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الغرامات والعقوبات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/fines" 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'] ?? '') ?>" class="form-input" style="min-width:200px;"></div>
<div><label class="form-label" style="font-size:12px;">الحالة</label><select name="status" class="form-select"><option value="">الكل</option><option value="imposed" <?= ($filters['status'] ?? '') === 'imposed' ? 'selected' : '' ?>>مفروضة</option><option value="paid" <?= ($filters['status'] ?? '') === 'paid' ? 'selected' : '' ?>>مدفوعة</option><option value="appealed" <?= ($filters['status'] ?? '') === 'appealed' ? 'selected' : '' ?>>متظلم</option><option value="cancelled" <?= ($filters['status'] ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغاة</option><option value="waived" <?= ($filters['status'] ?? '') === 'waived' ? 'selected' : '' ?>>معفاة</option></select></div>
<div><label class="form-label" style="font-size:12px;">نوع العقوبة</label><select name="penalty_type" class="form-select"><option value="">الكل</option><option value="warning">لفت نظر</option><option value="caution">إنذار</option><option value="fine">غرامة</option><option value="suspension">إيقاف</option><option value="ban">منع دخول</option><option value="termination">إنهاء</option></select></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></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '') ?></a></td>
<td style="font-weight:600;color:<?= in_array($r['penalty_type'], ['suspension','ban','termination']) ? '#DC2626' : '#D97706' ?>;"><?= e(\App\Modules\Fines\Models\Fine::getPenaltyTypeLabel($r['penalty_type'])) ?></td>
<td><?= bccomp($r['amount'] ?? '0', '0', 2) > 0 ? money($r['amount']) : '—' ?></td>
<td style="font-size:12px;"><?= ($r['suspension_from'] && $r['suspension_to']) ? e($r['suspension_from']) . ' → ' . e($r['suspension_to']) : '—' ?></td>
<td><span style="color:<?= match($r['status']) { 'imposed' => '#DC2626', 'paid' => '#059669', 'appealed' => '#D97706', 'cancelled' => '#6B7280', 'waived' => '#0284C7', default => '#6B7280' } ?>;font-weight:600;"><?= match($r['status']) { 'imposed' => 'مفروضة', 'paid' => 'مدفوعة', 'appealed' => 'متظلم', 'appeal_upheld' => 'تأييد', 'appeal_modified' => 'تعديل', 'cancelled' => 'ملغاة', 'waived' => 'معفاة', default => $r['status'] } ?></span></td>
<td>
<div style="display:flex;gap:5px;flex-wrap:wrap;">
<?php if ($r['status'] === 'imposed' && $r['penalty_type'] === 'fine'): ?>
<form method="POST" action="/fines/<?= (int) $r['id'] ?>/pay" style="display:inline;"><?= csrf_field() ?><input type="hidden" name="payment_method" value="cash"><button type="submit" class="btn btn-sm btn-primary">دفع</button></form>
<?php endif; ?>
<?php if (in_array($r['status'], ['imposed']) && !$r['appeal_submitted']): ?>
<form method="POST" action="/fines/<?= (int) $r['id'] ?>/appeal" style="display:inline;"><?= csrf_field() ?><input type="hidden" name="appeal_notes" value="تظلم"><button type="submit" class="btn btn-sm btn-outline">تظلم</button></form>
<?php endif; ?>
<?php if ($r['status'] === 'appealed'): ?>
<form method="POST" action="/fines/<?= (int) $r['id'] ?>/appeal-decide" style="display:flex;gap:3px;"><?= csrf_field() ?>
<button type="submit" name="appeal_decision" value="upheld" class="btn btn-sm" style="background:#DC2626;color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:11px;">تأييد</button>
<button type="submit" name="appeal_decision" value="cancelled" class="btn btn-sm" style="background:#059669;color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:11px;">إلغاء</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><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'); ?>تسجيل مخالفة — <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/violations/store/<?= (int) $member['id'] ?>">
<?= csrf_field() ?>
<div class="card" style="padding:20px;">
<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><input type="date" name="violation_date" value="<?= e(date('Y-m-d')) ?>" class="form-input" required></div>
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">وصف المخالفة <span style="color:#DC2626;">*</span></label><textarea name="description" class="form-textarea" rows="4" required placeholder="وصف تفصيلي للمخالفة..."></textarea></div>
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">أدلة / ملاحظات</label><textarea name="evidence_notes" class="form-textarea" rows="2"></textarea></div>
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:15px;">تسجيل المخالفة</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline" style="margin-top:15px;">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المخالفات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/violations" style="display:flex;gap:10px;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" class="form-input" style="min-width:200px;"></div>
<div><label class="form-label" style="font-size:12px;">الحالة</label><select name="status" class="form-select"><option value="">الكل</option><option value="reported" <?= ($filters['status'] ?? '') === 'reported' ? 'selected' : '' ?>>مُبلّغ</option><option value="penalty_imposed" <?= ($filters['status'] ?? '') === 'penalty_imposed' ? 'selected' : '' ?>>تم فرض عقوبة</option></select></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></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td style="font-size:13px;"><?= e($r['violation_date']) ?></td>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '') ?></a></td>
<td style="font-size:13px;max-width:300px;overflow:hidden;text-overflow:ellipsis;"><?= e(mb_substr($r['description'], 0, 100)) ?></td>
<td style="font-size:13px;"><?= e($r['reported_by_name'] ?? '—') ?></td>
<td><span style="color:<?= $r['status'] === 'reported' ? '#D97706' : '#059669' ?>;font-weight:600;"><?= $r['status'] === 'reported' ? 'مُبلّغ' : 'تم فرض عقوبة' ?></span></td>
<td>
<?php if ($r['status'] === 'reported'): ?>
<form method="POST" action="/fines/impose/<?= (int) $r['id'] ?>" style="display:flex;gap:5px;align-items:center;">
<?= csrf_field() ?>
<select name="penalty_type" class="form-select" style="width:auto;font-size:12px;" required><option value="">عقوبة</option><option value="warning">لفت نظر</option><option value="caution">إنذار</option><option value="fine">غرامة</option><option value="suspension">إيقاف</option><option value="ban">منع دخول</option><option value="termination">إنهاء عضوية</option></select>
<input type="number" name="amount" placeholder="المبلغ" class="form-input" style="width:80px;font-size:12px;" step="0.01">
<button type="submit" class="btn btn-sm" style="background:#DC2626;color:#fff;border:none;padding:5px 10px;border-radius:4px;cursor:pointer;">فرض</button>
</form>
<?php else: ?><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><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
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('fines', [
'label_ar' => 'المخالفات والغرامات',
'label_en' => 'Violations & Fines',
'icon' => 'alert',
'route' => '/violations',
'permission' => 'fine.view',
'parent' => null,
'order' => 530,
'children' => [
['label_ar' => 'المخالفات', 'label_en' => 'Violations', 'route' => '/violations', 'permission' => 'fine.view', 'order' => 1],
['label_ar' => 'الغرامات', 'label_en' => 'Fines', 'route' => '/fines', 'permission' => 'fine.view', 'order' => 2],
],
]);
PermissionRegistry::register('fines', [
'fine.view' => ['ar' => 'عرض الغرامات', 'en' => 'View Fines'],
'fine.impose' => ['ar' => 'فرض غرامة', 'en' => 'Impose Fine'],
'fine.collect' => ['ar' => 'تحصيل غرامة', 'en' => 'Collect Fine'],
'fine.waive' => ['ar' => 'إعفاء من غرامة', 'en' => 'Waive Fine'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Installments\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Installments\Models\InstallmentPlan;
use App\Modules\Installments\Models\InstallmentScheduleItem;
use App\Modules\Installments\Services\InstallmentCalculator;
use App\Modules\Payments\Services\PaymentService;
class InstallmentController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'status' => $request->get('status', ''),
'overdue_only'=> $request->get('overdue_only', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = InstallmentPlan::search($filters, 25, $page);
return $this->view('Installments.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(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('العضو غير موجود');
$existingActive = InstallmentPlan::getActiveForMember((int) $memberId);
return $this->view('Installments.Views.create', [
'member' => $member,
'existingActive' => $existingActive,
]);
}
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('العضو غير موجود');
$totalAmount = trim($request->post('total_amount', '0'));
$downPayment = trim($request->post('down_payment', '0'));
$months = (int) $request->post('number_of_months', 0);
$startDate = $request->post('start_date', date('Y-m-d'));
$downPaymentReceipt = trim($request->post('down_payment_receipt', ''));
$notes = trim($request->post('notes', ''));
if (!is_numeric($totalAmount) || !is_numeric($downPayment)) {
return $this->redirect("/installments/create/{$memberId}")->withError('المبالغ غير صالحة');
}
$calc = InstallmentCalculator::calculate($totalAmount, $downPayment, $months, $startDate);
if (!$calc['success']) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $calc['errors']));
return $this->redirect("/installments/create/{$memberId}");
}
$employee = App::getInstance()->currentEmployee();
$db->beginTransaction();
try {
$planId = $db->insert('installment_plans', [
'member_id' => (int) $memberId,
'related_entity_type' => $request->post('related_entity_type') ?: null,
'related_entity_id' => $request->post('related_entity_id') ? (int) $request->post('related_entity_id') : null,
'total_amount' => $calc['total_amount'],
'down_payment' => $calc['down_payment'],
'remaining_balance' => $calc['remaining_balance'],
'interest_rate' => $calc['interest_rate'],
'total_interest' => $calc['total_interest'],
'total_with_interest' => $calc['total_with_interest'],
'number_of_months' => $calc['number_of_months'],
'monthly_payment' => $calc['monthly_payment'],
'start_date' => $calc['start_date'],
'down_payment_receipt' => $downPaymentReceipt ?: null,
'cash_settlement_date' => $calc['cash_settlement_date'],
'is_cash_settled' => 0,
'status' => 'active',
'notes' => $notes ?: null,
'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,
]);
foreach ($calc['schedule'] as $item) {
$db->insert('installment_schedule', [
'installment_plan_id' => $planId,
'installment_number' => $item['number'],
'due_date' => $item['due_date'],
'amount' => $item['amount'],
'principal' => $item['principal'],
'interest' => $item['interest'],
'remaining_after' => $item['remaining_after'],
'paid_amount' => '0.00',
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
$db->commit();
EventBus::dispatch('installment.created', [
'plan_id' => $planId,
'member_id' => (int) $memberId,
'total' => $calc['total_with_interest'],
'months' => $months,
]);
return $this->redirect("/installments/{$planId}")->withSuccess('تم إنشاء خطة التقسيط — ' . $months . ' شهر');
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect("/installments/create/{$memberId}")->withError('فشل في إنشاء الخطة: ' . $e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT p.*, m.full_name_ar as member_name, m.membership_number
FROM installment_plans p JOIN members m ON m.id = p.member_id
WHERE p.id = ?",
[(int) $id]
);
if (!$plan) return $this->redirect('/installments')->withError('خطة التقسيط غير موجودة');
$schedule = InstallmentScheduleItem::getForPlan((int) $id);
$earlyPayoff = null;
if ($plan['status'] === 'active') {
$earlyPayoff = InstallmentCalculator::calculateEarlyPayoff((int) $id);
}
$paymentMethods = $db->select("SELECT * FROM payment_methods WHERE is_active = 1 ORDER BY sort_order");
return $this->view('Installments.Views.show', [
'plan' => $plan,
'schedule' => $schedule,
'earlyPayoff' => $earlyPayoff,
'paymentMethods' => $paymentMethods,
]);
}
public function recordPayment(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$plan = $db->selectOne("SELECT * FROM installment_plans WHERE id = ? AND status = 'active'", [(int) $id]);
if (!$plan) return $this->redirect('/installments')->withError('خطة التقسيط غير موجودة أو غير نشطة');
$nextItem = InstallmentScheduleItem::getNextPending((int) $id);
if (!$nextItem) return $this->redirect("/installments/{$id}")->withError('لا توجد أقساط متبقية');
$data = $request->all();
$data['member_id'] = (int) $plan['member_id'];
$data['amount'] = $nextItem['amount'];
$data['payment_type'] = 'installment';
$data['payment_method'] = $data['payment_method'] ?? 'cash';
$data['related_entity_type'] = 'installment_schedule';
$data['related_entity_id'] = (int) $nextItem['id'];
$data['description'] = 'قسط رقم ' . $nextItem['installment_number'] . ' — خطة #' . $id;
$result = PaymentService::processPayment($data);
if (!$result['success']) {
return $this->redirect("/installments/{$id}")->withError($result['error']);
}
// Mark schedule item as paid
$db->update('installment_schedule', [
'paid_amount' => $nextItem['amount'],
'payment_id' => $result['payment_id'],
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $nextItem['id']]);
// Check if plan is fully paid
$remaining = InstallmentScheduleItem::getNextPending((int) $id);
if (!$remaining) {
$db->update('installment_plans', [
'status' => 'completed',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('installment.completed', ['plan_id' => (int) $id, 'member_id' => (int) $plan['member_id']]);
}
EventBus::dispatch('installment.paid', [
'plan_id' => (int) $id,
'member_id' => (int) $plan['member_id'],
'item_number' => (int) $nextItem['installment_number'],
'amount' => $nextItem['amount'],
'receipt_number'=> $result['receipt_number'],
]);
return $this->redirect("/installments/{$id}")->withSuccess('تم تسجيل القسط رقم ' . $nextItem['installment_number'] . ' — إيصال: ' . $result['receipt_number']);
}
public function earlyPayoff(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$plan = $db->selectOne("SELECT * FROM installment_plans WHERE id = ? AND status = 'active'", [(int) $id]);
if (!$plan) return $this->redirect('/installments')->withError('خطة التقسيط غير موجودة أو غير نشطة');
$payoff = InstallmentCalculator::calculateEarlyPayoff((int) $id);
// Check if within cash settlement window
$isCashSettlement = false;
if ($plan['cash_settlement_date'] && date('Y-m-d') <= $plan['cash_settlement_date']) {
$isCashSettlement = true;
}
$payoffAmount = $isCashSettlement ? $payoff['remaining_principal'] : $payoff['total_early_payoff'];
$data = $request->all();
$data['member_id'] = (int) $plan['member_id'];
$data['amount'] = $payoffAmount;
$data['payment_type'] = 'installment';
$data['payment_method'] = $data['payment_method'] ?? 'cash';
$data['description'] = 'سداد مبكر — خطة #' . $id . ($isCashSettlement ? ' (بدون فائدة — تسوية نقدية)' : '');
$result = PaymentService::processPayment($data);
if (!$result['success']) {
return $this->redirect("/installments/{$id}")->withError($result['error']);
}
// Mark all remaining items as paid
$db->update('installment_schedule', [
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`installment_plan_id` = ? AND `status` IN (\'pending\',\'overdue\')', [(int) $id]);
$db->update('installment_plans', [
'status' => 'completed',
'is_cash_settled' => $isCashSettlement ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('installment.early_payoff', [
'plan_id' => (int) $id,
'member_id' => (int) $plan['member_id'],
'amount' => $payoffAmount,
'is_cash_settled' => $isCashSettlement,
'interest_saved' => $payoff['interest_saved'],
]);
$msg = $isCashSettlement
? 'تم التسوية النقدية بدون فائدة — إيصال: ' . $result['receipt_number']
: 'تم السداد المبكر — وفر ' . money($payoff['interest_saved']) . ' فائدة — إيصال: ' . $result['receipt_number'];
return $this->redirect("/installments/{$id}")->withSuccess($msg);
}
public function memberPlans(Request $request, string $memberId): Response
{
$plans = InstallmentPlan::getForMember((int) $memberId);
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
return $this->view('Installments.Views.index', [
'rows' => array_map(fn($p) => array_merge($p, ['member_name' => $member['full_name_ar'] ?? '', 'membership_number' => $member['membership_number'] ?? '']), $plans),
'pagination' => ['last_page' => 1, 'current_page' => 1],
'filters' => ['search' => '', 'status' => '', 'overdue_only' => ''],
'member' => $member,
]);
}
public function calculate(Request $request): Response
{
$totalAmount = trim($request->post('total_amount', '0'));
$downPayment = trim($request->post('down_payment', '0'));
$months = (int) $request->post('number_of_months', 0);
$startDate = $request->post('start_date', date('Y-m-d'));
$calc = InstallmentCalculator::calculate($totalAmount, $downPayment, $months, $startDate);
return $this->json($calc);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Installments\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class InstallmentPlan extends Model
{
protected static string $table = 'installment_plans';
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', 'related_entity_type', 'related_entity_id',
'total_amount', 'down_payment', 'remaining_balance',
'interest_rate', 'total_interest', 'total_with_interest',
'number_of_months', 'monthly_payment', 'start_date',
'down_payment_receipt', 'cash_settlement_date', 'is_cash_settled',
'status', 'notes',
];
public static function getForMember(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM installment_plans WHERE member_id = ? ORDER BY created_at DESC",
[$memberId]
);
}
public static function getActiveForMember(int $memberId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM installment_plans WHERE member_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
[$memberId]
);
}
public static function hasOverdue(int $memberId): bool
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COUNT(*) as cnt FROM installment_schedule s
JOIN installment_plans p ON p.id = s.installment_plan_id
WHERE p.member_id = ? AND p.status = 'active' AND s.status = 'pending' AND s.due_date < CURDATE()",
[$memberId]
);
return (int) ($row['cnt'] ?? 0) > 0;
}
public static function getOverdueCount(int $memberId): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COUNT(*) as cnt FROM installment_schedule s
JOIN installment_plans p ON p.id = s.installment_plan_id
WHERE p.member_id = ? AND p.status = 'active' AND s.status = 'pending' AND s.due_date < CURDATE()",
[$memberId]
);
return (int) ($row['cnt'] ?? 0);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND p.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['member_id'])) {
$where .= ' AND p.member_id = ?';
$params[] = (int) $filters['member_id'];
}
if (!empty($filters['overdue_only'])) {
$where .= ' AND EXISTS (SELECT 1 FROM installment_schedule s WHERE s.installment_plan_id = p.id AND s.status = \'pending\' AND s.due_date < CURDATE())';
}
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR m.membership_number LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s;
$params[] = $s;
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM installment_plans p JOIN members m ON m.id = p.member_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,
(SELECT COUNT(*) FROM installment_schedule s WHERE s.installment_plan_id = p.id AND s.status = 'paid') as paid_count,
(SELECT COUNT(*) FROM installment_schedule s WHERE s.installment_plan_id = p.id AND s.status = 'pending' AND s.due_date < CURDATE()) as overdue_count,
(SELECT COALESCE(SUM(s.paid_amount),0) FROM installment_schedule s WHERE s.installment_plan_id = p.id) as total_paid_installments
FROM installment_plans p
JOIN members m ON m.id = p.member_id
WHERE {$where}
ORDER BY p.created_at DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Installments\Models;
use App\Core\Model;
use App\Core\App;
class InstallmentScheduleItem extends Model
{
protected static string $table = 'installment_schedule';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static array $fillable = [
'installment_plan_id', 'installment_number', 'due_date',
'amount', 'principal', 'interest', 'remaining_after',
'paid_amount', 'payment_id', 'status', 'paid_at',
];
public static function getForPlan(int $planId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT s.*, py.receipt_id, r.receipt_number
FROM installment_schedule s
LEFT JOIN payments py ON py.id = s.payment_id
LEFT JOIN receipts r ON r.id = py.receipt_id
WHERE s.installment_plan_id = ?
ORDER BY s.installment_number ASC",
[$planId]
);
}
public static function getNextPending(int $planId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM installment_schedule WHERE installment_plan_id = ? AND status = 'pending' ORDER BY installment_number ASC LIMIT 1",
[$planId]
);
}
public static function getRemainingBalance(int $planId): string
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(amount - paid_amount), 0) as remaining FROM installment_schedule WHERE installment_plan_id = ? AND status IN ('pending','overdue')",
[$planId]
);
return $row['remaining'] ?? '0.00';
}
public static function getOverdueForPlan(int $planId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM installment_schedule WHERE installment_plan_id = ? AND status = 'pending' AND due_date < CURDATE() ORDER BY installment_number ASC",
[$planId]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/installments', 'Installments\Controllers\InstallmentController@index', ['auth'], 'installment.view'],
['GET', '/installments/create/{memberId}', 'Installments\Controllers\InstallmentController@create', ['auth'], 'installment.create_plan'],
['POST', '/installments/store/{memberId}', 'Installments\Controllers\InstallmentController@store', ['auth'], 'installment.create_plan'],
['GET', '/installments/{id}', 'Installments\Controllers\InstallmentController@show', ['auth'], 'installment.view'],
['POST', '/installments/{id}/pay', 'Installments\Controllers\InstallmentController@recordPayment', ['auth'], 'installment.record_payment'],
['POST', '/installments/{id}/early-payoff', 'Installments\Controllers\InstallmentController@earlyPayoff', ['auth'], 'installment.modify_plan'],
['POST', '/api/installments/calculate', 'Installments\Controllers\InstallmentController@calculate', ['auth'], 'installment.create_plan'],
['GET', '/members/{memberId}/installments', 'Installments\Controllers\InstallmentController@memberPlans', ['auth'], 'installment.view'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Installments\Services;
use App\Modules\Rules\Services\RuleEngine;
final class InstallmentCalculator
{
/**
* Calculate a full installment plan with schedule.
*/
public static function calculate(string $totalAmount, string $downPayment, int $months, ?string $startDate = null): array
{
$interestData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$annualRate = $interestData['percentage'] ?? '22.00';
$minDownData = RuleEngine::get('INSTALLMENT_MIN_DOWN_PAYMENT');
$minDownPct = $minDownData['percentage'] ?? '25.00';
$maxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
$maxMonths = $maxMonthsData['months'] ?? 30;
$errors = [];
// Validate down payment >= 25%
$minDown = bcdiv(bcmul($totalAmount, $minDownPct, 4), '100', 2);
if (bccomp($downPayment, $minDown, 2) < 0) {
$errors[] = "الحد الأدنى للمقدم {$minDownPct}% = " . number_format((float) $minDown, 2) . ' ج.م';
}
if (bccomp($downPayment, $totalAmount, 2) >= 0) {
$errors[] = 'المقدم لا يمكن أن يكون أكبر من أو يساوي المبلغ الكلي';
}
if ($months < 1 || $months > $maxMonths) {
$errors[] = "عدد الأقساط يجب أن يكون بين 1 و {$maxMonths} شهر";
}
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
$remaining = bcsub($totalAmount, $downPayment, 2);
$startDate = $startDate ?: date('Y-m-d');
// Calculate interest on remaining balance
// Simple interest: total_interest = remaining * (annual_rate/100) * (months/12)
$monthlyRate = bcdiv($annualRate, '1200', 10); // annual / 12 / 100
$totalInterest = '0.00';
// Generate schedule with diminishing balance interest
$schedule = [];
$monthlyPrincipal = bcdiv($remaining, (string) $months, 2);
$runningBalance = $remaining;
for ($i = 1; $i <= $months; $i++) {
$interestForMonth = bcmul($runningBalance, $monthlyRate, 2);
$totalInterest = bcadd($totalInterest, $interestForMonth, 2);
$monthlyTotal = bcadd($monthlyPrincipal, $interestForMonth, 2);
// Last month: adjust for rounding
if ($i === $months) {
$monthlyPrincipal = $runningBalance;
$monthlyTotal = bcadd($monthlyPrincipal, $interestForMonth, 2);
}
$runningBalance = bcsub($runningBalance, $monthlyPrincipal, 2);
if (bccomp($runningBalance, '0', 2) < 0) {
$runningBalance = '0.00';
}
$dueDate = date('Y-m-d', strtotime($startDate . " +{$i} months"));
$schedule[] = [
'number' => $i,
'due_date' => $dueDate,
'amount' => $monthlyTotal,
'principal' => $monthlyPrincipal,
'interest' => $interestForMonth,
'remaining_after' => $runningBalance,
];
}
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$avgMonthly = $months > 0 ? bcdiv($totalWithInterest, (string) $months, 2) : '0.00';
// Cash settlement date (30 days from start)
$cashWindowData = RuleEngine::get('CASH_PAYMENT_WINDOW');
$cashDays = $cashWindowData['days'] ?? 30;
$cashSettlementDate = date('Y-m-d', strtotime($startDate . " +{$cashDays} days"));
return [
'success' => true,
'total_amount' => $totalAmount,
'down_payment' => $downPayment,
'remaining_balance' => $remaining,
'interest_rate' => $annualRate,
'total_interest' => $totalInterest,
'total_with_interest' => $totalWithInterest,
'number_of_months' => $months,
'monthly_payment' => $avgMonthly,
'start_date' => $startDate,
'cash_settlement_date'=> $cashSettlementDate,
'cash_days' => $cashDays,
'schedule' => $schedule,
];
}
/**
* Calculate early payoff: remaining principal with zero further interest.
*/
public static function calculateEarlyPayoff(int $planId): array
{
$remainingPrincipal = '0.00';
$db = \App\Core\App::getInstance()->db();
$items = $db->select(
"SELECT * FROM installment_schedule WHERE installment_plan_id = ? AND status IN ('pending','overdue') ORDER BY installment_number ASC",
[$planId]
);
foreach ($items as $item) {
$remainingPrincipal = bcadd($remainingPrincipal, $item['principal'], 2);
}
// Only first pending item's interest is due (current month)
$currentInterest = '0.00';
if (!empty($items)) {
$currentInterest = $items[0]['interest'] ?? '0.00';
}
$totalDue = bcadd($remainingPrincipal, $currentInterest, 2);
return [
'remaining_principal' => $remainingPrincipal,
'current_interest' => $currentInterest,
'total_early_payoff' => $totalDue,
'items_remaining' => count($items),
'interest_saved' => bcsub(
array_reduce($items, fn($carry, $i) => bcadd($carry, $i['interest'], 2), '0.00'),
$currentInterest,
2
),
];
}
}
\ 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'); ?>
<?php if ($existingActive): ?>
<div style="padding:15px;background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;margin-bottom:20px;color:#DC2626;">
<strong>⚠ يوجد خطة تقسيط نشطة بالفعل</strong> — خطة #<?= (int) $existingActive['id'] ?> بمبلغ <?= money($existingActive['total_with_interest']) ?>
<a href="/installments/<?= (int) $existingActive['id'] ?>" style="color:#0D7377;margin-right:10px;">عرض الخطة</a>
</div>
<?php endif; ?>
<form method="POST" action="/installments/store/<?= (int) $member['id'] ?>">
<?= csrf_field() ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="margin-bottom:15px;padding:10px;background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;">
<strong>العضو:</strong> <?= e($member['full_name_ar']) ?><strong>قيمة العضوية:</strong> <?= $member['membership_value'] ? money($member['membership_value']) : '—' ?>
</div>
<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><input type="number" name="total_amount" value="<?= e($member['membership_value'] ?? '') ?>" class="form-input" step="0.01" required style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">المقدم (≥25%) <span style="color:#DC2626;">*</span></label><input type="number" name="down_payment" class="form-input" step="0.01" required style="direction:ltr;text-align:left;" placeholder="الحد الأدنى 25%"></div>
<div class="form-group"><label class="form-label">عدد الأشهر (≤30) <span style="color:#DC2626;">*</span></label><input type="number" name="number_of_months" class="form-input" min="1" max="30" required></div>
<div class="form-group"><label class="form-label">تاريخ البداية</label><input type="date" name="start_date" value="<?= e(date('Y-m-d')) ?>" class="form-input"></div>
<div class="form-group"><label class="form-label">رقم إيصال المقدم</label><input type="text" name="down_payment_receipt" class="form-input"></div>
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">ملاحظات</label><textarea name="notes" class="form-textarea" rows="2"></textarea></div>
</div>
</div>
<button type="submit" class="btn btn-primary">إنشاء خطة التقسيط</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة الأقساط<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/installments" 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="status" class="form-select"><option value="">الكل</option><option value="active" <?= ($filters['status'] ?? '') === 'active' ? 'selected' : '' ?>>نشط</option><option value="completed" <?= ($filters['status'] ?? '') === 'completed' ? 'selected' : '' ?>>مكتمل</option><option value="cancelled" <?= ($filters['status'] ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغى</option></select></div>
<div><label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer;margin-top:20px;"><input type="checkbox" name="overdue_only" value="1" <?= ($filters['overdue_only'] ?? '') ? 'checked' : '' ?>> متأخرة فقط</label></div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/installments" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</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><th>الحالة</th><th>الإجراءات</th></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><?= (int) $r['id'] ?></td>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '') ?></a><br><small style="color:#9CA3AF;"><?= e($r['membership_number'] ?? '') ?></small></td>
<td style="font-weight:600;"><?= money($r['total_with_interest']) ?></td>
<td><?= money($r['down_payment']) ?></td>
<td><?= (int) $r['number_of_months'] ?> شهر</td>
<td style="color:#059669;"><?= money($r['total_paid_installments'] ?? '0') ?></td>
<td><?php if (($r['overdue_count'] ?? 0) > 0): ?><span style="color:#DC2626;font-weight:600;"><?= (int) $r['overdue_count'] ?> قسط</span><?php else: ?><?php endif; ?></td>
<td><span style="color:<?= $r['status'] === 'active' ? '#059669' : ($r['status'] === 'completed' ? '#0284C7' : '#6B7280') ?>;font-weight:600;"><?= $r['status'] === 'active' ? 'نشط' : ($r['status'] === 'completed' ? 'مكتمل' : $r['status']) ?></span></td>
<td><a href="/installments/<?= (int) $r['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="9" 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'); ?>خطة تقسيط #<?= (int) $plan['id'] ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/members/<?= (int) $plan['member_id'] ?>" class="btn btn-outline">← العودة للعضو</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<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;">
<tr><td style="padding:6px 0;color:#6B7280;width:40%;">العضو</td><td style="padding:6px 0;font-weight:600;"><a href="/members/<?= (int) $plan['member_id'] ?>" style="color:#0D7377;"><?= e($plan['member_name']) ?></a></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">المبلغ الكلي</td><td style="padding:6px 0;font-weight:700;"><?= money($plan['total_amount']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">المقدم</td><td style="padding:6px 0;"><?= money($plan['down_payment']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">المتبقي</td><td style="padding:6px 0;"><?= money($plan['remaining_balance']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">نسبة الفائدة</td><td style="padding:6px 0;"><?= e($plan['interest_rate']) ?>%</td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">إجمالي الفائدة</td><td style="padding:6px 0;"><?= money($plan['total_interest']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">الإجمالي مع الفائدة</td><td style="padding:6px 0;font-weight:700;color:#0D7377;"><?= money($plan['total_with_interest']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">عدد الأشهر</td><td style="padding:6px 0;"><?= (int) $plan['number_of_months'] ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">القسط الشهري</td><td style="padding:6px 0;font-weight:600;"><?= money($plan['monthly_payment']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">الحالة</td><td style="padding:6px 0;font-weight:600;color:<?= $plan['status'] === 'active' ? '#059669' : '#0284C7' ?>;"><?= $plan['status'] === 'active' ? 'نشط' : ($plan['status'] === 'completed' ? 'مكتمل' : $plan['status']) ?></td></tr>
<?php if ($plan['is_cash_settled']): ?><tr><td style="padding:6px 0;color:#6B7280;">تسوية نقدية</td><td style="padding:6px 0;color:#059669;font-weight:600;">✓ بدون فائدة</td></tr><?php endif; ?>
</table>
</div>
<div>
<?php if ($earlyPayoff && $plan['status'] === 'active'): ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h4 style="color:#D97706;margin-bottom:15px;">السداد المبكر</h4>
<table style="width:100%;font-size:14px;">
<tr><td style="padding:6px 0;color:#6B7280;">أصل المتبقي</td><td style="padding:6px 0;"><?= money($earlyPayoff['remaining_principal']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">فائدة الشهر الحالي</td><td style="padding:6px 0;"><?= money($earlyPayoff['current_interest']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">إجمالي السداد المبكر</td><td style="padding:6px 0;font-weight:700;color:#0D7377;"><?= money($earlyPayoff['total_early_payoff']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">توفير الفائدة</td><td style="padding:6px 0;color:#059669;font-weight:600;"><?= money($earlyPayoff['interest_saved']) ?></td></tr>
</table>
<?php if ($plan['cash_settlement_date'] && date('Y-m-d') <= $plan['cash_settlement_date']): ?>
<div style="margin-top:10px;padding:10px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:6px;font-size:13px;color:#059669;">
<strong>✓ ضمن فترة التسوية النقدية</strong> — السداد حتى <?= e($plan['cash_settlement_date']) ?> بدون فائدة = <?= money($earlyPayoff['remaining_principal']) ?>
</div>
<?php endif; ?>
<form method="POST" action="/installments/<?= (int) $plan['id'] ?>/early-payoff" style="margin-top:15px;">
<?= csrf_field() ?>
<select name="payment_method" class="form-select" style="margin-bottom:10px;">
<?php foreach ($paymentMethods as $pm): ?><option value="<?= e($pm['code']) ?>"><?= e($pm['name_ar']) ?></option><?php endforeach; ?>
</select>
<button type="submit" class="btn btn-primary" onclick="return confirm('هل تريد السداد المبكر؟')">سداد مبكر</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
<div class="card">
<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>
</div>
<div class="table-responsive"><table class="data-table"><thead><tr><th>#</th><th>تاريخ الاستحقاق</th><th>المبلغ</th><th>أصل</th><th>فائدة</th><th>المتبقي</th><th>الحالة</th><th>إيصال</th><th>الإجراءات</th></tr></thead><tbody>
<?php foreach ($schedule as $s): ?>
<tr style="<?= $s['status'] === 'pending' && $s['due_date'] < date('Y-m-d') ? 'background:#FEF2F2;' : '' ?>">
<td><?= (int) $s['installment_number'] ?></td>
<td style="font-size:13px;"><?= e($s['due_date']) ?></td>
<td style="font-weight:600;"><?= money($s['amount']) ?></td>
<td style="font-size:13px;"><?= money($s['principal']) ?></td>
<td style="font-size:13px;color:#D97706;"><?= money($s['interest']) ?></td>
<td style="font-size:13px;"><?= money($s['remaining_after']) ?></td>
<td>
<?php if ($s['status'] === 'paid'): ?><span style="color:#059669;font-weight:600;">✓ مدفوع</span>
<?php elseif ($s['due_date'] < date('Y-m-d')): ?><span style="color:#DC2626;font-weight:600;">● متأخر</span>
<?php else: ?><span style="color:#6B7280;">○ معلق</span><?php endif; ?>
</td>
<td style="font-size:12px;"><?= e($s['receipt_number'] ?? '—') ?></td>
<td>
<?php if ($s['status'] !== 'paid' && $plan['status'] === 'active'): ?>
<form method="POST" action="/installments/<?= (int) $plan['id'] ?>/pay" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="payment_method" value="cash">
<button type="submit" class="btn btn-sm btn-primary" onclick="return confirm('تسجيل دفع القسط #<?= (int) $s['installment_number'] ?>؟')">دفع</button>
</form>
<?php else: ?><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody></table></div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('installments', [
'label_ar' => 'الأقساط',
'label_en' => 'Installments',
'icon' => 'calendar',
'route' => '/installments',
'permission' => 'installment.view',
'parent' => null,
'order' => 510,
'children' => [
['label_ar' => 'كل الخطط', 'label_en' => 'All Plans', 'route' => '/installments', 'permission' => 'installment.view', 'order' => 1],
],
]);
PermissionRegistry::register('installments', [
'installment.view' => ['ar' => 'عرض الأقساط', 'en' => 'View Installments'],
'installment.create_plan' => ['ar' => 'إنشاء خطة تقسيط', 'en' => 'Create Installment Plan'],
'installment.record_payment' => ['ar' => 'تسجيل دفعة قسط', 'en' => 'Record Installment Payment'],
'installment.modify_plan' => ['ar' => 'تعديل خطة التقسيط', 'en' => 'Modify Installment Plan'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Subscriptions\Models\Subscription;
use App\Modules\Subscriptions\Services\SubscriptionGenerator;
use App\Modules\Subscriptions\Services\SubscriptionCalculator;
use App\Modules\Payments\Services\PaymentService;
class SubscriptionController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'financial_year' => $request->get('financial_year', ''),
'status' => $request->get('status', ''),
'person_type' => $request->get('person_type', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Subscription::search($filters, 30, $page);
$currentYear = self::currentFinancialYear();
return $this->view('Subscriptions.Views.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'currentYear' => $currentYear,
]);
}
public function memberSubscriptions(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('العضو غير موجود');
$subscriptions = Subscription::getForMember((int) $memberId);
$unpaidYears = Subscription::getUnpaidYears((int) $memberId);
$lateFine = SubscriptionCalculator::calculateLateFine((int) $memberId, self::currentFinancialYear());
return $this->view('Subscriptions.Views.member-subscriptions', [
'member' => $member,
'subscriptions' => $subscriptions,
'unpaidYears' => $unpaidYears,
'lateFine' => $lateFine,
]);
}
public function pay(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$sub = $db->selectOne("SELECT s.*, m.full_name_ar as member_name FROM subscriptions s JOIN members m ON m.id = s.member_id WHERE s.id = ?", [(int) $id]);
if (!$sub) return $this->redirect('/subscriptions')->withError('الاشتراك غير موجود');
if ($sub['status'] === 'paid') return $this->redirect('/subscriptions')->withError('الاشتراك مدفوع بالفعل');
$amount = bcsub(bcadd($sub['total_amount'], $sub['fine_amount'], 2), $sub['paid_amount'], 2);
$data = $request->all();
$data['member_id'] = (int) $sub['member_id'];
$data['amount'] = $amount;
$data['payment_type'] = 'annual_subscription';
$data['payment_method'] = $data['payment_method'] ?? 'cash';
$data['related_entity_type'] = 'subscriptions';
$data['related_entity_id'] = (int) $id;
$data['description'] = 'اشتراك سنوي ' . $sub['financial_year'] . ' — ' . ($sub['person_name'] ?? $sub['member_name']);
$result = PaymentService::processPayment($data);
if (!$result['success']) {
return $this->redirect("/members/{$sub['member_id']}/subscriptions")->withError($result['error']);
}
$db->update('subscriptions', [
'paid_amount' => bcadd($sub['paid_amount'], $amount, 2),
'payment_id' => $result['payment_id'],
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
EventBus::dispatch('subscription.paid', [
'subscription_id' => (int) $id,
'member_id' => (int) $sub['member_id'],
'year' => $sub['financial_year'],
'amount' => $amount,
]);
return $this->redirect("/members/{$sub['member_id']}/subscriptions")->withSuccess('تم تسجيل الاشتراك — إيصال: ' . $result['receipt_number']);
}
public function exempt(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$sub = $db->selectOne("SELECT * FROM subscriptions WHERE id = ?", [(int) $id]);
if (!$sub) return $this->redirect('/subscriptions')->withError('الاشتراك غير موجود');
$reason = trim((string) $request->post('exemption_reason', ''));
if ($reason === '') return $this->redirect("/members/{$sub['member_id']}/subscriptions")->withError('يجب إدخال سبب الإعفاء');
$employee = App::getInstance()->currentEmployee();
$db->update('subscriptions', [
'status' => 'exempt',
'exempted_by' => $employee ? (int) $employee->id : null,
'exemption_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect("/members/{$sub['member_id']}/subscriptions")->withSuccess('تم إعفاء الاشتراك');
}
public function batchGenerateForm(Request $request): Response
{
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Subscriptions.Views.batch-generate', ['branches' => $branches, 'result' => null]);
}
public function batchGenerate(Request $request): Response
{
$year = trim((string) $request->post('financial_year', ''));
$branchId = $request->post('branch_id', '') !== '' ? (int) $request->post('branch_id') : null;
if ($year === '') return $this->redirect('/subscriptions/batch-generate')->withError('السنة المالية مطلوبة');
$result = SubscriptionGenerator::generateForYear($year, $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('Subscriptions.Views.batch-generate', ['branches' => $branches, 'result' => $result]);
}
private static function currentFinancialYear(): string
{
$month = (int) date('n');
$year = (int) date('Y');
if ($month >= 7) return $year . '/' . ($year + 1);
return ($year - 1) . '/' . $year;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Subscription extends Model
{
protected static string $table = 'subscriptions';
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', 'financial_year', 'person_type', 'person_id', 'person_name',
'base_amount', 'development_fee', 'discount_amount', 'total_amount',
'paid_amount', 'fine_amount', 'status', 'paid_at', 'payment_id',
'exempted_by', 'exemption_reason',
];
public static function getForMember(int $memberId, ?string $year = null): array
{
$db = App::getInstance()->db();
$where = 'member_id = ?';
$params = [$memberId];
if ($year) {
$where .= ' AND financial_year = ?';
$params[] = $year;
}
return $db->select("SELECT * FROM subscriptions WHERE {$where} ORDER BY financial_year DESC, person_type ASC", $params);
}
public static function getMemberYearTotal(int $memberId, string $year): array
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(total_amount),0) as total, COALESCE(SUM(paid_amount),0) as paid, COALESCE(SUM(fine_amount),0) as fines, COUNT(*) as count
FROM subscriptions WHERE member_id = ? AND financial_year = ?",
[$memberId, $year]
);
return $row ?: ['total' => '0.00', 'paid' => '0.00', 'fines' => '0.00', 'count' => 0];
}
public static function getUnpaidYears(int $memberId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT financial_year, SUM(total_amount) as total, SUM(paid_amount) as paid, SUM(fine_amount) as fines
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue')
GROUP BY financial_year ORDER BY financial_year ASC",
[$memberId]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['financial_year'])) { $where .= ' AND s.financial_year = ?'; $params[] = $filters['financial_year']; }
if (!empty($filters['status'])) { $where .= ' AND s.status = ?'; $params[] = $filters['status']; }
if (!empty($filters['person_type'])) { $where .= ' AND s.person_type = ?'; $params[] = $filters['person_type']; }
if (!empty($filters['search'])) {
$where .= ' AND (m.full_name_ar LIKE ? OR m.membership_number LIKE ? OR s.person_name LIKE ?)';
$s = '%' . $filters['search'] . '%'; $params[] = $s; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM subscriptions s JOIN members m ON m.id = s.member_id WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT s.*, m.full_name_ar as member_name, m.membership_number
FROM subscriptions s JOIN members m ON m.id = s.member_id
WHERE {$where} ORDER BY s.financial_year DESC, m.full_name_ar ASC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/subscriptions', 'Subscriptions\Controllers\SubscriptionController@index', ['auth'], 'subscription.view'],
['GET', '/subscriptions/batch-generate', 'Subscriptions\Controllers\SubscriptionController@batchGenerateForm', ['auth'], 'subscription.generate_batch'],
['POST', '/subscriptions/batch-generate', 'Subscriptions\Controllers\SubscriptionController@batchGenerate', ['auth'], 'subscription.generate_batch'],
['GET', '/members/{memberId}/subscriptions', 'Subscriptions\Controllers\SubscriptionController@memberSubscriptions',['auth'], 'subscription.view'],
['POST', '/subscriptions/{id}/pay', 'Subscriptions\Controllers\SubscriptionController@pay', ['auth'], 'subscription.collect'],
['POST', '/subscriptions/{id}/exempt', 'Subscriptions\Controllers\SubscriptionController@exempt', ['auth'], 'subscription.exempt'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions\Services;
use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
final class SubscriptionCalculator
{
public static function calculateLateFine(int $memberId, string $financialYear): array
{
$db = App::getInstance()->db();
$unpaidYears = $db->select(
"SELECT financial_year, SUM(total_amount) as total, SUM(paid_amount) as paid
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue') AND financial_year <= ?
GROUP BY financial_year ORDER BY financial_year ASC",
[$memberId, $financialYear]
);
$totalFine = '0.00';
$yearCount = count($unpaidYears);
$fineDetails = [];
$maxYearsData = RuleEngine::get('LATE_SUB_FINE_MAX_YEARS');
$maxYears = $maxYearsData['years'] ?? 5;
$dropYearsData = RuleEngine::get('LATE_SUB_DROP_YEARS');
$dropYears = $dropYearsData['years'] ?? 5;
$shouldDrop = $yearCount >= $dropYears;
foreach ($unpaidYears as $idx => $uy) {
$yearNum = $idx + 1;
if ($yearNum > $maxYears) break;
$unpaid = bcsub($uy['total'], $uy['paid'], 2);
if (bccomp($unpaid, '0', 2) <= 0) continue;
// Fine percentages: year 1 = 100%, year 2 = 200%, year 3 = 300%
$ruleCode = 'LATE_SUB_FINE_YEAR_' . min($yearNum, 3);
$fineData = RuleEngine::get($ruleCode);
$finePct = $fineData['percentage_of_subscription'] ?? (string) ($yearNum * 100);
$fineAmount = bcdiv(bcmul($unpaid, $finePct, 4), '100', 2);
$totalFine = bcadd($totalFine, $fineAmount, 2);
$fineDetails[] = [
'financial_year' => $uy['financial_year'],
'unpaid' => $unpaid,
'fine_percentage' => $finePct,
'fine_amount' => $fineAmount,
'year_number' => $yearNum,
];
}
return [
'total_fine' => $totalFine,
'details' => $fineDetails,
'years_unpaid' => $yearCount,
'should_drop' => $shouldDrop,
'max_years' => $maxYears,
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Rules\Services\RuleEngine;
final class SubscriptionGenerator
{
public static function generateForYear(string $financialYear, ?int $branchId = null): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$ts = date('Y-m-d H:i:s');
$empId = $employee ? (int) $employee->id : null;
// Get rates from service catalog
$memberRate = self::getRate('SVC_ANNUAL_MEMBER');
$spouseRate = self::getRate('SVC_ANNUAL_SPOUSE');
$childRate = self::getRate('SVC_ANNUAL_CHILD');
$tempRate = self::getRate('SVC_ANNUAL_TEMP');
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$devFee = $devFeeData['amount'] ?? '35.00';
// Get active members
$memberWhere = "m.status = 'active' AND m.is_archived = 0 AND m.membership_type NOT IN ('honorary')";
$memberParams = [];
if ($branchId) {
$memberWhere .= ' AND m.branch_id = ?';
$memberParams[] = $branchId;
}
$members = $db->select("SELECT m.id, m.full_name_ar, m.membership_number FROM members m WHERE {$memberWhere}", $memberParams);
$created = 0;
$skipped = 0;
foreach ($members as $member) {
$memberId = (int) $member['id'];
// Check if already generated
$existing = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'member'",
[$memberId, $financialYear]
);
if ($existing) { $skipped++; continue; }
// Member subscription
$total = bcadd($memberRate, $devFee, 2);
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
'person_type' => 'member',
'person_id' => $memberId,
'person_name' => $member['full_name_ar'],
'base_amount' => $memberRate,
'development_fee' => $devFee,
'total_amount' => $total,
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
'created_by' => $empId,
]);
$created++;
// Spouses
if ($db->tableExists('spouses')) {
$spouses = $db->select("SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$memberId]);
foreach ($spouses as $sp) {
$spTotal = bcadd($spouseRate, $devFee, 2);
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
'person_type' => 'spouse',
'person_id' => (int) $sp['id'],
'person_name' => $sp['full_name_ar'],
'base_amount' => $spouseRate,
'development_fee' => $devFee,
'total_amount' => $spTotal,
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
'created_by' => $empId,
]);
$created++;
}
}
// Children
if ($db->tableExists('children')) {
$children = $db->select("SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$memberId]);
foreach ($children as $ch) {
$chTotal = bcadd($childRate, $devFee, 2);
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
'person_type' => 'child',
'person_id' => (int) $ch['id'],
'person_name' => $ch['full_name_ar'],
'base_amount' => $childRate,
'development_fee' => $devFee,
'total_amount' => $chTotal,
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
'created_by' => $empId,
]);
$created++;
}
}
// Temporary members
if ($db->tableExists('temporary_members')) {
$temps = $db->select("SELECT id, full_name_ar FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$memberId]);
foreach ($temps as $t) {
$tTotal = bcadd($tempRate, $devFee, 2);
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
'person_type' => 'temporary',
'person_id' => (int) $t['id'],
'person_name' => $t['full_name_ar'],
'base_amount' => $tempRate,
'development_fee' => $devFee,
'total_amount' => $tTotal,
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
'created_by' => $empId,
]);
$created++;
}
}
}
Logger::info("Subscription batch generated", ['year' => $financialYear, 'created' => $created, 'skipped' => $skipped]);
return ['created' => $created, 'skipped' => $skipped, 'members_processed' => count($members)];
}
private static function getRate(string $serviceCode): string
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT base_amount FROM service_catalog WHERE service_code = ? AND is_active = 1 AND branch_id IS NULL ORDER BY effective_from DESC LIMIT 1",
[$serviceCode]
);
return $row['base_amount'] ?? '0.00';
}
}
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>توليد اشتراكات سنوية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php if ($result !== null): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#F0FDF4;border:1px solid #BBF7D0;">
<h3 style="color:#059669;margin-bottom:10px;">تم التوليد بنجاح</h3>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:15px;">
<div style="text-align:center;"><div style="font-size:28px;font-weight:700;color:#059669;"><?= (int) $result['created'] ?></div><div style="color:#6B7280;">سجل تم إنشاؤه</div></div>
<div style="text-align:center;"><div style="font-size:28px;font-weight:700;color:#D97706;"><?= (int) $result['skipped'] ?></div><div style="color:#6B7280;">تم تخطيه (موجود)</div></div>
<div style="text-align:center;"><div style="font-size:28px;font-weight:700;color:#0284C7;"><?= (int) $result['members_processed'] ?></div><div style="color:#6B7280;">عضو تمت معالجته</div></div>
</div>
</div>
<?php endif; ?>
<div class="card" style="padding:20px;">
<form method="POST" action="/subscriptions/batch-generate">
<?= csrf_field() ?>
<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><input type="text" name="financial_year" class="form-input" required placeholder="مثال: 2025/2026" style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">الفرع (اختياري)</label><select name="branch_id" class="form-select"><option value="">كل الفروع</option><?php foreach ($branches as $b): ?><option value="<?= (int) $b['id'] ?>"><?= e($b['name_ar']) ?></option><?php endforeach; ?></select></div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:15px;" onclick="return confirm('توليد اشتراكات لكل الأعضاء النشطين؟')">توليد الاشتراكات</button>
</form>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الاشتراكات السنوية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/subscriptions/batch-generate" class="btn btn-primary">توليد اشتراكات</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/subscriptions" 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="text" name="financial_year" value="<?= e($filters['financial_year'] ?? '') ?>" placeholder="<?= e($currentYear) ?>" class="form-input" style="width:120px;"></div>
<div><label class="form-label" style="font-size:12px;">الحالة</label><select name="status" class="form-select"><option value="">الكل</option><option value="pending" <?= ($filters['status'] ?? '') === 'pending' ? 'selected' : '' ?>>معلق</option><option value="paid" <?= ($filters['status'] ?? '') === 'paid' ? 'selected' : '' ?>>مدفوع</option><option value="overdue" <?= ($filters['status'] ?? '') === 'overdue' ? 'selected' : '' ?>>متأخر</option><option value="exempt" <?= ($filters['status'] ?? '') === 'exempt' ? 'selected' : '' ?>>معفى</option></select></div>
<div><label class="form-label" style="font-size:12px;">النوع</label><select name="person_type" class="form-select"><option value="">الكل</option><option value="member" <?= ($filters['person_type'] ?? '') === 'member' ? 'selected' : '' ?>>عضو</option><option value="spouse" <?= ($filters['person_type'] ?? '') === 'spouse' ? 'selected' : '' ?>>زوجة</option><option value="child" <?= ($filters['person_type'] ?? '') === 'child' ? 'selected' : '' ?>>ابن</option><option value="temporary" <?= ($filters['person_type'] ?? '') === 'temporary' ? 'selected' : '' ?>>مؤقت</option></select></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><th>الإجمالي</th><th>الحالة</th></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td><a href="/members/<?= (int) $r['member_id'] ?>/subscriptions" style="color:#0D7377;"><?= e($r['member_name'] ?? '') ?></a></td>
<td style="font-weight:600;"><?= e($r['financial_year']) ?></td>
<td style="font-size:13px;"><?= match($r['person_type']) { 'member' => 'عضو', 'spouse' => 'زوجة', 'child' => 'ابن', 'temporary' => 'مؤقت', default => $r['person_type'] } ?></td>
<td style="font-size:13px;"><?= e($r['person_name'] ?? '—') ?></td>
<td><?= money($r['base_amount']) ?></td>
<td style="font-size:12px;"><?= money($r['development_fee']) ?></td>
<td style="font-size:12px;color:#DC2626;"><?= bccomp($r['fine_amount'] ?? '0', '0', 2) > 0 ? money($r['fine_amount']) : '—' ?></td>
<td style="font-weight:600;"><?= money(bcadd($r['total_amount'], $r['fine_amount'] ?? '0', 2)) ?></td>
<td><span style="color:<?= match($r['status']) { 'paid' => '#059669', 'exempt' => '#0284C7', 'overdue' => '#DC2626', default => '#D97706' } ?>;font-weight:600;"><?= match($r['status']) { 'paid' => 'مدفوع', 'pending' => 'معلق', 'overdue' => 'متأخر', 'exempt' => 'معفى', default => $r['status'] } ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="9" 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'); ?>اشتراكات العضو: <?= e($member['full_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?><a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">← العودة للعضو</a><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php if ($lateFine['should_drop']): ?>
<div style="padding:15px;background:#FEF2F2;border:2px solid #DC2626;border-radius:8px;margin-bottom:20px;color:#DC2626;font-weight:600;">
⚠ هذا العضو متأخر <?= (int) $lateFine['years_unpaid'] ?> سنوات — يجب إسقاط العضوية
</div>
<?php elseif (!empty($lateFine['details'])): ?>
<div style="padding:15px;background:#FFF7ED;border:1px solid #FED7AA;border-radius:8px;margin-bottom:20px;">
<strong style="color:#D97706;">غرامات تأخير: <?= money($lateFine['total_fine']) ?></strong><?= (int) $lateFine['years_unpaid'] ?> سنة متأخرة
</div>
<?php endif; ?>
<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><th>الحالة</th><th>الإجراءات</th></tr></thead><tbody>
<?php foreach ($subscriptions as $s): ?>
<tr>
<td style="font-weight:600;"><?= e($s['financial_year']) ?></td>
<td><?= match($s['person_type']) { 'member' => 'عضو', 'spouse' => 'زوجة', 'child' => 'ابن', 'temporary' => 'مؤقت', default => $s['person_type'] } ?></td>
<td style="font-size:13px;"><?= e($s['person_name'] ?? '—') ?></td>
<td><?= money($s['base_amount']) ?></td>
<td style="font-size:12px;"><?= money($s['development_fee']) ?></td>
<td style="color:#DC2626;"><?= bccomp($s['fine_amount'] ?? '0', '0', 2) > 0 ? money($s['fine_amount']) : '—' ?></td>
<td style="color:#059669;"><?= bccomp($s['paid_amount'] ?? '0', '0', 2) > 0 ? money($s['paid_amount']) : '—' ?></td>
<td><span style="color:<?= match($s['status']) { 'paid' => '#059669', 'exempt' => '#0284C7', 'overdue' => '#DC2626', default => '#D97706' } ?>;font-weight:600;"><?= match($s['status']) { 'paid' => 'مدفوع', 'pending' => 'معلق', 'overdue' => 'متأخر', 'exempt' => 'معفى', default => $s['status'] } ?></span></td>
<td>
<?php if (in_array($s['status'], ['pending', 'overdue'])): ?>
<div style="display:flex;gap:5px;">
<form method="POST" action="/subscriptions/<?= (int) $s['id'] ?>/pay" style="display:inline;"><?= csrf_field() ?><input type="hidden" name="payment_method" value="cash"><button type="submit" class="btn btn-sm btn-primary" onclick="return confirm('تسجيل دفع الاشتراك؟')">دفع</button></form>
<form method="POST" action="/subscriptions/<?= (int) $s['id'] ?>/exempt" style="display:inline;"><?= csrf_field() ?><input type="hidden" name="exemption_reason" value="قرار مجلس"><button type="submit" class="btn btn-sm btn-outline" onclick="return confirm('إعفاء من الاشتراك؟')">إعفاء</button></form>
</div>
<?php else: ?><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($subscriptions)): ?><tr><td colspan="9" style="text-align:center;padding:30px;color:#6B7280;">لا توجد اشتراكات</td></tr><?php endif; ?>
</tbody></table></div></div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('subscriptions', [
'label_ar' => 'الاشتراكات السنوية',
'label_en' => 'Annual Subscriptions',
'icon' => 'repeat',
'route' => '/subscriptions',
'permission' => 'subscription.view',
'parent' => null,
'order' => 520,
'children' => [
['label_ar' => 'كل الاشتراكات', 'label_en' => 'All Subscriptions', 'route' => '/subscriptions', 'permission' => 'subscription.view', 'order' => 1],
['label_ar' => 'توليد دفعة', 'label_en' => 'Batch Generate', 'route' => '/subscriptions/batch-generate', 'permission' => 'subscription.generate_batch', 'order' => 2],
],
]);
PermissionRegistry::register('subscriptions', [
'subscription.view' => ['ar' => 'عرض الاشتراكات', 'en' => 'View Subscriptions'],
'subscription.collect' => ['ar' => 'تحصيل اشتراك', 'en' => 'Collect Subscription'],
'subscription.exempt' => ['ar' => 'إعفاء من اشتراك', 'en' => 'Exempt Subscription'],
'subscription.generate_batch' => ['ar' => 'توليد اشتراكات', 'en' => 'Generate Batch'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `installment_plans` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`related_entity_type` VARCHAR(100) NULL,
`related_entity_id` BIGINT UNSIGNED NULL,
`total_amount` DECIMAL(15,2) NOT NULL,
`down_payment` DECIMAL(15,2) NOT NULL,
`remaining_balance` DECIMAL(15,2) NOT NULL,
`interest_rate` DECIMAL(5,2) NOT NULL DEFAULT 22.00,
`total_interest` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`total_with_interest` DECIMAL(15,2) NOT NULL,
`number_of_months` INT UNSIGNED NOT NULL,
`monthly_payment` DECIMAL(15,2) NOT NULL,
`start_date` DATE NOT NULL,
`down_payment_receipt` VARCHAR(50) NULL,
`cash_settlement_date` DATE NULL,
`is_cash_settled` TINYINT(1) NOT NULL DEFAULT 0,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`notes` 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_installment_plans_member` (`member_id`),
INDEX `idx_installment_plans_status` (`status`),
CONSTRAINT `fk_installment_plans_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `chk_installment_down` CHECK (`down_payment` >= 0),
CONSTRAINT `chk_installment_total` CHECK (`total_amount` > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `installment_plans`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `installment_schedule` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`installment_plan_id` BIGINT UNSIGNED NOT NULL,
`installment_number` INT UNSIGNED NOT NULL,
`due_date` DATE NOT NULL,
`amount` DECIMAL(15,2) NOT NULL,
`principal` DECIMAL(15,2) NOT NULL,
`interest` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`remaining_after` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`paid_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`payment_id` BIGINT UNSIGNED NULL,
`status` VARCHAR(30) NOT NULL DEFAULT 'pending',
`paid_at` TIMESTAMP NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_installment_sched_plan` (`installment_plan_id`),
INDEX `idx_installment_sched_due` (`due_date`),
INDEX `idx_installment_sched_status` (`status`),
CONSTRAINT `fk_installment_sched_plan` FOREIGN KEY (`installment_plan_id`) REFERENCES `installment_plans`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_installment_sched_payment` FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `installment_schedule`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `subscriptions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`financial_year` VARCHAR(10) NOT NULL,
`person_type` VARCHAR(50) NOT NULL,
`person_id` BIGINT UNSIGNED NULL,
`person_name` VARCHAR(200) NULL,
`base_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`development_fee` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`discount_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`total_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`paid_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`fine_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`paid_at` TIMESTAMP NULL DEFAULT NULL,
`payment_id` BIGINT UNSIGNED NULL,
`exempted_by` BIGINT UNSIGNED NULL,
`exemption_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_subscriptions_member` (`member_id`),
INDEX `idx_subscriptions_year` (`financial_year`),
INDEX `idx_subscriptions_status` (`status`),
INDEX `idx_subscriptions_person` (`person_type`, `person_id`),
CONSTRAINT `fk_subscriptions_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `fk_subscriptions_payment` FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `subscriptions`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `violations` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`violation_date` DATE NOT NULL,
`description` TEXT NOT NULL,
`reported_by` BIGINT UNSIGNED NULL,
`evidence_notes` TEXT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'reported',
`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_violations_member` (`member_id`),
INDEX `idx_violations_status` (`status`),
INDEX `idx_violations_date` (`violation_date`),
CONSTRAINT `fk_violations_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `violations`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `fines` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`violation_id` BIGINT UNSIGNED NULL,
`fine_type` VARCHAR(50) NOT NULL DEFAULT 'violation_fine',
`amount` DECIMAL(15,2) NOT NULL,
`penalty_type` VARCHAR(50) NOT NULL DEFAULT 'fine',
`suspension_from` DATE NULL,
`suspension_to` DATE NULL,
`appeal_submitted` TINYINT(1) NOT NULL DEFAULT 0,
`appeal_date` DATE NULL,
`appeal_notes` TEXT NULL,
`appeal_decision` VARCHAR(50) NULL,
`appeal_decided_by` BIGINT UNSIGNED NULL,
`appeal_decided_at` TIMESTAMP NULL DEFAULT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'imposed',
`paid_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
`payment_id` BIGINT UNSIGNED NULL,
`paid_at` TIMESTAMP NULL DEFAULT NULL,
`notes` 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_fines_member` (`member_id`),
INDEX `idx_fines_violation` (`violation_id`),
INDEX `idx_fines_type` (`fine_type`),
INDEX `idx_fines_penalty` (`penalty_type`),
INDEX `idx_fines_status` (`status`),
INDEX `idx_fines_suspension` (`suspension_from`, `suspension_to`),
CONSTRAINT `fk_fines_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `fk_fines_violation` FOREIGN KEY (`violation_id`) REFERENCES `violations`(`id`) ON DELETE SET NULL,
CONSTRAINT `fk_fines_payment` FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`) ON DELETE SET NULL,
CONSTRAINT `chk_fines_amount` CHECK (`amount` >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `fines`",
];
\ 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