Commit 194da186 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Overhaul subscription display: FIFO enforcement, fine calculations, year grouping

- Add FIFO payment validation: must pay oldest year first before newer years
- Add OverdueFineApplicator::applyForMember() for on-demand fine recalculation
- Rewrite view with year-grouped sections, fine breakdown panels, totals
- Add data migration to fix corrupted rows (paid_amount with no payment_id)
- Show fine calculation details (percentage × base = amount, from rules engine)
- Disable pay buttons for non-oldest years with Arabic tooltip
- Summary cards showing total debt, fines, and years overdue
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4b76c4b3
......@@ -11,6 +11,7 @@ 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\Subscriptions\Services\OverdueFineApplicator;
use App\Modules\Payments\Services\PaymentService;
class SubscriptionController extends Controller
......@@ -42,15 +43,54 @@ class SubscriptionController extends Controller
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$lateFine = OverdueFineApplicator::applyForMember((int) $memberId);
$subscriptions = Subscription::getForMember((int) $memberId);
$unpaidYears = Subscription::getUnpaidYears((int) $memberId);
$lateFine = SubscriptionCalculator::calculateLateFine((int) $memberId, self::currentFinancialYear());
$grouped = [];
foreach ($subscriptions as $s) {
$grouped[$s['financial_year']][] = $s;
}
$yearTotals = [];
foreach ($grouped as $fy => $rows) {
$totalAmount = '0.00';
$totalPaid = '0.00';
$totalFine = '0.00';
$totalDev = '0.00';
$allPaid = true;
$anyPaid = false;
foreach ($rows as $r) {
$totalAmount = bcadd($totalAmount, $r['total_amount'], 2);
$totalPaid = bcadd($totalPaid, $r['paid_amount'] ?? '0', 2);
$totalFine = bcadd($totalFine, $r['fine_amount'] ?? '0', 2);
$totalDev = bcadd($totalDev, $r['development_fee'] ?? '0', 2);
if ($r['status'] !== 'paid' && $r['status'] !== 'exempt') $allPaid = false;
if ($r['status'] === 'paid') $anyPaid = true;
}
$yearTotals[$fy] = [
'total_amount' => $totalAmount,
'total_paid' => $totalPaid,
'total_fine' => $totalFine,
'total_dev' => $totalDev,
'year_status' => $allPaid ? 'paid' : ($anyPaid ? 'partial' : 'unpaid'),
'count' => count($rows),
];
}
$oldestUnpaidYear = null;
foreach ($unpaidYears as $uy) {
$oldestUnpaidYear = $uy['financial_year'];
break;
}
return $this->view('Subscriptions.Views.member-subscriptions', [
'member' => $member,
'subscriptions' => $subscriptions,
'unpaidYears' => $unpaidYears,
'grouped' => $grouped,
'yearTotals' => $yearTotals,
'lateFine' => $lateFine,
'oldestUnpaidYear' => $oldestUnpaidYear,
]);
}
......@@ -61,6 +101,15 @@ class SubscriptionController extends Controller
if (!$sub) return $this->redirect('/subscriptions')->withError('الاشتراك غير موجود');
if ($sub['status'] === 'paid') return $this->redirect('/subscriptions')->withError('الاشتراك مدفوع بالفعل');
$olderUnpaid = $db->selectOne(
"SELECT financial_year FROM subscriptions WHERE member_id = ? AND financial_year < ? AND status IN ('pending','overdue') LIMIT 1",
[(int) $sub['member_id'], $sub['financial_year']]
);
if ($olderUnpaid) {
return $this->redirect("/members/{$sub['member_id']}/subscriptions")
->withError('يجب سداد اشتراكات السنة الأقدم أولاً (' . $olderUnpaid['financial_year'] . ')');
}
$amount = bcsub(bcadd($sub['total_amount'], $sub['fine_amount'], 2), $sub['paid_amount'], 2);
$data = $request->all();
......
......@@ -70,6 +70,27 @@ final class OverdueFineApplicator
return $results;
}
public static function applyForMember(int $memberId): array
{
$db = App::getInstance()->db();
$currentFY = self::currentFinancialYear();
$calc = SubscriptionCalculator::calculateLateFine($memberId, $currentFY);
foreach ($calc['details'] as $detail) {
if ($detail['in_grace'] ?? false) continue;
if (bccomp($detail['fine_amount'], '0', 2) <= 0) continue;
$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']]
);
}
return $calc;
}
public static function expireReinstatements(): array
{
$db = App::getInstance()->db();
......
<?php
declare(strict_types=1);
return [
'up' => "UPDATE subscriptions SET paid_amount = 0.00, updated_at = NOW() WHERE paid_amount > 0 AND payment_id IS NULL AND status IN ('pending', 'overdue')",
'down' => "SELECT 1",
];
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