Commit fc2128c5 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix cron job: delegate to OverdueFineApplicator, enforce consecutive-year drop rule

- OverdueFineJob now delegates fine calculation/distribution/drop to
  OverdueFineApplicator::run() which already does proportional distribution
- Added reinstatement expiry call (12-month window enforcement)
- Only marks subscriptions overdue when financial_year < current (grace period)
- SubscriptionCalculator: drop check now verifies 5 CONSECUTIVE unpaid years
  instead of just checking if oldest overdue >= 5 (prevents false drops when
  a member paid middle years)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 5287a41a
...@@ -76,7 +76,24 @@ final class SubscriptionCalculator ...@@ -76,7 +76,24 @@ final class SubscriptionCalculator
$yearCount = count(array_filter($fineDetails, fn($d) => !($d['in_grace'] ?? false))); $yearCount = count(array_filter($fineDetails, fn($d) => !($d['in_grace'] ?? false)));
$oldestOverdue = !empty($fineDetails) ? max(array_column($fineDetails, 'years_overdue')) : 0; $oldestOverdue = !empty($fineDetails) ? max(array_column($fineDetails, 'years_overdue')) : 0;
$shouldDrop = $oldestOverdue >= $dropYears;
// Check for CONSECUTIVE unpaid years (not just oldest overdue)
$shouldDrop = false;
if ($oldestOverdue >= $dropYears) {
$unpaidStarts = array_map(fn($uy) => (int) explode('/', $uy['financial_year'])[0], $unpaidYears);
sort($unpaidStarts);
$consecutive = 1;
$maxConsecutive = 1;
for ($i = 1, $c = count($unpaidStarts); $i < $c; $i++) {
if ($unpaidStarts[$i] === $unpaidStarts[$i - 1] + 1) {
$consecutive++;
if ($consecutive > $maxConsecutive) $maxConsecutive = $consecutive;
} else {
$consecutive = 1;
}
}
$shouldDrop = $maxConsecutive >= $dropYears;
}
return [ return [
'total_fine' => $totalFine, 'total_fine' => $totalFine,
......
...@@ -4,9 +4,8 @@ declare(strict_types=1); ...@@ -4,9 +4,8 @@ declare(strict_types=1);
namespace CronJobs; namespace CronJobs;
use App\Core\Database; use App\Core\Database;
use App\Core\EventBus;
use App\Core\Logger; use App\Core\Logger;
use App\Modules\Subscriptions\Services\SubscriptionCalculator; use App\Modules\Subscriptions\Services\OverdueFineApplicator;
class OverdueFineJob class OverdueFineJob
{ {
...@@ -22,11 +21,12 @@ class OverdueFineJob ...@@ -22,11 +21,12 @@ class OverdueFineJob
{ {
$processed = 0; $processed = 0;
// Mark unpaid subscriptions as overdue // Mark unpaid subscriptions as overdue (past grace period)
$currentFY = self::currentFinancialYear();
$overdue = $this->db->select( $overdue = $this->db->select(
"SELECT s.id, s.member_id, s.total_amount, s.financial_year "SELECT s.id FROM subscriptions s
FROM subscriptions s WHERE s.status = 'pending' AND s.paid_amount = 0 AND s.financial_year < ?",
WHERE s.status = 'pending' AND s.paid_amount = 0" [$currentFY]
); );
foreach ($overdue as $sub) { foreach ($overdue as $sub) {
...@@ -37,56 +37,26 @@ class OverdueFineJob ...@@ -37,56 +37,26 @@ class OverdueFineJob
$processed++; $processed++;
} }
// Apply calculated fines to overdue subscriptions // Delegate fine calculation, distribution, and drop logic to OverdueFineApplicator
$currentFY = self::currentFinancialYear(); $result = OverdueFineApplicator::run();
$membersWithOverdue = $this->db->select( $processed += $result['fines_applied'] + $result['members_dropped'];
"SELECT DISTINCT member_id FROM subscriptions WHERE status = 'overdue'"
);
foreach ($membersWithOverdue as $row) { // Expire reinstatement windows (12-month rule)
try { $expireResult = OverdueFineApplicator::expireReinstatements();
$calc = SubscriptionCalculator::calculateLateFine((int) $row['member_id'], $currentFY); $processed += $expireResult['expired'];
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"
);
foreach ($members as $m) {
$this->db->update('members', [
'status' => 'dropped',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ? AND `status` = \'active\'', [(int) $m['member_id']]);
if ($this->db->pdo()->rowCount() > 0) {
Logger::warning("Membership dropped: member #{$m['member_id']} ({$m['unpaid_years']} years)");
$processed++;
EventBus::dispatch('member.dropped', [ if (!empty($result['errors'])) {
'member_id' => (int) $m['member_id'], foreach ($result['errors'] as $err) {
'reason' => 'تأخر عن سداد الاشتراك السنوي 5 سنوات متتالية', Logger::error("OverdueFineJob: " . $err);
'years_unpaid' => (int) $m['unpaid_years'],
]);
} }
} }
return ['processed' => $processed]; return [
'processed' => $processed,
'fines_applied' => $result['fines_applied'],
'members_dropped' => $result['members_dropped'],
'reinstatements_expired' => $expireResult['expired'],
];
} }
private static function currentFinancialYear(): string private static function currentFinancialYear(): string
......
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