diff --git a/fusion_accounting_followup/__init__.py b/fusion_accounting_followup/__init__.py index e69de29b..9898e1c8 100644 --- a/fusion_accounting_followup/__init__.py +++ b/fusion_accounting_followup/__init__.py @@ -0,0 +1,5 @@ +from . import models +from . import services +from . import controllers +from . import wizards +from . import reports diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index a3936fac..8891f162 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.0', + 'version': '19.0.1.0.1', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py index e69de29b..62b02039 100644 --- a/fusion_accounting_followup/services/__init__.py +++ b/fusion_accounting_followup/services/__init__.py @@ -0,0 +1 @@ +from . import overdue_aging diff --git a/fusion_accounting_followup/services/overdue_aging.py b/fusion_accounting_followup/services/overdue_aging.py new file mode 100644 index 00000000..f9315bc8 --- /dev/null +++ b/fusion_accounting_followup/services/overdue_aging.py @@ -0,0 +1,88 @@ +"""Aging bucket primitives. + +Pure-Python: callers pass a list of move-line dicts with `date_maturity` +and `amount_residual`; we bucket them into 0/30/60/90/120+ days overdue.""" + +from dataclasses import dataclass, field +from datetime import date + + +BUCKETS = [ + ('current', 0, 0), + ('1_30', 1, 30), + ('31_60', 31, 60), + ('61_90', 61, 90), + ('91_120', 91, 120), + ('120_plus', 121, None), +] + + +@dataclass +class AgingBucket: + name: str + days_min: int + days_max: int | None + amount: float = 0.0 + line_count: int = 0 + + +@dataclass +class AgingReport: + as_of: date + buckets: list[AgingBucket] = field(default_factory=list) + total_amount: float = 0.0 + total_overdue_amount: float = 0.0 + line_count: int = 0 + + def to_dict(self): + return { + 'as_of': str(self.as_of), + 'total_amount': self.total_amount, + 'total_overdue_amount': self.total_overdue_amount, + 'line_count': self.line_count, + 'buckets': [{ + 'name': b.name, 'days_min': b.days_min, 'days_max': b.days_max, + 'amount': b.amount, 'line_count': b.line_count, + } for b in self.buckets], + } + + +def compute_aging(*, move_lines: list[dict], as_of: date | None = None) -> AgingReport: + """Bucket move-line dicts into aging brackets. + + Each dict needs: date_maturity (date), amount_residual (float). + `as_of` defaults to today.""" + as_of = as_of or date.today() + report = AgingReport(as_of=as_of) + for name, days_min, days_max in BUCKETS: + report.buckets.append(AgingBucket(name=name, days_min=days_min, days_max=days_max)) + + for ml in move_lines: + maturity = ml.get('date_maturity') + amount = ml.get('amount_residual', 0.0) + if not maturity: + continue + days_overdue = (as_of - maturity).days + bucket = _find_bucket(report.buckets, days_overdue) + if bucket: + bucket.amount += amount + bucket.line_count += 1 + report.total_amount += amount + if days_overdue > 0: + report.total_overdue_amount += amount + report.line_count += 1 + + return report + + +def _find_bucket(buckets: list[AgingBucket], days_overdue: int) -> AgingBucket | None: + if days_overdue <= 0: + return next((b for b in buckets if b.name == 'current'), None) + for b in buckets: + if b.name == 'current': + continue + if b.days_max is None and days_overdue >= b.days_min: + return b + if b.days_max is not None and b.days_min <= days_overdue <= b.days_max: + return b + return None diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index e69de29b..525812c7 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -0,0 +1 @@ +from . import test_overdue_aging diff --git a/fusion_accounting_followup/tests/test_overdue_aging.py b/fusion_accounting_followup/tests/test_overdue_aging.py new file mode 100644 index 00000000..c1b620bd --- /dev/null +++ b/fusion_accounting_followup/tests/test_overdue_aging.py @@ -0,0 +1,69 @@ +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_followup.services.overdue_aging import ( + compute_aging, BUCKETS, +) + + +@tagged('post_install', '-at_install') +class TestOverdueAging(TransactionCase): + + def test_empty_lines_returns_zero_buckets(self): + report = compute_aging(move_lines=[], as_of=date(2026, 4, 19)) + self.assertEqual(report.total_amount, 0) + self.assertEqual(len(report.buckets), len(BUCKETS)) + for b in report.buckets: + self.assertEqual(b.amount, 0) + + def test_current_bucket_for_future_maturity(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': date(2026, 5, 19), 'amount_residual': 100}] + report = compute_aging(move_lines=lines, as_of=as_of) + current = next(b for b in report.buckets if b.name == 'current') + self.assertEqual(current.amount, 100) + self.assertEqual(report.total_overdue_amount, 0) + + def test_30_day_bucket(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}] + report = compute_aging(move_lines=lines, as_of=as_of) + b = next(b for b in report.buckets if b.name == '1_30') + self.assertEqual(b.amount, 200) + + def test_60_day_bucket(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 300}] + report = compute_aging(move_lines=lines, as_of=as_of) + b = next(b for b in report.buckets if b.name == '31_60') + self.assertEqual(b.amount, 300) + + def test_120_plus_bucket(self): + as_of = date(2026, 4, 19) + lines = [{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}] + report = compute_aging(move_lines=lines, as_of=as_of) + b = next(b for b in report.buckets if b.name == '120_plus') + self.assertEqual(b.amount, 500) + + def test_total_overdue_excludes_current(self): + as_of = date(2026, 4, 19) + lines = [ + {'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}, + {'date_maturity': as_of - timedelta(days=10), 'amount_residual': 200}, + {'date_maturity': as_of - timedelta(days=50), 'amount_residual': 300}, + ] + report = compute_aging(move_lines=lines, as_of=as_of) + self.assertEqual(report.total_amount, 600) + self.assertEqual(report.total_overdue_amount, 500) + + def test_buckets_sum_equals_total(self): + as_of = date(2026, 4, 19) + lines = [ + {'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}, + {'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}, + {'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300}, + {'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}, + ] + report = compute_aging(move_lines=lines, as_of=as_of) + bucket_sum = sum(b.amount for b in report.buckets) + self.assertAlmostEqual(bucket_sum, report.total_amount, places=2)