feat(fusion_accounting_followup): overdue_aging service with 6 buckets

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 20:35:39 -04:00
parent ea2f44287f
commit 4ce0edc698
6 changed files with 165 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
from . import models
from . import services
from . import controllers
from . import wizards
from . import reports

View File

@@ -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': """

View File

@@ -0,0 +1 @@
from . import overdue_aging

View File

@@ -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

View File

@@ -0,0 +1 @@
from . import test_overdue_aging

View File

@@ -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)