Commit c5610491 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(rentals): configurable VAT, deposit payment ref, bulk invoice generation

- VAT % now reads default from RENTAL_VAT_PCT business rule (seeded at 1%);
  contract form pre-fills with live rule value instead of hardcoded 1
- Deposit row in contract_show now shows payment reference (receipt number)
  when deposit_payment_id is set
- Bulk invoice generation: POST /contracts/{id}/invoices/bulk-generate
  generates all monthly invoices from start to end date, skipping existing;
  button added to page_actions and invoices table header with JS confirm
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent e6a79887
......@@ -15,6 +15,7 @@ use App\Modules\Rentals\Services\RentalDepositService;
use App\Modules\Rentals\Services\RentalInvoiceService;
use App\Modules\Rentals\Models\RentalInvoice;
use App\Modules\Facilities\Models\Facility;
use App\Modules\Rules\Services\RuleEngine;
class RentalController extends Controller
{
......@@ -189,12 +190,14 @@ class RentalController extends Controller
{
$entities = RentalEntity::search(['status' => 'active'], 1000, 1)['data'] ?? [];
$facilities = Facility::allActive();
$defaultVat = (float) (RuleEngine::getValue('RENTAL_VAT_PCT') ?? 1.00);
return $this->view('Rentals.Views.contract_form', [
'contract' => null,
'entities' => $entities,
'facilities' => $facilities,
'statuses' => RentalContract::getStatuses(),
'defaultVat' => $defaultVat,
]);
}
......@@ -381,6 +384,27 @@ class RentalController extends Controller
]);
}
/**
* Bulk-generate all monthly invoices from contract start to end, skipping existing ones.
*/
public function bulkGenerateInvoices(Request $request, string $id): Response
{
$contract = RentalContract::find((int) $id);
if (!$contract) {
return $this->redirect('/rentals')->withError('العقد غير موجود');
}
try {
$count = RentalInvoiceService::bulkGenerateInvoices((int) $id);
if ($count === 0) {
return $this->redirect('/rentals/contracts/' . $id)->withWarning('جميع الفواتير الشهرية موجودة مسبقاً');
}
return $this->redirect('/rentals/contracts/' . $id)->withSuccess('تم توليد ' . $count . ' فاتورة بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/rentals/contracts/' . $id)->withError($e->getMessage());
}
}
/**
* Generate and store a new invoice.
*/
......
......@@ -17,8 +17,9 @@ return [
['POST', '/rentals/contracts/{id:\d+}/deposit/refund', 'Rentals\Controllers\RentalController@refundDeposit', ['auth', 'csrf'], 'rental.manage_deposit'],
// Invoices
['GET', '/rentals/contracts/{id:\d+}/invoices/create', 'Rentals\Controllers\RentalController@createInvoice', ['auth'], 'rental.manage_contract'],
['POST', '/rentals/contracts/{id:\d+}/invoices', 'Rentals\Controllers\RentalController@storeInvoice', ['auth', 'csrf'], 'rental.manage_contract'],
['GET', '/rentals/invoices/{id:\d+}', 'Rentals\Controllers\RentalController@showInvoice', ['auth'], 'rental.view'],
['POST', '/rentals/invoices/{id:\d+}/pay', 'Rentals\Controllers\RentalController@payInvoice', ['auth', 'csrf'], 'rental.manage_contract'],
['GET', '/rentals/contracts/{id:\d+}/invoices/create', 'Rentals\Controllers\RentalController@createInvoice', ['auth'], 'rental.manage_contract'],
['POST', '/rentals/contracts/{id:\d+}/invoices', 'Rentals\Controllers\RentalController@storeInvoice', ['auth', 'csrf'], 'rental.manage_contract'],
['POST', '/rentals/contracts/{id:\d+}/invoices/bulk-generate', 'Rentals\Controllers\RentalController@bulkGenerateInvoices', ['auth', 'csrf'], 'rental.manage_contract'],
['GET', '/rentals/invoices/{id:\d+}', 'Rentals\Controllers\RentalController@showInvoice', ['auth'], 'rental.view'],
['POST', '/rentals/invoices/{id:\d+}/pay', 'Rentals\Controllers\RentalController@payInvoice', ['auth', 'csrf'], 'rental.manage_contract'],
];
......@@ -41,8 +41,9 @@ final class RentalContractService
$discountAmount = round($subtotal * ($discountPercentage / 100), 2);
$totalAmount = $subtotal - $discountAmount;
// VAT — always 1% of (base + utilities) per invoice, stored on contract for reference
$vatPercentage = (float) ($data['vat_percentage'] ?? 1.00);
// VAT — default from RENTAL_VAT_PCT rule (1%), overridable per contract
$defaultVat = (float) (RuleEngine::getValue('RENTAL_VAT_PCT') ?? 1.00);
$vatPercentage = (float) ($data['vat_percentage'] ?? $defaultVat);
// Utilities — optional % of the monthly base amount
$utilitiesPercentage = (float) ($data['utilities_percentage'] ?? 0.00);
......
......@@ -115,6 +115,59 @@ final class RentalInvoiceService
]);
}
/**
* Generate all monthly invoices for a contract from start_date to end_date.
*
* Skips months that already have an invoice (by checking period_start overlap).
* Due date defaults to the last day of each period.
* Returns count of newly created invoices.
*/
public static function bulkGenerateInvoices(int $contractId): int
{
$contract = RentalContract::find($contractId);
if (!$contract) {
throw new \RuntimeException('Contract not found');
}
$status = is_object($contract) ? $contract->status : ($contract['status'] ?? '');
if (!in_array($status, ['approved', 'active'], true)) {
throw new \RuntimeException('Cannot generate invoices for a contract that is not approved or active');
}
$startDate = is_object($contract) ? ($contract->start_date ?? '') : ($contract['start_date'] ?? '');
$endDate = is_object($contract) ? ($contract->end_date ?? '') : ($contract['end_date'] ?? '');
if (!$startDate || !$endDate) {
throw new \RuntimeException('Contract is missing start_date or end_date');
}
// Load existing invoices to avoid duplicates
$existing = RentalInvoice::getForContract($contractId);
$existingStarts = array_column($existing, 'period_start');
$current = new \DateTimeImmutable(date('Y-m-01', strtotime($startDate)));
$end = new \DateTimeImmutable($endDate);
$created = 0;
while ($current <= $end) {
$periodStart = $current->format('Y-m-d');
// Last day of the current month (or contract end, whichever is earlier)
$monthEnd = new \DateTimeImmutable($current->format('Y-m-t'));
$periodEnd = $monthEnd > $end ? $end->format('Y-m-d') : $monthEnd->format('Y-m-d');
// Skip if already exists
if (!in_array($periodStart, $existingStarts, true)) {
self::generateInvoice($contractId, $periodStart, $periodEnd, $periodEnd);
$created++;
}
$current = $current->modify('first day of next month');
}
return $created;
}
/**
* Calculate the monthly base amount (contract total / number of months).
*/
......
......@@ -100,7 +100,7 @@ $__template->layout('Layout.main');
<div>
<label class="form-label">نسبة القيمة المضافة (%)</label>
<input type="number" name="vat_percentage" id="vat_percentage" value="<?= e(old('vat_percentage', '1')) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="1">
<input type="number" name="vat_percentage" id="vat_percentage" value="<?= e(old('vat_percentage', (string) ($defaultVat ?? 1))) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="1">
<small style="color:#6B7280;">تُحتسب على الإيجار الشهري + المرافق</small>
</div>
......
......@@ -7,6 +7,12 @@ $__template->layout('Layout.main');
<?php $__template->section('page_actions'); ?>
<?php if (in_array($contract->status ?? 'draft', ['approved','active']) && can('rental.manage_contract')): ?>
<form method="POST" action="/rentals/contracts/<?= (int) $contract->id ?>/invoices/bulk-generate" style="display:inline;" onsubmit="return confirm('هل تريد توليد كل الفواتير الشهرية حتى نهاية العقد؟ الفواتير المولودة مسبقاً لن تتكرر.');">
<?= \App\Core\CSRF::field() ?>
<button type="submit" class="btn btn-outline" style="margin-left:8px;color:#059669;border-color:#059669;">
<i data-lucide="layers" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> توليد كل الفواتير
</button>
</form>
<a href="/rentals/contracts/<?= (int) $contract->id ?>/invoices/create" class="btn btn-primary" style="margin-left:8px;">
<i data-lucide="file-plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> فاتورة جديدة
</a>
......@@ -99,8 +105,20 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
<!-- Deposit row -->
<div style="padding:0 20px 20px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:12px 20px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<span style="font-size:13px;color:#374151;">التأمين (<?= (float) ($contract->deposit_percentage ?? 0) ?>%): <strong><?= money((float) ($contract->deposit_amount ?? 0)) ?></strong></span>
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:12px 20px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap;">
<span style="font-size:13px;color:#374151;font-weight:600;">
<i data-lucide="shield" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;color:#6B7280;"></i>
التأمين (<?= (float) ($contract->deposit_percentage ?? 0) ?>%):
<strong style="font-size:15px;"><?= money((float) ($contract->deposit_amount ?? 0)) ?></strong>
</span>
<?php if (!empty($contract->deposit_payment_id)): ?>
<span style="font-size:13px;color:#6B7280;">
<i data-lucide="receipt" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>
رقم الإيصال: <strong style="color:#374151;"><?= (int) $contract->deposit_payment_id ?></strong>
</span>
<?php endif; ?>
</div>
<span class="badge" style="background:<?= RentalContract::getDepositStatusColor($depositStatus) ?>15;color:<?= RentalContract::getDepositStatusColor($depositStatus) ?>;font-size:12px;padding:4px 12px;border-radius:10px;font-weight:600;">
<?= e(RentalContract::getDepositStatusLabel($depositStatus)) ?>
</span>
......@@ -185,7 +203,15 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;">
<h3 style="margin:0;color:#1A1A2E;"><i data-lucide="receipt" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> الفواتير</h3>
<?php if (in_array($cStatus, ['approved','active']) && can('rental.manage_contract')): ?>
<a href="/rentals/contracts/<?= (int) $contract->id ?>/invoices/create" class="btn btn-primary" style="font-size:13px;padding:6px 12px;">+ فاتورة جديدة</a>
<div style="display:flex;gap:8px;">
<form method="POST" action="/rentals/contracts/<?= (int) $contract->id ?>/invoices/bulk-generate" style="margin:0;" onsubmit="return confirm('توليد كل الفواتير حتى نهاية العقد؟');">
<?= \App\Core\CSRF::field() ?>
<button type="submit" class="btn btn-outline" style="font-size:13px;padding:6px 12px;color:#059669;border-color:#059669;">
<i data-lucide="layers" style="width:14px;height:14px;vertical-align:middle;margin-left:3px;"></i> توليد الكل
</button>
</form>
<a href="/rentals/contracts/<?= (int) $contract->id ?>/invoices/create" class="btn btn-primary" style="font-size:13px;padding:6px 12px;">+ فاتورة جديدة</a>
</div>
<?php endif; ?>
</div>
<?php if (!empty($invoices)): ?>
......
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$existing = $db->selectOne(
"SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL",
['RENTAL_VAT_PCT']
);
if ($existing) {
return;
}
$db->insert('business_rules', [
'rule_code' => 'RENTAL_VAT_PCT',
'category' => 'sports_activity',
'name_ar' => 'نسبة القيمة المضافة على الإيجارات',
'name_en' => 'Rental VAT Percentage',
'description_ar' => 'نسبة ضريبة القيمة المضافة المطبقة على فواتير الإيجار المؤسسي (1% وفقاً للقانون)',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"1.00"}',
'parameters_json' => '{"percentage":"decimal"}',
'effective_from' => date('Y-m-d'),
'is_active' => 1,
'version' => 1,
]);
};
......@@ -186,6 +186,7 @@ app/Modules/Rentals/
| POST | /rentals/contracts/{id}/deposit/refund | RentalController@refundDeposit | rental.manage_deposit |
| GET | /rentals/contracts/{id}/invoices/create | RentalController@createInvoice | rental.manage_contract |
| POST | /rentals/contracts/{id}/invoices | RentalController@storeInvoice | rental.manage_contract |
| POST | /rentals/contracts/{id}/invoices/bulk-generate | RentalController@bulkGenerateInvoices | rental.manage_contract |
| GET | /rentals/invoices/{id} | RentalController@showInvoice | rental.view |
| POST | /rentals/invoices/{id}/pay | RentalController@payInvoice | rental.manage_contract |
......@@ -315,6 +316,7 @@ partially_refunded → refunded (on refund payment processing)
| RENTAL_BULK_MIN_UNITS | Minimum units for bulk discount eligibility (default: 24) |
| RENTAL_BULK_MIN_MONTHS | Minimum months for bulk discount eligibility (default: 2) |
| RENTAL_BULK_DISCOUNT_PCT | Bulk discount percentage (default: 15%) |
| RENTAL_VAT_PCT | VAT percentage on rental invoices (default: 1.00%, seeded via Phase_95_001) |
---
......
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