Commit fd764d71 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fixed Membership

parent 891ae661
......@@ -1247,6 +1247,124 @@ final class AccountingIntegrationService
// PRIVATE HELPERS
// ────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────
// MEMBERSHIP TRANSFERS & WAIVERS
// ────────────────────────────────────────────────────────────
public static function onTransferCompleted(array $data): void
{
$transferId = (int) ($data['transfer_id'] ?? 0);
$feeAmount = (string) ($data['fee_amount'] ?? '0.00');
$transferType = $data['transfer_type'] ?? 'separation';
$sourceMemberId = (int) ($data['source_member_id'] ?? 0);
if (bccomp($feeAmount, '0.00', 2) <= 0) {
return;
}
$creditCode = match ($transferType) {
'divorce' => AccountCodes::DIVORCE_FEE_REVENUE,
'death' => AccountCodes::DEATH_TRANSFER_REVENUE,
default => AccountCodes::SEPARATION_FEE_REVENUE,
};
$typeLabel = match ($transferType) {
'divorce' => 'رسوم تحويل طلاق',
'death' => 'رسوم نقل وفاة',
default => 'رسوم فصل/تحويل',
};
$debitAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
$creditAccount = self::getAccountByCode($creditCode);
if (!$debitAccount || !$creditAccount) return;
$description = $typeLabel . ' — طلب تحويل #' . $transferId;
JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Transfer fee — request #' . $transferId,
'reference_type' => 'transfer_request',
'reference_id' => $transferId,
'source_module' => 'transfers',
'is_auto_generated' => 1,
], [
['account_id' => (int) $debitAccount['id'], 'debit' => $feeAmount, 'credit' => '0.00', 'description_ar' => $description, 'member_id' => $sourceMemberId > 0 ? $sourceMemberId : null],
['account_id' => (int) $creditAccount['id'], 'debit' => '0.00', 'credit' => $feeAmount, 'description_ar' => $description, 'member_id' => $sourceMemberId > 0 ? $sourceMemberId : null],
], true);
}
public static function onWaiverCompleted(array $data): void
{
$waiverId = (int) ($data['waiver_id'] ?? 0);
$feeAmount = (string) ($data['fee_amount'] ?? '0.00');
$sourceMemberId = (int) ($data['source_member_id'] ?? 0);
if (bccomp($feeAmount, '0.00', 2) <= 0) {
return;
}
$debitAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
$creditAccount = self::getAccountByCode(AccountCodes::WAIVER_FEE_REVENUE);
if (!$debitAccount || !$creditAccount) return;
$description = 'رسوم تنازل عن العضوية — طلب #' . $waiverId;
JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Waiver fee — request #' . $waiverId,
'reference_type' => 'waiver_request',
'reference_id' => $waiverId,
'source_module' => 'waivers',
'is_auto_generated' => 1,
], [
['account_id' => (int) $debitAccount['id'], 'debit' => $feeAmount, 'credit' => '0.00', 'description_ar' => $description, 'member_id' => $sourceMemberId > 0 ? $sourceMemberId : null],
['account_id' => (int) $creditAccount['id'], 'debit' => '0.00', 'credit' => $feeAmount, 'description_ar' => $description, 'member_id' => $sourceMemberId > 0 ? $sourceMemberId : null],
], true);
}
public static function onMemberDropped(array $data): void
{
$db = App::getInstance()->db();
$memberId = (int) ($data['member_id'] ?? 0);
$reason = $data['reason'] ?? 'إسقاط العضوية';
if ($memberId <= 0) return;
$outstanding = $db->selectOne(
"SELECT COALESCE(SUM(CASE WHEN s.status IN ('pending','overdue') THEN (s.base_amount + s.development_fee + s.fine_amount - s.discount_amount) ELSE 0 END), 0) as total
FROM subscriptions s WHERE s.member_id = ?",
[$memberId]
);
$amount = (string) ($outstanding['total'] ?? '0.00');
if (bccomp($amount, '0.00', 2) <= 0) return;
$debitAccount = self::getAccountByCode(AccountCodes::MISCELLANEOUS_REVENUE);
$creditAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
if (!$debitAccount || !$creditAccount) return;
$description = 'إسقاط مديونية عضوية — ' . $reason;
JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Bad debt write-off — member #' . $memberId,
'reference_type' => 'member_drop',
'reference_id' => $memberId,
'source_module' => 'members',
'is_auto_generated' => 1,
], [
['account_id' => (int) $debitAccount['id'], 'debit' => $amount, 'credit' => '0.00', 'description_ar' => $description, 'member_id' => $memberId],
['account_id' => (int) $creditAccount['id'], 'debit' => '0.00', 'credit' => $amount, 'description_ar' => $description, 'member_id' => $memberId],
], true);
}
// ────────────────────────────────────────────────────────────
// HELPERS
// ────────────────────────────────────────────────────────────
private static function getPaymentTypeLabel(string $type): string
{
return match ($type) {
......
......@@ -380,6 +380,34 @@ EventBus::listen('tournament.fee_collected', function (array $data): void {
}
}, 50);
// ── Membership Transfers & Waivers ─────────────────────────
// When a transfer/separation is completed, post AR write-off if fee was pre-collected
EventBus::listen('transfer.completed', function (array $data): void {
try {
AccountingIntegrationService::onTransferCompleted($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (transfer.completed): ' . $e->getMessage());
}
}, 50);
// When a waiver is completed, post AR write-off
EventBus::listen('waiver.completed', function (array $data): void {
try {
AccountingIntegrationService::onWaiverCompleted($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (waiver.completed): ' . $e->getMessage());
}
}, 50);
// When a member is dropped (5-year unpaid / installment default), write off AR
EventBus::listen('member.dropped', function (array $data): void {
try {
AccountingIntegrationService::onMemberDropped($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (member.dropped): ' . $e->getMessage());
}
}, 50);
// ── Statement Integration ───────────────────────────────────
// Auto-records customer/supplier transactions for account statements and credit limits
StatementIntegrationService::registerListeners();
......
......@@ -59,6 +59,17 @@ final class CarnetPrintService
}
}
// Check unpaid fines
if ($db->tableExists('fines')) {
$unpaidFines = $db->selectOne(
"SELECT COUNT(*) as cnt FROM fines WHERE member_id = ? AND penalty_type = 'fine' AND status = 'imposed' AND (amount - paid_amount) > 0",
[$memberId]
);
if (((int) ($unpaidFines['cnt'] ?? 0)) > 0) {
$reasons[] = 'يوجد غرامات مالية غير مسددة';
}
}
return $reasons;
}
......
......@@ -108,6 +108,12 @@ final class PaymentRequestService
return ['success' => false, 'error' => 'طلب الدفع ملغى'];
}
// Atomic lock: mark as processing to prevent double-execution
$db->query(
"UPDATE payment_requests SET status = 'processing', updated_at = NOW() WHERE id = ? AND status = 'pending'",
[$requestId]
);
$paymentResult = PaymentService::processPayment([
'member_id' => (int) $request['member_id'],
'amount' => (string) $request['amount'],
......
......@@ -87,6 +87,13 @@ class FineController extends Controller
$db->update('violations', ['status' => 'penalty_imposed', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $violationId]);
// Update member status for suspension/ban/termination
if (in_array($penaltyType, ['suspension', 'ban'])) {
$db->update('members', ['status' => 'suspended', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ? AND `status` = \'active\'', [(int) $violation['member_id']]);
} elseif ($penaltyType === 'termination') {
$db->update('members', ['status' => 'terminated', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ? AND `status` IN (\'active\',\'suspended\')', [(int) $violation['member_id']]);
}
if (WorkflowEngine::hasDefinition('violation_penalty')) {
WorkflowEngine::createInstance('violation_penalty', 'fines', (int) $fine->id);
}
......@@ -110,13 +117,21 @@ class FineController extends Controller
public function pay(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ?", [(int) $id]);
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ? FOR UPDATE", [(int) $id]);
if (!$fine) return $this->redirect('/fines')->withError('الغرامة غير موجودة');
if ($fine['penalty_type'] !== 'fine') return $this->redirect('/fines')->withError('هذه العقوبة ليست غرامة مالية');
if ($fine['status'] === 'paid') return $this->redirect('/fines')->withError('الغرامة مسددة بالكامل بالفعل');
$remaining = bcsub($fine['amount'], $fine['paid_amount'], 2);
if (bccomp($remaining, '0', 2) <= 0) return $this->redirect('/fines')->withError('الغرامة مسددة بالكامل');
// Prevent double payment — check if a payment already exists for this fine
$existingPayment = $db->selectOne(
"SELECT id FROM payments WHERE related_entity_type = 'fines' AND related_entity_id = ? AND is_voided = 0 LIMIT 1",
[(int) $id]
);
if ($existingPayment) return $this->redirect('/fines')->withError('تم سداد هذه الغرامة بالفعل — لا يمكن الدفع مرتين');
$data = $request->all();
$data['member_id'] = (int) $fine['member_id'];
$data['amount'] = $remaining;
......@@ -135,7 +150,7 @@ class FineController extends Controller
'paid_at' => date('Y-m-d H:i:s'),
'status' => 'paid',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
], '`id` = ? AND `status` != \'paid\'', [(int) $id]);
try {
WorkflowEngine::transitionByEntity('fines', (int) $id, 'pay');
......
<?php
declare(strict_types=1);
namespace App\Modules\Installments\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class DefaultChecker
{
private const DEFAULT_GRACE_DAYS = 90;
public static function run(): array
{
$db = App::getInstance()->db();
$results = ['plans_defaulted' => 0, 'members_dropped' => 0, 'errors' => []];
$defaultedPlans = $db->select(
"SELECT ip.id as plan_id, ip.member_id, ip.status as plan_status,
COUNT(isi.id) as overdue_count
FROM installment_plans ip
JOIN installment_schedule isi ON isi.installment_plan_id = ip.id
WHERE ip.status = 'active'
AND isi.status = 'pending'
AND isi.due_date < DATE_SUB(NOW(), INTERVAL ? DAY)
GROUP BY ip.id, ip.member_id, ip.status
HAVING overdue_count >= 3",
[self::DEFAULT_GRACE_DAYS]
);
foreach ($defaultedPlans as $plan) {
$planId = (int) $plan['plan_id'];
$memberId = (int) $plan['member_id'];
try {
// Mark plan as defaulted
$db->query(
"UPDATE installment_plans SET status = 'defaulted', updated_at = NOW() WHERE id = ?",
[$planId]
);
// Mark all pending items as overdue
$db->query(
"UPDATE installment_schedule SET status = 'overdue', updated_at = NOW()
WHERE installment_plan_id = ? AND status = 'pending'",
[$planId]
);
$results['plans_defaulted']++;
// Drop the member
$member = $db->selectOne(
"SELECT status FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if ($member && $member['status'] === 'active') {
$db->query(
"UPDATE members SET status = 'dropped', updated_at = NOW() WHERE id = ?",
[$memberId]
);
$results['members_dropped']++;
EventBus::dispatch('member.dropped', [
'member_id' => $memberId,
'reason' => 'عدم الالتزام بسداد الأقساط المستحقة',
'installment_plan_id' => $planId,
]);
Logger::info("Member dropped for installment default", [
'member_id' => $memberId,
'plan_id' => $planId,
]);
}
} catch (\Throwable $e) {
$results['errors'][] = "Plan #{$planId}: " . $e->getMessage();
Logger::error("DefaultChecker error", ['plan_id' => $planId, 'error' => $e->getMessage()]);
}
}
return $results;
}
}
......@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Installments\Services;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Members\Services\BoardOfferService;
final class InstallmentCalculator
{
......@@ -21,6 +22,12 @@ final class InstallmentCalculator
$maxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
$maxMonths = $maxMonthsData['months'] ?? 30;
$offerOverrides = BoardOfferService::getInstallmentOverrides();
if (!empty($offerOverrides)) {
if (isset($offerOverrides['max_months'])) $maxMonths = $offerOverrides['max_months'];
if (isset($offerOverrides['interest_rate'])) $annualRate = $offerOverrides['interest_rate'];
}
$errors = [];
// Validate down payment >= 25%
......
......@@ -208,6 +208,7 @@ class MemberController extends Controller
public function payMembership(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
......@@ -218,6 +219,15 @@ class MemberController extends Controller
$months = ($paymentType === 'down_payment') ? min(30, max(1, (int) $request->post('installment_months', 30))) : null;
// Cancel any pending individual addition_fee requests — the collective payment subsumes them
$pendingAdditions = $db->select(
"SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND status IN ('pending','processing') AND is_voided = 0",
[(int) $id]
);
foreach ($pendingAdditions as $pa) {
PaymentRequestService::cancelRequest((int) $pa['id'], 'ألغي تلقائياً — مشمول في الفاتورة المجمعة');
}
$bill = BillingService::getMemberBill((int) $id);
$breakdown = [];
foreach ($bill['items'] ?? [] as $item) {
......@@ -277,6 +287,13 @@ class MemberController extends Controller
$fee = $entity['addition_fee'] ?? '0.00';
if (bccomp($fee, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('لا توجد رسوم مستحقة');
// Check if already paid via payments table
$alreadyPaid = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND is_voided = 0 LIMIT 1",
[(int) $id, $entityType, $entityId]
);
if ($alreadyPaid) return $this->redirect('/members/' . $id)->withError('تم سداد رسوم هذا التابع بالفعل');
$nameLabel = $entity['full_name_ar'] ?? '';
$typeLabel = match ($entityType) {
'spouses' => 'زوجة',
......
<?php
declare(strict_types=1);
namespace App\Modules\Members\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Members\Services\AutoFreezeService;
class ReportController extends Controller
{
public function index(Request $request): Response
{
return $this->view('Members.Views.reports.index', []);
}
public function childrenAgingOut(Request $request): Response
{
$db = App::getInstance()->db();
$minAge = max(0, (int) $request->get('min_age', 23));
$maxAge = min(99, (int) $request->get('max_age', 25));
$children = $db->select(
"SELECT c.id, c.full_name_ar, c.date_of_birth, c.gender, c.is_frozen, c.status as child_status,
m.id as member_id, m.full_name_ar as parent_name, m.membership_number, m.phone_mobile,
TIMESTAMPDIFF(YEAR, c.date_of_birth, CURDATE()) as current_age
FROM children c
JOIN members m ON m.id = c.member_id AND m.is_archived = 0
WHERE c.is_archived = 0
AND TIMESTAMPDIFF(YEAR, c.date_of_birth, CURDATE()) BETWEEN ? AND ?
ORDER BY c.date_of_birth ASC",
[$minAge, $maxAge]
);
$nearThreshold = AutoFreezeService::getChildrenNearingThreshold();
return $this->view('Members.Views.reports.children-aging', [
'children' => $children,
'nearThreshold' => $nearThreshold,
'minAge' => $minAge,
'maxAge' => $maxAge,
]);
}
public function transfers(Request $request): Response
{
$db = App::getInstance()->db();
$dateFrom = $request->get('date_from', date('Y-m-01'));
$dateTo = $request->get('date_to', date('Y-m-d'));
$type = $request->get('type', '');
$where = "tr.status = 'completed' AND tr.completed_at BETWEEN ? AND ?";
$params = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'];
if ($type !== '') {
$where .= " AND tr.transfer_type = ?";
$params[] = $type;
}
$transfers = $db->select(
"SELECT tr.*, m.full_name_ar as source_name, m.membership_number as old_number,
tm.full_name_ar as target_name, tr.new_membership_number
FROM transfer_requests tr
LEFT JOIN members m ON m.id = tr.source_member_id
LEFT JOIN members tm ON tm.id = tr.target_member_id
WHERE {$where}
ORDER BY tr.completed_at DESC",
$params
);
return $this->view('Members.Views.reports.transfers', [
'transfers' => $transfers,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'type' => $type,
]);
}
public function waivers(Request $request): Response
{
$db = App::getInstance()->db();
$dateFrom = $request->get('date_from', date('Y-m-01'));
$dateTo = $request->get('date_to', date('Y-m-d'));
$waivers = $db->select(
"SELECT wr.*, ms.full_name_ar as source_name, ms.membership_number,
mt.full_name_ar as target_name
FROM waiver_requests wr
LEFT JOIN members ms ON ms.id = wr.source_member_id
LEFT JOIN members mt ON mt.id = wr.target_member_id
WHERE wr.status = 'completed' AND wr.updated_at BETWEEN ? AND ?
ORDER BY wr.updated_at DESC",
[$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59']
);
return $this->view('Members.Views.reports.waivers', [
'waivers' => $waivers,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
]);
}
public function subscriptionStatus(Request $request): Response
{
$db = App::getInstance()->db();
$financialYear = $request->get('financial_year', self::currentFY());
$summary = $db->select(
"SELECT s.status, COUNT(*) as cnt, SUM(s.total_amount) as total, SUM(s.paid_amount) as paid,
SUM(s.fine_amount) as fines
FROM subscriptions s
WHERE s.financial_year = ?
GROUP BY s.status",
[$financialYear]
);
$unpaidMembers = $db->select(
"SELECT m.id, m.full_name_ar, m.membership_number, m.phone_mobile,
SUM(s.total_amount - s.paid_amount) as outstanding
FROM subscriptions s
JOIN members m ON m.id = s.member_id AND m.is_archived = 0
WHERE s.financial_year = ? AND s.status IN ('pending','overdue')
GROUP BY m.id, m.full_name_ar, m.membership_number, m.phone_mobile
ORDER BY outstanding DESC
LIMIT 100",
[$financialYear]
);
return $this->view('Members.Views.reports.subscription-status', [
'summary' => $summary,
'unpaidMembers' => $unpaidMembers,
'financialYear' => $financialYear,
]);
}
public function ageReport(Request $request): Response
{
$db = App::getInstance()->db();
$minAge = max(0, (int) $request->get('min_age', 18));
$maxAge = min(120, (int) $request->get('max_age', 65));
$members = $db->select(
"SELECT m.id, m.full_name_ar, m.membership_number, m.date_of_birth, m.gender, m.status,
m.phone_mobile, m.branch_id, b.name_ar as branch_name,
TIMESTAMPDIFF(YEAR, m.date_of_birth, CURDATE()) as age
FROM members m
LEFT JOIN branches b ON b.id = m.branch_id
WHERE m.is_archived = 0
AND m.date_of_birth IS NOT NULL
AND TIMESTAMPDIFF(YEAR, m.date_of_birth, CURDATE()) BETWEEN ? AND ?
ORDER BY m.date_of_birth ASC",
[$minAge, $maxAge]
);
return $this->view('Members.Views.reports.age-report', [
'members' => $members,
'minAge' => $minAge,
'maxAge' => $maxAge,
'total' => count($members),
]);
}
public function unpaidDebts(Request $request): Response
{
$db = App::getInstance()->db();
$unpaidFines = $db->select(
"SELECT f.id, f.amount, f.paid_amount, f.penalty_type, f.created_at,
m.id as member_id, m.full_name_ar, m.membership_number, m.phone_mobile
FROM fines f
JOIN members m ON m.id = f.member_id AND m.is_archived = 0
WHERE f.status = 'imposed' AND f.penalty_type = 'fine' AND (f.amount - f.paid_amount) > 0
ORDER BY f.created_at DESC"
);
$unpaidInstallments = $db->select(
"SELECT ip.id as plan_id, ip.total_with_interest, ip.member_id,
m.full_name_ar, m.membership_number, m.phone_mobile,
COUNT(isi.id) as overdue_items,
SUM(isi.amount) as overdue_amount
FROM installment_plans ip
JOIN members m ON m.id = ip.member_id AND m.is_archived = 0
JOIN installment_schedule isi ON isi.installment_plan_id = ip.id AND isi.status IN ('pending','overdue') AND isi.due_date < CURDATE()
WHERE ip.status = 'active'
GROUP BY ip.id, ip.total_with_interest, ip.member_id, m.full_name_ar, m.membership_number, m.phone_mobile
ORDER BY overdue_amount DESC"
);
return $this->view('Members.Views.reports.unpaid-debts', [
'unpaidFines' => $unpaidFines,
'unpaidInstallments' => $unpaidInstallments,
]);
}
private static function currentFY(): string
{
$month = (int) date('n');
$year = (int) date('Y');
return $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year;
}
}
......@@ -17,4 +17,12 @@ return [
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth', 'csrf'], 'member.fill_form'],
['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['auth'], 'member.view'],
// Reports
['GET', '/reports', 'Members\Controllers\ReportController@index', ['auth'], 'member.reports'],
['GET', '/reports/children-aging', 'Members\Controllers\ReportController@childrenAgingOut', ['auth'], 'member.reports'],
['GET', '/reports/transfers', 'Members\Controllers\ReportController@transfers', ['auth'], 'member.reports'],
['GET', '/reports/waivers', 'Members\Controllers\ReportController@waivers', ['auth'], 'member.reports'],
['GET', '/reports/subscription-status', 'Members\Controllers\ReportController@subscriptionStatus', ['auth'], 'member.reports'],
['GET', '/reports/age-report', 'Members\Controllers\ReportController@ageReport', ['auth'], 'member.reports'],
['GET', '/reports/unpaid-debts', 'Members\Controllers\ReportController@unpaidDebts', ['auth'], 'member.reports'],
];
\ No newline at end of file
......@@ -145,6 +145,113 @@ final class AutoFreezeService
return ['allowed' => true];
}
/**
* Freeze temporary members who have reached their age limit.
* Categories: sisters@25, stepchildren@25, orphans@25
*/
public static function freezeTemporaryAtAgeLimit(): array
{
$db = App::getInstance()->db();
$today = new \DateTimeImmutable('today');
$now = date('Y-m-d H:i:s');
$frozenCount = 0;
$ageLimitCategories = ['sisters_under_25', 'stepchildren_under_25', 'orphan_sponsored'];
$temps = $db->select(
"SELECT id, date_of_birth, category FROM temporary_members
WHERE is_archived = 0
AND status = 'active'
AND category IN ('" . implode("','", $ageLimitCategories) . "')
AND date_of_birth IS NOT NULL"
);
foreach ($temps as $temp) {
$dob = new \DateTimeImmutable($temp['date_of_birth']);
$age = (int) $dob->diff($today)->y;
if ($age >= 25) {
$db->update(
'temporary_members',
[
'status' => 'expired',
'updated_at' => $now,
],
'id = ?',
[$temp['id']]
);
$frozenCount++;
}
}
return ['expired_count' => $frozenCount];
}
/**
* Flag honorary memberships that have passed their 1-year term without renewal.
*/
public static function flagHonoraryExpiry(): array
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$expiredCount = 0;
$expired = $db->select(
"SELECT hm.id, hm.member_id FROM honorary_members hm
JOIN members m ON m.id = hm.member_id AND m.is_archived = 0 AND m.status = 'active'
WHERE hm.end_date < CURDATE() AND hm.is_archived = 0"
);
foreach ($expired as $hm) {
$db->update('members', ['status' => 'expired', 'updated_at' => $now], 'id = ?', [(int) $hm['member_id']]);
$expiredCount++;
}
return ['expired_count' => $expiredCount];
}
/**
* Report children within 6 months of reaching transfer-required age (25 for males).
*/
public static function getChildrenNearingThreshold(): array
{
$db = App::getInstance()->db();
$today = new \DateTimeImmutable('today');
$sixMonthsLater = $today->modify('+6 months');
$children = $db->select(
"SELECT c.id, c.full_name_ar, c.date_of_birth, c.gender, c.member_id, m.full_name_ar as parent_name
FROM children c
JOIN members m ON m.id = c.member_id AND m.is_archived = 0
WHERE c.gender = 'male'
AND c.is_frozen = 0
AND c.is_archived = 0
AND c.status = 'active'
AND c.date_of_birth IS NOT NULL"
);
$nearThreshold = [];
foreach ($children as $child) {
$dob = new \DateTimeImmutable($child['date_of_birth']);
$ageAtCheck = (int) $dob->diff($sixMonthsLater)->y;
$currentAge = (int) $dob->diff($today)->y;
if ($currentAge < 25 && $ageAtCheck >= 25) {
$turnsDate = $dob->modify('+25 years')->format('Y-m-d');
$nearThreshold[] = [
'child_id' => (int) $child['id'],
'name' => $child['full_name_ar'],
'parent_name' => $child['parent_name'],
'member_id' => (int) $child['member_id'],
'current_age' => $currentAge,
'turns_25_on' => $turnsDate,
];
}
}
return $nearThreshold;
}
/**
* Get current financial year string.
* FY runs July to June: if month >= 7, FY is "thisYear/thisYear+1", else "lastYear/thisYear".
......
......@@ -5,6 +5,7 @@ namespace App\Modules\Members\Services;
use App\Core\App;
use App\Modules\ServiceCatalog\Models\ServicePrice;
use App\Modules\Members\Services\BoardOfferService;
/**
* Calculates the TOTAL bill for a member including all additions.
......@@ -252,10 +253,25 @@ final class BillingService
}
} catch (\Throwable $e) {}
// ── 6. Board Offers (cash discount) ──
$cashOffer = BoardOfferService::getCashDiscount();
if ($cashOffer && ($cashOffer['applies_to'] === 'membership_fee' || $cashOffer['applies_to'] === 'all')) {
$items[] = [
'type' => 'board_offer',
'label' => 'عرض مجلس الإدارة: ' . $cashOffer['title_ar'] . ' (' . $cashOffer['discount_percentage'] . '%)',
'amount' => '0.00',
'paid' => false,
'included' => false,
'category' => 'offer',
'offer' => $cashOffer,
];
}
// ── Calculate totals ──
$totalRequired = '0.00';
$totalPaid = '0.00';
$totalPending = '0.00';
$totalInQueue = '0.00';
$totalIncluded = '0.00';
foreach ($items as $item) {
......@@ -266,6 +282,8 @@ final class BillingService
$totalRequired = bcadd($totalRequired, $item['amount'], 2);
if ($item['paid']) {
$totalPaid = bcadd($totalPaid, $item['amount'], 2);
} elseif (!empty($item['in_queue'])) {
$totalInQueue = bcadd($totalInQueue, $item['amount'], 2);
} else {
$totalPending = bcadd($totalPending, $item['amount'], 2);
}
......@@ -277,12 +295,13 @@ final class BillingService
'total_required' => $totalRequired,
'total_paid' => $totalPaid,
'total_pending' => $totalPending,
'total_in_queue' => $totalInQueue,
'total_included' => $totalIncluded,
'form_fee_paid' => $formFeePaid,
'form_fee_pending' => $formFeePending ?? false,
'membership_paid' => $membershipPaid,
'membership_pending' => $membershipPending ?? false,
'all_paid' => (bccomp($totalPending, '0', 2) <= 0),
'all_paid' => (bccomp($totalPending, '0', 2) <= 0 && bccomp($totalInQueue, '0', 2) <= 0),
];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
final class BoardOfferService
{
public static function getActiveOffers(string $type = ''): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$where = "is_active = 1 AND effective_from <= ? AND effective_to >= ?";
$params = [$today, $today];
if ($type !== '') {
$where .= " AND offer_type = ?";
$params[] = $type;
}
return $db->select("SELECT * FROM board_offers WHERE {$where} ORDER BY effective_from DESC", $params);
}
public static function getCashDiscount(): ?array
{
$offers = self::getActiveOffers('cash_discount');
return $offers[0] ?? null;
}
public static function getInstallmentTermsOverride(): ?array
{
$offers = self::getActiveOffers('installment_terms');
return $offers[0] ?? null;
}
public static function getSubscriptionDiscount(): ?array
{
$offers = self::getActiveOffers('subscription_discount');
return $offers[0] ?? null;
}
public static function applyCashDiscount(string $amount): array
{
$offer = self::getCashDiscount();
if (!$offer) {
return ['amount' => $amount, 'discount' => '0.00', 'offer' => null];
}
$pct = $offer['discount_percentage'] ?? '0';
$discount = bcdiv(bcmul($amount, $pct, 4), '100', 2);
$final = bcsub($amount, $discount, 2);
return [
'amount' => $final,
'discount' => $discount,
'offer' => $offer,
];
}
public static function getInstallmentOverrides(): array
{
$offer = self::getInstallmentTermsOverride();
if (!$offer) {
return [];
}
$overrides = [];
if ($offer['custom_months']) {
$overrides['max_months'] = (int) $offer['custom_months'];
}
if ($offer['custom_interest_rate'] !== null) {
$overrides['interest_rate'] = $offer['custom_interest_rate'];
}
$overrides['offer'] = $offer;
return $overrides;
}
}
......@@ -34,7 +34,8 @@ final class FormFeeService
/**
* Get the form fee for adding a dependant.
* Returns '0.00' if still on initial form (no membership number).
* Returns 570 EGP per person after activation.
* Returns 570 EGP + annual subscription after activation.
* Per regulations: "أي إضافة بعد إنشاء العضوية: 570 جنيه + الاشتراك السنوي"
*/
public static function getFormFee(int $memberId, array $member): string
{
......@@ -42,6 +43,23 @@ final class FormFeeService
return '0.00';
}
$feeData = RuleEngine::get('FORM_ADDITION_FEE');
$formAmount = ($feeData && isset($feeData['amount'])) ? $feeData['amount'] : ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00');
$annualSub = self::getAnnualSubscriptionForAddition();
return bcadd($formAmount, $annualSub, 2);
}
/**
* Get just the 570 form fee portion (without annual subscription).
*/
public static function getFormFeeOnly(int $memberId, array $member): string
{
if (self::isOnInitialForm($member)) {
return '0.00';
}
$feeData = RuleEngine::get('FORM_ADDITION_FEE');
if ($feeData && isset($feeData['amount'])) {
return $feeData['amount'];
......@@ -49,6 +67,19 @@ final class FormFeeService
return ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00');
}
private static function getAnnualSubscriptionForAddition(): string
{
$month = (int) date('n');
$year = (int) date('Y');
$fy = $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year;
$ratesData = RuleEngine::get('membership.annual_rates.' . $fy);
$childRate = $ratesData['child'] ?? '222';
$devFee = $ratesData['dev'] ?? '35';
return bcadd($childRate, $devFee, 2);
}
/**
* Check if this spouse is in a "free" slot (zero dependant fee).
* Free slots apply ALWAYS — on initial form AND after activation.
......
......@@ -1064,13 +1064,24 @@ final class MembershipRulesService
// Check outstanding installments
$unpaidInstallments = $db->selectOne(
"SELECT COUNT(*) as cnt FROM installment_payments WHERE member_id = ? AND status IN ('pending','overdue')",
"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 IN ('pending','overdue') AND s.due_date < CURDATE()",
[$memberId]
);
if ((int) ($unpaidInstallments['cnt'] ?? 0) > 0) {
return ['allowed' => false, 'reason' => 'يوجد أقساط مستحقة غير مسددة'];
}
// Check unpaid fines
$unpaidFines = $db->selectOne(
"SELECT COUNT(*) as cnt FROM fines WHERE member_id = ? AND penalty_type = 'fine' AND status = 'imposed' AND (amount - paid_amount) > 0",
[$memberId]
);
if ((int) ($unpaidFines['cnt'] ?? 0) > 0) {
return ['allowed' => false, 'reason' => 'يوجد غرامات غير مسددة'];
}
return ['allowed' => true, 'reason' => 'مسموح بالطباعة'];
}
......
<?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="/reports/age-report" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">العمر من</label>
<input type="number" name="min_age" value="<?= (int) $minAge ?>" min="0" max="120" class="form-input" style="width:80px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">العمر إلى</label>
<input type="number" name="max_age" value="<?= (int) $maxAge ?>" min="0" max="120" class="form-input" style="width:80px;">
</div>
<button type="submit" class="btn btn-outline">تصفية</button>
<a href="/reports/age-report" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card">
<div style="padding:15px;border-bottom:1px solid #E5E7EB;">
<strong>النتائج:</strong> <?= (int) $total ?> عضو بين <?= (int) $minAge ?> و <?= (int) $maxAge ?> سنة
</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>
</tr>
</thead>
<tbody>
<?php if (empty($members)): ?>
<tr><td colspan="8" style="text-align:center;padding:30px;color:#6B7280;">لا توجد نتائج</td></tr>
<?php else: ?>
<?php foreach ($members as $m): ?>
<tr>
<td><?= e($m['membership_number'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $m['id'] ?>"><?= e($m['full_name_ar']) ?></a></td>
<td><?= e($m['date_of_birth']) ?></td>
<td><?= (int) $m['age'] ?> سنة</td>
<td><?= $m['gender'] === 'male' ? 'ذكر' : 'أنثى' ?></td>
<td><?= e($m['branch_name'] ?? '—') ?></td>
<td>
<?php
$statusMap = ['active' => 'نشط', 'suspended' => 'موقوف', 'dropped' => 'مسقط', 'frozen' => 'مجمد'];
echo e($statusMap[$m['status']] ?? $m['status']);
?>
</td>
<td style="direction:ltr;text-align:right;"><?= e($m['phone_mobile'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?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="/reports/children-aging" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">العمر من</label>
<input type="number" name="min_age" value="<?= (int) $minAge ?>" min="0" max="99" class="form-input" style="width:80px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">العمر إلى</label>
<input type="number" name="max_age" value="<?= (int) $maxAge ?>" min="0" max="99" class="form-input" style="width:80px;">
</div>
<button type="submit" class="btn btn-outline">تصفية</button>
<a href="/reports/children-aging" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<?php if (!empty($nearThreshold)): ?>
<div class="card" style="margin-bottom:20px;padding:15px;border-right:4px solid #F59E0B;">
<h4 style="margin:0 0 10px;color:#D97706;">أبناء خلال 6 أشهر من سن 25 (يتطلبون إجراء)</h4>
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>الابن</th><th>تاريخ الميلاد</th><th>العمر</th><th>ولي الأمر</th><th>رقم العضوية</th></tr></thead>
<tbody>
<?php foreach ($nearThreshold as $c): ?>
<tr>
<td><?= e($c['full_name_ar']) ?></td>
<td><?= e($c['date_of_birth']) ?></td>
<td><?= (int) $c['current_age'] ?> سنة</td>
<td><a href="/members/<?= (int) $c['member_id'] ?>"><?= e($c['parent_name']) ?></a></td>
<td><?= e($c['membership_number'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div class="card">
<div style="padding:15px;border-bottom:1px solid #E5E7EB;">
<strong>النتائج:</strong> <?= count($children) ?> ابن/ابنة بين <?= (int) $minAge ?> و <?= (int) $maxAge ?> سنة
</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>
</tr>
</thead>
<tbody>
<?php if (empty($children)): ?>
<tr><td colspan="8" style="text-align:center;padding:30px;color:#6B7280;">لا توجد نتائج</td></tr>
<?php else: ?>
<?php foreach ($children as $c): ?>
<tr>
<td><?= e($c['full_name_ar']) ?></td>
<td><?= e($c['date_of_birth']) ?></td>
<td><?= (int) $c['current_age'] ?> سنة</td>
<td><?= $c['gender'] === 'male' ? 'ذكر' : 'أنثى' ?></td>
<td>
<?php if ($c['is_frozen']): ?>
<span class="badge badge-warning">مجمد</span>
<?php elseif ($c['child_status'] === 'active'): ?>
<span class="badge badge-success">نشط</span>
<?php else: ?>
<span class="badge badge-secondary"><?= e($c['child_status'] ?? '—') ?></span>
<?php endif; ?>
</td>
<td><a href="/members/<?= (int) $c['member_id'] ?>"><?= e($c['parent_name']) ?></a></td>
<td><?= e($c['membership_number'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($c['phone_mobile'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>التقارير<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;">
<a href="/reports/children-aging" class="card" style="padding:20px;text-decoration:none;color:inherit;">
<h3 style="margin:0 0 8px;">أبناء يقتربون من السن القانوني</h3>
<p style="color:#6B7280;font-size:14px;margin:0;">الأبناء المقتربون من سن 25 (موعد التحويل/الفصل)</p>
</a>
<a href="/reports/transfers" class="card" style="padding:20px;text-decoration:none;color:inherit;">
<h3 style="margin:0 0 8px;">التحويلات والانفصالات</h3>
<p style="color:#6B7280;font-size:14px;margin:0;">جميع التحويلات المكتملة بحسب الفترة الزمنية</p>
</a>
<a href="/reports/waivers" class="card" style="padding:20px;text-decoration:none;color:inherit;">
<h3 style="margin:0 0 8px;">التنازلات</h3>
<p style="color:#6B7280;font-size:14px;margin:0;">طلبات التنازل المكتملة مع المبالغ</p>
</a>
<a href="/reports/subscription-status" class="card" style="padding:20px;text-decoration:none;color:inherit;">
<h3 style="margin:0 0 8px;">حالة الاشتراكات</h3>
<p style="color:#6B7280;font-size:14px;margin:0;">ملخص الاشتراكات السنوية — مدفوع/متأخر/معلق</p>
</a>
<a href="/reports/age-report" class="card" style="padding:20px;text-decoration:none;color:inherit;">
<h3 style="margin:0 0 8px;">تقرير الأعمار</h3>
<p style="color:#6B7280;font-size:14px;margin:0;">توزيع الأعضاء حسب الفئة العمرية</p>
</a>
<a href="/reports/unpaid-debts" class="card" style="padding:20px;text-decoration:none;color:inherit;">
<h3 style="margin:0 0 8px;">المديونيات المعلقة</h3>
<p style="color:#6B7280;font-size:14px;margin:0;">غرامات وأقساط غير مسددة</p>
</a>
</div>
<?php $__template->endSection(); ?>
<?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="/reports/subscription-status" 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="financial_year" value="<?= e($financialYear) ?>" placeholder="2024/2025" class="form-input" style="width:120px;">
</div>
<button type="submit" class="btn btn-outline">عرض</button>
</form>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:15px;margin-bottom:20px;">
<?php
$statusLabels = ['paid' => 'مدفوع', 'pending' => 'معلق', 'overdue' => 'متأخر', 'partial' => 'جزئي'];
$statusColors = ['paid' => '#10B981', 'pending' => '#F59E0B', 'overdue' => '#EF4444', 'partial' => '#6366F1'];
foreach ($summary as $s):
$label = $statusLabels[$s['status']] ?? $s['status'];
$color = $statusColors[$s['status']] ?? '#6B7280';
?>
<div class="card" style="padding:15px;border-right:4px solid <?= $color ?>;">
<div style="font-size:13px;color:#6B7280;"><?= e($label) ?></div>
<div style="font-size:24px;font-weight:bold;margin:5px 0;"><?= (int) $s['cnt'] ?></div>
<div style="font-size:12px;color:#6B7280;">
إجمالي: <?= money($s['total'] ?? '0') ?><br>
مدفوع: <?= money($s['paid'] ?? '0') ?><br>
غرامات: <?= money($s['fines'] ?? '0') ?>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="card">
<div style="padding:15px;border-bottom:1px solid #E5E7EB;">
<strong>أعلى 100 عضو بمبالغ متأخرة — <?= e($financialYear) ?></strong>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم العضوية</th>
<th>الاسم</th>
<th>الهاتف</th>
<th>المبلغ المستحق</th>
</tr>
</thead>
<tbody>
<?php if (empty($unpaidMembers)): ?>
<tr><td colspan="4" style="text-align:center;padding:30px;color:#6B7280;">لا توجد مبالغ متأخرة</td></tr>
<?php else: ?>
<?php foreach ($unpaidMembers as $m): ?>
<tr>
<td><?= e($m['membership_number'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $m['id'] ?>"><?= e($m['full_name_ar']) ?></a></td>
<td style="direction:ltr;text-align:right;"><?= e($m['phone_mobile'] ?? '—') ?></td>
<td style="color:#EF4444;font-weight:bold;"><?= money($m['outstanding'] ?? '0') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?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="/reports/transfers" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" value="<?= e($dateFrom) ?>" class="form-input">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" value="<?= e($dateTo) ?>" class="form-input">
</div>
<div>
<label class="form-label" style="font-size:12px;">النوع</label>
<select name="type" class="form-select" style="min-width:140px;">
<option value="">الكل</option>
<option value="separation" <?= $type === 'separation' ? 'selected' : '' ?>>انفصال</option>
<option value="divorce" <?= $type === 'divorce' ? 'selected' : '' ?>>طلاق</option>
<option value="death" <?= $type === 'death' ? 'selected' : '' ?>>وفاة</option>
</select>
</div>
<button type="submit" class="btn btn-outline">تصفية</button>
<a href="/reports/transfers" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card">
<div style="padding:15px;border-bottom:1px solid #E5E7EB;">
<strong>النتائج:</strong> <?= count($transfers) ?> عملية تحويل مكتملة
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>تاريخ الإتمام</th>
<th>النوع</th>
<th>المصدر</th>
<th>الرقم القديم</th>
<th>المستفيد</th>
<th>الرقم الجديد</th>
</tr>
</thead>
<tbody>
<?php if (empty($transfers)): ?>
<tr><td colspan="6" style="text-align:center;padding:30px;color:#6B7280;">لا توجد نتائج</td></tr>
<?php else: ?>
<?php foreach ($transfers as $t): ?>
<tr>
<td><?= e($t['completed_at'] ?? '—') ?></td>
<td>
<?= match($t['transfer_type'] ?? '') {
'separation' => 'انفصال',
'divorce' => 'طلاق',
'death' => 'وفاة',
default => e($t['transfer_type'] ?? '—'),
} ?>
</td>
<td><?= e($t['source_name'] ?? '—') ?></td>
<td><?= e($t['old_number'] ?? '—') ?></td>
<td><?= e($t['target_name'] ?? '—') ?></td>
<td><?= e($t['new_membership_number'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير المديونيات المعلقة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<h3 style="margin:0 0 15px;">غرامات غير مسددة</h3>
<div class="card" style="margin-bottom:30px;">
<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>
</tr>
</thead>
<tbody>
<?php if (empty($unpaidFines)): ?>
<tr><td colspan="8" style="text-align:center;padding:30px;color:#6B7280;">لا توجد غرامات معلقة</td></tr>
<?php else: ?>
<?php foreach ($unpaidFines as $f): ?>
<tr>
<td><?= e($f['membership_number'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $f['member_id'] ?>"><?= e($f['full_name_ar']) ?></a></td>
<td><?= e($f['penalty_type'] ?? '—') ?></td>
<td><?= money($f['amount']) ?></td>
<td><?= money($f['paid_amount'] ?? '0') ?></td>
<td style="color:#EF4444;font-weight:bold;"><?= money(bcsub($f['amount'], $f['paid_amount'] ?? '0', 2)) ?></td>
<td><?= e($f['created_at'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($f['phone_mobile'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<h3 style="margin:0 0 15px;">أقساط متأخرة</h3>
<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 if (empty($unpaidInstallments)): ?>
<tr><td colspan="6" style="text-align:center;padding:30px;color:#6B7280;">لا توجد أقساط متأخرة</td></tr>
<?php else: ?>
<?php foreach ($unpaidInstallments as $i): ?>
<tr>
<td><?= e($i['membership_number'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $i['member_id'] ?>"><?= e($i['full_name_ar']) ?></a></td>
<td><?= money($i['total_with_interest'] ?? '0') ?></td>
<td><?= (int) $i['overdue_items'] ?> قسط</td>
<td style="color:#EF4444;font-weight:bold;"><?= money($i['overdue_amount'] ?? '0') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($i['phone_mobile'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?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="/reports/waivers" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" value="<?= e($dateFrom) ?>" class="form-input">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" value="<?= e($dateTo) ?>" class="form-input">
</div>
<button type="submit" class="btn btn-outline">تصفية</button>
<a href="/reports/waivers" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card">
<div style="padding:15px;border-bottom:1px solid #E5E7EB;">
<strong>النتائج:</strong> <?= count($waivers) ?> عملية تنازل مكتملة
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>المتنازل</th>
<th>رقم العضوية</th>
<th>المتنازل إليه</th>
<th>نسبة الرسم</th>
<th>مبلغ الرسم</th>
</tr>
</thead>
<tbody>
<?php if (empty($waivers)): ?>
<tr><td colspan="6" style="text-align:center;padding:30px;color:#6B7280;">لا توجد نتائج</td></tr>
<?php else: ?>
<?php foreach ($waivers as $w): ?>
<tr>
<td><?= e($w['updated_at'] ?? '—') ?></td>
<td><?= e($w['source_name'] ?? '—') ?></td>
<td><?= e($w['membership_number'] ?? '—') ?></td>
<td><?= e($w['target_name'] ?? '—') ?></td>
<td><?= e($w['waiver_fee_percentage'] ?? '30') ?>%</td>
<td><?= isset($w['waiver_fee_amount']) ? money($w['waiver_fee_amount']) : '—' ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
......@@ -37,6 +37,8 @@ MenuRegistry::register('membership', [
['label_ar' => 'حالات الطلاق', 'label_en' => 'Divorce Cases', 'route' => '/divorce', 'permission' => 'transfer.view', 'order' => 41],
['label_ar' => 'حالات الوفاة', 'label_en' => 'Death Cases', 'route' => '/death', 'permission' => 'transfer.view', 'order' => 42],
['label_ar' => 'طلبات التنازل', 'label_en' => 'Waiver Requests', 'route' => '/waivers', 'permission' => 'waiver.view', 'order' => 43],
// ── Reports ─────────────────────────────────
['label_ar' => 'التقارير', 'label_en' => 'Reports', 'route' => '/reports', 'permission' => 'member.reports', 'order' => 50],
],
]);
......@@ -51,4 +53,5 @@ PermissionRegistry::register('members', [
'member.pay_form_fee' => ['ar' => 'دفع رسوم النموذج', 'en' => 'Pay Form Fee'],
'member.pay_membership' => ['ar' => 'دفع رسوم العضوية', 'en' => 'Pay Membership Fee'],
'member.fill_form' => ['ar' => 'تعبئة نموذج العضو', 'en' => 'Fill Member Form'],
'member.reports' => ['ar' => 'تقارير العضوية', 'en' => 'Membership Reports'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Subscriptions\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class OverdueFineApplicator
{
public static function run(): array
{
$db = App::getInstance()->db();
$currentFY = self::currentFinancialYear();
$results = ['fines_applied' => 0, 'members_dropped' => 0, 'errors' => []];
$members = $db->select(
"SELECT DISTINCT s.member_id
FROM subscriptions s
JOIN members m ON m.id = s.member_id AND m.is_archived = 0 AND m.status = 'active'
WHERE s.status IN ('pending','overdue') AND s.financial_year < ?",
[$currentFY]
);
foreach ($members as $row) {
$memberId = (int) $row['member_id'];
try {
$calc = SubscriptionCalculator::calculateLateFine($memberId, $currentFY);
// Update fine amounts on individual subscriptions
foreach ($calc['details'] as $detail) {
$db->query(
"UPDATE subscriptions SET fine_amount = ?, status = 'overdue', updated_at = NOW()
WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
[$detail['fine_amount'], $memberId, $detail['financial_year']]
);
}
if (bccomp($calc['total_fine'], '0', 2) > 0) {
$results['fines_applied']++;
}
// Drop membership if 5+ consecutive years unpaid
if ($calc['should_drop']) {
$db->query(
"UPDATE members SET status = 'dropped', updated_at = NOW() WHERE id = ? AND status = 'active'",
[$memberId]
);
$results['members_dropped']++;
EventBus::dispatch('member.dropped', [
'member_id' => $memberId,
'reason' => 'تأخر عن سداد الاشتراك السنوي 5 سنوات متتالية',
'years_unpaid' => $calc['years_unpaid'],
]);
Logger::info("Member dropped for non-payment", [
'member_id' => $memberId,
'years' => $calc['years_unpaid'],
]);
}
} catch (\Throwable $e) {
$results['errors'][] = "Member #{$memberId}: " . $e->getMessage();
Logger::error("OverdueFineApplicator error", ['member_id' => $memberId, 'error' => $e->getMessage()]);
}
}
return $results;
}
private static function currentFinancialYear(): string
{
$month = (int) date('n');
$year = (int) date('Y');
return $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year;
}
}
......@@ -141,6 +141,8 @@ final class TransferProcessor
'target_member_id' => $newMemberId,
'new_number' => $newNumber,
'transfer_type' => $request['transfer_type'],
'fee_amount' => $request['total_fee'] ?? $request['separation_fee'] ?? '0.00',
'old_number' => $sourceMember['membership_number'] ?? null,
]);
Logger::info("Transfer completed", [
......
......@@ -66,6 +66,8 @@ final class WaiverProcessor
'source_member_id' => (int) $waiver['source_member_id'],
'target_member_id' => (int) $waiver['target_member_id'],
'membership_number'=> $waiver['membership_number'],
'fee_amount' => $waiver['waiver_fee_amount'] ?? '0.00',
'fee_percentage' => $waiver['waiver_fee_percentage'] ?? '30',
]);
return ['success' => true, 'snapshot_id' => $snapshotId];
......
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\EventBus;
use App\Core\Logger;
class InstallmentDefaultJob
{
private const OVERDUE_THRESHOLD_DAYS = 90;
private const MIN_OVERDUE_ITEMS = 3;
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$processed = 0;
$defaulted = $this->db->select(
"SELECT p.id as plan_id, p.member_id, COUNT(s.id) as overdue_count
FROM installment_plans p
JOIN installment_schedule s ON s.installment_plan_id = p.id
WHERE p.status = 'active'
AND s.status = 'pending'
AND s.due_date < DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY p.id, p.member_id
HAVING overdue_count >= ?",
[self::OVERDUE_THRESHOLD_DAYS, self::MIN_OVERDUE_ITEMS]
);
foreach ($defaulted as $plan) {
$planId = (int) $plan['plan_id'];
$memberId = (int) $plan['member_id'];
$this->db->update('installment_plans', [
'status' => 'defaulted',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$planId]);
$this->db->query(
"UPDATE installment_schedule SET status = 'overdue', updated_at = NOW()
WHERE installment_plan_id = ? AND status = 'pending'",
[$planId]
);
$member = $this->db->selectOne(
"SELECT status FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if ($member && $member['status'] === 'active') {
$this->db->update('members', [
'status' => 'dropped',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$memberId]);
EventBus::dispatch('member.dropped', [
'member_id' => $memberId,
'reason' => 'عدم الالتزام بسداد الأقساط المستحقة',
'installment_plan_id' => $planId,
]);
Logger::warning("Member #{$memberId} dropped: installment default (plan #{$planId})");
}
$processed++;
}
return ['processed' => $processed];
}
}
......@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Subscriptions\Services\SubscriptionCalculator;
class OverdueFineJob
{
......@@ -13,7 +15,6 @@ class OverdueFineJob
public function shouldRun(): bool
{
// Run after October 1 (month 10+)
return (int) date('n') >= 10;
}
......@@ -21,7 +22,7 @@ class OverdueFineJob
{
$processed = 0;
// Find unpaid subscriptions that are overdue (past September 30)
// Mark unpaid subscriptions as overdue
$overdue = $this->db->select(
"SELECT s.id, s.member_id, s.total_amount, s.financial_year
FROM subscriptions s
......@@ -29,7 +30,6 @@ class OverdueFineJob
);
foreach ($overdue as $sub) {
// Mark as overdue if not already
$this->db->update('subscriptions', [
'status' => 'overdue',
'updated_at' => date('Y-m-d H:i:s'),
......@@ -37,10 +37,32 @@ class OverdueFineJob
$processed++;
}
// Check for 5+ consecutive years of non-payment → drop membership
// Apply calculated fines to overdue subscriptions
$currentFY = self::currentFinancialYear();
$membersWithOverdue = $this->db->select(
"SELECT DISTINCT member_id FROM subscriptions WHERE status = 'overdue'"
);
foreach ($membersWithOverdue as $row) {
try {
$calc = SubscriptionCalculator::calculateLateFine((int) $row['member_id'], $currentFY);
foreach ($calc['details'] as $detail) {
$this->db->query(
"UPDATE subscriptions SET fine_amount = ?, updated_at = NOW()
WHERE member_id = ? AND financial_year = ? AND status = 'overdue'",
[$detail['fine_amount'], (int) $row['member_id'], $detail['financial_year']]
);
}
} catch (\Throwable $e) {
Logger::error("Fine calculation failed for member #{$row['member_id']}: " . $e->getMessage());
}
}
// Drop memberships with 5+ consecutive years unpaid
$members = $this->db->select(
"SELECT s.member_id, COUNT(DISTINCT s.financial_year) as unpaid_years
FROM subscriptions s
JOIN members m ON m.id = s.member_id AND m.status = 'active' AND m.is_archived = 0
WHERE s.status IN ('pending','overdue') AND s.paid_amount = 0
GROUP BY s.member_id
HAVING unpaid_years >= 5"
......@@ -53,11 +75,24 @@ class OverdueFineJob
], '`id` = ? AND `status` = \'active\'', [(int) $m['member_id']]);
if ($this->db->pdo()->rowCount() > 0) {
Logger::warning("Membership dropped due to 5+ years non-payment: member #{$m['member_id']}");
Logger::warning("Membership dropped: member #{$m['member_id']} ({$m['unpaid_years']} years)");
$processed++;
EventBus::dispatch('member.dropped', [
'member_id' => (int) $m['member_id'],
'reason' => 'تأخر عن سداد الاشتراك السنوي 5 سنوات متتالية',
'years_unpaid' => (int) $m['unpaid_years'],
]);
}
}
return ['processed' => $processed];
}
private static function currentFinancialYear(): string
{
$month = (int) date('n');
$year = (int) date('Y');
return $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `board_offers` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`title_ar` VARCHAR(200) NOT NULL,
`offer_type` ENUM('cash_discount','installment_terms','subscription_discount') NOT NULL,
`discount_percentage` DECIMAL(5,2) NULL,
`custom_months` INT NULL,
`custom_interest_rate` DECIMAL(5,2) NULL,
`applies_to` ENUM('membership_fee','subscription','all') NOT NULL DEFAULT 'membership_fee',
`effective_from` DATE NOT NULL,
`effective_to` DATE NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`board_decision_number` VARCHAR(50) NULL,
`board_decision_date` DATE NULL,
`notes` TEXT NULL,
`created_by` INT UNSIGNED NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS `board_offers`",
];
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