Commit 6cb14f82 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix subscription penalty escalation to use correct per-year overdue model

The old calculator applied a flat percentage by position (oldest=10%, next=50%,
next=100%). The correct model: each year's penalty escalates based on how long
THAT SPECIFIC year has been overdue:
- Same year past grace (3 months): 10%
- 1 year overdue: 50%
- 2 years overdue: 100%
- 3 years overdue: 200%
- 4 years overdue: 300%
- 5+ years: membership dropped

Also adds:
- Grace period rule (SUB_GRACE_MONTHS = 3, extended = 4 with trustees approval)
- YEAR_4 (200%) and YEAR_5 (300%) rules in DB
- canReinstate() method for 12-month reinstatement window
- expireReinstatements() for permanent drop after window expires
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4bc28278
......@@ -29,8 +29,9 @@ final class OverdueFineApplicator
try {
$calc = SubscriptionCalculator::calculateLateFine($memberId, $currentFY);
// Update fine amounts on individual subscriptions
foreach ($calc['details'] as $detail) {
if (($detail['in_grace'] ?? false)) continue;
$db->query(
"UPDATE subscriptions SET fine_amount = ?, status = 'overdue', updated_at = NOW()
WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
......@@ -42,7 +43,6 @@ final class OverdueFineApplicator
$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'",
......@@ -51,14 +51,14 @@ final class OverdueFineApplicator
$results['members_dropped']++;
EventBus::dispatch('member.dropped', [
'member_id' => $memberId,
'reason' => 'تأخر عن سداد الاشتراك السنوي 5 سنوات متتالية',
'member_id' => $memberId,
'reason' => 'تأخر عن سداد الاشتراك السنوي ' . $calc['years_overdue'] . ' سنوات متتالية',
'years_unpaid' => $calc['years_unpaid'],
]);
Logger::info("Member dropped for non-payment", [
'member_id' => $memberId,
'years' => $calc['years_unpaid'],
'years' => $calc['years_overdue'],
]);
}
} catch (\Throwable $e) {
......@@ -70,6 +70,38 @@ final class OverdueFineApplicator
return $results;
}
public static function expireReinstatements(): array
{
$db = App::getInstance()->db();
$results = ['expired' => 0];
$windowData = \App\Modules\Rules\Services\RuleEngine::get('REINSTATEMENT_WINDOW');
$windowMonths = (int) ($windowData['months'] ?? 12);
$expired = $db->select(
"SELECT id, full_name_ar FROM members
WHERE status = 'dropped'
AND updated_at < DATE_SUB(NOW(), INTERVAL ? MONTH)
AND status != 'permanently_dropped'",
[$windowMonths]
);
foreach ($expired as $m) {
$db->query(
"UPDATE members SET status = 'permanently_dropped', updated_at = NOW() WHERE id = ?",
[(int) $m['id']]
);
$results['expired']++;
EventBus::dispatch('member.permanently_dropped', [
'member_id' => (int) $m['id'],
'reason' => 'انتهاء مهلة إعادة العضوية (' . $windowMonths . ' شهر) بعد إسقاط العضوية',
]);
}
return $results;
}
private static function currentFinancialYear(): string
{
$month = (int) date('n');
......
......@@ -20,45 +20,114 @@ final class SubscriptionCalculator
);
$totalFine = '0.00';
$yearCount = count($unpaidYears);
$fineDetails = [];
$maxYearsData = RuleEngine::get('LATE_SUB_FINE_MAX_YEARS');
$maxYears = $maxYearsData['years'] ?? 5;
$maxYears = (int) ($maxYearsData['years'] ?? 5);
$dropYearsData = RuleEngine::get('LATE_SUB_DROP_YEARS');
$dropYears = $dropYearsData['years'] ?? 5;
$dropYears = (int) ($dropYearsData['years'] ?? 5);
$graceData = RuleEngine::get('SUB_GRACE_MONTHS');
$graceMonths = (int) ($graceData['months'] ?? 3);
$shouldDrop = $yearCount >= $dropYears;
$currentFYStart = (int) explode('/', $financialYear)[0];
foreach ($unpaidYears as $idx => $uy) {
$yearNum = $idx + 1;
if ($yearNum > $maxYears) break;
foreach ($unpaidYears as $uy) {
$subFYStart = (int) explode('/', $uy['financial_year'])[0];
$yearsOverdue = $currentFYStart - $subFYStart;
if ($yearsOverdue === 0) {
$graceEnd = mktime(0, 0, 0, 7 + $graceMonths, 1, $subFYStart);
if (time() < $graceEnd) {
$fineDetails[] = [
'financial_year' => $uy['financial_year'],
'unpaid' => bcsub($uy['total'], $uy['paid'], 2),
'fine_percentage' => '0',
'fine_amount' => '0.00',
'years_overdue' => 0,
'in_grace' => true,
];
continue;
}
}
$unpaid = bcsub($uy['total'], $uy['paid'], 2);
if (bccomp($unpaid, '0', 2) <= 0) continue;
$ruleCode = 'LATE_SUB_FINE_YEAR_' . min($yearNum, 5);
$penaltyLevel = $yearsOverdue + 1;
if ($penaltyLevel > $maxYears) $penaltyLevel = $maxYears;
$ruleCode = 'LATE_SUB_FINE_YEAR_' . $penaltyLevel;
$fineData = RuleEngine::get($ruleCode);
$finePct = $fineData['percentage_of_subscription'] ?? '0';
$fineAmount = bcdiv(bcmul($unpaid, $finePct, 4), '100', 2);
// Fine is calculated on the full subscription amount (total_amount), not remaining unpaid
$fineAmount = bcdiv(bcmul($uy['total'], $finePct, 4), '100', 2);
$totalFine = bcadd($totalFine, $fineAmount, 2);
$fineDetails[] = [
'financial_year' => $uy['financial_year'],
'unpaid' => $unpaid,
'financial_year' => $uy['financial_year'],
'unpaid' => $unpaid,
'fine_percentage' => $finePct,
'fine_amount' => $fineAmount,
'year_number' => $yearNum,
'fine_amount' => $fineAmount,
'years_overdue' => $yearsOverdue,
'in_grace' => false,
];
}
$yearCount = count(array_filter($fineDetails, fn($d) => !($d['in_grace'] ?? false)));
$oldestOverdue = !empty($fineDetails) ? max(array_column($fineDetails, 'years_overdue')) : 0;
$shouldDrop = $oldestOverdue >= $dropYears;
return [
'total_fine' => $totalFine,
'details' => $fineDetails,
'years_unpaid' => count($unpaidYears),
'years_overdue' => $oldestOverdue,
'should_drop' => $shouldDrop,
'max_years' => $maxYears,
'grace_months' => $graceMonths,
];
}
public static function canReinstate(int $memberId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT status, updated_at FROM members WHERE id = ? AND status = 'dropped'",
[$memberId]
);
if (!$member) {
return ['eligible' => false, 'reason' => 'العضو ليس مسقطاً'];
}
$windowData = RuleEngine::get('REINSTATEMENT_WINDOW');
$windowMonths = (int) ($windowData['months'] ?? 12);
$droppedAt = strtotime($member['updated_at']);
$deadline = strtotime("+{$windowMonths} months", $droppedAt);
if (time() > $deadline) {
return [
'eligible' => false,
'reason' => 'انتهت مهلة إعادة العضوية (' . $windowMonths . ' شهر)',
'dropped_at' => $member['updated_at'],
'deadline' => date('Y-m-d', $deadline),
];
}
$totalDebt = $db->selectOne(
"SELECT COALESCE(SUM(total_amount - paid_amount + fine_amount), 0) as debt
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue')",
[$memberId]
);
return [
'total_fine' => $totalFine,
'details' => $fineDetails,
'years_unpaid' => $yearCount,
'should_drop' => $shouldDrop,
'max_years' => $maxYears,
'eligible' => true,
'total_debt' => $totalDebt['debt'] ?? '0.00',
'dropped_at' => $member['updated_at'],
'deadline' => date('Y-m-d', $deadline),
'days_remaining' => max(0, (int) ceil(($deadline - time()) / 86400)),
];
}
}
\ No newline at end of file
}
......@@ -3,16 +3,18 @@ declare(strict_types=1);
return [
'up' => "
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"10.00\"}', name_ar = 'غرامة تأخير أولية (أكتوبر-يونيو)' WHERE rule_code = 'LATE_SUB_FINE_YEAR_1';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"50.00\"}', name_ar = 'غرامة تأخير السنة المالية الأولى' WHERE rule_code = 'LATE_SUB_FINE_YEAR_2';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"100.00\"}', name_ar = 'غرامة تأخير السنة المالية الثانية' WHERE rule_code = 'LATE_SUB_FINE_YEAR_3';
INSERT INTO business_rules (rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, created_at, updated_at) VALUES ('LATE_SUB_FINE_YEAR_4', 'penalty', 'غرامة تأخير السنة المالية الثالثة', 'Late Fine Year 3 (200%)', 'percentage', '{\"percentage_of_subscription\":\"200.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE current_value_json = '{\"percentage_of_subscription\":\"200.00\"}', name_ar = 'غرامة تأخير السنة المالية الثالثة';
INSERT INTO business_rules (rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, created_at, updated_at) VALUES ('LATE_SUB_FINE_YEAR_5', 'penalty', 'غرامة تأخير السنة المالية الرابعة', 'Late Fine Year 4 (300%)', 'percentage', '{\"percentage_of_subscription\":\"300.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE current_value_json = '{\"percentage_of_subscription\":\"300.00\"}', name_ar = 'غرامة تأخير السنة المالية الرابعة'
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"10.00\"}', name_ar = 'غرامة تأخير — بعد فترة السماح (أكتوبر)', name_en = 'Late Fine — After Grace (Oct)' WHERE rule_code = 'LATE_SUB_FINE_YEAR_1';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"50.00\"}', name_ar = 'غرامة تأخير — سنة واحدة متأخرة', name_en = 'Late Fine — 1 Year Overdue (50%)' WHERE rule_code = 'LATE_SUB_FINE_YEAR_2';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"100.00\"}', name_ar = 'غرامة تأخير — سنتان متأخرة', name_en = 'Late Fine — 2 Years Overdue (100%)' WHERE rule_code = 'LATE_SUB_FINE_YEAR_3';
INSERT INTO business_rules (rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, created_at, updated_at) VALUES ('LATE_SUB_FINE_YEAR_4', 'penalty', 'غرامة تأخير — 3 سنوات متأخرة', 'Late Fine — 3 Years Overdue (200%)', 'percentage', '{\"percentage_of_subscription\":\"200.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE current_value_json = '{\"percentage_of_subscription\":\"200.00\"}', name_ar = 'غرامة تأخير — 3 سنوات متأخرة';
INSERT INTO business_rules (rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, created_at, updated_at) VALUES ('LATE_SUB_FINE_YEAR_5', 'penalty', 'غرامة تأخير — 4 سنوات متأخرة', 'Late Fine — 4 Years Overdue (300%)', 'percentage', '{\"percentage_of_subscription\":\"300.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE current_value_json = '{\"percentage_of_subscription\":\"300.00\"}', name_ar = 'غرامة تأخير — 4 سنوات متأخرة';
INSERT INTO business_rules (rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, created_at, updated_at) VALUES ('SUB_GRACE_MONTHS', 'penalty', 'فترة السماح للاشتراك السنوي', 'Subscription Grace Period (months)', 'integer', '{\"months\":3}', '{\"months\":\"integer\"}', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE current_value_json = '{\"months\":3}', name_ar = 'فترة السماح للاشتراك السنوي';
INSERT INTO business_rules (rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, created_at, updated_at) VALUES ('SUB_EXTENDED_GRACE_MONTHS', 'penalty', 'فترة السماح الممتدة (بقرار مجلس الأمناء)', 'Extended Grace Period (Trustees)', 'integer', '{\"months\":4}', '{\"months\":\"integer\"}', 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE current_value_json = '{\"months\":4}', name_ar = 'فترة السماح الممتدة (بقرار مجلس الأمناء)'
",
'down' => "
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"100.00\"}', name_ar = 'غرامة تأخير سنة أولى' WHERE rule_code = 'LATE_SUB_FINE_YEAR_1';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"200.00\"}', name_ar = 'غرامة تأخير سنة ثانية' WHERE rule_code = 'LATE_SUB_FINE_YEAR_2';
UPDATE business_rules SET current_value_json = '{\"percentage_of_subscription\":\"300.00\"}', name_ar = 'غرامة تأخير سنة ثالثة' WHERE rule_code = 'LATE_SUB_FINE_YEAR_3';
DELETE FROM business_rules WHERE rule_code IN ('LATE_SUB_FINE_YEAR_4', 'LATE_SUB_FINE_YEAR_5')
DELETE FROM business_rules WHERE rule_code IN ('LATE_SUB_FINE_YEAR_4', 'LATE_SUB_FINE_YEAR_5', 'SUB_GRACE_MONTHS', 'SUB_EXTENDED_GRACE_MONTHS')
",
];
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