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

feat(rentals): escalation tiers, dual utilities modes, bank-rate late fee, grace period

## Schema (Phase_96_001)
- escalation_type / escalation_rate / escalation_tiers_json — flat or tiered annual rent increases
- utilities_mode / utilities_rent_pct / utilities_facility_pct / facility_monthly_cost — support rent%, facility-cost%, or both
- payment_due_day — configurable per-contract (default day 5)
- late_fee_bank_rate — annual bank rate for daily penalty calculation
- grace_period_months / early_termination_months — additional contract terms
- Data migration: backfills utilities_rent_pct from utilities_percentage

## Service layer
- RentalContractService: computes escalated total_amount across flat/tiered modes; handles all utilities modes; recalculates VAT and grand_total
- RentalInvoiceService: calcBase() now escalation-aware (by period); bulkGenerate skips grace months and uses payment_due_day; calcLateFee supports bank-rate daily formula
- Seeds RENTAL_LATE_FEE_BANK_RATE business rule (27.25% annual)

## UI
- contract_form: new sections for escalation (dynamic tiers table), utilities mode, payment terms, extra contract conditions; year-by-year preview
- contract_show: mode-aware utilities display, escalation card, grace/termination info
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 8ae05b0b
...@@ -191,6 +191,7 @@ class RentalController extends Controller ...@@ -191,6 +191,7 @@ class RentalController extends Controller
$entities = RentalEntity::search(['status' => 'active'], 1000, 1)['data'] ?? []; $entities = RentalEntity::search(['status' => 'active'], 1000, 1)['data'] ?? [];
$facilities = Facility::allActive(); $facilities = Facility::allActive();
$defaultVat = (float) (RuleEngine::getValue('RENTAL_VAT_PCT') ?? 1.00); $defaultVat = (float) (RuleEngine::getValue('RENTAL_VAT_PCT') ?? 1.00);
$bankRate = (float) (RuleEngine::getValue('RENTAL_LATE_FEE_BANK_RATE', 'annual_rate') ?? 27.25);
return $this->view('Rentals.Views.contract_form', [ return $this->view('Rentals.Views.contract_form', [
'contract' => null, 'contract' => null,
...@@ -198,6 +199,9 @@ class RentalController extends Controller ...@@ -198,6 +199,9 @@ class RentalController extends Controller
'facilities' => $facilities, 'facilities' => $facilities,
'statuses' => RentalContract::getStatuses(), 'statuses' => RentalContract::getStatuses(),
'defaultVat' => $defaultVat, 'defaultVat' => $defaultVat,
'bankRate' => $bankRate,
'escalationTypes' => RentalContract::getEscalationTypes(),
'utilitiesModes' => RentalContract::getUtilitiesModes(),
]); ]);
} }
...@@ -221,6 +225,19 @@ class RentalController extends Controller ...@@ -221,6 +225,19 @@ class RentalController extends Controller
$lateFeeRate = (float) $request->post('late_fee_rate', 0.00); $lateFeeRate = (float) $request->post('late_fee_rate', 0.00);
$notes = trim((string) $request->post('notes', '')); $notes = trim((string) $request->post('notes', ''));
// New escalation / utilities / terms fields
$escalationType = trim((string) $request->post('escalation_type', 'none'));
$escalationRate = (float) $request->post('escalation_rate', 0.00);
$escalationTiersJson = trim((string) $request->post('escalation_tiers_json', ''));
$utilitiesMode = trim((string) $request->post('utilities_mode', 'rent_pct'));
$utilitiesRentPct = (float) $request->post('utilities_rent_pct', 0.00);
$utilitiesFacilityPct = (float) $request->post('utilities_facility_pct', 0.00);
$facilityMonthlyCost = $request->post('facility_monthly_cost', '') !== '' ? (float) $request->post('facility_monthly_cost', 0) : null;
$paymentDueDay = (int) $request->post('payment_due_day', 5);
$lateFeeBankRate = (float) $request->post('late_fee_bank_rate', 0.00);
$gracePeriodMonths = (int) $request->post('grace_period_months', 0);
$earlyTerminationMonths = (int) $request->post('early_termination_months', 0);
// Validation // Validation
$errors = []; $errors = [];
...@@ -268,6 +285,17 @@ class RentalController extends Controller ...@@ -268,6 +285,17 @@ class RentalController extends Controller
'utilities_percentage' => $utilitiesPercentage, 'utilities_percentage' => $utilitiesPercentage,
'late_fee_type' => $lateFeeType, 'late_fee_type' => $lateFeeType,
'late_fee_rate' => $lateFeeRate, 'late_fee_rate' => $lateFeeRate,
'escalation_type' => $escalationType,
'escalation_rate' => $escalationRate,
'escalation_tiers_json' => $escalationTiersJson ?: null,
'utilities_mode' => $utilitiesMode,
'utilities_rent_pct' => $utilitiesRentPct,
'utilities_facility_pct' => $utilitiesFacilityPct,
'facility_monthly_cost' => $facilityMonthlyCost,
'payment_due_day' => $paymentDueDay,
'late_fee_bank_rate' => $lateFeeBankRate,
'grace_period_months' => $gracePeriodMonths,
'early_termination_months'=> $earlyTerminationMonths,
'deposit_percentage' => $depositPercentage, 'deposit_percentage' => $depositPercentage,
'notes' => $notes ?: null, 'notes' => $notes ?: null,
]); ]);
......
...@@ -35,6 +35,17 @@ class RentalContract extends Model ...@@ -35,6 +35,17 @@ class RentalContract extends Model
'grand_total', 'grand_total',
'late_fee_type', 'late_fee_type',
'late_fee_rate', 'late_fee_rate',
'escalation_type',
'escalation_rate',
'escalation_tiers_json',
'utilities_mode',
'utilities_rent_pct',
'utilities_facility_pct',
'facility_monthly_cost',
'payment_due_day',
'late_fee_bank_rate',
'grace_period_months',
'early_termination_months',
'deposit_percentage', 'deposit_percentage',
'deposit_amount', 'deposit_amount',
'deposit_status', 'deposit_status',
...@@ -60,6 +71,31 @@ class RentalContract extends Model ...@@ -60,6 +71,31 @@ class RentalContract extends Model
]; ];
} }
/**
* Escalation types with Arabic labels.
*/
public static function getEscalationTypes(): array
{
return [
'none' => 'لا يوجد',
'flat' => 'موحد',
'tiered' => 'متدرج',
];
}
/**
* Utilities modes with Arabic labels.
*/
public static function getUtilitiesModes(): array
{
return [
'none' => 'لا يوجد',
'rent_pct' => 'نسبة من الإيجار',
'facility_cost_pct' => 'نسبة من تكلفة المرفق',
'both' => 'الاثنان معاً',
];
}
/** /**
* Calculate monthly invoice amounts from a contract row. * Calculate monthly invoice amounts from a contract row.
* Returns [base, utilities, vat, total]. * Returns [base, utilities, vat, total].
......
...@@ -16,7 +16,7 @@ final class RentalContractService ...@@ -16,7 +16,7 @@ final class RentalContractService
* Create a new rental contract. * Create a new rental contract.
* *
* Generates the contract number, calculates totals (subtotal = units * rate), * Generates the contract number, calculates totals (subtotal = units * rate),
* applies bulk discount if eligible, and calculates deposit. * applies bulk discount if eligible, calculates escalation and deposit.
*/ */
public static function createContract(array $data): object public static function createContract(array $data): object
{ {
...@@ -39,25 +39,73 @@ final class RentalContractService ...@@ -39,25 +39,73 @@ final class RentalContractService
$discountPercentage = self::calculateBulkDiscount($totalUnits, $months); $discountPercentage = self::calculateBulkDiscount($totalUnits, $months);
$discountAmount = round($subtotal * ($discountPercentage / 100), 2); $discountAmount = round($subtotal * ($discountPercentage / 100), 2);
$totalAmount = $subtotal - $discountAmount; $baseNetTotal = $subtotal - $discountAmount; // before escalation
// VAT — default from RENTAL_VAT_PCT rule (1%), overridable per contract // VAT — default from RENTAL_VAT_PCT rule (1%), overridable per contract
$defaultVat = (float) (RuleEngine::getValue('RENTAL_VAT_PCT') ?? 1.00); $defaultVat = (float) (RuleEngine::getValue('RENTAL_VAT_PCT') ?? 1.00);
$vatPercentage = (float) ($data['vat_percentage'] ?? $defaultVat); $vatPercentage = (float) ($data['vat_percentage'] ?? $defaultVat);
// Utilities — optional % of the monthly base amount // ── Escalation ──────────────────────────────────────────────
$utilitiesPercentage = (float) ($data['utilities_percentage'] ?? 0.00); $escalationType = $data['escalation_type'] ?? 'none';
$escalationRate = (float) ($data['escalation_rate'] ?? 0.00);
$tiersJson = $data['escalation_tiers_json'] ?? null;
$tiersArray = [];
if ($tiersJson && is_string($tiersJson)) {
$decoded = json_decode($tiersJson, true);
$tiersArray = is_array($decoded) ? $decoded : [];
}
// base_monthly_year1 = net total / months (year-1 unescalated monthly)
$baseMonthlyYear1 = $months > 0 ? ($baseNetTotal / $months) : $baseNetTotal;
if ($escalationType !== 'none' && $months > 0) {
$totalAmount = round(self::computeEscalatedTotal($baseMonthlyYear1, $months, $escalationType, $escalationRate, $tiersArray), 2);
} else {
$totalAmount = $baseNetTotal;
}
// ── Utilities ────────────────────────────────────────────────
$utilitiesMode = $data['utilities_mode'] ?? 'rent_pct';
$utilitiesRentPct = (float) ($data['utilities_rent_pct'] ?? 0.00);
$utilitiesFacilityPct = (float) ($data['utilities_facility_pct'] ?? 0.00);
$facilityMonthlyCost = isset($data['facility_monthly_cost']) && $data['facility_monthly_cost'] !== ''
? (float) $data['facility_monthly_cost']
: null;
// legacy utilities_percentage for backward compat with old invoice calc
$utilitiesPercentage = match ($utilitiesMode) {
'rent_pct', 'both' => $utilitiesRentPct,
default => 0.00,
};
// utilities_amount = total utilities across the whole contract
$monthlyUtilFromRent = 0.00;
$monthlyUtilFromFacility = 0.00;
if (in_array($utilitiesMode, ['rent_pct', 'both'], true)) {
$monthlyUtilFromRent = $baseMonthlyYear1 * ($utilitiesRentPct / 100);
}
if (in_array($utilitiesMode, ['facility_cost_pct', 'both'], true) && $facilityMonthlyCost !== null) {
$monthlyUtilFromFacility = $facilityMonthlyCost * ($utilitiesFacilityPct / 100);
}
// Grand total across whole contract = total + utilities_total + vat_total $utilitiesAmount = round(($monthlyUtilFromRent + $monthlyUtilFromFacility) * max(1, $months), 2);
$vatAmount = round($totalAmount * ($vatPercentage / 100), 2);
$utilitiesAmount = round($totalAmount * ($utilitiesPercentage / 100), 2); // ── VAT & Grand Total ─────────────────────────────────────────
$vatAmount = round(($totalAmount + $utilitiesAmount) * ($vatPercentage / 100), 2);
$grandTotal = $totalAmount + $utilitiesAmount + $vatAmount; $grandTotal = $totalAmount + $utilitiesAmount + $vatAmount;
// Late fee // ── Late fee ──────────────────────────────────────────────────
$lateFeeType = $data['late_fee_type'] ?? 'none'; $lateFeeType = $data['late_fee_type'] ?? 'none';
$lateFeeRate = (float) ($data['late_fee_rate'] ?? 0.00); $lateFeeRate = (float) ($data['late_fee_rate'] ?? 0.00);
$lateFeeBankRate = (float) ($data['late_fee_bank_rate'] ?? 0.00);
// ── Payment & Terms ───────────────────────────────────────────
$paymentDueDay = (int) ($data['payment_due_day'] ?? 5);
$gracePeriodMonths = (int) ($data['grace_period_months'] ?? 0);
$earlyTerminationMonths = (int) ($data['early_termination_months'] ?? 0);
// Deposit — % of grand total // ── Deposit — % of grand total ────────────────────────────────
$depositPercentage = (float) ($data['deposit_percentage'] ?? 0); $depositPercentage = (float) ($data['deposit_percentage'] ?? 0);
$depositAmount = round($grandTotal * ($depositPercentage / 100), 2); $depositAmount = round($grandTotal * ($depositPercentage / 100), 2);
...@@ -82,6 +130,17 @@ final class RentalContractService ...@@ -82,6 +130,17 @@ final class RentalContractService
'grand_total' => $grandTotal, 'grand_total' => $grandTotal,
'late_fee_type' => $lateFeeType, 'late_fee_type' => $lateFeeType,
'late_fee_rate' => $lateFeeRate, 'late_fee_rate' => $lateFeeRate,
'escalation_type' => $escalationType,
'escalation_rate' => $escalationRate,
'escalation_tiers_json' => ($tiersJson !== '' && $tiersJson !== null) ? $tiersJson : null,
'utilities_mode' => $utilitiesMode,
'utilities_rent_pct' => $utilitiesRentPct,
'utilities_facility_pct' => $utilitiesFacilityPct,
'facility_monthly_cost' => $facilityMonthlyCost,
'payment_due_day' => $paymentDueDay,
'late_fee_bank_rate' => $lateFeeBankRate,
'grace_period_months' => $gracePeriodMonths,
'early_termination_months' => $earlyTerminationMonths,
'deposit_percentage' => $depositPercentage, 'deposit_percentage' => $depositPercentage,
'deposit_amount' => $depositAmount, 'deposit_amount' => $depositAmount,
'deposit_status' => 'pending', 'deposit_status' => 'pending',
...@@ -101,6 +160,77 @@ final class RentalContractService ...@@ -101,6 +160,77 @@ final class RentalContractService
return $contract; return $contract;
} }
/**
* Compute the sum of all escalated monthly payments across the contract duration.
*
* For 'flat' : month m (0-indexed) → base × (1 + rate/100)^floor(m/12)
* For 'tiered': same but rate for each year comes from the tier covering that year
*
* Tiers format: [{months: N, annual_rate: R}, ...] — each tier covers N months (cumulative).
*/
private static function computeEscalatedTotal(
float $baseMonthlyYear1,
int $totalMonths,
string $escType,
float $escRate,
array $tiers
): float {
$total = 0.0;
for ($m = 0; $m < $totalMonths; $m++) {
$yearIndex = (int) floor($m / 12); // 0 = year 1, 1 = year 2 …
$multiplier = 1.0;
if ($escType === 'flat') {
$multiplier = (1 + $escRate / 100) ** $yearIndex;
} elseif ($escType === 'tiered') {
// Each tier covers `months` months from the start; derive per-year rate
$cumulativeMonths = 0;
$appliedRate = 0.0;
$tierForYear = 0.0;
// Map yearIndex → rate from tiers
// year 0 = months 0-11, year 1 = months 12-23 …
$yearStartMonth = $yearIndex * 12;
foreach ($tiers as $tier) {
$tierMonths = (int) ($tier['months'] ?? 12);
$tierRate = (float) ($tier['annual_rate'] ?? 0.00);
$cumulativeMonths += $tierMonths;
// if year-start-month falls within this tier's cumulative range, use its rate
if ($yearStartMonth < $cumulativeMonths) {
$tierForYear = $tierRate;
break;
}
$appliedRate = $tierRate;
}
// Build multiplier: for each year Y from 1 to yearIndex, compound its tier rate
$compound = 1.0;
$cumM = 0;
for ($y = 0; $y < $yearIndex; $y++) {
$yStartMonth = $y * 12;
$yRate = 0.0;
$cumMInner = 0;
foreach ($tiers as $tier) {
$tierMonths = (int) ($tier['months'] ?? 12);
$tierRate = (float) ($tier['annual_rate'] ?? 0.00);
$cumMInner += $tierMonths;
if ($yStartMonth < $cumMInner) {
$yRate = $tierRate;
break;
}
}
$compound *= (1 + $yRate / 100);
}
$multiplier = $compound;
}
$total += $baseMonthlyYear1 * $multiplier;
}
return round($total, 2);
}
/** /**
* Activate a rental contract. * Activate a rental contract.
*/ */
......
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
return [
'up' => function (\App\Core\Database $db): void {
$table = 'rental_contracts';
$columns = [
'escalation_type' => "ALTER TABLE `{$table}` ADD COLUMN `escalation_type` VARCHAR(10) NOT NULL DEFAULT 'none' AFTER `late_fee_rate`",
'escalation_rate' => "ALTER TABLE `{$table}` ADD COLUMN `escalation_rate` DECIMAL(5,2) NOT NULL DEFAULT 0.00 AFTER `escalation_type`",
'escalation_tiers_json' => "ALTER TABLE `{$table}` ADD COLUMN `escalation_tiers_json` JSON NULL AFTER `escalation_rate`",
'utilities_mode' => "ALTER TABLE `{$table}` ADD COLUMN `utilities_mode` VARCHAR(20) NOT NULL DEFAULT 'rent_pct' AFTER `escalation_tiers_json`",
'utilities_rent_pct' => "ALTER TABLE `{$table}` ADD COLUMN `utilities_rent_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00 AFTER `utilities_mode`",
'utilities_facility_pct' => "ALTER TABLE `{$table}` ADD COLUMN `utilities_facility_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00 AFTER `utilities_rent_pct`",
'facility_monthly_cost' => "ALTER TABLE `{$table}` ADD COLUMN `facility_monthly_cost` DECIMAL(15,2) NULL AFTER `utilities_facility_pct`",
'payment_due_day' => "ALTER TABLE `{$table}` ADD COLUMN `payment_due_day` TINYINT UNSIGNED NOT NULL DEFAULT 5 AFTER `facility_monthly_cost`",
'late_fee_bank_rate' => "ALTER TABLE `{$table}` ADD COLUMN `late_fee_bank_rate` DECIMAL(7,4) NOT NULL DEFAULT 0.00 AFTER `payment_due_day`",
'grace_period_months' => "ALTER TABLE `{$table}` ADD COLUMN `grace_period_months` TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER `late_fee_bank_rate`",
'early_termination_months' => "ALTER TABLE `{$table}` ADD COLUMN `early_termination_months` TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER `grace_period_months`",
];
foreach ($columns as $column => $sql) {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
[$table, $column]
);
if (!$exists) {
$db->raw($sql);
}
}
// Data migration: backfill utilities_rent_pct from legacy utilities_percentage
$db->raw(
"UPDATE `{$table}` SET `utilities_rent_pct` = `utilities_percentage` WHERE `utilities_rent_pct` = 0.00 AND `utilities_percentage` > 0"
);
},
'down' => "
ALTER TABLE `rental_contracts`
DROP COLUMN IF EXISTS `escalation_type`,
DROP COLUMN IF EXISTS `escalation_rate`,
DROP COLUMN IF EXISTS `escalation_tiers_json`,
DROP COLUMN IF EXISTS `utilities_mode`,
DROP COLUMN IF EXISTS `utilities_rent_pct`,
DROP COLUMN IF EXISTS `utilities_facility_pct`,
DROP COLUMN IF EXISTS `facility_monthly_cost`,
DROP COLUMN IF EXISTS `payment_due_day`,
DROP COLUMN IF EXISTS `late_fee_bank_rate`,
DROP COLUMN IF EXISTS `grace_period_months`,
DROP COLUMN IF EXISTS `early_termination_months`;
",
];
<?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_LATE_FEE_BANK_RATE']
);
if ($existing) {
return;
}
$db->insert('business_rules', [
'rule_code' => 'RENTAL_LATE_FEE_BANK_RATE',
'category' => 'rentals',
'name_ar' => 'النسبة البنكية للغرامات',
'name_en' => 'Bank Late Fee Rate',
'description_ar' => 'النسبة السنوية المعتمدة لاحتساب غرامات التأخير اليومية على عقود الإيجار',
'data_type' => 'percentage',
'current_value_json' => '{"annual_rate":"27.25"}',
'parameters_json' => '{"annual_rate":"decimal"}',
'effective_from' => date('Y-m-d'),
'is_active' => 1,
'version' => 1,
]);
};
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