Commit 8b7ccbd6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(installments): correct interest formula to flat simple interest prorated by duration

All calculation sites now use: interest = remaining × (rate/100) × (months/12)
instead of diminishing-balance amortization.

Changes across all 6 sites:
- InstallmentCalculator: flat interest, equal monthly instalments
- PricingEngine: same formula
- RetroactiveMembershipService: same formula
- retroactive-wizard JS: updated preview + shows المبلغ المتبقي in summary
- Members/show.php: preview panel now includes months factor (was missing)
- Installments/create.php: added live المبلغ المتبقي preview panel
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 0d8aafd7
......@@ -86,9 +86,14 @@ class InstallmentController extends Controller
$existingActive = $db->selectOne("SELECT * FROM installment_plans WHERE member_id = ? AND status = 'active'", [(int) $memberId]);
$rateData = \App\Modules\Rules\Services\RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$maxMonthsData = \App\Modules\Rules\Services\RuleEngine::get('INSTALLMENT_MAX_MONTHS');
return $this->view('Installments.Views.create', [
'member' => $member,
'existingActive' => $existingActive,
'interestRate' => $rateData['percentage'] ?? '22.00',
'maxMonths' => $maxMonthsData['months'] ?? 30,
]);
}
......
......@@ -51,46 +51,47 @@ final class InstallmentCalculator
$remaining = bcsub($totalAmount, $downPayment, 2);
$startDate = $startDate ?: date('Y-m-d');
// Calculate interest on remaining balance
// Simple interest: total_interest = remaining * (annual_rate/100) * (months/12)
$monthlyRate = bcdiv($annualRate, '1200', 10); // annual / 12 / 100
$totalInterest = '0.00';
// Flat simple interest: remaining × (annual_rate/100) × (months/12)
$totalInterest = bcmul(
bcmul($remaining, bcdiv($annualRate, '100', 10), 10),
bcdiv((string) $months, '12', 10),
2
);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
// Generate schedule with diminishing balance interest
$schedule = [];
// Per-instalment: equal share of interest, flat principal
$monthlyPrincipal = bcdiv($remaining, (string) $months, 2);
$monthlyInterest = bcdiv($totalInterest, (string) $months, 2);
$schedule = [];
$runningBalance = $remaining;
for ($i = 1; $i <= $months; $i++) {
$interestForMonth = bcmul($runningBalance, $monthlyRate, 2);
$totalInterest = bcadd($totalInterest, $interestForMonth, 2);
$monthlyTotal = bcadd($monthlyPrincipal, $interestForMonth, 2);
// Last month: adjust for rounding
if ($i === $months) {
$monthlyPrincipal = $runningBalance;
$monthlyTotal = bcadd($monthlyPrincipal, $interestForMonth, 2);
}
$isLast = ($i === $months);
$principal = $isLast ? $runningBalance : $monthlyPrincipal;
$interest = $isLast
? bcsub($totalWithInterest, bcadd($remaining, bcsub($totalInterest, $monthlyInterest, 2), 2), 2) // absorb rounding
: $monthlyInterest;
if (bccomp($interest, '0', 2) < 0) $interest = '0.00';
$amount = bcadd($principal, $interest, 2);
$runningBalance = bcsub($runningBalance, $monthlyPrincipal, 2);
if (bccomp($runningBalance, '0', 2) < 0) {
$runningBalance = '0.00';
}
$runningBalance = bcsub($runningBalance, $principal, 2);
if (bccomp($runningBalance, '0', 2) < 0) $runningBalance = '0.00';
$dueDate = date('Y-m-d', strtotime($startDate . " +{$i} months"));
$schedule[] = [
'number' => $i,
'due_date' => $dueDate,
'amount' => $monthlyTotal,
'principal' => $monthlyPrincipal,
'interest' => $interestForMonth,
'amount' => $amount,
'principal' => $principal,
'interest' => $interest,
'remaining_after' => $runningBalance,
];
}
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$avgMonthly = $months > 0 ? bcdiv($totalWithInterest, (string) $months, 2) : '0.00';
$avgMonthly = $monthlyPayment;
// Cash settlement date (30 days from start)
$cashWindowData = RuleEngine::get('CASH_PAYMENT_WINDOW');
......
......@@ -8,23 +8,66 @@
</div>
<?php endif; ?>
<?php if (can('installment.create_plan')): ?>
<form method="POST" action="/installments/store/<?= (int) $member['id'] ?>">
<form method="POST" action="/installments/store/<?= (int) $member['id'] ?>" novalidate>
<?= csrf_field() ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="margin-bottom:15px;padding:10px;background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;">
<strong>العضو:</strong> <?= e($member['full_name_ar']) ?><strong>قيمة العضوية:</strong> <?= $member['membership_value'] ? money($member['membership_value']) : '—' ?>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group"><label class="form-label">المبلغ الإجمالي <span style="color:#DC2626;">*</span></label><input type="number" name="total_amount" value="<?= e($member['membership_value'] ?? '') ?>" class="form-input" step="0.01" required style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">المقدم (≥25%) <span style="color:#DC2626;">*</span></label><input type="number" name="down_payment" class="form-input" step="0.01" required style="direction:ltr;text-align:left;" placeholder="الحد الأدنى 25%"></div>
<div class="form-group"><label class="form-label">عدد الأشهر (≤30) <span style="color:#DC2626;">*</span></label><input type="number" name="number_of_months" class="form-input" min="1" max="30" required></div>
<div class="form-group"><label class="form-label">المبلغ الإجمالي <span style="color:#DC2626;">*</span></label><input type="number" id="ci_total" name="total_amount" value="<?= e($member['membership_value'] ?? '') ?>" class="form-input" step="0.01" required style="direction:ltr;text-align:left;" oninput="ciRecalc()"></div>
<div class="form-group"><label class="form-label">المقدم (≥25%) <span style="color:#DC2626;">*</span></label><input type="number" id="ci_down" name="down_payment" class="form-input" step="0.01" required style="direction:ltr;text-align:left;" placeholder="الحد الأدنى 25%" oninput="ciRecalc()"></div>
<div class="form-group"><label class="form-label">عدد الأشهر (≤<?= (int) ($maxMonths ?? 30) ?>) <span style="color:#DC2626;">*</span></label><input type="number" id="ci_months" name="number_of_months" class="form-input" min="1" max="<?= (int) ($maxMonths ?? 30) ?>" required oninput="ciRecalc()"></div>
<div class="form-group"><label class="form-label">تاريخ البداية <span style="color:#DC2626;">*</span></label><input type="date" name="start_date" value="<?= e(date('Y-m-d')) ?>" class="form-input" required></div>
<div class="form-group"><label class="form-label">رقم إيصال المقدم</label><input type="text" name="down_payment_receipt" class="form-input"></div>
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">ملاحظات</label><textarea name="notes" class="form-textarea" rows="2"></textarea></div>
</div>
<!-- Live preview -->
<div id="ciPreview" style="display:none;margin-top:16px;padding:14px 16px;background:#F0FDF4;border:1px solid #86EFAC;border-radius:8px;font-size:13px;">
<strong style="color:#166534;display:block;margin-bottom:8px;">معاينة الخطة</strong>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:10px;">
<div style="background:#fff;padding:10px;border-radius:6px;border:1px solid #D1FAE5;">
<div style="font-size:11px;color:#6B7280;margin-bottom:2px;">المبلغ المتبقي</div>
<div id="ci_remaining" style="font-weight:700;color:#0284C7;"></div>
</div>
<div style="background:#fff;padding:10px;border-radius:6px;border:1px solid #D1FAE5;">
<div style="font-size:11px;color:#6B7280;margin-bottom:2px;">إجمالي الفائدة</div>
<div id="ci_interest" style="font-weight:700;color:#D97706;"></div>
</div>
<div style="background:#fff;padding:10px;border-radius:6px;border:1px solid #D1FAE5;">
<div style="font-size:11px;color:#6B7280;margin-bottom:2px;">الإجمالي مع الفائدة</div>
<div id="ci_total_wi" style="font-weight:700;color:#059669;"></div>
</div>
<div style="background:#fff;padding:10px;border-radius:6px;border:1px solid #D1FAE5;">
<div style="font-size:11px;color:#6B7280;margin-bottom:2px;">القسط الشهري</div>
<div id="ci_monthly" style="font-weight:700;color:#374151;font-size:15px;"></div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">إنشاء خطة التقسيط</button>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">إلغاء</a>
</form>
<script>
function ciRecalc() {
const total = parseFloat(document.getElementById('ci_total').value) || 0;
const down = parseFloat(document.getElementById('ci_down').value) || 0;
const months = parseInt(document.getElementById('ci_months').value) || 0;
const rate = <?= json_encode((float) ($interestRate ?? 22)) ?>;
const preview = document.getElementById('ciPreview');
if (!total || !down || !months || down >= total) { preview.style.display = 'none'; return; }
const remaining = total - down;
const totalInterest = Math.round(remaining * (rate / 100) * (months / 12) * 100) / 100;
const totalWI = remaining + totalInterest;
const monthly = Math.round((totalWI / months) * 100) / 100;
const fmt = v => v.toLocaleString('ar-EG', {minimumFractionDigits:2});
document.getElementById('ci_remaining').textContent = fmt(remaining) + ' ج.م';
document.getElementById('ci_interest').textContent = fmt(totalInterest) + ' ج.م';
document.getElementById('ci_total_wi').textContent = fmt(totalWI) + ' ج.م';
document.getElementById('ci_monthly').textContent = fmt(monthly) + ' ج.م';
preview.style.display = '';
}
</script>
<?php endif; ?>
<?php $__template->endSection(); ?>
\ No newline at end of file
......@@ -539,18 +539,33 @@ final class RetroactiveMembershipService
$remaining = bcsub($totalAmount, $downPayment, 2);
if (bccomp($remaining, '0', 2) <= 0) $remaining = '0.00';
$monthlyRate = bcdiv($interestRate, '1200', 10);
// Flat simple interest: remaining × (annual_rate/100) × (months/12)
$totalInterest = bccomp($remaining, '0', 2) > 0
? bcmul(
bcmul($remaining, bcdiv($interestRate, '100', 10), 10),
bcdiv((string) $months, '12', 10),
2
)
: '0.00';
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$avgMonthly = bccomp($remaining, '0', 2) > 0 ? bcdiv($totalWithInterest, (string) $months, 2) : '0.00';
$monthlyPrincipal = bccomp($remaining, '0', 2) > 0 ? bcdiv($remaining, (string) $months, 2) : '0.00';
$monthlyInterest = bccomp($remaining, '0', 2) > 0 ? bcdiv($totalInterest, (string) $months, 2) : '0.00';
$totalInterest = '0.00';
$runningBalance = $remaining;
$schedule = [];
for ($i = 1; $i <= $months; $i++) {
$interest = bcmul($runningBalance, $monthlyRate, 2);
$totalInterest = bcadd($totalInterest, $interest, 2);
$principal = ($i === $months) ? $runningBalance : $monthlyPrincipal;
$isLast = ($i === $months);
$principal = $isLast ? $runningBalance : $monthlyPrincipal;
$interest = $isLast
? bcsub($totalWithInterest, bcadd($remaining, bcsub($totalInterest, $monthlyInterest, 2), 2), 2)
: $monthlyInterest;
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';
......@@ -565,9 +580,6 @@ final class RetroactiveMembershipService
];
}
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$avgMonthly = bccomp($remaining, '0', 2) > 0 ? bcdiv($totalWithInterest, (string) $months, 2) : '0.00';
// down_payment_receipt is VARCHAR(50) in DB
$planId = $db->insert('installment_plans', [
'member_id' => $memberId,
......
......@@ -709,7 +709,8 @@ function updateInstallmentVisibility() {
if (show) recalcInstallments();
}
// Installment schedule calculation
// Installment schedule calculation — flat simple interest
// Formula: interest = remaining × (rate/100) × (months/12)
function recalcInstallments() {
const total = parseFloat(document.querySelector('[name=membership_value]').value) || 150000;
const down = parseFloat(document.getElementById('instDown').value) || 37500;
......@@ -718,19 +719,23 @@ function recalcInstallments() {
const startDate = document.getElementById('instStart').value || document.querySelector('[name=join_date]').value || new Date().toISOString().split('T')[0];
const remaining = total - down;
const monthlyRate = rate / 1200;
// Flat simple interest prorated by duration
const totalInterest = Math.round(remaining * (rate / 100) * (months / 12) * 100) / 100;
const totalWithInterest = remaining + totalInterest;
const monthlyPayment = Math.round((totalWithInterest / months) * 100) / 100;
const monthlyPrincipal = remaining / months;
let balance = remaining;
let totalInterest = 0;
const monthlyInterest = totalInterest / months;
const tbody = document.getElementById('installmentSchedule');
tbody.innerHTML = '';
let balance = remaining;
for (let i = 1; i <= months; i++) {
const interest = Math.round(balance * monthlyRate * 100) / 100;
totalInterest += interest;
const principal = (i === months) ? balance : monthlyPrincipal;
const isLast = (i === months);
const principal = isLast ? balance : Math.round(monthlyPrincipal * 100) / 100;
const interest = Math.round(monthlyInterest * 100) / 100;
const amount = Math.round((principal + interest) * 100) / 100;
balance -= principal;
balance = Math.round((balance - principal) * 100) / 100;
if (balance < 0) balance = 0;
const dueDate = new Date(startDate);
......@@ -760,7 +765,7 @@ function recalcInstallments() {
</tr>`;
}
document.getElementById('instSummary').textContent = `— إجمالي الفائدة: ${totalInterest.toLocaleString('ar-EG', {minimumFractionDigits:2})} ج.م | القسط الشهري ≈ ${Math.round((remaining + totalInterest) / months).toLocaleString()} ج.م`;
document.getElementById('instSummary').textContent = `— المتبقي: ${remaining.toLocaleString('ar-EG', {minimumFractionDigits:2})} ج.م | الفائدة: ${totalInterest.toLocaleString('ar-EG', {minimumFractionDigits:2})} ج.م | القسط الشهري: ${monthlyPayment.toLocaleString('ar-EG', {minimumFractionDigits:2})} ج.م`;
updatePaidInstallments();
}
......
......@@ -339,13 +339,15 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false));
<?php
$minDown = bcdiv(bcmul($bill['total_pending'], '25', 2), '100', 2);
$remaining = bcsub($bill['total_pending'], $minDown, 2);
$interest = bcdiv(bcmul($remaining, '22', 2), '100', 2);
// Flat simple interest on remaining for 30 months: remaining × (22/100) × (30/12)
$interest = bcmul(bcmul($remaining, bcdiv('22', '100', 8), 8), bcdiv('30', '12', 8), 2);
$totalWithInterest = bcadd($remaining, $interest, 2);
$monthlyEst = bcdiv($totalWithInterest, '30', 2);
?>
<table style="width:100%;font-size:13px;margin-bottom:10px;">
<tr><td style="color:#6B7280;">المقدم (25%)</td><td style="text-align:left;font-weight:600;"><?= money($minDown) ?></td></tr>
<tr><td style="color:#6B7280;">الفائدة (22%)</td><td style="text-align:left;color:#D97706;"><?= money($interest) ?></td></tr>
<tr><td style="color:#6B7280;">المتبقي</td><td style="text-align:left;"><?= money($remaining) ?></td></tr>
<tr><td style="color:#6B7280;">الفائدة (22% × 30 شهر)</td><td style="text-align:left;color:#D97706;"><?= money($interest) ?></td></tr>
<tr><td style="color:#6B7280;">القسط (~30 شهر)</td><td style="text-align:left;font-weight:600;"><?= money($monthlyEst) ?></td></tr>
</table>
<form method="POST" action="/members/<?= (int) $member->id ?>/pay-membership">
......
......@@ -156,29 +156,36 @@ final class PricingEngine
{
$rateData = RuleEngine::require('INSTALLMENT_INTEREST_RATE');
$annualRate = $rateData['percentage'];
$monthlyRate = bcdiv($annualRate, '1200', 8);
$remaining = bcsub($totalAmount, $downPayment, 2);
$totalInterest = bcmul($remaining, bcmul($monthlyRate, (string) $months, 8), 2);
// Flat simple interest: remaining × (annual_rate/100) × (months/12)
$totalInterest = bcmul(
bcmul($remaining, bcdiv($annualRate, '100', 10), 10),
bcdiv((string) $months, '12', 10),
2
);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
$monthlyPrincipal = bcdiv($remaining, (string) $months, 2);
$monthlyInterest = bcdiv($totalInterest, (string) $months, 2);
$schedule = [];
$balance = $remaining;
$balance = $remaining;
$startDate = new \DateTime();
$startDate->modify('+1 month');
for ($i = 1; $i <= $months; $i++) {
$interest = bcmul($balance, $monthlyRate, 2);
$principal = bcsub($monthlyPayment, $interest, 2);
if ($i === $months) {
$principal = $balance;
$monthlyPayment = bcadd($principal, $interest, 2);
}
$isLast = ($i === $months);
$principal = $isLast ? $balance : $monthlyPrincipal;
$interest = $isLast
? bcsub($totalWithInterest, bcadd($remaining, bcsub($totalInterest, $monthlyInterest, 2), 2), 2)
: $monthlyInterest;
if (bccomp($interest, '0', 2) < 0) $interest = '0.00';
$amount = bcadd($principal, $interest, 2);
$balance = bcsub($balance, $principal, 2);
if (bccomp($balance, '0', 2) < 0) {
$balance = '0.00';
}
if (bccomp($balance, '0', 2) < 0) $balance = '0.00';
$dueDate = clone $startDate;
$dueDate->modify('+' . ($i - 1) . ' months');
......@@ -186,7 +193,7 @@ final class PricingEngine
$schedule[] = [
'number' => $i,
'due_date' => $dueDate->format('Y-m-d'),
'amount' => $monthlyPayment,
'amount' => $amount,
'principal' => $principal,
'interest' => $interest,
'remaining' => $balance,
......@@ -194,15 +201,15 @@ final class PricingEngine
}
return [
'total_amount' => $totalAmount,
'down_payment' => $downPayment,
'remaining' => $remaining,
'annual_rate' => $annualRate,
'total_interest' => $totalInterest,
'total_amount' => $totalAmount,
'down_payment' => $downPayment,
'remaining' => $remaining,
'annual_rate' => $annualRate,
'total_interest' => $totalInterest,
'total_with_interest' => $totalWithInterest,
'monthly_payment' => bcdiv($totalWithInterest, (string) $months, 2),
'months' => $months,
'schedule' => $schedule,
'monthly_payment' => $monthlyPayment,
'months' => $months,
'schedule' => $schedule,
];
}
......
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