From 3e73ca0eb7c08816785b9d7838771a183f360d8a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 16:46:54 -0400 Subject: [PATCH] feat(fusion_accounting_assets): 3 depreciation methods (straight, declining, units) Made-with: Cursor --- fusion_accounting_assets/__init__.py | 1 + fusion_accounting_assets/__manifest__.py | 2 +- fusion_accounting_assets/services/__init__.py | 1 + .../services/depreciation_methods.py | 116 ++++++++++++++++++ fusion_accounting_assets/tests/__init__.py | 1 + .../tests/test_depreciation_methods.py | 88 +++++++++++++ 6 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_assets/services/depreciation_methods.py create mode 100644 fusion_accounting_assets/tests/test_depreciation_methods.py diff --git a/fusion_accounting_assets/__init__.py b/fusion_accounting_assets/__init__.py index e69de29b..99464a75 100644 --- a/fusion_accounting_assets/__init__.py +++ b/fusion_accounting_assets/__init__.py @@ -0,0 +1 @@ +from . import services diff --git a/fusion_accounting_assets/__manifest__.py b/fusion_accounting_assets/__manifest__.py index 20925376..e383eab8 100644 --- a/fusion_accounting_assets/__manifest__.py +++ b/fusion_accounting_assets/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Assets', - 'version': '19.0.1.0.0', + 'version': '19.0.1.0.1', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented asset management with depreciation schedules.', 'description': """ diff --git a/fusion_accounting_assets/services/__init__.py b/fusion_accounting_assets/services/__init__.py index e69de29b..ebcefb4a 100644 --- a/fusion_accounting_assets/services/__init__.py +++ b/fusion_accounting_assets/services/__init__.py @@ -0,0 +1 @@ +from . import depreciation_methods diff --git a/fusion_accounting_assets/services/depreciation_methods.py b/fusion_accounting_assets/services/depreciation_methods.py new file mode 100644 index 00000000..6b9afa6b --- /dev/null +++ b/fusion_accounting_assets/services/depreciation_methods.py @@ -0,0 +1,116 @@ +"""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 diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py index e69de29b..30d9e7e1 100644 --- a/fusion_accounting_assets/tests/__init__.py +++ b/fusion_accounting_assets/tests/__init__.py @@ -0,0 +1 @@ +from . import test_depreciation_methods diff --git a/fusion_accounting_assets/tests/test_depreciation_methods.py b/fusion_accounting_assets/tests/test_depreciation_methods.py new file mode 100644 index 00000000..5571d11e --- /dev/null +++ b/fusion_accounting_assets/tests/test_depreciation_methods.py @@ -0,0 +1,88 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_assets.services.depreciation_methods import ( + straight_line, declining_balance, units_of_production, +) + + +@tagged('post_install', '-at_install') +class TestStraightLine(TransactionCase): + + def test_total_equals_cost_minus_salvage(self): + steps = straight_line(cost=10000, salvage_value=1000, n_periods=5) + total = sum(s.period_amount for s in steps) + self.assertAlmostEqual(total, 9000, places=2) + + def test_per_period_equal_except_last(self): + steps = straight_line(cost=10000, salvage_value=0, n_periods=4) + self.assertEqual([s.period_amount for s in steps], [2500.0] * 4) + + def test_last_period_absorbs_rounding(self): + steps = straight_line(cost=10000, salvage_value=0, n_periods=3) + total = sum(s.period_amount for s in steps) + self.assertAlmostEqual(total, 10000, places=2) + + def test_zero_periods_returns_empty(self): + self.assertEqual(straight_line(cost=10000, n_periods=0), []) + + def test_book_value_decreasing(self): + steps = straight_line(cost=10000, salvage_value=1000, n_periods=5) + for i in range(1, len(steps)): + self.assertLess(steps[i].book_value_at_end, steps[i - 1].book_value_at_end) + + +@tagged('post_install', '-at_install') +class TestDecliningBalance(TransactionCase): + + def test_total_does_not_exceed_depreciable(self): + steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.20) + total = sum(s.period_amount for s in steps) + self.assertLessEqual(total, 9000.01) + + def test_does_not_go_below_salvage(self): + steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.50) + for s in steps: + self.assertGreaterEqual(s.book_value_at_end, 999.99) + + def test_zero_rate_returns_empty(self): + self.assertEqual(declining_balance(cost=10000, n_periods=5, rate=0), []) + + def test_pathological_100pct_rate_one_period(self): + steps = declining_balance(cost=10000, salvage_value=500, n_periods=10, rate=1.0) + self.assertEqual(len(steps), 1) + self.assertAlmostEqual(steps[0].period_amount, 9500, places=2) + + +@tagged('post_install', '-at_install') +class TestUnitsOfProduction(TransactionCase): + + def test_total_proportional_to_units_used(self): + steps = units_of_production( + cost=20000, salvage_value=2000, + total_units_expected=10000, + units_per_period=[1000, 2000, 3000, 4000], + ) + total = sum(s.period_amount for s in steps) + self.assertAlmostEqual(total, 18000, places=1) + + def test_partial_use_partial_depreciation(self): + steps = units_of_production( + cost=10000, salvage_value=0, + total_units_expected=1000, + units_per_period=[200], + ) + self.assertAlmostEqual(steps[0].period_amount, 2000, places=2) + + def test_zero_total_units_returns_empty(self): + self.assertEqual( + units_of_production(cost=10000, total_units_expected=0, units_per_period=[100]), + [], + ) + + def test_does_not_overshoot_salvage(self): + steps = units_of_production( + cost=10000, salvage_value=1000, + total_units_expected=1000, + units_per_period=[2000], + ) + self.assertAlmostEqual(steps[0].period_amount, 9000, places=2)