Assets from purchase invoice lines, straight-line depreciation/amortization with salvage value, disposals, and [Asset Mgmt] sources in Batch GL Entries. Green third workspace with an app access guard.
136 lines
5.1 KiB
PHP
136 lines
5.1 KiB
PHP
<?php
|
|
/**
|
|
* DepreciationCalculator
|
|
*
|
|
* Straight-line depreciation (tangible) and amortization (intangible) maths.
|
|
* No database access — AssetManager and AssetRunManager call it.
|
|
*
|
|
* Conventions:
|
|
* - Amounts are rounded to 2 decimals per month; the last month of the life
|
|
* takes whatever is left so the book value ends exactly at salvage value.
|
|
* - The month the asset is put in service counts as a full month.
|
|
* - The month an asset is disposed of gets no charge.
|
|
* - Periods are 'YYYY-MM' strings.
|
|
*/
|
|
final class DepreciationCalculator
|
|
{
|
|
public static function periodOf(string $date): string
|
|
{
|
|
if (!preg_match('/^(\d{4})-(\d{2})/', $date, $m)) {
|
|
throw new Exception("Invalid date '{$date}'.");
|
|
}
|
|
return "{$m[1]}-{$m[2]}";
|
|
}
|
|
|
|
public static function addMonths(string $period, int $months): string
|
|
{
|
|
[$y, $m] = self::splitPeriod($period);
|
|
$index = $y * 12 + ($m - 1) + $months;
|
|
return sprintf('%04d-%02d', intdiv($index, 12), $index % 12 + 1);
|
|
}
|
|
|
|
/** Number of months from $from to $to (0 when equal, negative when $to is earlier). */
|
|
public static function monthsBetween(string $from, string $to): int
|
|
{
|
|
[$fy, $fm] = self::splitPeriod($from);
|
|
[$ty, $tm] = self::splitPeriod($to);
|
|
return ($ty * 12 + $tm) - ($fy * 12 + $fm);
|
|
}
|
|
|
|
public static function periodEndDate(string $period): string
|
|
{
|
|
[$y, $m] = self::splitPeriod($period);
|
|
return date('Y-m-t', mktime(0, 0, 0, $m, 1, $y));
|
|
}
|
|
|
|
public static function depreciableBase(float $cost, float $salvage): float
|
|
{
|
|
return round($cost - $salvage, 2);
|
|
}
|
|
|
|
public static function monthlyAmount(float $cost, float $salvage, int $life_months): float
|
|
{
|
|
if ($life_months <= 0) return 0.0;
|
|
return round(self::depreciableBase($cost, $salvage) / $life_months, 2);
|
|
}
|
|
|
|
public static function firstPeriod(string $in_service_date): string
|
|
{
|
|
return self::periodOf($in_service_date);
|
|
}
|
|
|
|
public static function lastPeriod(string $in_service_date, int $life_months): string
|
|
{
|
|
return self::addMonths(self::firstPeriod($in_service_date), max(1, $life_months) - 1);
|
|
}
|
|
|
|
/**
|
|
* Charge for one period.
|
|
*
|
|
* @param int $period_index 1 for the in-service month, up to $life_months.
|
|
* @param float $accumulated Total already charged before this period.
|
|
*/
|
|
public static function amountFor(float $cost, float $salvage, int $life_months, int $period_index, float $accumulated): float
|
|
{
|
|
if ($life_months <= 0 || $period_index < 1 || $period_index > $life_months) return 0.0;
|
|
|
|
$remaining = round(self::depreciableBase($cost, $salvage) - $accumulated, 2);
|
|
if ($remaining <= 0) return 0.0;
|
|
if ($period_index === $life_months) return $remaining;
|
|
|
|
return min(self::monthlyAmount($cost, $salvage, $life_months), $remaining);
|
|
}
|
|
|
|
/**
|
|
* Full projected schedule.
|
|
*
|
|
* @return array<int, array{period: string, amount: float, accumulated: float, book_value: float}>
|
|
*/
|
|
public static function schedule(float $cost, float $salvage, int $life_months, string $in_service_date): array
|
|
{
|
|
$rows = [];
|
|
$accumulated = 0.0;
|
|
$period = self::firstPeriod($in_service_date);
|
|
|
|
for ($i = 1; $i <= $life_months; $i++) {
|
|
$amount = self::amountFor($cost, $salvage, $life_months, $i, $accumulated);
|
|
$accumulated = round($accumulated + $amount, 2);
|
|
$rows[] = [
|
|
'period' => $period,
|
|
'amount' => $amount,
|
|
'accumulated' => $accumulated,
|
|
'book_value' => round($cost - $accumulated, 2),
|
|
];
|
|
$period = self::addMonths($period, 1);
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/** 1-based position of $period in the asset's life (0 or less = before in service). */
|
|
public static function periodIndex(string $in_service_date, string $period): int
|
|
{
|
|
return self::monthsBetween(self::firstPeriod($in_service_date), $period) + 1;
|
|
}
|
|
|
|
public static function assertInputs(float $cost, float $salvage, int $life_months): void
|
|
{
|
|
if ($cost <= 0) throw new Exception('Cost must be greater than zero.');
|
|
if ($salvage < 0) throw new Exception('Salvage value cannot be negative.');
|
|
if ($salvage >= $cost) throw new Exception('Salvage value must be less than cost.');
|
|
if ($life_months < 1) throw new Exception('Useful life must be at least 1 month.');
|
|
if ($life_months > 1200) throw new Exception('Useful life cannot exceed 1200 months.');
|
|
if (self::monthlyAmount($cost, $salvage, $life_months) <= 0) {
|
|
throw new Exception('The amount to depreciate is too small for this useful life.');
|
|
}
|
|
}
|
|
|
|
private static function splitPeriod(string $period): array
|
|
{
|
|
if (!preg_match('/^(\d{4})-(\d{2})$/', $period, $m) || (int)$m[2] < 1 || (int)$m[2] > 12) {
|
|
throw new Exception("Invalid period '{$period}'.");
|
|
}
|
|
return [(int)$m[1], (int)$m[2]];
|
|
}
|
|
}
|