Commit 0dd4abfc authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix membership fee calculations, subscription escalation, and over-25 children freeze

- TemporaryFeeCalculator: resolve current pricing_configs price instead of stored
  membership_value, matching SpouseFeeCalculator and ChildFeeCalculator behaviour
- SubscriptionGenerator: add per-dependent dedup guards (spouse/child/temporary)
  to prevent duplicate subscription rows on repeated batch runs
- Phase_89_001 migration: idempotent fix for subscription late-fine escalation rules
  (10/50/100/200/300% over 5 years, correct from the broken seed 100/200/300%)
- Members show view: add warning banner listing active male children aged 25+ with
  direct freeze button, surfacing the existing freeze route that was never linked
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 110d7771
......@@ -820,6 +820,38 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<!-- Children -->
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f476; الأبناء (<?= count($children) ?>)</div>
<?php
$childrenOver25Male = array_filter($children, function($c) {
return ($c['gender'] ?? '') === 'male'
&& ($c['status'] ?? '') === 'active'
&& (int) ($c['age_years'] ?? 0) >= 25;
});
?>
<?php if (!empty($childrenOver25Male)): ?>
<div style="margin-bottom:12px;padding:12px 15px;background:#FFF7ED;border:2px solid #F59E0B;border-radius:8px;">
<div style="font-weight:700;color:#D97706;margin-bottom:8px;font-size:13px;">&#x26a0;&#xfe0f; أبناء ذكور بلغوا 25 سنة — يجب تجميد عضوياتهم</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<?php foreach ($childrenOver25Male as $c25): ?>
<div style="display:flex;align-items:center;justify-content:space-between;background:#fff;border:1px solid #FDE68A;border-radius:6px;padding:8px 12px;">
<span style="font-size:13px;color:#374151;">
<strong><?= e($c25['full_name_ar']) ?></strong>
— عمره <strong style="color:#DC2626;"><?= (int) $c25['age_years'] ?> سنة</strong>
</span>
<?php if (can('child.freeze')): ?>
<form method="POST" action="/members/<?= (int) $member->id ?>/children/<?= (int) $c25['id'] ?>/freeze" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="freeze_reason" value="بلوغ سن 25 — تجميد تلقائي">
<button type="submit" class="btn btn-sm" style="background:#D97706;color:#fff;border-color:#D97706;font-size:12px;"
onclick="return confirm('تجميد عضوية <?= e($c25['full_name_ar']) ?>؟')">
&#x1f9ca; تجميد العضوية
</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($children as $cIdx => $c): ?>
<?php
......
......@@ -82,6 +82,11 @@ final class SubscriptionGenerator
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) {
$existingSp = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'spouse' AND person_id = ?",
[$memberId, $financialYear, (int) $sp['id']]
);
if ($existingSp) { $skipped++; continue; }
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -104,6 +109,11 @@ final class SubscriptionGenerator
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) {
$existingCh = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'child' AND person_id = ?",
[$memberId, $financialYear, (int) $ch['id']]
);
if ($existingCh) { $skipped++; continue; }
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......@@ -126,6 +136,11 @@ final class SubscriptionGenerator
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) {
$existingTmp = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type = 'temporary' AND person_id = ?",
[$memberId, $financialYear, (int) $t['id']]
);
if ($existingTmp) { $skipped++; continue; }
$db->insert('subscriptions', [
'member_id' => $memberId,
'financial_year' => $financialYear,
......
......@@ -19,6 +19,26 @@ final class TemporaryFeeCalculator
$membershipValue = $member['membership_value'] ?? '0.00';
$isOnInitialForm = FormFeeService::isOnInitialForm($member);
// Always use current pricing_configs price (same logic as SpouseFeeCalculator / ChildFeeCalculator)
$qualId = !empty($member['qualification_id']) ? (int) $member['qualification_id'] : null;
$branchId = (int) ($member['branch_id'] ?? 1);
$mType = $member['membership_type'] ?? 'working';
if ($qualId) {
$currentPricing = $db->selectOne(
"SELECT price FROM pricing_configs WHERE branch_id = ? AND qualification_id = ? AND membership_type = ? AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE()) ORDER BY effective_from DESC LIMIT 1",
[$branchId, $qualId, $mType]
);
} else {
$currentPricing = $db->selectOne(
"SELECT price FROM pricing_configs WHERE branch_id = ? AND membership_type = ? AND is_active = 1 AND effective_from <= CURDATE() AND (effective_to IS NULL OR effective_to >= CURDATE()) ORDER BY price ASC LIMIT 1",
[$branchId, $mType]
);
}
if ($currentPricing && bccomp($currentPricing['price'], '0.01', 2) >= 0) {
$membershipValue = $currentPricing['price'];
}
if (bccomp($membershipValue, '0.00', 2) <= 0 && !$isOnInitialForm) {
return ['fee' => '0.00', 'error' => 'قيمة العضوية غير محددة'];
}
......
<?php
declare(strict_types=1);
/**
* Idempotent migration: ensures subscription late-fine escalation rules exist
* with the correct percentages (10/50/100/200/300) and that the grace period
* rules exist. Safe to run multiple times — uses ON DUPLICATE KEY UPDATE.
*
* Corrects a data issue where the original seed set YEAR_1=100%, YEAR_2=200%,
* YEAR_3=300% (no YEAR_4/YEAR_5), whereas the correct business rule is:
* Year 1 after grace: 10%
* Year 2: 50%
* Year 3: 100%
* Year 4: 200%
* Year 5: 300%
*/
return [
'up' => "
INSERT INTO business_rules
(rule_code, category, name_ar, name_en, data_type, current_value_json, parameters_json, is_active, effective_from, created_at, updated_at)
VALUES
('LATE_SUB_FINE_YEAR_1', 'penalty', 'غرامة تأخير — بعد فترة السماح (10%)', 'Late Fine Year 1 — After Grace (10%)', 'percentage', '{\"percentage_of_subscription\":\"10.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_2', 'penalty', 'غرامة تأخير — سنة متأخرة (50%)', 'Late Fine Year 2 — 1 Year Overdue (50%)', 'percentage', '{\"percentage_of_subscription\":\"50.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_3', 'penalty', 'غرامة تأخير — سنتان متأخرة (100%)', 'Late Fine Year 3 — 2 Years Overdue (100%)', 'percentage', '{\"percentage_of_subscription\":\"100.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_4', 'penalty', 'غرامة تأخير — 3 سنوات متأخرة (200%)', 'Late Fine Year 4 — 3 Years Overdue (200%)', 'percentage', '{\"percentage_of_subscription\":\"200.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_YEAR_5', 'penalty', 'غرامة تأخير — 4 سنوات متأخرة (300%)', 'Late Fine Year 5 — 4 Years Overdue (300%)', 'percentage', '{\"percentage_of_subscription\":\"300.00\"}', '{\"percentage_of_subscription\":\"decimal\"}', 1, '2020-01-01', NOW(), NOW()),
('SUB_GRACE_MONTHS', 'penalty', 'فترة السماح للاشتراك السنوي (شهور)', 'Subscription Grace Period (months)', 'integer', '{\"months\":3}', '{\"months\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('SUB_EXTENDED_GRACE_MONTHS', 'penalty', 'فترة السماح الممتدة بقرار مجلس الأمناء', 'Extended Grace Period (Trustees Council)', 'integer', '{\"months\":4}', '{\"months\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_FINE_MAX_YEARS', 'penalty', 'الحد الأقصى لسنوات الغرامة', 'Max Late Fine Years', 'integer', '{\"years\":5}', '{\"years\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('LATE_SUB_DROP_YEARS', 'penalty', 'سنوات التأخر قبل إسقاط العضوية', 'Years Before Membership Drop', 'integer', '{\"years\":5}', '{\"years\":\"integer\"}', 1, '2020-01-01', NOW(), NOW()),
('REINSTATEMENT_WINDOW', 'penalty', 'نافذة إعادة العضوية المسقطة (شهور)', 'Reinstatement Window (months)', 'integer', '{\"months\":12}', '{\"months\":\"integer\"}', 1, '2020-01-01', NOW(), NOW())
ON DUPLICATE KEY UPDATE
current_value_json = VALUES(current_value_json),
name_ar = VALUES(name_ar),
name_en = VALUES(name_en),
updated_at = NOW()
",
'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', 'SUB_GRACE_MONTHS', 'SUB_EXTENDED_GRACE_MONTHS', 'LATE_SUB_FINE_MAX_YEARS', 'LATE_SUB_DROP_YEARS', 'REINSTATEMENT_WINDOW')
",
];
This diff is collapsed.
This diff is collapsed.
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