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.
*/ */
......
...@@ -14,10 +14,12 @@ final class RentalInvoiceService ...@@ -14,10 +14,12 @@ final class RentalInvoiceService
/** /**
* Generate a single monthly invoice for a contract. * Generate a single monthly invoice for a contract.
* *
* Calculates: base (monthly share) + utilities (% of base) + VAT (1% of base+utilities). * Calculates: base (monthly share with escalation) + utilities (% of base) + VAT.
* Late fee is NOT added at generation — it is added at payment time if overdue. * Late fee is NOT added at generation — it is added at payment time if overdue.
*
* @param ?string $periodStart If provided, used to compute the escalated base for that month.
*/ */
public static function generateInvoice(int $contractId, string $periodStart, string $periodEnd, string $dueDate, ?string $notes = null): object public static function generateInvoice(int $contractId, string $periodStart, string $periodEnd, string $dueDate, ?string $notes = null, ?string $periodStartForEscalation = null): object
{ {
$contract = RentalContract::find($contractId); $contract = RentalContract::find($contractId);
if (!$contract) { if (!$contract) {
...@@ -29,7 +31,7 @@ final class RentalInvoiceService ...@@ -29,7 +31,7 @@ final class RentalInvoiceService
throw new \RuntimeException('Cannot generate invoice for a contract that is not approved or active'); throw new \RuntimeException('Cannot generate invoice for a contract that is not approved or active');
} }
$base = self::calcBase($contract); $base = self::calcBase($contract, $periodStartForEscalation ?? $periodStart);
$utils = self::calcUtilities($contract, $base); $utils = self::calcUtilities($contract, $base);
$vat = self::calcVat($contract, $base + $utils); $vat = self::calcVat($contract, $base + $utils);
$total = round($base + $utils + $vat, 2); $total = round($base + $utils + $vat, 2);
...@@ -119,7 +121,8 @@ final class RentalInvoiceService ...@@ -119,7 +121,8 @@ final class RentalInvoiceService
* Generate all monthly invoices for a contract from start_date to end_date. * 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). * Skips months that already have an invoice (by checking period_start overlap).
* Due date defaults to the last day of each period. * Skips the first grace_period_months months (no rent charged).
* Due date uses the contract's payment_due_day (default 5); capped to last day of month.
* Returns count of newly created invoices. * Returns count of newly created invoices.
*/ */
public static function bulkGenerateInvoices(int $contractId): int public static function bulkGenerateInvoices(int $contractId): int
...@@ -141,13 +144,19 @@ final class RentalInvoiceService ...@@ -141,13 +144,19 @@ final class RentalInvoiceService
throw new \RuntimeException('Contract is missing start_date or end_date'); throw new \RuntimeException('Contract is missing start_date or end_date');
} }
$gracePeriodMonths = (int) (is_object($contract) ? ($contract->grace_period_months ?? 0) : ($contract['grace_period_months'] ?? 0));
$paymentDueDay = (int) (is_object($contract) ? ($contract->payment_due_day ?? 5) : ($contract['payment_due_day'] ?? 5));
$paymentDueDay = max(1, min(28, $paymentDueDay));
// Load existing invoices to avoid duplicates // Load existing invoices to avoid duplicates
$existing = RentalInvoice::getForContract($contractId); $existing = RentalInvoice::getForContract($contractId);
$existingStarts = array_column($existing, 'period_start'); $existingStarts = array_column($existing, 'period_start');
$current = new \DateTimeImmutable(date('Y-m-01', strtotime($startDate))); $contractStart = new \DateTimeImmutable(date('Y-m-01', strtotime($startDate)));
$current = $contractStart;
$end = new \DateTimeImmutable($endDate); $end = new \DateTimeImmutable($endDate);
$created = 0; $created = 0;
$monthIndex = 0; // 0-based from contract start
while ($current <= $end) { while ($current <= $end) {
$periodStart = $current->format('Y-m-d'); $periodStart = $current->format('Y-m-d');
...@@ -156,24 +165,38 @@ final class RentalInvoiceService ...@@ -156,24 +165,38 @@ final class RentalInvoiceService
$monthEnd = new \DateTimeImmutable($current->format('Y-m-t')); $monthEnd = new \DateTimeImmutable($current->format('Y-m-t'));
$periodEnd = $monthEnd > $end ? $end->format('Y-m-d') : $monthEnd->format('Y-m-d'); $periodEnd = $monthEnd > $end ? $end->format('Y-m-d') : $monthEnd->format('Y-m-d');
// Skip if already exists // Build due date: payment_due_day of this month, capped to last day
if (!in_array($periodStart, $existingStarts, true)) { $daysInMonth = (int) $current->format('t');
self::generateInvoice($contractId, $periodStart, $periodEnd, $periodEnd); $effectiveDueDay = min($paymentDueDay, $daysInMonth);
$dueDate = $current->format('Y-m-') . str_pad((string) $effectiveDueDay, 2, '0', STR_PAD_LEFT);
// Skip grace period months
if ($monthIndex >= $gracePeriodMonths && !in_array($periodStart, $existingStarts, true)) {
self::generateInvoice($contractId, $periodStart, $periodEnd, $dueDate, null, $periodStart);
$created++; $created++;
} }
$current = $current->modify('first day of next month'); $current = $current->modify('first day of next month');
$monthIndex++;
} }
return $created; return $created;
} }
/** /**
* Calculate the monthly base amount (contract total / number of months). * Calculate the monthly base amount for a specific period.
*
* If periodStart is provided and escalation_type is 'flat' or 'tiered':
* - base_monthly_year1 = (subtotal - discount_amount) / total_months
* - month_index = months between contract.start_date and periodStart (0-based)
* - return base_monthly_year1 × getMultiplierForMonth(monthIndex, ...)
*
* Otherwise: backward-compatible total_amount / months.
*
* @param ?string $periodStart ISO date of the invoice period start (e.g. '2025-01-01')
*/ */
public static function calcBase(object|array $contract): float public static function calcBase(object|array $contract, ?string $periodStart = null): float
{ {
$totalAmount = (float) (is_object($contract) ? $contract->total_amount : ($contract['total_amount'] ?? 0));
$startDate = is_object($contract) ? ($contract->start_date ?? '') : ($contract['start_date'] ?? ''); $startDate = is_object($contract) ? ($contract->start_date ?? '') : ($contract['start_date'] ?? '');
$endDate = is_object($contract) ? ($contract->end_date ?? '') : ($contract['end_date'] ?? ''); $endDate = is_object($contract) ? ($contract->end_date ?? '') : ($contract['end_date'] ?? '');
...@@ -183,7 +206,81 @@ final class RentalInvoiceService ...@@ -183,7 +206,81 @@ final class RentalInvoiceService
$months = max(1, ($diff->y * 12) + $diff->m + ($diff->d > 0 ? 1 : 0)); $months = max(1, ($diff->y * 12) + $diff->m + ($diff->d > 0 ? 1 : 0));
} }
return round($totalAmount / $months, 2); $escType = is_object($contract) ? ($contract->escalation_type ?? 'none') : ($contract['escalation_type'] ?? 'none');
$escType = (string) $escType;
if ($periodStart !== null && in_array($escType, ['flat', 'tiered'], true)) {
// Year-1 base = net total before escalation / months
$subtotal = (float) (is_object($contract) ? ($contract->subtotal ?? 0) : ($contract['subtotal'] ?? 0));
$discountAmount = (float) (is_object($contract) ? ($contract->discount_amount ?? 0) : ($contract['discount_amount'] ?? 0));
$baseMonthlyY1 = ($subtotal - $discountAmount) / max(1, $months);
// month index: 0 = first month of contract
$contractStart = new \DateTimeImmutable(date('Y-m-01', strtotime($startDate)));
$periodDt = new \DateTimeImmutable(date('Y-m-01', strtotime($periodStart)));
$indexDiff = $contractStart->diff($periodDt);
$monthIndex = ($indexDiff->y * 12) + $indexDiff->m;
$escRate = (float) (is_object($contract) ? ($contract->escalation_rate ?? 0) : ($contract['escalation_rate'] ?? 0));
$tiersRaw = is_object($contract) ? ($contract->escalation_tiers_json ?? null) : ($contract['escalation_tiers_json'] ?? null);
$tiers = [];
if ($tiersRaw && is_string($tiersRaw)) {
$decoded = json_decode($tiersRaw, true);
$tiers = is_array($decoded) ? $decoded : [];
}
$multiplier = self::getMultiplierForMonth($monthIndex, $escType, $escRate, $tiers);
return round($baseMonthlyY1 * $multiplier, 2);
}
// Backward-compatible path
$totalAmount = (float) (is_object($contract) ? $contract->total_amount : ($contract['total_amount'] ?? 0));
return round($totalAmount / max(1, $months), 2);
}
/**
* Get the escalation multiplier for a given month index (0-based from contract start).
*
* For 'none' : return 1.0
* For 'flat' : return (1 + rate/100)^yearIndex
* For 'tiered': compound the tier rate for each prior year
*
* Tiers: [{months: N, annual_rate: R}, ...] — each tier covers N months from contract start.
*/
public static function getMultiplierForMonth(int $monthIndex, string $escType, float $escRate, array $tiers): float
{
if ($escType === 'none' || $escType === '') {
return 1.0;
}
$yearIndex = (int) floor($monthIndex / 12); // 0 = year 1, no escalation yet
if ($escType === 'flat') {
return (1 + $escRate / 100) ** $yearIndex;
}
if ($escType === 'tiered') {
// Compound a multiplier: for each year Y from 0 to yearIndex-1, apply rate of the
// tier that year Y's start-month falls into.
$compound = 1.0;
for ($y = 0; $y < $yearIndex; $y++) {
$yStartMonth = $y * 12;
$cumTierMonths = 0;
$rateForYear = 0.0;
foreach ($tiers as $tier) {
$tMonths = (int) ($tier['months'] ?? 12);
$cumTierMonths += $tMonths;
if ($yStartMonth < $cumTierMonths) {
$rateForYear = (float) ($tier['annual_rate'] ?? 0.00);
break;
}
}
$compound *= (1 + $rateForYear / 100);
}
return $compound;
}
return 1.0;
} }
/** /**
...@@ -208,19 +305,23 @@ final class RentalInvoiceService ...@@ -208,19 +305,23 @@ final class RentalInvoiceService
* Calculate late fee based on contract late_fee_type and days overdue. * Calculate late fee based on contract late_fee_type and days overdue.
* *
* late_fee_type: none | daily | weekly | monthly * late_fee_type: none | daily | weekly | monthly
* late_fee_rate: % of invoice total per period.
* *
* Examples: * For daily with bank rate:
* daily 2% → 2% of total per overdue day * daily_rate = late_fee_bank_rate / 365
* weekly 5% → 5% of total per overdue week (or fraction) * fee = invoiceTotal × daily_rate × overdue_days
* monthly 10% → 10% of total per overdue month (or fraction) *
* For daily with flat rate (backward compat):
* fee = invoiceTotal × (late_fee_rate/100) × overdue_days
*
* For weekly/monthly: fee = invoiceTotal × (late_fee_rate/100) × periods
*/ */
public static function calcLateFee(object|array $invoice, object|array $contract, string $paidAt): float 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'); $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)); $lateFeeRate = (float) (is_object($contract) ? ($contract->late_fee_rate ?? 0) : ($contract['late_fee_rate'] ?? 0));
$lateFeeBankRate = (float) (is_object($contract) ? ($contract->late_fee_bank_rate ?? 0) : ($contract['late_fee_bank_rate'] ?? 0));
if ($lateFeeType === 'none' || $lateFeeRate <= 0) { if ($lateFeeType === 'none') {
return 0.00; return 0.00;
} }
...@@ -243,8 +344,23 @@ final class RentalInvoiceService ...@@ -243,8 +344,23 @@ final class RentalInvoiceService
$overdueDays = (int) $due->diff($paid)->days; $overdueDays = (int) $due->diff($paid)->days;
if ($lateFeeType === 'daily') {
if ($lateFeeBankRate > 0) {
// Bank annual rate → daily
$dailyRate = $lateFeeBankRate / 100 / 365;
return round($invoiceTotal * $dailyRate * $overdueDays, 2);
}
if ($lateFeeRate > 0) {
return round($invoiceTotal * ($lateFeeRate / 100) * $overdueDays, 2);
}
return 0.00;
}
if ($lateFeeRate <= 0) {
return 0.00;
}
$periods = match ($lateFeeType) { $periods = match ($lateFeeType) {
'daily' => $overdueDays,
'weekly' => (int) ceil($overdueDays / 7), 'weekly' => (int) ceil($overdueDays / 7),
'monthly' => (int) ceil($overdueDays / 30), 'monthly' => (int) ceil($overdueDays / 30),
default => 0, default => 0,
......
...@@ -104,53 +104,174 @@ $__template->layout('Layout.main'); ...@@ -104,53 +104,174 @@ $__template->layout('Layout.main');
<small style="color:#6B7280;">تُحتسب على الإيجار الشهري + المرافق</small> <small style="color:#6B7280;">تُحتسب على الإيجار الشهري + المرافق</small>
</div> </div>
<div></div>
</div>
</div>
<!-- ── المرافق ── -->
<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;gap:15px;">
<div style="grid-column:1/-1;">
<label class="form-label">طريقة المرافق</label>
<select name="utilities_mode" id="utilities_mode" class="form-input" onchange="toggleUtilitiesMode()">
<?php foreach (($utilitiesModes ?? []) as $val => $label): ?>
<option value="<?= e($val) ?>" <?= old('utilities_mode', 'rent_pct') === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- نسبة من الإيجار -->
<div id="utils_rent_pct_wrap" style="display:<?= in_array(old('utilities_mode','rent_pct'), ['rent_pct','both']) ? 'block' : 'none' ?>;">
<label class="form-label">نسبة المرافق من الإيجار (%)</label>
<input type="number" name="utilities_rent_pct" id="utilities_rent_pct" value="<?= e(old('utilities_rent_pct', '0')) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="0">
<small style="color:#6B7280;">نسبة من الإيجار الشهري (السنة الأولى) مقابل المرافق</small>
</div>
<!-- نسبة من تكلفة المرفق -->
<div id="utils_facility_pct_wrap" style="display:<?= in_array(old('utilities_mode','rent_pct'), ['facility_cost_pct','both']) ? 'block' : 'none' ?>;">
<label class="form-label">نسبة المرافق من تكلفة المرفق (%)</label>
<input type="number" name="utilities_facility_pct" id="utilities_facility_pct" value="<?= e(old('utilities_facility_pct', '0')) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="0">
</div>
<div id="facility_monthly_cost_wrap" style="display:<?= in_array(old('utilities_mode','rent_pct'), ['facility_cost_pct','both']) ? 'block' : 'none' ?>;">
<label class="form-label">تكلفة المرفق الشهرية على النادي (ج)</label>
<input type="number" name="facility_monthly_cost" id="facility_monthly_cost" value="<?= e(old('facility_monthly_cost', '')) ?>" class="form-input" step="0.01" min="0" placeholder="0">
</div>
</div>
</div>
<!-- ── الزيادة السنوية ── -->
<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;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div> <div>
<label class="form-label">نسبة المرافق (%) — اختياري</label> <label class="form-label">نوع الزيادة</label>
<input type="number" name="utilities_percentage" id="utilities_percentage" value="<?= e(old('utilities_percentage', '0')) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="0"> <select name="escalation_type" id="escalation_type" class="form-input" onchange="toggleEscalation()">
<small style="color:#6B7280;">نسبة من الإيجار الشهري مقابل المرافق</small> <?php foreach (($escalationTypes ?? []) as $val => $label): ?>
<option value="<?= e($val) ?>" <?= old('escalation_type', 'none') === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div> </div>
<!-- Flat rate -->
<div id="esc_flat_wrap" style="display:<?= old('escalation_type', 'none') === 'flat' ? 'block' : 'none' ?>;">
<label class="form-label">نسبة الزيادة السنوية (%)</label>
<input type="number" name="escalation_rate" id="escalation_rate" value="<?= e(old('escalation_rate', '0')) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="0">
<small style="color:#6B7280;">تُطبق على الإيجار الشهري في بداية كل سنة</small>
</div> </div>
</div> </div>
<!-- ── غرامات التأخير ── --> <!-- Tiered escalation table -->
<div id="esc_tiered_wrap" style="display:<?= old('escalation_type', 'none') === 'tiered' ? 'block' : 'none' ?>;">
<table style="width:100%;border-collapse:collapse;font-size:14px;" id="tiersTable">
<thead>
<tr style="background:#F9FAFB;border-bottom:2px solid #E5E7EB;">
<th style="padding:10px 12px;text-align:right;width:50px;">#</th>
<th style="padding:10px 12px;text-align:right;">المدة بالأشهر</th>
<th style="padding:10px 12px;text-align:right;">نسبة الزيادة السنوية (%)</th>
<th style="padding:10px 12px;text-align:center;width:80px;">حذف</th>
</tr>
</thead>
<tbody id="tiersBody">
<!-- rows injected by JS -->
</tbody>
</table>
<button type="button" onclick="addTierRow()" class="btn btn-outline" style="margin-top:10px;font-size:13px;padding:6px 14px;">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> إضافة شريحة
</button>
<input type="hidden" name="escalation_tiers_json" id="escalation_tiers_json" value="<?= e(old('escalation_tiers_json', '[]')) ?>">
</div>
</div>
</div>
<!-- ── شروط الدفع والغرامات ── -->
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">غرامات التأخير</h3> <h3 style="margin:0;color:#0D7377;">شروط الدفع والغرامات</h3>
</div> </div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;"> <div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">يوم استحقاق الإيجار (من كل شهر)</label>
<input type="number" name="payment_due_day" id="payment_due_day" value="<?= e(old('payment_due_day', '5')) ?>" class="form-input" min="1" max="28" placeholder="5">
<small id="due_window_hint" style="color:#6B7280;">نافذة السداد: من يوم 1 حتى يوم 5</small>
</div>
<div></div>
<div> <div>
<label class="form-label">نوع الغرامة</label> <label class="form-label">نوع الغرامة</label>
<select name="late_fee_type" id="late_fee_type" class="form-input" onchange="toggleLateFeeRate()"> <select name="late_fee_type" id="late_fee_type" class="form-input" onchange="toggleLateFeeRate()">
<option value="none" <?= old('late_fee_type', 'none') === 'none' ? 'selected' : '' ?>>لا يوجد</option> <option value="none" <?= old('late_fee_type', 'none') === 'none' ? 'selected' : '' ?>>لا يوجد</option>
<option value="daily" <?= old('late_fee_type', 'none') === 'daily' ? 'selected' : '' ?>>يومي</option> <option value="daily" <?= old('late_fee_type', 'none') === 'daily' ? 'selected' : '' ?>>يومي ثابت</option>
<option value="daily_bank" <?= old('late_fee_type', 'none') === 'daily_bank' ? 'selected' : '' ?>>يومي بالنسبة البنكية</option>
<option value="weekly" <?= old('late_fee_type', 'none') === 'weekly' ? 'selected' : '' ?>>أسبوعي</option> <option value="weekly" <?= old('late_fee_type', 'none') === 'weekly' ? 'selected' : '' ?>>أسبوعي</option>
<option value="monthly" <?= old('late_fee_type', 'none') === 'monthly' ? 'selected' : '' ?>>شهري</option> <option value="monthly" <?= old('late_fee_type', 'none') === 'monthly' ? 'selected' : '' ?>>شهري</option>
</select> </select>
</div> </div>
<div id="late_fee_rate_wrap" style="display:<?= in_array(old('late_fee_type', 'none'), ['daily','weekly','monthly']) ? 'block' : 'none' ?>;"> <div id="late_fee_rate_wrap" style="display:<?= in_array(old('late_fee_type', 'none'), ['daily','weekly','monthly']) ? 'block' : 'none' ?>;">
<label class="form-label">نسبة الغرامة (%)</label> <label class="form-label">نسبة الغرامة اليومية (%)</label>
<input type="number" name="late_fee_rate" id="late_fee_rate" value="<?= e(old('late_fee_rate', '0')) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="0"> <input type="number" name="late_fee_rate" id="late_fee_rate" value="<?= e(old('late_fee_rate', '0')) ?>" class="form-input" step="0.01" min="0" max="100" placeholder="0">
<small style="color:#6B7280;">% من إجمالي الفاتورة لكل فترة تأخير</small> <small style="color:#6B7280;">% من إجمالي الفاتورة لكل فترة تأخير</small>
</div> </div>
<div id="late_fee_bank_rate_wrap" style="display:<?= old('late_fee_type', 'none') === 'daily_bank' ? 'block' : 'none' ?>;">
<label class="form-label">النسبة البنكية السنوية (%)</label>
<input type="number" name="late_fee_bank_rate" id="late_fee_bank_rate" value="<?= e(old('late_fee_bank_rate', (string) ($bankRate ?? 27.25))) ?>" class="form-input" step="0.0001" min="0" placeholder="<?= e((string) ($bankRate ?? 27.25)) ?>">
<small style="color:#6B7280;">النسبة السنوية المعتمدة ÷ 365 = نسبة يومية</small>
</div>
</div>
</div>
<!-- ── شروط العقد الإضافية ── -->
<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;gap:15px;">
<div>
<label class="form-label">فترة الإعفاء (أشهر)</label>
<input type="number" name="grace_period_months" id="grace_period_months" value="<?= e(old('grace_period_months', '0')) ?>" class="form-input" min="0" placeholder="0">
<small style="color:#6B7280;">عدد الأشهر الأولى بدون إيجار (إعفاء كامل)</small>
</div>
<div>
<label class="form-label">غرامة الإنهاء المبكر (أشهر إيجار)</label>
<input type="number" name="early_termination_months" id="early_termination_months" value="<?= e(old('early_termination_months', '0')) ?>" class="form-input" min="0" placeholder="0">
<small style="color:#6B7280;">عدد أشهر الإيجار المدفوعة كغرامة عند الإنهاء المبكر</small>
</div>
</div> </div>
</div> </div>
<!-- ── ملخص تقديري ── --> <!-- ── ملخص تقديري ── -->
<div class="card" style="margin-bottom:20px;background:#F0FDF4;border:1px solid #BBF7D0;"> <div class="card" style="margin-bottom:20px;background:#F0FDF4;border:1px solid #BBF7D0;">
<div style="padding:15px 20px;border-bottom:1px solid #BBF7D0;"> <div style="padding:15px 20px;border-bottom:1px solid #BBF7D0;">
<h3 style="margin:0;color:#059669;">ملخص الفاتورة الشهرية التقديري</h3> <h3 style="margin:0;color:#059669;">ملخص الفاتورة التقديري</h3>
</div> </div>
<!-- Static summary (year 1 / flat) -->
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:15px;text-align:center;"> <div style="padding:20px;display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:15px;text-align:center;">
<div> <div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">الإيجار الشهري</div> <div style="font-size:12px;color:#6B7280;margin-bottom:4px;">الإيجار الشهري (سنة 1)</div>
<div id="preview_base" style="font-size:20px;font-weight:700;color:#0D7377;"></div> <div id="preview_base" style="font-size:20px;font-weight:700;color:#0D7377;"></div>
</div> </div>
<div> <div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">المرافق</div> <div style="font-size:12px;color:#6B7280;margin-bottom:4px;">المرافق الشهرية</div>
<div id="preview_utils" style="font-size:20px;font-weight:700;color:#7C3AED;"></div> <div id="preview_utils" style="font-size:20px;font-weight:700;color:#7C3AED;"></div>
</div> </div>
<div> <div>
...@@ -162,7 +283,7 @@ $__template->layout('Layout.main'); ...@@ -162,7 +283,7 @@ $__template->layout('Layout.main');
<div id="preview_total" style="font-size:20px;font-weight:700;color:#059669;"></div> <div id="preview_total" style="font-size:20px;font-weight:700;color:#059669;"></div>
</div> </div>
</div> </div>
<div style="padding:0 20px 15px;display:grid;grid-template-columns:1fr 1fr;gap:15px;text-align:center;"> <div style="padding:0 20px 10px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;text-align:center;">
<div> <div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">عدد الأشهر</div> <div style="font-size:12px;color:#6B7280;margin-bottom:4px;">عدد الأشهر</div>
<div id="preview_months" style="font-size:16px;font-weight:600;color:#374151;"></div> <div id="preview_months" style="font-size:16px;font-weight:600;color:#374151;"></div>
...@@ -171,6 +292,18 @@ $__template->layout('Layout.main'); ...@@ -171,6 +292,18 @@ $__template->layout('Layout.main');
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">التأمين المطلوب</div> <div style="font-size:12px;color:#6B7280;margin-bottom:4px;">التأمين المطلوب</div>
<div id="preview_deposit" style="font-size:16px;font-weight:600;color:#374151;"></div> <div id="preview_deposit" style="font-size:16px;font-weight:600;color:#374151;"></div>
</div> </div>
<div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">أشهر الإعفاء / يوم الاستحقاق</div>
<div id="preview_grace_due" style="font-size:16px;font-weight:600;color:#374151;"></div>
</div>
</div>
<!-- Escalation year-by-year breakdown (hidden when no escalation) -->
<div id="escalation_breakdown_wrap" style="display:none;padding:0 20px 20px;">
<div style="border-top:1px dashed #86EFAC;padding-top:15px;">
<div style="font-size:13px;font-weight:600;color:#059669;margin-bottom:8px;">توزيع الإيجار السنوي مع الزيادة</div>
<div id="escalation_breakdown_table" style="overflow-x:auto;"></div>
</div>
</div> </div>
</div> </div>
...@@ -184,66 +317,251 @@ $__template->layout('Layout.main'); ...@@ -184,66 +317,251 @@ $__template->layout('Layout.main');
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
const ids = ['total_units','unit_rate','vat_percentage','utilities_percentage','deposit_percentage','start_date','end_date']; const ids = ['total_units','unit_rate','vat_percentage','utilities_rent_pct','utilities_facility_pct',
'facility_monthly_cost','deposit_percentage','start_date','end_date','payment_due_day',
'escalation_type','escalation_rate','grace_period_months','utilities_mode'];
ids.forEach(id => { ids.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.addEventListener('input', calcPreview); if (el) el.addEventListener('input', calcPreview);
if (el) el.addEventListener('change', calcPreview);
});
// Payment due day hint
const dueDayEl = document.getElementById('payment_due_day');
if (dueDayEl) {
dueDayEl.addEventListener('input', function() {
const v = parseInt(this.value) || 5;
document.getElementById('due_window_hint').textContent = 'نافذة السداد: من يوم 1 حتى يوم ' + v;
}); });
}
// Restore tiers from old input if present
const tiersInput = document.getElementById('escalation_tiers_json');
if (tiersInput && tiersInput.value && tiersInput.value !== '[]') {
try {
const tiers = JSON.parse(tiersInput.value);
if (Array.isArray(tiers)) {
tiers.forEach(t => addTierRow(t.months, t.annual_rate));
}
} catch(e) {}
}
calcPreview(); calcPreview();
}); });
function toggleUtilitiesMode() {
const mode = document.getElementById('utilities_mode').value;
document.getElementById('utils_rent_pct_wrap').style.display = ['rent_pct','both'].includes(mode) ? 'block' : 'none';
document.getElementById('utils_facility_pct_wrap').style.display = ['facility_cost_pct','both'].includes(mode) ? 'block' : 'none';
document.getElementById('facility_monthly_cost_wrap').style.display = ['facility_cost_pct','both'].includes(mode) ? 'block' : 'none';
calcPreview();
}
function toggleEscalation() {
const type = document.getElementById('escalation_type').value;
document.getElementById('esc_flat_wrap').style.display = type === 'flat' ? 'block' : 'none';
document.getElementById('esc_tiered_wrap').style.display = type === 'tiered' ? 'block' : 'none';
calcPreview();
}
function toggleLateFeeRate() { function toggleLateFeeRate() {
const type = document.getElementById('late_fee_type').value; const type = document.getElementById('late_fee_type').value;
const wrap = document.getElementById('late_fee_rate_wrap'); document.getElementById('late_fee_rate_wrap').style.display = ['daily','weekly','monthly'].includes(type) ? 'block' : 'none';
wrap.style.display = ['daily','weekly','monthly'].includes(type) ? 'block' : 'none'; document.getElementById('late_fee_bank_rate_wrap').style.display = type === 'daily_bank' ? 'block' : 'none';
// map daily_bank → late_fee_type = daily + late_fee_bank_rate set
}
// Tiers table
let tierCount = 0;
function addTierRow(months, rate) {
tierCount++;
const tbody = document.getElementById('tiersBody');
const tr = document.createElement('tr');
tr.setAttribute('data-tier', tierCount);
tr.style.borderBottom = '1px solid #F3F4F6';
tr.innerHTML = `
<td style="padding:8px 12px;color:#6B7280;">${tierCount}</td>
<td style="padding:8px 12px;">
<input type="number" class="form-input tier-months" min="1" value="${months || 12}" style="width:100px;" oninput="syncTiersJson()">
</td>
<td style="padding:8px 12px;">
<input type="number" class="form-input tier-rate" min="0" max="100" step="0.01" value="${rate || 0}" style="width:120px;" oninput="syncTiersJson()">
</td>
<td style="padding:8px 12px;text-align:center;">
<button type="button" onclick="removeTierRow(this)" style="background:none;border:none;color:#DC2626;cursor:pointer;">
<i data-lucide="trash-2" style="width:14px;height:14px;"></i>
</button>
</td>
`;
tbody.appendChild(tr);
if (typeof lucide !== 'undefined') lucide.createIcons();
syncTiersJson();
calcPreview();
}
function removeTierRow(btn) {
btn.closest('tr').remove();
// renumber
document.querySelectorAll('#tiersBody tr').forEach((tr, i) => {
tr.cells[0].textContent = i + 1;
});
tierCount = document.querySelectorAll('#tiersBody tr').length;
syncTiersJson();
calcPreview();
}
function syncTiersJson() {
const rows = document.querySelectorAll('#tiersBody tr');
const tiers = [];
rows.forEach(row => {
const months = parseInt(row.querySelector('.tier-months')?.value) || 12;
const rate = parseFloat(row.querySelector('.tier-rate')?.value) || 0;
tiers.push({ months, annual_rate: rate });
});
document.getElementById('escalation_tiers_json').value = JSON.stringify(tiers);
calcPreview();
}
function getCalcMonths(startVal, endVal) {
if (!startVal || !endVal) return 1;
const s = new Date(startVal), e = new Date(endVal);
if (e <= s) return 1;
const y = e.getFullYear() - s.getFullYear();
const m = e.getMonth() - s.getMonth();
const d = e.getDate() - s.getDate();
return Math.max(1, y * 12 + m + (d > 0 ? 1 : 0));
}
function getEscalationMultiplier(monthIndex, escType, escRate, tiers) {
if (escType === 'none' || escType === '') return 1.0;
const yearIndex = Math.floor(monthIndex / 12);
if (escType === 'flat') {
return Math.pow(1 + escRate / 100, yearIndex);
}
if (escType === 'tiered') {
let compound = 1.0;
for (let y = 0; y < yearIndex; y++) {
const yStart = y * 12;
let cum = 0, rateForYear = 0;
for (const tier of tiers) {
cum += tier.months;
if (yStart < cum) { rateForYear = tier.annual_rate; break; }
}
compound *= (1 + rateForYear / 100);
}
return compound;
}
return 1.0;
} }
function calcPreview() { function calcPreview() {
const units = parseFloat(document.getElementById('total_units')?.value) || 0; const units = parseFloat(document.getElementById('total_units')?.value) || 0;
const rate = parseFloat(document.getElementById('unit_rate')?.value) || 0; const rate = parseFloat(document.getElementById('unit_rate')?.value) || 0;
const vatPct = parseFloat(document.getElementById('vat_percentage')?.value) || 1; const vatPct = parseFloat(document.getElementById('vat_percentage')?.value) || 1;
const utilsPct = parseFloat(document.getElementById('utilities_percentage')?.value) || 0;
const depositPct = parseFloat(document.getElementById('deposit_percentage')?.value) || 0; const depositPct = parseFloat(document.getElementById('deposit_percentage')?.value) || 0;
const startVal = document.getElementById('start_date')?.value; const startVal = document.getElementById('start_date')?.value;
const endVal = document.getElementById('end_date')?.value; const endVal = document.getElementById('end_date')?.value;
const graceMo = parseInt(document.getElementById('grace_period_months')?.value) || 0;
const dueDay = parseInt(document.getElementById('payment_due_day')?.value) || 5;
const escType = document.getElementById('escalation_type')?.value || 'none';
const escRate = parseFloat(document.getElementById('escalation_rate')?.value) || 0;
const utilsMode = document.getElementById('utilities_mode')?.value || 'rent_pct';
const utilsRentPct = parseFloat(document.getElementById('utilities_rent_pct')?.value) || 0;
const utilsFacPct = parseFloat(document.getElementById('utilities_facility_pct')?.value) || 0;
const facilityMonthly = parseFloat(document.getElementById('facility_monthly_cost')?.value) || 0;
if (!units || !rate) { clearPreview(); return; } if (!units || !rate) { clearPreview(); return; }
const subtotal = units * rate; const subtotal = units * rate;
const months = getCalcMonths(startVal, endVal);
let months = 1; const baseMonthlyY1 = subtotal / months;
if (startVal && endVal) {
const s = new Date(startVal), e = new Date(endVal); // Tiers
if (e > s) { let tiers = [];
const diffMs = e - s; try { tiers = JSON.parse(document.getElementById('escalation_tiers_json')?.value || '[]'); } catch(e) {}
const diffDays = diffMs / (1000 * 60 * 60 * 24);
months = Math.max(1, Math.ceil(diffDays / 30)); // Monthly utilities (year-1 base)
} let utilsRent = 0, utilsFac = 0;
if (['rent_pct','both'].includes(utilsMode)) utilsRent = baseMonthlyY1 * (utilsRentPct / 100);
if (['facility_cost_pct','both'].includes(utilsMode)) utilsFac = facilityMonthly * (utilsFacPct / 100);
const monthlyUtils = utilsRent + utilsFac;
// Year-1 monthly numbers (unescalated)
const monthlyBase = Math.round(baseMonthlyY1 * 100) / 100;
const monthlyU = Math.round(monthlyUtils * 100) / 100;
const monthlyVat = Math.round((monthlyBase + monthlyU) * (vatPct / 100) * 100) / 100;
const monthlyTotal = Math.round((monthlyBase + monthlyU + monthlyVat) * 100) / 100;
// Grand total (escalation-aware)
let grandBase = 0;
for (let m = 0; m < months; m++) {
grandBase += baseMonthlyY1 * getEscalationMultiplier(m, escType, escRate, tiers);
} }
const grandUtils = monthlyUtils * months;
const monthlyBase = Math.round((subtotal / months) * 100) / 100; const grandVat = (grandBase + grandUtils) * (vatPct / 100);
const monthlyUtils = Math.round(monthlyBase * (utilsPct / 100) * 100) / 100; const grandTotal = Math.round((grandBase + grandUtils + grandVat) * 100) / 100;
const monthlyVat = Math.round((monthlyBase + monthlyUtils) * (vatPct / 100) * 100) / 100;
const monthlyTotal = Math.round((monthlyBase + monthlyUtils + monthlyVat) * 100) / 100;
const totalWithUtils = subtotal + (subtotal * utilsPct / 100);
const totalVat = subtotal * (vatPct / 100);
const grandTotal = Math.round((subtotal + subtotal * utilsPct / 100 + totalVat) * 100) / 100;
const deposit = Math.round(grandTotal * (depositPct / 100) * 100) / 100; const deposit = Math.round(grandTotal * (depositPct / 100) * 100) / 100;
const fmt = v => v.toLocaleString('ar-EG', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' ج'; const fmt = v => v.toLocaleString('ar-EG', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' ج';
document.getElementById('preview_base').textContent = fmt(monthlyBase); document.getElementById('preview_base').textContent = fmt(monthlyBase);
document.getElementById('preview_utils').textContent = fmt(monthlyUtils); document.getElementById('preview_utils').textContent = fmt(monthlyU);
document.getElementById('preview_vat').textContent = fmt(monthlyVat); document.getElementById('preview_vat').textContent = fmt(monthlyVat);
document.getElementById('preview_total').textContent = fmt(monthlyTotal); document.getElementById('preview_total').textContent = fmt(monthlyTotal);
document.getElementById('preview_months').textContent = months + ' شهر'; document.getElementById('preview_months').textContent = months + ' شهر';
document.getElementById('preview_deposit').textContent = fmt(deposit); document.getElementById('preview_deposit').textContent = fmt(deposit);
document.getElementById('preview_grace_due').textContent = (graceMo > 0 ? graceMo + ' شهر إعفاء · ' : '') + 'استحقاق يوم ' + dueDay;
// Year-by-year breakdown
const breakdownWrap = document.getElementById('escalation_breakdown_wrap');
if (escType !== 'none' && months > 1) {
breakdownWrap.style.display = 'block';
const totalYears = Math.ceil(months / 12);
let html = '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
html += '<thead><tr style="background:#DCFCE7;">' +
'<th style="padding:7px 10px;text-align:right;">السنة</th>' +
'<th style="padding:7px 10px;text-align:left;">إيجار شهري</th>' +
'<th style="padding:7px 10px;text-align:left;">مرافق</th>' +
'<th style="padding:7px 10px;text-align:left;">VAT</th>' +
'<th style="padding:7px 10px;text-align:left;">إجمالي شهري</th>' +
'</tr></thead><tbody>';
for (let yr = 0; yr < totalYears; yr++) {
const mult = getEscalationMultiplier(yr * 12, escType, escRate, tiers);
const b = Math.round(baseMonthlyY1 * mult * 100) / 100;
const u = Math.round(monthlyUtils * 100) / 100; // utils don't escalate in this model
const v = Math.round((b + u) * (vatPct / 100) * 100) / 100;
const t = Math.round((b + u + v) * 100) / 100;
html += `<tr style="border-bottom:1px solid #F0FDF4;">
<td style="padding:6px 10px;color:#6B7280;">سنة ${yr + 1}</td>
<td style="padding:6px 10px;font-weight:600;color:#0D7377;">${fmt(b)}</td>
<td style="padding:6px 10px;color:#7C3AED;">${fmt(u)}</td>
<td style="padding:6px 10px;color:#D97706;">${fmt(v)}</td>
<td style="padding:6px 10px;font-weight:700;color:#059669;">${fmt(t)}</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('escalation_breakdown_table').innerHTML = html;
} else {
breakdownWrap.style.display = 'none';
}
} }
function clearPreview() { function clearPreview() {
['preview_base','preview_utils','preview_vat','preview_total','preview_months','preview_deposit'] ['preview_base','preview_utils','preview_vat','preview_total','preview_months','preview_deposit','preview_grace_due']
.forEach(id => { document.getElementById(id).textContent = '—'; }); .forEach(id => { const el = document.getElementById(id); if (el) el.textContent = '—'; });
document.getElementById('escalation_breakdown_wrap').style.display = 'none';
} }
// Sync late_fee_type: daily_bank is a UI convenience — maps to type=daily + bank_rate set
// The server reads late_fee_type directly (accepts 'daily_bank' gracefully or
// the controller should map it). We'll let the form submit 'daily_bank' and handle it
// in the service by checking late_fee_bank_rate > 0 when type = 'daily'.
// Actually, map to 'daily' on submit for backward compat:
document.getElementById('contractForm')?.addEventListener('submit', function() {
const typeEl = document.getElementById('late_fee_type');
if (typeEl && typeEl.value === 'daily_bank') {
typeEl.value = 'daily';
}
});
</script> </script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
<?php <?php
use App\Modules\Rentals\Models\RentalContract; use App\Modules\Rentals\Models\RentalContract;
use App\Modules\Rentals\Models\RentalInvoice; use App\Modules\Rentals\Models\RentalInvoice;
use App\Modules\Rentals\Services\RentalInvoiceService;
$__template->layout('Layout.main'); $__template->layout('Layout.main');
?> ?>
<?php $__template->section('title'); ?><?= e($contract->contract_number) ?><?php $__template->endSection(); ?> <?php $__template->section('title'); ?><?= e($contract->contract_number) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?> <?php $__template->section('page_actions'); ?>
<?php if (in_array($contract->status ?? 'draft', ['approved','active']) && can('rental.manage_contract')): ?> <?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('هل تريد توليد كل الفواتير الشهرية حتى نهاية العقد؟ الفواتير المولودة مسبقاً لن تتكرر.');"> <?php
$bulkConfirmMsg = 'هل تريد توليد كل الفواتير الشهرية حتى نهاية العقد؟ الفواتير المولودة مسبقاً لن تتكرر.';
if ($gracePeriod > 0) {
$bulkConfirmMsg .= ' سيتم تخطي أول ' . $gracePeriod . ' شهر (فترة الإعفاء) بدون فواتير.';
}
?>
<form method="POST" action="/rentals/contracts/<?= (int) $contract->id ?>/invoices/bulk-generate" style="display:inline;" onsubmit="return confirm(<?= json_encode($bulkConfirmMsg) ?>);">
<?= \App\Core\CSRF::field() ?> <?= \App\Core\CSRF::field() ?>
<button type="submit" class="btn btn-outline" style="margin-left:8px;color:#059669;border-color:#059669;"> <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> توليد كل الفواتير <i data-lucide="layers" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> توليد كل الفواتير
...@@ -28,6 +35,14 @@ $depositStatus = $contract->deposit_status ?? 'pending'; ...@@ -28,6 +35,14 @@ $depositStatus = $contract->deposit_status ?? 'pending';
$activityTypes = ['practice' => 'تدريب', 'competitive' => 'تنافسي']; $activityTypes = ['practice' => 'تدريب', 'competitive' => 'تنافسي'];
$timeTiers = ['AM' => 'صباحي', 'PM' => 'مسائي']; $timeTiers = ['AM' => 'صباحي', 'PM' => 'مسائي'];
$lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => 'أسبوعي', 'monthly' => 'شهري']; $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => 'أسبوعي', 'monthly' => 'شهري'];
$escalationTypes = RentalContract::getEscalationTypes();
$utilitiesModes = RentalContract::getUtilitiesModes();
$escType = $contract->escalation_type ?? 'none';
$utilitiesMode = $contract->utilities_mode ?? 'rent_pct';
$gracePeriod = (int) ($contract->grace_period_months ?? 0);
$earlyTerm = (int) ($contract->early_termination_months ?? 0);
$paymentDueDay = (int) ($contract->payment_due_day ?? 5);
$lateFeeBankRate = (float) ($contract->late_fee_bank_rate ?? 0);
?> ?>
<!-- Header --> <!-- Header -->
...@@ -70,7 +85,17 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => ...@@ -70,7 +85,17 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
<div style="font-size:18px;font-weight:700;color:#374151;"><?= money((float) ($contract->total_amount ?? 0)) ?></div> <div style="font-size:18px;font-weight:700;color:#374151;"><?= money((float) ($contract->total_amount ?? 0)) ?></div>
</div> </div>
<div style="background:#F5F3FF;padding:15px;border-radius:8px;text-align:center;"> <div style="background:#F5F3FF;padding:15px;border-radius:8px;text-align:center;">
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">مرافق (<?= (float) ($contract->utilities_percentage ?? 0) ?>%)</div> <div style="font-size:12px;color:#6B7280;margin-bottom:4px;">مرافق
<?php if ($utilitiesMode === 'rent_pct'): ?>
(<?= (float) ($contract->utilities_rent_pct ?? 0) ?>% من الإيجار)
<?php elseif ($utilitiesMode === 'facility_cost_pct'): ?>
(<?= (float) ($contract->utilities_facility_pct ?? 0) ?>% من التكلفة)
<?php elseif ($utilitiesMode === 'both'): ?>
(<?= (float) ($contract->utilities_rent_pct ?? 0) ?>% إيجار + <?= (float) ($contract->utilities_facility_pct ?? 0) ?>% تكلفة)
<?php else: ?>
(<?= (float) ($contract->utilities_percentage ?? 0) ?>%)
<?php endif; ?>
</div>
<div style="font-size:18px;font-weight:700;color:#7C3AED;"><?= money((float) ($contract->utilities_amount ?? 0)) ?></div> <div style="font-size:18px;font-weight:700;color:#7C3AED;"><?= money((float) ($contract->utilities_amount ?? 0)) ?></div>
</div> </div>
<div style="background:#FFFBEB;padding:15px;border-radius:8px;text-align:center;"> <div style="background:#FFFBEB;padding:15px;border-radius:8px;text-align:center;">
...@@ -85,8 +110,8 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => ...@@ -85,8 +110,8 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
<!-- Monthly preview row --> <!-- Monthly preview row -->
<?php <?php
use App\Modules\Rentals\Services\RentalInvoiceService; // Show year-1 monthly amount regardless of escalation (first month)
$mBase = RentalInvoiceService::calcBase($contract); $mBase = RentalInvoiceService::calcBase($contract, $contract->start_date ?? null);
$mUtils = RentalInvoiceService::calcUtilities($contract, $mBase); $mUtils = RentalInvoiceService::calcUtilities($contract, $mBase);
$mVat = RentalInvoiceService::calcVat($contract, $mBase + $mUtils); $mVat = RentalInvoiceService::calcVat($contract, $mBase + $mUtils);
$mTotal = round($mBase + $mUtils + $mVat, 2); $mTotal = round($mBase + $mUtils + $mVat, 2);
...@@ -136,6 +161,13 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => ...@@ -136,6 +161,13 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
<tr><td style="padding:6px 0;color:#6B7280;">نوع النشاط</td><td><?= e($activityTypes[$contract->activity_type ?? ''] ?? '—') ?></td></tr> <tr><td style="padding:6px 0;color:#6B7280;">نوع النشاط</td><td><?= e($activityTypes[$contract->activity_type ?? ''] ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">الفترة الزمنية</td><td><?= e($timeTiers[$contract->time_tier ?? ''] ?? '—') ?></td></tr> <tr><td style="padding:6px 0;color:#6B7280;">الفترة الزمنية</td><td><?= e($timeTiers[$contract->time_tier ?? ''] ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">التقرير الفني</td><td><?= ($contract->technical_report_submitted ?? 0) ? '<span style="color:#059669;font-weight:600;">نعم</span>' : '<span style="color:#DC2626;">لا</span>' ?></td></tr> <tr><td style="padding:6px 0;color:#6B7280;">التقرير الفني</td><td><?= ($contract->technical_report_submitted ?? 0) ? '<span style="color:#059669;font-weight:600;">نعم</span>' : '<span style="color:#DC2626;">لا</span>' ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">يوم الاستحقاق</td><td>يوم <?= $paymentDueDay ?> من كل شهر</td></tr>
<?php if ($gracePeriod > 0): ?>
<tr><td style="padding:6px 0;color:#6B7280;">فترة الإعفاء</td><td><span style="color:#059669;font-weight:600;"><?= $gracePeriod ?> شهر</span></td></tr>
<?php endif; ?>
<?php if ($earlyTerm > 0): ?>
<tr><td style="padding:6px 0;color:#6B7280;">غرامة الإنهاء المبكر</td><td><?= $earlyTerm ?> أشهر إيجار</td></tr>
<?php endif; ?>
</table> </table>
</div> </div>
<div class="card" style="padding:20px;"> <div class="card" style="padding:20px;">
...@@ -143,14 +175,27 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => ...@@ -143,14 +175,27 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
<table style="width:100%;font-size:14px;"> <table style="width:100%;font-size:14px;">
<tr> <tr>
<td style="padding:6px 0;color:#6B7280;width:45%;">نوع الغرامة</td> <td style="padding:6px 0;color:#6B7280;width:45%;">نوع الغرامة</td>
<td><?= e($lateFeeTypes[$contract->late_fee_type ?? 'none'] ?? 'لا يوجد') ?></td> <td>
<?php if (($contract->late_fee_type ?? 'none') === 'daily' && $lateFeeBankRate > 0): ?>
يومي بالنسبة البنكية
<?php else: ?>
<?= e($lateFeeTypes[$contract->late_fee_type ?? 'none'] ?? 'لا يوجد') ?>
<?php endif; ?>
</td>
</tr> </tr>
<?php if (($contract->late_fee_type ?? 'none') !== 'none'): ?> <?php if (($contract->late_fee_type ?? 'none') !== 'none'): ?>
<?php if ($lateFeeBankRate > 0): ?>
<tr>
<td style="padding:6px 0;color:#6B7280;">النسبة البنكية السنوية</td>
<td><?= $lateFeeBankRate ?>%</td>
</tr>
<?php elseif ((float) ($contract->late_fee_rate ?? 0) > 0): ?>
<tr> <tr>
<td style="padding:6px 0;color:#6B7280;">نسبة الغرامة</td> <td style="padding:6px 0;color:#6B7280;">نسبة الغرامة</td>
<td><?= (float) ($contract->late_fee_rate ?? 0) ?>%</td> <td><?= (float) ($contract->late_fee_rate ?? 0) ?>%</td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
<tr><td style="padding:6px 0;color:#6B7280;">اعتمد بواسطة</td><td><?= e($contract->approved_by ?? '—') ?></td></tr> <tr><td style="padding:6px 0;color:#6B7280;">اعتمد بواسطة</td><td><?= e($contract->approved_by ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">تاريخ الاعتماد</td><td><?= e($contract->approved_at ?? '—') ?></td></tr> <tr><td style="padding:6px 0;color:#6B7280;">تاريخ الاعتماد</td><td><?= e($contract->approved_at ?? '—') ?></td></tr>
</table> </table>
...@@ -163,6 +208,53 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => ...@@ -163,6 +208,53 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
</div> </div>
</div> </div>
<!-- Escalation Card -->
<?php if ($escType !== 'none'): ?>
<div class="card" style="margin-bottom:20px;padding:20px;">
<h4 style="color:#0D7377;margin:0 0 15px;"><i data-lucide="trending-up" style="width:17px;height:17px;vertical-align:middle;margin-left:6px;"></i> شروط الزيادة السنوية</h4>
<table style="width:100%;font-size:14px;">
<tr>
<td style="padding:6px 0;color:#6B7280;width:30%;">نوع الزيادة</td>
<td><strong><?= e($escalationTypes[$escType] ?? $escType) ?></strong></td>
</tr>
<?php if ($escType === 'flat'): ?>
<tr>
<td style="padding:6px 0;color:#6B7280;">نسبة الزيادة السنوية</td>
<td><strong style="color:#059669;"><?= (float) ($contract->escalation_rate ?? 0) ?>%</strong> سنوياً</td>
</tr>
<?php elseif ($escType === 'tiered' && !empty($contract->escalation_tiers_json)): ?>
<tr>
<td colspan="2" style="padding:6px 0;">
<?php
$tiersArr = json_decode((string) ($contract->escalation_tiers_json ?? '[]'), true);
if (is_array($tiersArr) && count($tiersArr) > 0):
?>
<table style="font-size:13px;width:auto;border-collapse:collapse;margin-top:6px;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:6px 12px;text-align:right;border:1px solid #E5E7EB;">#</th>
<th style="padding:6px 12px;text-align:right;border:1px solid #E5E7EB;">المدة (شهر)</th>
<th style="padding:6px 12px;text-align:right;border:1px solid #E5E7EB;">نسبة الزيادة السنوية</th>
</tr>
</thead>
<tbody>
<?php foreach ($tiersArr as $i => $tier): ?>
<tr>
<td style="padding:5px 12px;border:1px solid #E5E7EB;color:#6B7280;"><?= $i + 1 ?></td>
<td style="padding:5px 12px;border:1px solid #E5E7EB;"><?= (int) ($tier['months'] ?? 0) ?> شهر</td>
<td style="padding:5px 12px;border:1px solid #E5E7EB;font-weight:600;color:#059669;"><?= (float) ($tier['annual_rate'] ?? 0) ?>%</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
</table>
</div>
<?php endif; ?>
<!-- Action Buttons --> <!-- Action Buttons -->
<?php if ($cStatus === 'pending_approval' && can('rental.approve')): ?> <?php if ($cStatus === 'pending_approval' && can('rental.approve')): ?>
<div class="card" style="margin-bottom:20px;padding:20px;display:flex;align-items:center;gap:15px;background:#FFFBEB;border:1px solid #FDE68A;"> <div class="card" style="margin-bottom:20px;padding:20px;display:flex;align-items:center;gap:15px;background:#FFFBEB;border:1px solid #FDE68A;">
...@@ -204,7 +296,7 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' => ...@@ -204,7 +296,7 @@ $lateFeeTypes = ['none' => 'لا يوجد', 'daily' => 'يومي', 'weekly' =>
<h3 style="margin:0;color:#1A1A2E;"><i data-lucide="receipt" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> الفواتير</h3> <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')): ?> <?php if (in_array($cStatus, ['approved','active']) && can('rental.manage_contract')): ?>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<form method="POST" action="/rentals/contracts/<?= (int) $contract->id ?>/invoices/bulk-generate" style="margin:0;" onsubmit="return confirm('توليد كل الفواتير حتى نهاية العقد؟');"> <form method="POST" action="/rentals/contracts/<?= (int) $contract->id ?>/invoices/bulk-generate" style="margin:0;" onsubmit="return confirm(<?= json_encode($bulkConfirmMsg) ?>);">
<?= \App\Core\CSRF::field() ?> <?= \App\Core\CSRF::field() ?>
<button type="submit" class="btn btn-outline" style="font-size:13px;padding:6px 12px;color:#059669;border-color:#059669;"> <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> توليد الكل <i data-lucide="layers" style="width:14px;height:14px;vertical-align:middle;margin-left:3px;"></i> توليد الكل
......
<?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