Commit a64e13fa authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(installments): add early settlement — full principal-only payoff with interest waiver

- InstallmentCalculator: calculateEarlySettlement() sums all pending principal and interest separately; settlement_amount = principal only, all interest waived
- InstallmentController: earlySettlement() (confirmation page), processEarlySettlement() (executes: single PaymentService call, zeros interest on each settled row, marks plan completed + is_cash_settled=1, dispatches installment.early_settled), settlementReceipt() (print view)
- Routes: GET/POST /installments/{id}/early-settlement, GET /installments/{id}/settlement-receipt/{receiptId}
- show.php:  تسوية مبكرة button (requires installment.pay permission, only when active + pending > 0)
- early_settlement.php: breakdown table (original due / interest waived / principal to pay), pending items preview, mandatory confirmation checkbox, submit disabled until checked
- settlement_receipt.php: print-ready receipt showing original balance, interest waived, amount paid, settled items list, amount in words, stamp/signature area
- PaymentService: early_settlement payment type label
- Architecture Map: section 5.6, new route rows, new event row
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 69f09d80
......@@ -286,6 +286,186 @@ class InstallmentController extends Controller
);
}
/**
* GET /installments/{id}/early-settlement
* Show the early-settlement confirmation page with the breakdown.
*/
public function earlySettlement(Request $request, string $id): Response
{
$this->authorize('installment.pay');
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT ip.*, m.full_name_ar as member_name, m.membership_number, m.form_number
FROM installment_plans ip JOIN members m ON m.id = ip.member_id
WHERE ip.id = ?",
[(int) $id]
);
if (!$plan) {
return $this->redirect('/installments')->withError('الخطة غير موجودة');
}
if ($plan['status'] !== 'active') {
return $this->redirect("/installments/{$id}")->withError('لا يمكن التسوية المبكرة — الخطة ليست نشطة');
}
$calc = InstallmentCalculator::calculateEarlySettlement((int) $id);
if (!($calc['success'] ?? false)) {
return $this->redirect("/installments/{$id}")->withError($calc['error'] ?? 'خطأ في الحساب');
}
return $this->view('Installments.Views.early_settlement', [
'plan' => $plan,
'calc' => $calc,
]);
}
/**
* POST /installments/{id}/early-settlement
* Execute the early settlement: charge principal only, waive all remaining interest.
*/
public function processEarlySettlement(Request $request, string $id): Response
{
$this->authorize('installment.pay');
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT * FROM installment_plans WHERE id = ? AND status = 'active'",
[(int) $id]
);
if (!$plan) {
return $this->redirect('/installments')->withError('الخطة غير موجودة أو غير نشطة');
}
$calc = InstallmentCalculator::calculateEarlySettlement((int) $id);
if (!($calc['success'] ?? false)) {
return $this->redirect("/installments/{$id}")->withError($calc['error'] ?? 'خطأ في الحساب');
}
$paymentMethod = trim((string) $request->post('payment_method', 'cash'));
$settlementAmount = $calc['settlement_amount'];
// Single payment for the entire remaining principal
$result = PaymentService::processPayment([
'member_id' => (int) $plan['member_id'],
'amount' => $settlementAmount,
'payment_type' => 'early_settlement',
'payment_method' => $paymentMethod,
'related_entity_type' => 'installment_plan',
'related_entity_id' => (int) $id,
'description' => 'تسوية مبكرة لخطة التقسيط #' . $id
. ' — ' . $calc['items_count'] . ' قسط — أصل الدين فقط بدون فوائد',
]);
if (!$result['success']) {
return $this->redirect("/installments/{$id}/early-settlement")->withError($result['error']);
}
$ts = date('Y-m-d H:i:s');
$db->beginTransaction();
try {
foreach ($calc['pending_items'] as $item) {
$db->update('installment_schedule', [
'interest' => '0.00',
'amount' => $item['principal'],
'paid_amount' => $item['principal'],
'payment_id' => $result['payment_id'],
'status' => 'paid',
'paid_at' => $ts,
'updated_at' => $ts,
], '`id` = ?', [(int) $item['id']]);
}
$db->update('installment_plans', [
'status' => 'completed',
'is_cash_settled' => 1,
'updated_at' => $ts,
], '`id` = ?', [(int) $id]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect("/installments/{$id}/early-settlement")
->withError('خطأ أثناء تسجيل التسوية: ' . $e->getMessage());
}
EventBus::dispatch('installment.early_settled', [
'plan_id' => (int) $id,
'member_id' => (int) $plan['member_id'],
'payment_id' => $result['payment_id'],
'receipt_id' => $result['receipt_id'],
'settlement_amount' => $settlementAmount,
'interest_waived' => $calc['total_interest_waived'],
'items_settled' => $calc['items_count'],
]);
return $this->redirect("/installments/{$id}/settlement-receipt/{$result['receipt_id']}")
->withSuccess('تمت التسوية المبكرة بنجاح — إيصال: ' . $result['receipt_number']);
}
/**
* GET /installments/{id}/settlement-receipt/{receiptId}
* Print-friendly early settlement receipt.
*/
public function settlementReceipt(Request $request, string $id, string $receiptId): Response
{
$this->authorize('installment.view');
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT ip.*, m.full_name_ar as member_name, m.form_number, m.membership_number
FROM installment_plans ip JOIN members m ON m.id = ip.member_id
WHERE ip.id = ?",
[(int) $id]
);
if (!$plan) {
return $this->redirect('/installments')->withError('الخطة غير موجودة');
}
$receipt = $db->selectOne(
"SELECT r.*, p.payment_method, p.amount as payment_amount,
e.full_name_ar as cashier_name
FROM receipts r
LEFT JOIN payments p ON p.id = r.payment_id
LEFT JOIN employees e ON e.id = r.issued_by_employee_id
WHERE r.id = ? AND r.member_id = ?",
[(int) $receiptId, (int) $plan['member_id']]
);
if (!$receipt) {
return $this->redirect("/installments/{$id}")->withError('الإيصال غير موجود');
}
// Reconstruct settlement breakdown from the paid items linked to this payment
$settledItems = $db->select(
"SELECT * FROM installment_schedule
WHERE installment_plan_id = ? AND payment_id = ?
ORDER BY installment_number ASC",
[(int) $id, (int) $receipt['payment_id']]
);
$totalPrincipal = array_reduce(
$settledItems,
fn ($carry, $item) => bcadd($carry, (string) $item['paid_amount'], 2),
'0.00'
);
// Reconstruct waived interest: we stored interest=0 but can derive from plan header
// We compute from the original plan totals vs what was already paid before settlement
$totalWaived = $db->selectOne(
"SELECT p2.amount as settlement_amt
FROM payments p2 WHERE p2.id = ?",
[(int) $receipt['payment_id']]
);
return $this->view('Installments.Views.settlement_receipt', [
'plan' => $plan,
'receipt' => $receipt,
'settledItems' => $settledItems,
'totalPrincipal' => $totalPrincipal,
'itemsCount' => count($settledItems),
]);
}
public function payInstallment(Request $request, string $planId, string $scheduleId): Response
{
$db = App::getInstance()->db();
......
......@@ -9,6 +9,11 @@ return [
['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'],
// Early settlement
['GET', '/installments/{id}/early-settlement', 'Installments\Controllers\InstallmentController@earlySettlement', ['auth'], 'installment.pay'],
['POST', '/installments/{id}/early-settlement', 'Installments\Controllers\InstallmentController@processEarlySettlement', ['auth', 'csrf'], 'installment.pay'],
['GET', '/installments/{id}/settlement-receipt/{receiptId}','Installments\Controllers\InstallmentController@settlementReceipt', ['auth'], 'installment.view'],
// Cheque uploads
['GET', '/installments/{planId}/cheques', 'Installments\Controllers\ChequeController@index', ['auth'], 'installment.view'],
['POST', '/installments/{planId}/cheques', 'Installments\Controllers\ChequeController@store', ['auth', 'csrf'], 'installment.create_plan'],
......
......@@ -151,4 +151,57 @@ final class InstallmentCalculator
),
];
}
/**
* Calculate early settlement: member pays ALL remaining principal now, ALL future interest is waived.
*
* Business rule: interest waiver is only valid when every single pending/overdue installment
* is being settled in the same transaction. Partial settlement is not allowed here.
*
* Returns:
* - pending_items : the unpaid schedule rows
* - items_count : how many will be settled
* - total_original_due : sum of amount (principal+interest) across all pending rows
* - remaining_principal : sum of principal only — what the member actually pays
* - total_interest_waived: sum of interest across all pending rows — fully waived
* - settlement_amount : equals remaining_principal (what is charged)
*/
public static function calculateEarlySettlement(int $planId): array
{
$db = \App\Core\App::getInstance()->db();
$items = $db->select(
"SELECT * FROM installment_schedule
WHERE installment_plan_id = ? AND status IN ('pending','overdue')
ORDER BY installment_number ASC",
[$planId]
);
if (empty($items)) {
return [
'success' => false,
'error' => 'لا توجد أقساط معلقة لهذه الخطة',
];
}
$totalOriginalDue = '0.00';
$remainingPrincipal = '0.00';
$totalInterestWaived = '0.00';
foreach ($items as $item) {
$totalOriginalDue = bcadd($totalOriginalDue, (string) $item['amount'], 2);
$remainingPrincipal = bcadd($remainingPrincipal, (string) $item['principal'], 2);
$totalInterestWaived = bcadd($totalInterestWaived, (string) $item['interest'], 2);
}
return [
'success' => true,
'pending_items' => $items,
'items_count' => count($items),
'total_original_due' => $totalOriginalDue,
'remaining_principal' => $remainingPrincipal,
'total_interest_waived'=> $totalInterestWaived,
'settlement_amount' => $remainingPrincipal,
];
}
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>خطة تقسيط #<?= (int) $plan['id'] ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('installment.pay') && $plan['status'] === 'active' && $pendingCount > 0): ?>
<a href="/installments/<?= (int) $plan['id'] ?>/early-settlement" class="btn btn-primary"
style="background:#7C3AED;border-color:#7C3AED;">
⚡ تسوية مبكرة
</a>
<?php endif; ?>
<?php if (can('installment.create_plan') && $plan['status'] === 'active'): ?>
<form method="POST" action="/installments/<?= (int) $plan['id'] ?>/recalculate" style="display:inline;">
<?= csrf_field() ?>
......
......@@ -313,6 +313,7 @@ final class PaymentService
'development_fee' => 'رسوم تنمية',
'down_payment' => 'مقدم تقسيط',
'installment' => 'قسط شهري',
'early_settlement' => 'تسوية مبكرة للأقساط',
'fine' => 'غرامة',
'separation_fee' => 'رسوم فصل',
'divorce_fee' => 'رسوم طلاق',
......
......@@ -134,8 +134,12 @@ app/Modules/Installments/
| POST | /installments/store/{memberId} | InstallmentController@store | auth, csrf | installment.create_plan |
| GET | /installments/{id} | InstallmentController@show | auth | installment.view |
| POST | /installments/{planId}/pay/{scheduleId} | InstallmentController@payInstallment | auth, csrf | installment.pay |
| GET | /installments/{id}/early-settlement | InstallmentController@earlySettlement | auth | installment.pay |
| POST | /installments/{id}/early-settlement | InstallmentController@processEarlySettlement | auth, csrf | installment.pay |
| GET | /installments/{id}/settlement-receipt/{receiptId} | InstallmentController@settlementReceipt | auth | installment.view |
| GET | /installments/{planId}/cheques | ChequeController@index | auth | installment.view |
| POST | /installments/{planId}/cheques | ChequeController@store | auth, csrf | installment.create_plan |
| POST | /installments/{planId}/cheques/batch | ChequeController@storeBatch | auth, csrf | installment.create_plan |
---
......@@ -219,6 +223,40 @@ app/Modules/Installments/
- Return: total_early_payoff, interest_saved
```
### 5.6 Early Settlement (Full — all pending items at once)
```
GET /installments/{id}/early-settlement
→ InstallmentController::earlySettlement()
→ InstallmentCalculator::calculateEarlySettlement($planId)
* Loads all pending/overdue schedule rows
* Computes: remaining_principal (sum of principal), total_interest_waived (sum of interest)
* settlement_amount = remaining_principal (interest fully waived)
→ renders early_settlement.php confirmation page with breakdown + checkbox
POST /installments/{id}/early-settlement
→ InstallmentController::processEarlySettlement()
1. Re-run calculateEarlySettlement() for freshness
2. PaymentService::processPayment(payment_type='early_settlement', amount=settlement_amount)
3. DB transaction:
a. For each pending item: set interest=0, amount=principal, paid_amount=principal,
payment_id=result.payment_id, status='paid', paid_at=now
b. Plan: status='completed', is_cash_settled=1
4. Dispatch installment.early_settled event
5. Redirect to settlement_receipt
GET /installments/{id}/settlement-receipt/{receiptId}
→ InstallmentController::settlementReceipt()
→ renders settlement_receipt.php (print-friendly)
Business rules enforced:
- Only available when plan.status='active' AND pending count > 0
- ALL pending rows are settled together — no partial interest waiver
- Confirmation checkbox required before submit button activates
- Single payment record covers all settled rows (linked via payment_id)
- interest_waived derived on receipt by querying paid rows with different payment_id
```
---
## 6. Interest Calculation Method
......@@ -249,6 +287,7 @@ For each month i:
|-------|---------|-----------------|
| `installment_plan.created` | `{plan_id, member_id}` | InstallmentController::store |
| `installment.paid` | `{plan_id, schedule_id, member_id}` | InstallmentController::payInstallment |
| `installment.early_settled` | `{plan_id, member_id, payment_id, receipt_id, settlement_amount, interest_waived, items_settled}` | InstallmentController::processEarlySettlement |
| `member.activated` | `{member_id, membership_number, payment_method:'installment'}` | ChequeService::activateMemberAfterCheques |
| `member.dropped` | `{member_id, reason, installment_plan_id}` | DefaultChecker, InstallmentDefaultJob |
......
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