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

feat(installments): business-intelligence show page + recalculate action

- Complete rewrite of show.php: 6 KPI cards (original amount, down payment,
  remaining balance, interest, monthly payment, member's outstanding balance),
  progress bar showing paid vs total installments, financial breakdown panels
  (paid vs remaining split), grand total formula ribbon, overdue alerts,
  next-due-date card with countdown, enhanced schedule table with status
  highlighting and per-row pay forms
- Add recalculate() action to InstallmentController: corrects pending-only
  installment rows to flat simple-interest formula without touching paid rows
- Register POST /installments/{id}/recalculate route
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 58084696
......@@ -173,6 +173,107 @@ class InstallmentController extends Controller
);
}
/**
* Recalculate pending installments using the correct flat simple-interest formula.
* Paid rows are left untouched; only pending rows and plan header are corrected.
*/
public function recalculate(Request $request, string $id): Response
{
$this->authorize('installment.create_plan');
$db = App::getInstance()->db();
$plan = $db->selectOne("SELECT * FROM installment_plans WHERE id = ?", [(int) $id]);
if (!$plan) return $this->redirect('/installments')->withError('الخطة غير موجودة');
if ($plan['status'] === 'completed') {
return $this->redirect("/installments/{$id}")->withError('الخطة مكتملة — لا يمكن إعادة الحساب');
}
$totalAmount = $plan['total_amount'];
$downPayment = $plan['down_payment'];
$remaining = bcsub($totalAmount, $downPayment, 2);
$months = (int) $plan['number_of_months'];
$rate = $plan['interest_rate'];
if (bccomp($remaining, '0', 2) <= 0 || $months < 1) {
return $this->redirect("/installments/{$id}")->withError('بيانات الخطة غير صالحة للحساب');
}
// Flat simple interest: remaining × (rate/100) × (months/12)
$correctTotalInterest = bcmul(
bcmul($remaining, bcdiv($rate, '100', 10), 10),
bcdiv((string) $months, '12', 10),
2
);
$correctTotalWI = bcadd($remaining, $correctTotalInterest, 2);
$flatMonthly = bcdiv($correctTotalWI, (string) $months, 2);
$flatPrincipal = bcdiv($remaining, (string) $months, 2);
$flatInterest = bcdiv($correctTotalInterest, (string) $months, 2);
$pendingRows = $db->select(
"SELECT id, installment_number FROM installment_schedule
WHERE installment_plan_id = ? AND status = 'pending'
ORDER BY installment_number ASC",
[(int) $id]
);
if (empty($pendingRows)) {
return $this->redirect("/installments/{$id}")->withError('لا توجد أقساط معلقة لإعادة حسابها');
}
// Compute running balance after all paid rows
$paidPrincipalSum = $db->selectOne(
"SELECT COALESCE(SUM(principal), 0) as s FROM installment_schedule
WHERE installment_plan_id = ? AND status = 'paid'",
[(int) $id]
);
$runningBalance = bcsub($remaining, (string) ($paidPrincipalSum['s'] ?? '0'), 2);
if (bccomp($runningBalance, '0', 2) < 0) $runningBalance = '0.00';
$db->beginTransaction();
try {
$pendingCount = count($pendingRows);
foreach ($pendingRows as $idx => $row) {
$isLast = ($idx === $pendingCount - 1);
$principal = $isLast ? $runningBalance : $flatPrincipal;
// Last row absorbs rounding in interest too
$interest = $isLast
? bcsub($correctTotalInterest, bcmul($flatInterest, (string) ($pendingCount - 1), 2), 2)
: $flatInterest;
if (bccomp($interest, '0', 2) < 0) $interest = '0.00';
$amount = bcadd($principal, $interest, 2);
$runningBalance = bcsub($runningBalance, $principal, 2);
if (bccomp($runningBalance, '0', 2) < 0) $runningBalance = '0.00';
$db->update('installment_schedule', [
'amount' => $amount,
'principal' => $principal,
'interest' => $interest,
'remaining_after' => $runningBalance,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $row['id']]);
}
// Update plan header
$db->update('installment_plans', [
'remaining_balance' => bcsub($remaining, (string) ($paidPrincipalSum['s'] ?? '0'), 2),
'total_interest' => $correctTotalInterest,
'total_with_interest' => $correctTotalWI,
'monthly_payment' => $flatMonthly,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect("/installments/{$id}")->withError('خطأ أثناء إعادة الحساب: ' . $e->getMessage());
}
return $this->redirect("/installments/{$id}")->withSuccess(
'تمت إعادة الحساب — ' . $pendingCount . ' قسط تم تصحيحه | القسط الشهري الصحيح: ' . money($flatMonthly)
);
}
public function payInstallment(Request $request, string $planId, string $scheduleId): Response
{
$db = App::getInstance()->db();
......
......@@ -7,6 +7,7 @@ return [
['POST', '/installments/store/{memberId}', 'Installments\Controllers\InstallmentController@store', ['auth', 'csrf'], 'installment.create_plan'],
['GET', '/installments/{id}', 'Installments\Controllers\InstallmentController@show', ['auth'], 'installment.view'],
['POST', '/installments/{planId}/pay/{scheduleId}', 'Installments\Controllers\InstallmentController@payInstallment', ['auth', 'csrf'], 'installment.pay'],
['POST', '/installments/{id}/recalculate', 'Installments\Controllers\InstallmentController@recalculate', ['auth', 'csrf'], 'installment.create_plan'],
// Cheque uploads
['GET', '/installments/{planId}/cheques', 'Installments\Controllers\ChequeController@index', ['auth'], 'installment.view'],
......
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