"""Depreciation method primitives. Three methods supported: - straight_line: equal periodic charge over useful_life - declining_balance: % per period of remaining book value - units_of_production: charge proportional to units used / total units expected All return a list of DepreciationStep dataclasses (period_index, period_amount, accumulated_depreciation, book_value_at_end). Total depreciation always sums to (cost - salvage_value), within 1-cent rounding tolerance. """ from dataclasses import dataclass from typing import Literal Method = Literal['straight_line', 'declining_balance', 'units_of_production'] @dataclass class DepreciationStep: period_index: int period_amount: float accumulated_depreciation: float book_value_at_end: float def straight_line(*, cost: float, salvage_value: float = 0.0, n_periods: int) -> list[DepreciationStep]: """Equal charge per period: (cost - salvage) / n_periods. Last period absorbs rounding so total == cost - salvage exactly. """ if n_periods < 1: return [] depreciable = cost - salvage_value per_period = round(depreciable / n_periods, 2) steps = [] accumulated = 0.0 for i in range(n_periods): if i == n_periods - 1: amount = round(depreciable - accumulated, 2) else: amount = per_period accumulated = round(accumulated + amount, 2) book = round(cost - accumulated, 2) steps.append(DepreciationStep( period_index=i, period_amount=amount, accumulated_depreciation=accumulated, book_value_at_end=book, )) return steps def declining_balance(*, cost: float, salvage_value: float = 0.0, n_periods: int, rate: float) -> list[DepreciationStep]: """Apply `rate` (e.g. 0.20 = 20%) to remaining book each period. Switches to straight-line when straight-line would deplete remaining book faster (typical Odoo behavior). Last step caps at salvage_value. """ if n_periods < 1 or rate <= 0: return [] if rate >= 1: # Pathological: 100%+ rate. Charge full depreciable amount in period 0. depreciable = round(cost - salvage_value, 2) return [DepreciationStep(0, depreciable, depreciable, round(salvage_value, 2))] steps = [] book = cost accumulated = 0.0 for i in range(n_periods): remaining_periods = n_periods - i db_amount = round(book * rate, 2) sl_amount = round((book - salvage_value) / remaining_periods, 2) if remaining_periods else 0.0 amount = max(db_amount, sl_amount) if book - amount < salvage_value: amount = round(book - salvage_value, 2) accumulated = round(accumulated + amount, 2) book = round(book - amount, 2) steps.append(DepreciationStep( period_index=i, period_amount=amount, accumulated_depreciation=accumulated, book_value_at_end=book, )) if book <= salvage_value: break return steps def units_of_production(*, cost: float, salvage_value: float = 0.0, total_units_expected: float, units_per_period: list[float]) -> list[DepreciationStep]: """Charge per period = (units_used / total_expected) * (cost - salvage).""" if total_units_expected <= 0: return [] depreciable = cost - salvage_value per_unit = depreciable / total_units_expected steps = [] accumulated = 0.0 for i, units in enumerate(units_per_period): amount = round(units * per_unit, 2) if accumulated + amount > depreciable: amount = round(depreciable - accumulated, 2) accumulated = round(accumulated + amount, 2) book = round(cost - accumulated, 2) steps.append(DepreciationStep( period_index=i, period_amount=amount, accumulated_depreciation=accumulated, book_value_at_end=book, )) if accumulated >= depreciable: break return steps