Commit e6a79887 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(rentals): add VAT, utilities, late fees, and monthly invoices

- Contracts now store vat_percentage (1%), utilities_percentage, late_fee_type
  (none/daily/weekly/monthly), late_fee_rate, and grand_total
- New rental_invoices table with per-invoice breakdown: base, utilities, VAT,
  late_fee, total; late fee is calculated at payment time based on days overdue
- RentalInvoiceService handles generation, late-fee calc, and mark-paid
- Accounting auto-posts on rental.invoice_paid: Dr. Cash, Cr. RentalRevenue
  (410521) + ServiceRevenue (410515) + TaxPayable (230804) + FineRevenue (410512)
- contract_form has live preview calculator for monthly invoice totals
- contract_show shows full financial breakdown and invoices table
- Migrations: Phase_95_001 (alter contracts), Phase_95_002 (create invoices)
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent ba9d9f81
......@@ -775,6 +775,120 @@ final class AccountingIntegrationService
}
}
/**
* Post journal entry when a rental invoice is paid.
*
* Dr. Cash / Bank (base + utilities + late_fee + vat) — full payment received
* Cr. Rental Revenue (410521) base amount
* Cr. Service Revenue (410515) utilities amount (if > 0)
* Cr. Tax Payable (230804) VAT amount
* Cr. Fine Revenue (410512) late fee amount (if > 0)
*/
public static function onRentalInvoicePaid(array $data): void
{
$db = App::getInstance()->db();
$invoiceId = (int) ($data['invoice_id'] ?? 0);
$contractId = (int) ($data['contract_id'] ?? 0);
$paymentId = (int) ($data['payment_id'] ?? 0);
$baseAmount = (string) ($data['base_amount'] ?? '0.00');
$utilsAmount = (string) ($data['utilities_amount'] ?? '0.00');
$vatAmount = (string) ($data['vat_amount'] ?? '0.00');
$lateFee = (string) ($data['late_fee_amount'] ?? '0.00');
$totalAmount = (string) ($data['total_amount'] ?? '0.00');
if ($invoiceId <= 0 || bccomp($totalAmount, '0.00', 2) <= 0) {
return;
}
$invoice = $db->selectOne("SELECT * FROM rental_invoices WHERE id = ?", [$invoiceId]);
$contract = $db->selectOne("SELECT * FROM rental_contracts WHERE id = ?", [$contractId]);
$invoiceNum = $invoice ? ($invoice['invoice_number'] ?? $invoiceId) : $invoiceId;
$contractNum = $contract ? ($contract['contract_number'] ?? $contractId) : $contractId;
// Determine debit account — try to match payment method from payments table
$payment = $paymentId > 0 ? $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]) : null;
$payMethod = $payment ? ($payment['payment_method'] ?? 'cash') : 'cash';
$debitCode = AccountCodes::debitAccountForMethod($payMethod);
$debitAccount = self::getAccountByCode($debitCode);
$rentalRevenue = self::getAccountByCode(AccountCodes::RENTAL_REVENUE);
$serviceRevenue = self::getAccountByCode(AccountCodes::SERVICE_REVENUE);
$taxPayable = self::getAccountByCode(AccountCodes::TAX_PAYABLE);
$fineRevenue = self::getAccountByCode(AccountCodes::FINE_REVENUE);
if (!$debitAccount || !$rentalRevenue || !$taxPayable) {
Logger::error('Rental invoice paid: missing accounts', ['invoice_id' => $invoiceId]);
return;
}
$lines = [];
// Dr. Cash/Bank — full amount received
$lines[] = [
'account_id' => (int) $debitAccount['id'],
'debit' => $totalAmount,
'credit' => '0.00',
'description_ar' => 'تحصيل فاتورة إيجار ' . $invoiceNum,
];
// Cr. Rental Revenue (base)
if (bccomp($baseAmount, '0.00', 2) > 0) {
$lines[] = [
'account_id' => (int) $rentalRevenue['id'],
'debit' => '0.00',
'credit' => $baseAmount,
'description_ar' => 'إيجار — ' . $invoiceNum . ' — عقد ' . $contractNum,
];
}
// Cr. Service Revenue (utilities)
if (bccomp($utilsAmount, '0.00', 2) > 0 && $serviceRevenue) {
$lines[] = [
'account_id' => (int) $serviceRevenue['id'],
'debit' => '0.00',
'credit' => $utilsAmount,
'description_ar' => 'مرافق إيجار — ' . $invoiceNum,
];
}
// Cr. Tax Payable (VAT)
if (bccomp($vatAmount, '0.00', 2) > 0) {
$lines[] = [
'account_id' => (int) $taxPayable['id'],
'debit' => '0.00',
'credit' => $vatAmount,
'description_ar' => 'ضريبة قيمة مضافة — ' . $invoiceNum,
];
}
// Cr. Fine Revenue (late fee)
if (bccomp($lateFee, '0.00', 2) > 0 && $fineRevenue) {
$lines[] = [
'account_id' => (int) $fineRevenue['id'],
'debit' => '0.00',
'credit' => $lateFee,
'description_ar' => 'غرامة تأخير إيجار — ' . $invoiceNum,
];
}
$result = JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => 'تحصيل فاتورة إيجار ' . $invoiceNum . ' — عقد ' . $contractNum,
'description_en' => 'Rental invoice collected ' . $invoiceNum,
'reference_type' => 'rental_invoice',
'reference_id' => $invoiceId,
'source_module' => 'rentals',
'is_auto_generated' => 1,
], $lines, true);
if ($result['success'] && !empty($result['entry_id'])) {
$db->update('rental_invoices', ['journal_entry_id' => (int) $result['entry_id']], 'id = ?', [$invoiceId]);
} else {
Logger::error('Rental invoice journal entry failed', ['invoice_id' => $invoiceId, 'error' => $result['error'] ?? '']);
}
}
// ────────────────────────────────────────────────────────────
// PROCUREMENT MODULE
// ────────────────────────────────────────────────────────────
......
......@@ -350,6 +350,15 @@ EventBus::listen('rental.deposit_refunded', function (array $data): void {
}
}, 50);
// When a rental invoice is paid, post: Dr. Cash, Cr. Rental Revenue + Utilities + VAT + Late Fee
EventBus::listen('rental.invoice_paid', function (array $data): void {
try {
AccountingIntegrationService::onRentalInvoicePaid($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (rental.invoice_paid): ' . $e->getMessage());
}
}, 50);
// ── Facility Entry ──────────────────────────────────────────
// When a facility entry payment is recorded (pool/gym access)
EventBus::listen('facility.entry_recorded', function (array $data): void {
......
......@@ -12,6 +12,8 @@ use App\Modules\Rentals\Models\RentalContract;
use App\Modules\Rentals\Models\RentalBooking;
use App\Modules\Rentals\Services\RentalContractService;
use App\Modules\Rentals\Services\RentalDepositService;
use App\Modules\Rentals\Services\RentalInvoiceService;
use App\Modules\Rentals\Models\RentalInvoice;
use App\Modules\Facilities\Models\Facility;
class RentalController extends Controller
......@@ -201,16 +203,20 @@ class RentalController extends Controller
*/
public function storeContract(Request $request): Response
{
$entityId = (int) $request->post('entity_id', 0);
$facilityId = (int) $request->post('facility_id', 0);
$activityType = trim((string) $request->post('activity_type', ''));
$startDate = trim((string) $request->post('start_date', ''));
$endDate = trim((string) $request->post('end_date', ''));
$totalUnits = (int) $request->post('total_units', 0);
$unitRate = (float) $request->post('unit_rate', 0);
$timeTier = trim((string) $request->post('time_tier', 'AM'));
$depositPercentage = (float) $request->post('deposit_percentage', 0);
$notes = trim((string) $request->post('notes', ''));
$entityId = (int) $request->post('entity_id', 0);
$facilityId = (int) $request->post('facility_id', 0);
$activityType = trim((string) $request->post('activity_type', ''));
$startDate = trim((string) $request->post('start_date', ''));
$endDate = trim((string) $request->post('end_date', ''));
$totalUnits = (int) $request->post('total_units', 0);
$unitRate = (float) $request->post('unit_rate', 0);
$timeTier = trim((string) $request->post('time_tier', 'AM'));
$depositPercentage = (float) $request->post('deposit_percentage', 0);
$vatPercentage = (float) $request->post('vat_percentage', 1.00);
$utilitiesPercentage = (float) $request->post('utilities_percentage', 0.00);
$lateFeeType = trim((string) $request->post('late_fee_type', 'none'));
$lateFeeRate = (float) $request->post('late_fee_rate', 0.00);
$notes = trim((string) $request->post('notes', ''));
// Validation
$errors = [];
......@@ -247,16 +253,20 @@ class RentalController extends Controller
}
$contract = RentalContractService::createContract([
'entity_id' => $entityId,
'facility_id' => $facilityId,
'activity_type' => $activityType ?: null,
'start_date' => $startDate,
'end_date' => $endDate,
'total_units' => $totalUnits,
'unit_rate' => $unitRate,
'time_tier' => $timeTier,
'deposit_percentage' => $depositPercentage,
'notes' => $notes ?: null,
'entity_id' => $entityId,
'facility_id' => $facilityId,
'activity_type' => $activityType ?: null,
'start_date' => $startDate,
'end_date' => $endDate,
'total_units' => $totalUnits,
'unit_rate' => $unitRate,
'time_tier' => $timeTier,
'vat_percentage' => $vatPercentage,
'utilities_percentage' => $utilitiesPercentage,
'late_fee_type' => $lateFeeType,
'late_fee_rate' => $lateFeeRate,
'deposit_percentage' => $depositPercentage,
'notes' => $notes ?: null,
]);
return $this->redirect('/rentals/contracts/' . $contract->id)->withSuccess('تم إنشاء العقد بنجاح');
......@@ -272,18 +282,20 @@ class RentalController extends Controller
return $this->redirect('/rentals')->withError('العقد غير موجود');
}
$entityId = (int) ($contract->entity_id ?? $contract['entity_id']);
$facilityId = (int) ($contract->facility_id ?? $contract['facility_id']);
$entityId = (int) $contract->entity_id;
$facilityId = (int) $contract->facility_id;
$entity = RentalEntity::find($entityId);
$facility = Facility::find($facilityId);
$bookings = RentalBooking::getForContract((int) $id);
$invoices = RentalInvoice::getForContract((int) $id);
return $this->view('Rentals.Views.contract_show', [
'contract' => $contract,
'entity' => $entity,
'facility' => $facility,
'bookings' => $bookings,
'invoices' => $invoices,
]);
}
......@@ -344,6 +356,106 @@ class RentalController extends Controller
}
}
// ─── Invoices ────────────────────────────────────────────────
/**
* Show form to generate a new invoice for a contract.
*/
public function createInvoice(Request $request, string $id): Response
{
$contract = RentalContract::find((int) $id);
if (!$contract) {
return $this->redirect('/rentals')->withError('العقد غير موجود');
}
$monthly = RentalInvoiceService::calcBase($contract);
$utils = RentalInvoiceService::calcUtilities($contract, $monthly);
$vat = RentalInvoiceService::calcVat($contract, $monthly + $utils);
return $this->view('Rentals.Views.invoice_form', [
'contract' => $contract,
'monthly_base' => $monthly,
'monthly_utils' => $utils,
'monthly_vat' => $vat,
'monthly_total' => round($monthly + $utils + $vat, 2),
]);
}
/**
* Generate and store a new invoice.
*/
public function storeInvoice(Request $request, string $id): Response
{
$contract = RentalContract::find((int) $id);
if (!$contract) {
return $this->redirect('/rentals')->withError('العقد غير موجود');
}
$periodStart = trim((string) $request->post('period_start', ''));
$periodEnd = trim((string) $request->post('period_end', ''));
$dueDate = trim((string) $request->post('due_date', ''));
$notes = trim((string) $request->post('notes', '')) ?: null;
$errors = [];
if (!$periodStart || !$periodEnd || !$dueDate) {
$errors[] = 'تاريخ الفترة وتاريخ الاستحقاق مطلوبة';
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
return $this->redirect('/rentals/contracts/' . $id . '/invoices/create');
}
try {
$invoice = RentalInvoiceService::generateInvoice((int) $id, $periodStart, $periodEnd, $dueDate, $notes);
return $this->redirect('/rentals/invoices/' . $invoice->id)->withSuccess('تم إنشاء الفاتورة بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/rentals/contracts/' . $id)->withError($e->getMessage());
}
}
/**
* Show invoice detail.
*/
public function showInvoice(Request $request, string $id): Response
{
$invoice = RentalInvoice::find((int) $id);
if (!$invoice) {
return $this->redirect('/rentals')->withError('الفاتورة غير موجودة');
}
$contractId = (int) $invoice->contract_id;
$contract = RentalContract::find($contractId);
$entity = $contract ? RentalEntity::find((int) $contract->entity_id) : null;
return $this->view('Rentals.Views.invoice_show', [
'invoice' => $invoice,
'contract' => $contract,
'entity' => $entity,
]);
}
/**
* Mark invoice as paid.
*/
public function payInvoice(Request $request, string $id): Response
{
$paymentId = (int) $request->post('payment_id', 0);
$paidAt = trim((string) $request->post('paid_at', date('Y-m-d H:i:s')));
if ($paymentId <= 0) {
return $this->redirect('/rentals/invoices/' . $id)->withError('رقم الدفعة مطلوب');
}
try {
RentalInvoiceService::markPaid((int) $id, $paymentId, $paidAt);
return $this->redirect('/rentals/invoices/' . $id)->withSuccess('تم تسجيل الدفع بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/rentals/invoices/' . $id)->withError($e->getMessage());
}
}
// ─── Private helpers ─────────────────────────────────────────
/**
......
......@@ -28,6 +28,13 @@ class RentalContract extends Model
'discount_percentage',
'discount_amount',
'total_amount',
'vat_percentage',
'vat_amount',
'utilities_percentage',
'utilities_amount',
'grand_total',
'late_fee_type',
'late_fee_rate',
'deposit_percentage',
'deposit_amount',
'deposit_status',
......@@ -40,6 +47,45 @@ class RentalContract extends Model
'notes',
];
/**
* Late fee types with Arabic labels.
*/
public static function getLateFeeTtypes(): array
{
return [
'none' => 'لا يوجد',
'daily' => 'يومي',
'weekly' => 'أسبوعي',
'monthly' => 'شهري',
];
}
/**
* Calculate monthly invoice amounts from a contract row.
* Returns [base, utilities, vat, total].
*/
public static function calcMonthlyInvoice(object|array $c): array
{
$total = (float) (is_object($c) ? $c->total_amount : ($c['total_amount'] ?? 0));
$months = (float) (is_object($c) ? ($c->contract_months ?? 1) : ($c['contract_months'] ?? 1));
$utilsPct = (float) (is_object($c) ? $c->utilities_percentage : ($c['utilities_percentage'] ?? 0));
$vatPct = (float) (is_object($c) ? $c->vat_percentage : ($c['vat_percentage'] ?? 1));
$startDate = is_object($c) ? ($c->start_date ?? '') : ($c['start_date'] ?? '');
$endDate = is_object($c) ? ($c->end_date ?? '') : ($c['end_date'] ?? '');
if ($startDate && $endDate) {
$diff = (new \DateTimeImmutable($startDate))->diff(new \DateTimeImmutable($endDate));
$months = max(1, ($diff->y * 12) + $diff->m + ($diff->d > 0 ? 1 : 0));
}
$base = round($total / max(1, $months), 2);
$utilities = round($base * ($utilsPct / 100), 2);
$vat = round(($base + $utilities) * ($vatPct / 100), 2);
$invoiceTotal = round($base + $utilities + $vat, 2);
return compact('base', 'utilities', 'vat', 'invoiceTotal');
}
/**
* Get all contract statuses with Arabic labels.
*/
......
<?php
declare(strict_types=1);
namespace App\Modules\Rentals\Models;
use App\Core\Model;
use App\Core\App;
class RentalInvoice extends Model
{
protected static string $table = 'rental_invoices';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'invoice_number',
'contract_id',
'entity_id',
'period_start',
'period_end',
'due_date',
'base_amount',
'utilities_amount',
'vat_amount',
'late_fee_amount',
'total_amount',
'status',
'paid_at',
'payment_id',
'notes',
'journal_entry_id',
];
public static function getStatuses(): array
{
return [
'unpaid' => 'غير مدفوعة',
'paid' => 'مدفوعة',
'overdue' => 'متأخرة',
'cancelled' => 'ملغاة',
];
}
public static function getStatusLabel(string $status): string
{
return self::getStatuses()[$status] ?? $status;
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'paid' => '#059669',
'overdue' => '#DC2626',
'cancelled' => '#6B7280',
default => '#D97706',
};
}
public static function generateNumber(): string
{
$prefix = 'RI-' . date('Ym') . '-';
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT invoice_number FROM rental_invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1",
[$prefix . '%']
);
$nextSeq = 1;
if ($row) {
$number = is_object($row) ? $row->invoice_number : ($row['invoice_number'] ?? '');
$parts = explode('-', (string) $number);
$nextSeq = ((int) ($parts[2] ?? 0)) + 1;
}
return $prefix . str_pad((string) $nextSeq, 4, '0', STR_PAD_LEFT);
}
public static function getForContract(int $contractId): array
{
return static::query()
->where('contract_id', '=', $contractId)
->orderBy('period_start', 'ASC')
->get();
}
}
......@@ -15,4 +15,10 @@ return [
['POST', '/rentals/contracts/{id:\d+}/approve', 'Rentals\Controllers\RentalController@approveContract', ['auth', 'csrf'], 'rental.approve'],
['POST', '/rentals/contracts/{id:\d+}/deposit/collect', 'Rentals\Controllers\RentalController@collectDeposit', ['auth', 'csrf'], 'rental.manage_deposit'],
['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'],
];
......@@ -37,13 +37,28 @@ final class RentalContractService
$months = ($diff->y * 12) + $diff->m + ($diff->d > 0 ? 1 : 0);
}
$discountPercentage = self::calculateBulkDiscount($totalUnits, $months);
$discountAmount = round($subtotal * ($discountPercentage / 100), 2);
$totalAmount = $subtotal - $discountAmount;
$discountPercentage = self::calculateBulkDiscount($totalUnits, $months);
$discountAmount = round($subtotal * ($discountPercentage / 100), 2);
$totalAmount = $subtotal - $discountAmount;
// Calculate deposit
$depositPercentage = (float) ($data['deposit_percentage'] ?? 0);
$depositAmount = round($totalAmount * ($depositPercentage / 100), 2);
// VAT — always 1% of (base + utilities) per invoice, stored on contract for reference
$vatPercentage = (float) ($data['vat_percentage'] ?? 1.00);
// Utilities — optional % of the monthly base amount
$utilitiesPercentage = (float) ($data['utilities_percentage'] ?? 0.00);
// Grand total across whole contract = total + utilities_total + vat_total
$vatAmount = round($totalAmount * ($vatPercentage / 100), 2);
$utilitiesAmount = round($totalAmount * ($utilitiesPercentage / 100), 2);
$grandTotal = $totalAmount + $utilitiesAmount + $vatAmount;
// Late fee
$lateFeeType = $data['late_fee_type'] ?? 'none';
$lateFeeRate = (float) ($data['late_fee_rate'] ?? 0.00);
// Deposit — % of grand total
$depositPercentage = (float) ($data['deposit_percentage'] ?? 0);
$depositAmount = round($grandTotal * ($depositPercentage / 100), 2);
$contract = RentalContract::create([
'contract_number' => $contractNumber,
......@@ -59,6 +74,13 @@ final class RentalContractService
'discount_percentage' => $discountPercentage,
'discount_amount' => $discountAmount,
'total_amount' => $totalAmount,
'vat_percentage' => $vatPercentage,
'vat_amount' => $vatAmount,
'utilities_percentage' => $utilitiesPercentage,
'utilities_amount' => $utilitiesAmount,
'grand_total' => $grandTotal,
'late_fee_type' => $lateFeeType,
'late_fee_rate' => $lateFeeRate,
'deposit_percentage' => $depositPercentage,
'deposit_amount' => $depositAmount,
'deposit_status' => 'pending',
......@@ -110,7 +132,7 @@ final class RentalContractService
throw new \RuntimeException('Contract not found');
}
$facilityId = (int) ($contract->facility_id ?? $contract['facility_id']);
$facilityId = (int) $contract->facility_id;
foreach ($schedule as $slot) {
RentalBooking::create([
......
<?php
declare(strict_types=1);
namespace App\Modules\Rentals\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Rentals\Models\RentalContract;
use App\Modules\Rentals\Models\RentalInvoice;
final class RentalInvoiceService
{
/**
* Generate a single monthly invoice for a contract.
*
* Calculates: base (monthly share) + utilities (% of base) + VAT (1% of base+utilities).
* Late fee is NOT added at generation — it is added at payment time if overdue.
*/
public static function generateInvoice(int $contractId, string $periodStart, string $periodEnd, string $dueDate, ?string $notes = null): object
{
$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 invoice for a contract that is not approved or active');
}
$base = self::calcBase($contract);
$utils = self::calcUtilities($contract, $base);
$vat = self::calcVat($contract, $base + $utils);
$total = round($base + $utils + $vat, 2);
$entityId = (int) (is_object($contract) ? $contract->entity_id : ($contract['entity_id'] ?? 0));
$invoice = RentalInvoice::create([
'invoice_number' => RentalInvoice::generateNumber(),
'contract_id' => $contractId,
'entity_id' => $entityId,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'due_date' => $dueDate,
'base_amount' => $base,
'utilities_amount' => $utils,
'vat_amount' => $vat,
'late_fee_amount' => 0.00,
'total_amount' => $total,
'status' => 'unpaid',
'notes' => $notes,
]);
Logger::info('Rental invoice generated', [
'invoice_id' => $invoice->id,
'contract_id' => $contractId,
'total' => $total,
]);
return $invoice;
}
/**
* Mark an invoice as paid and apply late fee if applicable.
*
* Late fee is calculated from due_date to paid_at based on contract late_fee_type/rate.
* Journal entry is dispatched via event.
*/
public static function markPaid(int $invoiceId, int $paymentId, string $paidAt): void
{
$invoice = RentalInvoice::find($invoiceId);
if (!$invoice) {
throw new \RuntimeException('Invoice not found');
}
$currentStatus = is_object($invoice) ? $invoice->status : ($invoice['status'] ?? '');
if ($currentStatus === 'paid') {
throw new \RuntimeException('Invoice is already paid');
}
$contractId = (int) (is_object($invoice) ? $invoice->contract_id : ($invoice['contract_id'] ?? 0));
$contract = RentalContract::find($contractId);
$lateFee = $contract ? self::calcLateFee($invoice, $contract, $paidAt) : 0.00;
$baseAmount = (float) (is_object($invoice) ? $invoice->base_amount : ($invoice['base_amount'] ?? 0));
$utilsAmount = (float) (is_object($invoice) ? $invoice->utilities_amount : ($invoice['utilities_amount'] ?? 0));
$vatAmount = (float) (is_object($invoice) ? $invoice->vat_amount : ($invoice['vat_amount'] ?? 0));
$newTotal = round($baseAmount + $utilsAmount + $vatAmount + $lateFee, 2);
$invoice->update([
'status' => 'paid',
'paid_at' => $paidAt,
'payment_id' => $paymentId,
'late_fee_amount' => $lateFee,
'total_amount' => $newTotal,
]);
EventBus::dispatch('rental.invoice_paid', [
'invoice_id' => $invoiceId,
'contract_id' => $contractId,
'payment_id' => $paymentId,
'base_amount' => $baseAmount,
'utilities_amount'=> $utilsAmount,
'vat_amount' => $vatAmount,
'late_fee_amount' => $lateFee,
'total_amount' => $newTotal,
'paid_at' => $paidAt,
]);
Logger::info('Rental invoice paid', [
'invoice_id' => $invoiceId,
'total' => $newTotal,
'late_fee' => $lateFee,
]);
}
/**
* Calculate the monthly base amount (contract total / number of months).
*/
public static function calcBase(object|array $contract): float
{
$totalAmount = (float) (is_object($contract) ? $contract->total_amount : ($contract['total_amount'] ?? 0));
$startDate = is_object($contract) ? ($contract->start_date ?? '') : ($contract['start_date'] ?? '');
$endDate = is_object($contract) ? ($contract->end_date ?? '') : ($contract['end_date'] ?? '');
$months = 1;
if ($startDate && $endDate) {
$diff = (new \DateTimeImmutable($startDate))->diff(new \DateTimeImmutable($endDate));
$months = max(1, ($diff->y * 12) + $diff->m + ($diff->d > 0 ? 1 : 0));
}
return round($totalAmount / $months, 2);
}
/**
* Calculate utilities surcharge on the monthly base.
*/
public static function calcUtilities(object|array $contract, float $base): float
{
$pct = (float) (is_object($contract) ? $contract->utilities_percentage : ($contract['utilities_percentage'] ?? 0));
return round($base * ($pct / 100), 2);
}
/**
* Calculate VAT (default 1%) on (base + utilities).
*/
public static function calcVat(object|array $contract, float $taxableAmount): float
{
$pct = (float) (is_object($contract) ? $contract->vat_percentage : ($contract['vat_percentage'] ?? 1.00));
return round($taxableAmount * ($pct / 100), 2);
}
/**
* Calculate late fee based on contract late_fee_type and days overdue.
*
* late_fee_type: none | daily | weekly | monthly
* late_fee_rate: % of invoice total per period.
*
* Examples:
* daily 2% → 2% of total per overdue day
* weekly 5% → 5% of total per overdue week (or fraction)
* monthly 10% → 10% of total per overdue month (or fraction)
*/
public static function calcLateFee(object|array $invoice, object|array $contract, string $paidAt): float
{
$lateFeeType = is_object($contract) ? ($contract->late_fee_type ?? 'none') : ($contract['late_fee_type'] ?? 'none');
$lateFeeRate = (float) (is_object($contract) ? ($contract->late_fee_rate ?? 0) : ($contract['late_fee_rate'] ?? 0));
if ($lateFeeType === 'none' || $lateFeeRate <= 0) {
return 0.00;
}
$dueDate = is_object($invoice) ? ($invoice->due_date ?? '') : ($invoice['due_date'] ?? '');
$invoiceBase = (float) (is_object($invoice) ? ($invoice->base_amount ?? 0) : ($invoice['base_amount'] ?? 0));
$invoiceUtils = (float) (is_object($invoice) ? ($invoice->utilities_amount ?? 0) : ($invoice['utilities_amount'] ?? 0));
$invoiceVat = (float) (is_object($invoice) ? ($invoice->vat_amount ?? 0) : ($invoice['vat_amount'] ?? 0));
$invoiceTotal = $invoiceBase + $invoiceUtils + $invoiceVat;
if (!$dueDate || $invoiceTotal <= 0) {
return 0.00;
}
$due = new \DateTimeImmutable($dueDate);
$paid = new \DateTimeImmutable($paidAt);
if ($paid <= $due) {
return 0.00;
}
$overdueDays = (int) $due->diff($paid)->days;
$periods = match ($lateFeeType) {
'daily' => $overdueDays,
'weekly' => (int) ceil($overdueDays / 7),
'monthly' => (int) ceil($overdueDays / 30),
default => 0,
};
return round($invoiceTotal * ($lateFeeRate / 100) * $periods, 2);
}
}
This diff is collapsed.
This diff is collapsed.
<?php
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>فاتورة إيجار جديدة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/rentals/contracts/<?= (int) $contract->id ?>" class="btn btn-outline">
<i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> العودة للعقد
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Summary Banner -->
<div class="card" style="margin-bottom:20px;padding:15px 20px;background:#F0FDF4;border:1px solid #BBF7D0;">
<div style="display:flex;align-items:center;gap:20px;flex-wrap:wrap;">
<code style="background:#fff;color:#0D7377;padding:4px 10px;border-radius:4px;font-weight:600;"><?= e($contract->contract_number) ?></code>
<div style="display:flex;gap:30px;font-size:14px;color:#374151;">
<span>إيجار شهري: <strong><?= money($monthly_base) ?></strong></span>
<?php if ($monthly_utils > 0): ?>
<span>مرافق: <strong style="color:#7C3AED;"><?= money($monthly_utils) ?></strong></span>
<?php endif; ?>
<span>VAT (<?= (float) ($contract->vat_percentage ?? 1) ?>%): <strong style="color:#D97706;"><?= money($monthly_vat) ?></strong></span>
<span>الإجمالي: <strong style="color:#059669;font-size:16px;"><?= money($monthly_total) ?></strong></span>
</div>
</div>
</div>
<form method="POST" action="/rentals/contracts/<?= (int) $contract->id ?>/invoices">
<?= \App\Core\CSRF::field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">بيانات الفاتورة</h3>
</div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div>
<label class="form-label">بداية الفترة <span style="color:#DC2626;">*</span></label>
<input type="date" name="period_start" value="<?= e(old('period_start')) ?>" class="form-input" required>
</div>
<div>
<label class="form-label">نهاية الفترة <span style="color:#DC2626;">*</span></label>
<input type="date" name="period_end" value="<?= e(old('period_end')) ?>" class="form-input" required>
</div>
<div>
<label class="form-label">تاريخ الاستحقاق <span style="color:#DC2626;">*</span></label>
<input type="date" name="due_date" value="<?= e(old('due_date')) ?>" class="form-input" required>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-input" rows="2" style="resize:vertical;"><?= e(old('notes')) ?></textarea>
</div>
</div>
</div>
<!-- Preview -->
<div class="card" style="margin-bottom:20px;padding:20px;background:#F9FAFB;">
<h4 style="margin:0 0 12px;color:#374151;">تفصيل الفاتورة</h4>
<table style="width:100%;font-size:14px;border-collapse:collapse;">
<tr style="border-bottom:1px solid #E5E7EB;">
<td style="padding:8px 0;color:#6B7280;">الإيجار الشهري</td>
<td style="padding:8px 0;text-align:left;font-weight:600;"><?= money($monthly_base) ?></td>
</tr>
<?php if ($monthly_utils > 0): ?>
<tr style="border-bottom:1px solid #E5E7EB;">
<td style="padding:8px 0;color:#6B7280;">مرافق (<?= (float) ($contract->utilities_percentage ?? 0) ?>%)</td>
<td style="padding:8px 0;text-align:left;font-weight:600;color:#7C3AED;"><?= money($monthly_utils) ?></td>
</tr>
<?php endif; ?>
<tr style="border-bottom:1px solid #E5E7EB;">
<td style="padding:8px 0;color:#6B7280;">القيمة المضافة (<?= (float) ($contract->vat_percentage ?? 1) ?>%)</td>
<td style="padding:8px 0;text-align:left;font-weight:600;color:#D97706;"><?= money($monthly_vat) ?></td>
</tr>
<?php if (($contract->late_fee_type ?? 'none') !== 'none'): ?>
<tr style="border-bottom:1px solid #E5E7EB;">
<td style="padding:8px 0;color:#6B7280;">غرامة التأخير</td>
<td style="padding:8px 0;text-align:left;color:#DC2626;font-size:12px;">تُحسب عند الدفع إن تأخر</td>
</tr>
<?php endif; ?>
<tr>
<td style="padding:10px 0;font-weight:700;font-size:15px;">الإجمالي</td>
<td style="padding:10px 0;text-align:left;font-weight:700;font-size:18px;color:#059669;"><?= money($monthly_total) ?></td>
</tr>
</table>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary">إنشاء الفاتورة</button>
<a href="/rentals/contracts/<?= (int) $contract->id ?>" class="btn btn-outline">إلغاء</a>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
use App\Modules\Rentals\Models\RentalInvoice;
use App\Modules\Rentals\Models\RentalContract;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?><?= e($invoice->invoice_number ?? '') ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if ($contract): ?>
<a href="/rentals/contracts/<?= (int) $contract->id ?>" class="btn btn-outline">
<i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> العودة للعقد
</a>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$iStatus = $invoice->status ?? 'unpaid';
$iColor = RentalInvoice::getStatusColor($iStatus);
?>
<!-- Header -->
<div class="card" style="margin-bottom:20px;padding:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div>
<div style="margin-bottom:6px;">
<code style="font-size:14px;background:#F0F9FF;color:#0284C7;padding:4px 12px;border-radius:4px;font-weight:600;"><?= e($invoice->invoice_number ?? '') ?></code>
</div>
<div style="font-size:14px;color:#374151;">
<?php if ($entity): ?>
<strong>الجهة:</strong> <?= e($entity->name_ar) ?>
&nbsp;|&nbsp;
<?php endif; ?>
<?php if ($contract): ?>
<strong>العقد:</strong> <a href="/rentals/contracts/<?= (int) $contract->id ?>" style="color:#0D7377;"><?= e($contract->contract_number) ?></a>
<?php endif; ?>
</div>
</div>
<span class="badge" style="background:<?= $iColor ?>15;color:<?= $iColor ?>;font-size:13px;padding:5px 14px;border-radius:10px;font-weight:600;">
<?= e(RentalInvoice::getStatusLabel($iStatus)) ?>
</span>
</div>
</div>
<!-- Financial Breakdown -->
<div class="card" style="margin-bottom:20px;padding:0;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;"><i data-lucide="receipt" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> تفصيل الفاتورة</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;">
<table style="font-size:14px;border-collapse:collapse;width:100%;">
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:10px 0;color:#6B7280;">الفترة</td>
<td style="padding:10px 0;font-weight:600;"><?= e($invoice->period_start ?? '') ?><?= e($invoice->period_end ?? '') ?></td>
</tr>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:10px 0;color:#6B7280;">تاريخ الاستحقاق</td>
<td style="padding:10px 0;font-weight:600;"><?= e($invoice->due_date ?? '') ?></td>
</tr>
<?php if ($iStatus === 'paid'): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:10px 0;color:#6B7280;">تاريخ الدفع</td>
<td style="padding:10px 0;font-weight:600;color:#059669;"><?= e($invoice->paid_at ?? '') ?></td>
</tr>
<?php endif; ?>
<?php if (!empty($invoice->notes)): ?>
<tr>
<td style="padding:10px 0;color:#6B7280;">ملاحظات</td>
<td style="padding:10px 0;"><?= nl2br(e($invoice->notes)) ?></td>
</tr>
<?php endif; ?>
</table>
<div style="background:#F9FAFB;border-radius:8px;padding:20px;">
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #E5E7EB;">
<span style="color:#6B7280;">الإيجار الأساسي</span>
<span style="font-weight:600;"><?= money((float) ($invoice->base_amount ?? 0)) ?></span>
</div>
<?php if ((float) ($invoice->utilities_amount ?? 0) > 0): ?>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #E5E7EB;">
<span style="color:#6B7280;">المرافق</span>
<span style="font-weight:600;color:#7C3AED;"><?= money((float) ($invoice->utilities_amount ?? 0)) ?></span>
</div>
<?php endif; ?>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #E5E7EB;">
<span style="color:#6B7280;">القيمة المضافة</span>
<span style="font-weight:600;color:#D97706;"><?= money((float) ($invoice->vat_amount ?? 0)) ?></span>
</div>
<?php if ((float) ($invoice->late_fee_amount ?? 0) > 0): ?>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #E5E7EB;">
<span style="color:#DC2626;">غرامة التأخير</span>
<span style="font-weight:600;color:#DC2626;"><?= money((float) ($invoice->late_fee_amount ?? 0)) ?></span>
</div>
<?php endif; ?>
<div style="display:flex;justify-content:space-between;padding:12px 0;margin-top:4px;border-top:2px solid #E5E7EB;">
<span style="font-weight:700;font-size:15px;">الإجمالي</span>
<span style="font-weight:700;font-size:20px;color:#059669;"><?= money((float) ($invoice->total_amount ?? 0)) ?></span>
</div>
</div>
</div>
</div>
</div>
<!-- Pay Action -->
<?php if ($iStatus === 'unpaid' && can('rental.manage_contract')): ?>
<div class="card" style="margin-bottom:20px;padding:20px;background:#F0FDF4;border:1px solid #BBF7D0;">
<h4 style="margin:0 0 15px;color:#059669;"><i data-lucide="banknote" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> تسجيل الدفع</h4>
<form method="POST" action="/rentals/invoices/<?= (int) $invoice->id ?>/pay" style="display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:end;">
<?= \App\Core\CSRF::field() ?>
<div>
<label class="form-label">رقم الدفعة <span style="color:#DC2626;">*</span></label>
<input type="number" name="payment_id" class="form-input" required placeholder="رقم الدفعة من النظام">
</div>
<div>
<label class="form-label">تاريخ الدفع</label>
<input type="datetime-local" name="paid_at" class="form-input" value="<?= date('Y-m-d\TH:i') ?>">
</div>
<button type="submit" class="btn btn-primary">تسجيل الدفع</button>
</form>
<?php if ($contract && ($contract->late_fee_type ?? 'none') !== 'none'): ?>
<div style="margin-top:10px;font-size:12px;color:#6B7280;">
<i data-lucide="info" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>
سيتم احتساب غرامة التأخير تلقائياً إن كان الدفع بعد تاريخ الاستحقاق
(<?= e(\App\Modules\Rentals\Models\RentalContract::getLateFeeTtypes()[$contract->late_fee_type ?? 'none'] ?? '') ?>
<?= (float) ($contract->late_fee_rate ?? 0) ?>% لكل فترة)
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($iStatus === 'overdue' && can('rental.manage_contract')): ?>
<div class="card" style="margin-bottom:20px;padding:20px;background:#FEF2F2;border:1px solid #FECACA;">
<h4 style="margin:0 0 15px;color:#DC2626;"><i data-lucide="alert-triangle" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> فاتورة متأخرة — تسجيل الدفع</h4>
<form method="POST" action="/rentals/invoices/<?= (int) $invoice->id ?>/pay" style="display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:end;">
<?= \App\Core\CSRF::field() ?>
<div>
<label class="form-label">رقم الدفعة <span style="color:#DC2626;">*</span></label>
<input type="number" name="payment_id" class="form-input" required placeholder="رقم الدفعة">
</div>
<div>
<label class="form-label">تاريخ الدفع</label>
<input type="datetime-local" name="paid_at" class="form-input" value="<?= date('Y-m-d\TH:i') ?>">
</div>
<button type="submit" class="btn btn-primary" style="background:#DC2626;">تسجيل الدفع + الغرامة</button>
</form>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE rental_contracts
ADD COLUMN vat_percentage decimal(5,2) NOT NULL DEFAULT 1.00 AFTER total_amount,
ADD COLUMN vat_amount decimal(15,2) NOT NULL DEFAULT 0.00 AFTER vat_percentage,
ADD COLUMN utilities_percentage decimal(5,2) NOT NULL DEFAULT 0.00 AFTER vat_amount,
ADD COLUMN utilities_amount decimal(15,2) NOT NULL DEFAULT 0.00 AFTER utilities_percentage,
ADD COLUMN grand_total decimal(15,2) NOT NULL DEFAULT 0.00 AFTER utilities_amount,
ADD COLUMN late_fee_type varchar(10) NOT NULL DEFAULT 'none' AFTER grand_total,
ADD COLUMN late_fee_rate decimal(5,2) NOT NULL DEFAULT 0.00 AFTER late_fee_type;
",
'down' => "
ALTER TABLE rental_contracts
DROP COLUMN vat_percentage,
DROP COLUMN vat_amount,
DROP COLUMN utilities_percentage,
DROP COLUMN utilities_amount,
DROP COLUMN grand_total,
DROP COLUMN late_fee_type,
DROP COLUMN late_fee_rate;
",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE rental_invoices (
id bigint unsigned NOT NULL AUTO_INCREMENT,
invoice_number varchar(50) NOT NULL,
contract_id bigint unsigned NOT NULL,
entity_id bigint unsigned NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
due_date date NOT NULL,
base_amount decimal(15,2) NOT NULL DEFAULT 0.00,
utilities_amount decimal(15,2) NOT NULL DEFAULT 0.00,
vat_amount decimal(15,2) NOT NULL DEFAULT 0.00,
late_fee_amount decimal(15,2) NOT NULL DEFAULT 0.00,
total_amount decimal(15,2) NOT NULL DEFAULT 0.00,
status varchar(20) NOT NULL DEFAULT 'unpaid',
paid_at timestamp NULL DEFAULT NULL,
payment_id bigint unsigned NULL DEFAULT NULL,
notes text NULL,
journal_entry_id bigint unsigned NULL DEFAULT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by bigint unsigned NULL,
updated_by bigint unsigned NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_rental_invoices_number (invoice_number),
KEY idx_rental_invoices_contract (contract_id),
KEY idx_rental_invoices_entity (entity_id),
KEY idx_rental_invoices_status (status),
KEY idx_rental_invoices_due_date (due_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
",
'down' => "DROP TABLE IF EXISTS rental_invoices;",
];
# Rentals Module — Architecture Map
> **Last updated:** 2026-06-10
> **Last updated:** 2026-07-18
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -27,24 +27,28 @@ It does **NOT** directly manage:
```
app/Modules/Rentals/
├── bootstrap.php # Permission registration (5 permissions)
├── Routes.php # Web routes (13 routes)
├── bootstrap.php # Permission + MenuRegistry (sidebar under العمليات, order 290)
├── Routes.php # Web routes (17 routes)
├── Controllers/
│ └── RentalController.php # Single controller (entities + contracts + deposits)
│ └── RentalController.php # Single controller (entities + contracts + deposits + invoices)
├── Models/
│ ├── RentalEntity.php # Tenant entity model (soft delete)
│ ├── RentalContract.php # Contract model (soft delete)
│ └── RentalBooking.php # Individual booking slots (no soft delete)
│ ├── RentalBooking.php # Individual booking slots (no soft delete)
│ └── RentalInvoice.php # Monthly invoice model (no soft delete)
├── Services/
│ ├── RentalContractService.php # Contract creation, activation, booking generation
│ └── RentalDepositService.php # Deposit collect/refund lifecycle
│ ├── RentalDepositService.php # Deposit collect/refund lifecycle
│ └── RentalInvoiceService.php # Invoice generation, late fee calc, mark paid
└── Views/
├── index.php # Contract list with filters
├── entities.php # Entity list with filters
├── entity_form.php # Create/edit entity form
├── entity_show.php # Entity detail with contracts
├── contract_form.php # Create contract form
└── contract_show.php # Contract detail with bookings + deposit status
├── contract_form.php # Create contract form (VAT + utilities + late fee + live calc)
├── contract_show.php # Contract detail with invoices + bookings + deposit
├── invoice_form.php # Create invoice for a contract
└── invoice_show.php # Invoice detail + pay action
```
---
......@@ -94,8 +98,15 @@ app/Modules/Rentals/
| subtotal | decimal(15,2) | NO | | 0.00 | units * rate |
| discount_percentage | decimal(5,2) | NO | | 0.00 | Bulk discount % |
| discount_amount | decimal(15,2) | NO | | 0.00 | |
| total_amount | decimal(15,2) | NO | | 0.00 | subtotal - discount |
| deposit_percentage | decimal(5,2) | NO | | 10.00 | % of total for deposit |
| total_amount | decimal(15,2) | NO | | 0.00 | subtotal - discount (net rental) |
| vat_percentage | decimal(5,2) | NO | | 1.00 | Always 1% by law |
| vat_amount | decimal(15,2) | NO | | 0.00 | total_amount * vat_percentage / 100 |
| utilities_percentage | decimal(5,2) | NO | | 0.00 | % of monthly base for utilities |
| utilities_amount | decimal(15,2) | NO | | 0.00 | total_amount * utilities_percentage / 100 |
| grand_total | decimal(15,2) | NO | | 0.00 | total + utilities + vat (full contract) |
| late_fee_type | varchar(10) | NO | | none | none/daily/weekly/monthly |
| late_fee_rate | decimal(5,2) | NO | | 0.00 | % of invoice total per late period |
| deposit_percentage | decimal(5,2) | NO | | 10.00 | % of grand_total for deposit |
| deposit_amount | decimal(15,2) | NO | | 0.00 | |
| deposit_status | varchar(20) | NO | | pending | pending/collected/partially_refunded/refunded |
| deposit_payment_id | bigint unsigned | YES | MUL | | FK to external payment |
......@@ -113,6 +124,32 @@ app/Modules/Rentals/
| created_by | bigint unsigned | YES | | | |
| updated_by | bigint unsigned | YES | | | |
### 3.4 `rental_invoices` Table
| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| invoice_number | varchar(50) | NO | UNI | | Format: RI-YYYYMM-XXXX |
| contract_id | bigint unsigned | NO | MUL | | FK to rental_contracts |
| entity_id | bigint unsigned | NO | MUL | | FK to rental_entities |
| period_start | date | NO | | | Billing period start |
| period_end | date | NO | | | Billing period end |
| due_date | date | NO | MUL | | Payment due date |
| base_amount | decimal(15,2) | NO | | 0.00 | Monthly share of net rental |
| utilities_amount | decimal(15,2) | NO | | 0.00 | Utilities surcharge |
| vat_amount | decimal(15,2) | NO | | 0.00 | 1% of (base + utilities) |
| late_fee_amount | decimal(15,2) | NO | | 0.00 | Set at payment if overdue |
| total_amount | decimal(15,2) | NO | | 0.00 | base + utilities + vat + late_fee |
| status | varchar(20) | NO | MUL | unpaid | unpaid/paid/overdue/cancelled |
| paid_at | timestamp | YES | | | |
| payment_id | bigint unsigned | YES | | | FK to external payment |
| notes | text | YES | | | |
| journal_entry_id | bigint unsigned | YES | | | FK to journal_entries |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | CURRENT_TIMESTAMP | On update |
| created_by | bigint unsigned | YES | | | |
| updated_by | bigint unsigned | YES | | | |
### 3.3 `rental_bookings` Table
| Column | Type | Nullable | Key | Default | Notes |
......@@ -147,6 +184,10 @@ app/Modules/Rentals/
| POST | /rentals/contracts/{id}/approve | RentalController@approveContract | rental.approve |
| POST | /rentals/contracts/{id}/deposit/collect | RentalController@collectDeposit | rental.manage_deposit |
| 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 |
| GET | /rentals/invoices/{id} | RentalController@showInvoice | rental.view |
| POST | /rentals/invoices/{id}/pay | RentalController@payInvoice | rental.manage_contract |
---
......@@ -200,6 +241,7 @@ partially_refunded → refunded (on refund payment processing)
| `rental.deposit_collected` | `{contract_id, payment_id}` | RentalDepositService::collectDeposit |
| `rental.deposit_refund_requested` | `{contract_id}` | RentalDepositService::requestRefund |
| `rental.deposit_refunded` | `{contract_id, payment_id}` | RentalDepositService::processRefund |
| `rental.invoice_paid` | `{invoice_id, contract_id, payment_id, base_amount, utilities_amount, vat_amount, late_fee_amount, total_amount, paid_at}` | RentalInvoiceService::markPaid |
---
......@@ -209,6 +251,7 @@ partially_refunded → refunded (on refund payment processing)
|-------|----------------|--------|
| `rental.deposit_collected` | Accounting | Auto-post journal entry for deposit liability |
| `rental.deposit_refunded` | Accounting | Auto-post journal entry for deposit refund |
| `rental.invoice_paid` | Accounting | Dr. Cash, Cr. RentalRevenue(410521) + ServiceRevenue(410515) + TaxPayable(230804) + FineRevenue(410512) |
---
......@@ -258,9 +301,10 @@ partially_refunded → refunded (on refund payment processing)
- The Reservations module queries `rental_bookings` directly via raw SQL
- If the Rentals table schema changes, the Reservations conflict detector will break silently (wrapped in try/catch)
### 10.4 No Menu Registration
- The Rentals module does NOT register a sidebar menu in `bootstrap.php`
- Only permissions are registered — navigation must be handled elsewhere or manually
### 10.4 Invoice Late Fee Applied at Payment Time
- Late fee is NOT stored on the invoice at generation — it is calculated in `RentalInvoiceService::calcLateFee()` when `markPaid()` is called
- If `paid_at``due_date`, late fee = 0
- If overdue: periods = ceil(days / period_length); fee = total × rate% × periods
---
......
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