Commit 2da473e7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Comprehensive subscription cycle fix: data integrity, fines, retroactive entries

Migration Phase_92_005:
- Fix missing dev fee on member rows
- Apply 50% discount to 2023/2024 rows that missed it
- Recalculate total_amount where it doesn't match (base - discount)
- Fix paid rows with 0 paid_amount (set paid_amount = total)
- Reset all fine_amounts to 0 (recalculated on page visit)

Code fixes:
- OverdueFineApplicator: only write fine_amount if actually changed
- SubscriptionCalculator: expose subscription_total in fine details
- SubscriptionController: pick single dev fee per year (not SUM)
- View: show correct base (subscription_total) in fine detail panel
- RetroactiveMembershipService: don't accept form paid_amount for
  status=paid (server calculates correct value); remove dev fee
  from paid_amount (dev fee is invoice-level, not per-row)
- RetroactiveWizardController: remove misleading 492/527 defaults
- OverdueFineJob: run year-round (grace period handled by calculator)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f0e364fd
......@@ -261,9 +261,9 @@ class RetroactiveWizardController extends Controller
'person_type' => $post["sub_person_type_{$i}"] ?? 'member',
'person_index' => (int) ($post["sub_person_index_{$i}"] ?? 0),
'person_name' => $post["sub_person_name_{$i}"] ?? $data['full_name_ar'],
'base_amount' => $post["sub_base_{$i}"] ?? '492.00',
'base_amount' => $post["sub_base_{$i}"] ?? '0.00',
'development_fee' => $post["sub_dev_fee_{$i}"] ?? (($post["sub_person_type_{$i}"] ?? 'member') === 'member' ? '35.00' : '0.00'),
'total_amount' => $post["sub_total_{$i}"] ?? '527.00',
'total_amount' => $post["sub_total_{$i}"] ?? '0.00',
'paid_amount' => $post["sub_paid_{$i}"] ?? '0.00',
'fine_amount' => $post["sub_fine_{$i}"] ?? '0.00',
'status' => $post["sub_status_{$i}"] ?? 'pending',
......
......@@ -660,14 +660,11 @@ final class RetroactiveMembershipService
// total_amount = base - discount (dev fee is separate, added at invoice level)
$totalAmount = bcsub($baseAmount, $discountAmount, 2);
// For paid status: paid_amount = total + dev fee (if member) since dev fee is part of what they paid
// For paid status: paid_amount = total (dev fee is invoice-level, not per-row paid)
$paidAmount = '0.00';
if ($status === 'paid') {
$paidAmount = ($personType === 'member')
? bcadd($totalAmount, $devFee, 2)
: $totalAmount;
}
if (isset($sub['paid_amount']) && bccomp((string) $sub['paid_amount'], '0', 2) > 0) {
$paidAmount = $totalAmount;
} elseif (isset($sub['paid_amount']) && bccomp((string) $sub['paid_amount'], '0', 2) > 0) {
$paidAmount = (string) $sub['paid_amount'];
}
......
......@@ -58,14 +58,16 @@ class SubscriptionController extends Controller
$totalAmount = '0.00';
$totalPaid = '0.00';
$totalFine = '0.00';
$totalDev = '0.00';
$devFee = '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 (bccomp($r['development_fee'] ?? '0', '0', 2) > 0 && bccomp($devFee, '0', 2) === 0) {
$devFee = $r['development_fee'];
}
if ($r['status'] !== 'paid' && $r['status'] !== 'exempt') $allPaid = false;
if ($r['status'] === 'paid') $anyPaid = true;
}
......@@ -73,7 +75,7 @@ class SubscriptionController extends Controller
'total_amount' => $totalAmount,
'total_paid' => $totalPaid,
'total_fine' => $totalFine,
'total_dev' => $totalDev,
'total_dev' => $devFee,
'year_status' => $allPaid ? 'paid' : ($anyPaid ? 'partial' : 'unpaid'),
'count' => count($rows),
];
......
......@@ -87,7 +87,7 @@ final class OverdueFineApplicator
private static function distributeFineForYear($db, int $memberId, string $financialYear, string $totalFine): void
{
$rows = $db->select(
"SELECT id, total_amount FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
"SELECT id, total_amount, paid_amount, fine_amount FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
[$memberId, $financialYear]
);
if (empty($rows)) return;
......@@ -109,12 +109,15 @@ final class OverdueFineApplicator
}
$fineDistributed = bcadd($fineDistributed, $rowFine, 2);
// Only update if fine actually changed to avoid unnecessary writes
if (bccomp($rowFine, (string) ($r['fine_amount'] ?? '0'), 2) !== 0) {
$db->query(
"UPDATE subscriptions SET fine_amount = ?, status = 'overdue', updated_at = NOW() WHERE id = ?",
[$rowFine, (int) $r['id']]
);
}
}
}
public static function expireReinstatements(): array
{
......
......@@ -66,6 +66,7 @@ final class SubscriptionCalculator
$fineDetails[] = [
'financial_year' => $uy['financial_year'],
'subscription_total' => $uy['total'],
'unpaid' => $unpaid,
'fine_percentage' => $finePct,
'fine_amount' => $fineAmount,
......
......@@ -109,8 +109,8 @@ foreach ($lateFine['details'] ?? [] as $d) {
<table style="width:100%;font-size:13px;color:#374151;">
<tr><td style="padding:4px 8px;color:#6B7280;">سنوات التأخير:</td><td style="padding:4px 8px;font-weight:600;"><?= (int) $fineDetail['years_overdue'] ?> سنة</td></tr>
<tr><td style="padding:4px 8px;color:#6B7280;">نسبة الغرامة:</td><td style="padding:4px 8px;font-weight:600;"><?= $fineDetail['fine_percentage'] ?>% <span style="font-size:11px;color:#9CA3AF;">(من قيمة الاشتراك الإجمالي)</span></td></tr>
<tr><td style="padding:4px 8px;color:#6B7280;">قيمة الاشتراك:</td><td style="padding:4px 8px;"><?= money($fineDetail['unpaid']) ?></td></tr>
<tr style="background:#FEF2F2;"><td style="padding:4px 8px;color:#991B1B;font-weight:600;">الحساب:</td><td style="padding:4px 8px;font-weight:700;color:#DC2626;"><?= money($fineDetail['unpaid']) ?> × <?= $fineDetail['fine_percentage'] ?>% = <?= money($fineDetail['fine_amount']) ?></td></tr>
<tr><td style="padding:4px 8px;color:#6B7280;">قيمة الاشتراك:</td><td style="padding:4px 8px;"><?= money($fineDetail['subscription_total'] ?? $fineDetail['unpaid']) ?></td></tr>
<tr style="background:#FEF2F2;"><td style="padding:4px 8px;color:#991B1B;font-weight:600;">الحساب:</td><td style="padding:4px 8px;font-weight:700;color:#DC2626;"><?= money($fineDetail['subscription_total'] ?? $fineDetail['unpaid']) ?> × <?= $fineDetail['fine_percentage'] ?>% = <?= money($fineDetail['fine_amount']) ?></td></tr>
<tr><td style="padding:4px 8px;color:#6B7280;">المرجع:</td><td style="padding:4px 8px;font-size:11px;font-family:monospace;">LATE_SUB_FINE_YEAR_<?= (int) $fineDetail['years_overdue'] + 1 ?></td></tr>
</table>
</div>
......
......@@ -14,7 +14,7 @@ class OverdueFineJob
public function shouldRun(): bool
{
return (int) date('n') >= 10;
return true;
}
public function run(): array
......
<?php
declare(strict_types=1);
/**
* Comprehensive subscription data fix:
*
* 1. Fix total_amount for individual-format rows where total != base - discount
* (caused by retroactive wizard using wrong rates or including dev fee in total)
* 2. Apply 50% discount to 2023/2024 rows that missed it
* 3. Set development_fee = 35.00 on member rows that are missing it
* 4. Fix paid status rows that have no payment_id (set status based on paid_amount)
*
* Only touches rows where the issue can be unambiguously identified.
* Bundled old-format rows (total includes multiple family members) are NOT touched.
*/
return function (\App\Core\Database $db): void {
$fixed = 0;
// --- Step 1: Fix missing development_fee on member rows (unpaid only) ---
$db->query(
"UPDATE subscriptions SET development_fee = 35.00, updated_at = NOW()
WHERE person_type = 'member' AND (development_fee IS NULL OR development_fee = 0)
AND status IN ('pending', 'overdue')"
);
// --- Step 2: Apply 2023/2024 discount to rows that missed it ---
// Only for individual-format rows (where total_amount is close to base_amount or base+devfee)
$missing2023Discount = $db->select(
"SELECT id, person_type, base_amount, development_fee, total_amount
FROM subscriptions
WHERE financial_year = '2023/2024'
AND (discount_amount IS NULL OR discount_amount = 0)
AND status IN ('pending', 'overdue')
AND base_amount > 0"
);
foreach ($missing2023Discount as $row) {
$base = (string) $row['base_amount'];
$discount = bcdiv(bcmul($base, '50', 4), '100', 2);
$newTotal = bcsub($base, $discount, 2);
$db->query(
"UPDATE subscriptions SET discount_amount = ?, total_amount = ?, updated_at = NOW() WHERE id = ?",
[$discount, $newTotal, (int) $row['id']]
);
$fixed++;
}
// --- Step 3: Fix total_amount where it doesn't match base - discount ---
// This catches rows where:
// - dev fee was included in total (difference = 35)
// - wrong rate was used for total (difference = 82 for member/spouse, 37 for child)
// We ONLY fix rows that are individual-format (not bundled family rows)
// A row is "bundled" if total_amount > base_amount * 2 (clearly includes other people)
$calcMismatch = $db->select(
"SELECT id, base_amount, discount_amount, total_amount, development_fee
FROM subscriptions
WHERE status IN ('pending', 'overdue')
AND ABS(total_amount - (base_amount - COALESCE(discount_amount, 0))) > 0.01
AND total_amount <= base_amount * 2
AND base_amount > 0"
);
foreach ($calcMismatch as $row) {
$base = (string) $row['base_amount'];
$discount = (string) ($row['discount_amount'] ?? '0.00');
$correctTotal = bcsub($base, $discount, 2);
if (bccomp($correctTotal, '0', 2) <= 0) continue;
$db->query(
"UPDATE subscriptions SET total_amount = ?, updated_at = NOW() WHERE id = ?",
[$correctTotal, (int) $row['id']]
);
$fixed++;
}
// --- Step 4: Fix status='paid' rows with no payment_id ---
// Old retroactive entries marked 'paid' with paid_amount=0 and no payment_id
// These are historical records — leave status as 'paid' (they were paid in reality)
// but ensure they don't interfere with fine calculations (paid_amount should = total_amount)
$paidNoPayment = $db->select(
"SELECT id, total_amount, development_fee, person_type
FROM subscriptions
WHERE status = 'paid' AND payment_id IS NULL AND paid_amount = 0"
);
foreach ($paidNoPayment as $row) {
// For paid historical records, set paid_amount = total so fine calc skips them correctly
$paidAmt = (string) $row['total_amount'];
$db->query(
"UPDATE subscriptions SET paid_amount = ?, updated_at = NOW() WHERE id = ?",
[$paidAmt, (int) $row['id']]
);
$fixed++;
}
// --- Step 5: Reset fine_amount to 0 for all unpaid rows ---
// Fines will be recalculated on next page visit via OverdueFineApplicator::applyForMember()
$db->query(
"UPDATE subscriptions SET fine_amount = 0.00, updated_at = NOW()
WHERE status IN ('pending', 'overdue') AND fine_amount > 0"
);
};
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