Compare commits

...

3 Commits

Author SHA1 Message Date
gsinghpal
e14ad21689 feat(fusion_accounting_reports): currency conversion service
Pure-Python helper for FX conversion at report end-date. Handles direct
rates, inverse rates, and fallback to most-recent-rate-on-or-before.
fetch_rates() pulls from res.currency.rate using the same
1/rate inversion convention Odoo uses internally.

Made-with: Cursor
2026-04-19 15:07:46 -04:00
gsinghpal
0a9ed635e8 feat(fusion_accounting_reports): pure-Python services for date+account+totaling
Three service modules with no Odoo dependencies:
- date_periods: fiscal year/month/quarter bounds + comparison derivation
- account_hierarchy: parent-child tree walker with type filtering
- totaling: move-line aggregation primitives

18 unit tests covering edge cases (December rollover, Feb 29, fiscal-
year-before-start, balance check tolerance).

Made-with: Cursor
2026-04-19 15:07:05 -04:00
gsinghpal
a93162cb70 feat(fusion_accounting_reports): Phase 2 skeleton + plan
46-task plan to replace Enterprise account_reports module:
- CORE scope: P&L, balance sheet, trial balance, GL with drill-down
- HYBRID engine: shared primitives + per-report models
- AI augmentation: anomaly detection + LLM-generated commentary
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- Same V19 conventions + test pyramid + perf-budget discipline as Phase 1

Skeleton: empty manifest + dirs + icon. Tasks 3-46 add the substance.
Made-with: Cursor
2026-04-19 15:03:03 -04:00
17 changed files with 693 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
# Phase 2 — Fusion Accounting Reports Implementation Plan
**Module:** `fusion_accounting_reports`
**Branch:** `fusion_accounting/phase-2-reports`
**Pre-phase tag:** `fusion_accounting/pre-phase-2`
**Estimated tasks:** 46
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
## Goal
Replace Odoo Enterprise's `account_reports` module with a Fusion-native financial reports engine. CORE scope: P&L (income statement), balance sheet, trial balance, general ledger with drill-down. AI augmentation: anomaly detection (variance vs prior period) + AI-generated commentary. Coexists with Enterprise (Enterprise wins by default; Fusion menu shows when Enterprise absent).
## Architecture (HYBRID engine)
```
fusion.report.engine (AbstractModel) ← shared primitives
├── compute_pnl(period, comparison=None)
├── compute_balance_sheet(date_to, comparison=None)
├── compute_trial_balance(period)
├── compute_gl(period, account_ids=None)
├── drill_down(report_type, line_id, period)
└── _walk_account_hierarchy(root_account_ids)
services/ ← pure-Python
├── date_periods.py → fiscal-period math, comparison-period derivation
├── account_hierarchy.py → recursive account tree walk + roll-ups
├── totaling.py → balance/credit/debit aggregation rules
├── currency_conversion.py → multi-currency revaluation at report date
├── anomaly_detection.py → variance vs prior-period statistical flags
└── commentary_generator.py → LLM prompt + parse for narrative
models/
├── fusion_report.py → report definition (metadata, line specs)
├── fusion_report_engine.py → AbstractModel orchestrator
├── fusion_report_pnl.py → P&L definition + execute
├── fusion_report_balance_sheet.py
├── fusion_report_trial_balance.py
├── fusion_report_general_ledger.py
├── fusion_report_anomaly.py → persisted flagged variances
├── fusion_report_commentary.py → cached AI narratives
└── fusion_unreconciled_gl_mv.py → MV for fast GL listing on large DBs
controllers/bank_rec_controller.py ← 8 JSON-RPC endpoints
├── /fusion/reports/run → execute one report
├── /fusion/reports/drill_down → drill into a report line
├── /fusion/reports/get_anomalies → list flagged variances
├── /fusion/reports/get_commentary → fetch / regenerate narrative
├── /fusion/reports/compare_periods → side-by-side comparison
├── /fusion/reports/export_pdf → PDF export
├── /fusion/reports/export_xlsx → XLSX export
└── /fusion/reports/list_available → list all report types
static/src/
├── scss/ ← report-specific design tokens
├── services/reports_service.js ← reactive state + RPC wrappers
├── views/reports_viewer/ ← top-level OWL controller
└── components/ ← report_table, drill_down_dialog,
period_filter, ai_commentary_panel,
anomaly_strip
```
## Coexistence
Same pattern as Phase 1: `group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Reports menu only visible when `account_reports` is NOT installed. Engine + AI tools always available.
## Tasks (46 total)
### Group 1: Foundation (tasks 1-2)
1. Safety net (tag pre-phase-2, branch phase-2-reports) — **DONE**
2. Plan doc + module skeleton
### Group 2: Engine primitives — TDD layered (tasks 3-8)
3. `services/date_periods.py` (fiscal periods, comparison derivation)
4. `services/currency_conversion.py` + `services/account_hierarchy.py` + `services/totaling.py`
5. `models/fusion_report.py` (report definition model)
6. `services/line_resolver.py` (compute report rows from definition)
7. `services/drill_down_resolver.py`
8. `models/fusion_report_engine.py` (5-method API: compute_pnl, compute_balance_sheet, compute_trial_balance, compute_gl, drill_down)
### Group 3: Per-report models (tasks 9-12)
9. P&L (income statement)
10. Balance sheet
11. Trial balance
12. General ledger
### Group 4: AI features (tasks 13-17)
13. Anomaly detection service (variance vs prior period)
14. AI commentary service
15. Commentary prompt + LLMProvider integration
16. `fusion.report.commentary` persisted model
17. `fusion.report.anomaly` persisted model
### Group 5: Backend wiring (tasks 18-20)
18. JSON-RPC controller (8 endpoints)
19. ReportsAdapter `_via_fusion` paths
20. 5 new AI tools
### Group 6: Tests + perf (tasks 21-25)
21. Property-based tests (totals balance invariant)
22. Integration tests — P&L correctness vs known fixtures
23. Integration tests — balance sheet + trial balance
24. Materialized view for GL
25. Cron jobs (anomaly scan + commentary refresh)
### Group 7: Frontend (tasks 26-33)
26. SCSS tokens + main report stylesheet
27. `reports_service.js`
28. `report_viewer` component (top-level)
29. `report_table` component (rows, totals, drill chevrons)
30. `drill_down_dialog`
31. `period_filter` (date range + comparison toggle)
32. `ai_commentary_panel` (Fusion-only)
33. `anomaly_strip` (Fusion-only)
### Group 8: Export + wizards (tasks 34-36)
34. PDF export (QWeb template per report)
35. XLSX export wizard
36. Period selection + comparison wizard
### Group 9: Migration + coexistence (tasks 37-39)
37. Migration wizard inheritance (cache existing definitions)
38. Menu + window actions with coexistence group filter
39. Coexistence test
### Group 10: Final tests + polish (tasks 40-46)
40. 5 OWL tour tests
41. Performance benchmarks
42. Optimize if benchmarks fail (conditional)
43. Local LLM compat test for commentary
44. Update meta-module manifest
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
46. End-to-end smoke + tag phase-2-complete + push
## Performance Targets (P95)
- `engine.compute_pnl` (1 year, 500 accounts): <2s
- `engine.compute_balance_sheet`: <2s
- `engine.compute_trial_balance`: <1s
- `engine.compute_gl` (1 month, all accounts): <3s
- `engine.drill_down` (1 line): <500ms
- Controller `run` endpoint: <2.5s
## V19 Conventions (from Phase 1 lessons)
- `models.Constraint` not `_sql_constraints`
- No `@api.depends('id')` on stored compute fields
- `@route(type='jsonrpc')` not `type='json'`
- `ir.cron` has no `numbercall` field
- `res.groups.user_ids` not `users`
- `ir.ui.menu.group_ids` not `groups_id`
- `res.users.all_group_ids` for searches
- `models.Constraint` for unique-keys
- Prefer `env.flush_all()` before MV REFRESH
## Test Targets
Match Phase 1's test pyramid:
- Unit (services pure-Python)
- Integration (engine end-to-end with factories)
- Property-based (Hypothesis, totals balance invariant)
- Controller (HttpCase JSON-RPC)
- MV correctness
- Performance benchmarks (tagged 'benchmark')
- OWL tours (tagged 'tour')
- Local LLM smoke (tagged 'local_llm', skips when no LLM)
Phase 1 final: 157 tests passing. Phase 2 target: ~120-150 additional.

View File

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

View File

@@ -0,0 +1,43 @@
{
'name': 'Fusion Accounting Reports',
'version': '19.0.1.0.0',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
'description': """
Fusion Accounting Reports
=========================
A Fusion-native replacement for Odoo Enterprise's account_reports module.
CORE scope (Phase 2):
- Income Statement (P&L)
- Balance Sheet
- Trial Balance
- General Ledger (with drill-down)
AI augmentation:
- Anomaly detection (variance vs prior period)
- AI commentary (LLM-generated narrative)
Coexists with Enterprise: when account_reports is installed, the Fusion
menu hides; the engine and AI tools remain available for the chat.
""",
'author': 'Fusion Accounting',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'account',
],
'data': [
'security/ir.model.access.csv',
],
'assets': {
'web.assets_backend': [
],
},
'installable': True,
'auto_install': False,
'application': False,
'icon': '/fusion_accounting_reports/static/description/icon.png',
}

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

View File

@@ -0,0 +1,4 @@
from . import date_periods
from . import account_hierarchy
from . import totaling
from . import currency_conversion

View File

@@ -0,0 +1,62 @@
"""Account hierarchy walker.
Given a flat list of accounts with parent_id pointers, build a tree and
provide a recursive walker that yields (account, depth, ancestors) tuples.
Used by report line resolvers to render group sub-totals."""
from dataclasses import dataclass, field
from typing import Iterator
@dataclass
class AccountNode:
id: int
code: str
name: str
account_type: str
parent_id: int | None
children: list['AccountNode'] = field(default_factory=list)
def build_tree(accounts: list[dict]) -> list[AccountNode]:
"""Build a forest from a flat list of account dicts.
Each dict must have keys: id, code, name, account_type, parent_id (nullable)."""
nodes: dict[int, AccountNode] = {}
for acc in accounts:
nodes[acc['id']] = AccountNode(
id=acc['id'], code=acc['code'], name=acc['name'],
account_type=acc['account_type'],
parent_id=acc.get('parent_id'),
)
roots: list[AccountNode] = []
for node in nodes.values():
if node.parent_id and node.parent_id in nodes:
nodes[node.parent_id].children.append(node)
else:
roots.append(node)
for node in nodes.values():
node.children.sort(key=lambda n: n.code)
roots.sort(key=lambda n: n.code)
return roots
def walk(roots: list[AccountNode], *, max_depth: int = 10) -> Iterator[tuple[AccountNode, int, list[AccountNode]]]:
"""Depth-first walk yielding (node, depth, ancestors)."""
def _walk(node: AccountNode, depth: int, ancestors: list[AccountNode]):
yield (node, depth, ancestors)
if depth < max_depth:
for child in node.children:
yield from _walk(child, depth + 1, ancestors + [node])
for root in roots:
yield from _walk(root, 0, [])
def filter_by_account_type(roots: list[AccountNode], type_prefix: str) -> list[AccountNode]:
"""Return all nodes whose account_type starts with type_prefix
(e.g. 'asset_' returns asset_receivable, asset_cash, etc.)."""
matches: list[AccountNode] = []
for node, _depth, _ancestors in walk(roots):
if node.account_type.startswith(type_prefix):
matches.append(node)
return matches

View File

@@ -0,0 +1,66 @@
"""Multi-currency conversion for financial reports.
Converts move-line amounts to the report's display currency at the
report end-date. Pure-Python - caller provides exchange rates as a
dict {(source_code, target_code, date): rate}."""
from dataclasses import dataclass
from datetime import date
@dataclass
class ConversionRate:
source: str
target: str
rate: float
rate_date: date
def convert_amount(amount: float, *, source_currency: str, target_currency: str,
rate_date: date, rates: dict) -> float:
"""Convert `amount` from source to target at the given date.
`rates` is a dict keyed by (source, target, date) -> rate.
If source == target, returns amount unchanged."""
if source_currency == target_currency:
return amount
key = (source_currency, target_currency, rate_date)
if key in rates:
return amount * rates[key]
inv_key = (target_currency, source_currency, rate_date)
if inv_key in rates:
inv = rates[inv_key]
if inv != 0:
return amount / inv
candidates = [
(d, r) for (s, t, d), r in rates.items()
if s == source_currency and t == target_currency and d <= rate_date
]
if candidates:
candidates.sort(key=lambda x: x[0], reverse=True)
return amount * candidates[0][1]
raise ValueError(
f"No exchange rate available for {source_currency}->{target_currency} on or before {rate_date}"
)
def fetch_rates(env, *, target_currency_id: int, as_of: date,
source_currency_ids: list[int] | None = None) -> dict:
"""Fetch all relevant rates from res.currency.rate as of a given date.
Returns the dict-of-rates structure consumed by convert_amount.
Pulls only rates where source != target and date <= as_of."""
Rate = env['res.currency.rate'].sudo()
target = env['res.currency'].browse(target_currency_id)
domain = [
('name', '<=', as_of),
('currency_id', '!=', target.id),
]
if source_currency_ids:
domain.append(('currency_id', 'in', source_currency_ids))
rates_recs = Rate.search(domain)
out = {}
for r in rates_recs:
out[(r.currency_id.name, target.name, r.name)] = (1.0 / r.rate) if r.rate else 0.0
return out

View File

@@ -0,0 +1,103 @@
"""Date period math for financial reports.
Pure-Python helpers that compute:
- Fiscal year start/end given any reference date + company fiscal year settings
- Comparison periods (prior year same period, prior period, etc.)
- Period boundaries for monthly / quarterly / yearly reporting
NO Odoo imports - all callers pass in primitive types so the same module
is unit-testable without an Odoo registry."""
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Literal
PeriodGranularity = Literal['month', 'quarter', 'year', 'custom']
ComparisonMode = Literal['none', 'previous_period', 'previous_year']
@dataclass(frozen=True)
class Period:
date_from: date
date_to: date
label: str
def __post_init__(self):
if self.date_from > self.date_to:
raise ValueError(f"date_from ({self.date_from}) > date_to ({self.date_to})")
@property
def days(self) -> int:
return (self.date_to - self.date_from).days + 1
def fiscal_year_bounds(reference_date: date, *, fy_start_month: int = 1,
fy_start_day: int = 1) -> Period:
"""Return the fiscal year period containing `reference_date`.
Default: calendar year (Jan 1 - Dec 31). Pass fy_start_month=4, fy_start_day=1
for an April-March fiscal year."""
if reference_date.month < fy_start_month or (
reference_date.month == fy_start_month and reference_date.day < fy_start_day
):
start_year = reference_date.year - 1
else:
start_year = reference_date.year
start = date(start_year, fy_start_month, fy_start_day)
next_start = date(start_year + 1, fy_start_month, fy_start_day)
end = next_start - timedelta(days=1)
return Period(date_from=start, date_to=end, label=f"FY {start_year}")
def month_bounds(reference_date: date) -> Period:
"""Return the calendar month containing `reference_date`."""
start = reference_date.replace(day=1)
if reference_date.month == 12:
next_start = date(reference_date.year + 1, 1, 1)
else:
next_start = date(reference_date.year, reference_date.month + 1, 1)
return Period(
date_from=start,
date_to=next_start - timedelta(days=1),
label=start.strftime('%B %Y'),
)
def quarter_bounds(reference_date: date) -> Period:
"""Return the calendar quarter containing `reference_date`."""
quarter = (reference_date.month - 1) // 3 + 1
start_month = (quarter - 1) * 3 + 1
start = date(reference_date.year, start_month, 1)
end_month = start_month + 2
if end_month == 12:
end = date(reference_date.year, 12, 31)
else:
end = date(reference_date.year, end_month + 1, 1) - timedelta(days=1)
return Period(date_from=start, date_to=end, label=f"Q{quarter} {reference_date.year}")
def comparison_period(period: Period, mode: ComparisonMode) -> Period | None:
"""Derive the comparison period for `period` per `mode`.
`previous_period`: same length, immediately before
`previous_year`: same calendar dates, one year earlier
`none`: returns None"""
if mode == 'none':
return None
if mode == 'previous_period':
days = period.days
new_to = period.date_from - timedelta(days=1)
new_from = new_to - timedelta(days=days - 1)
return Period(date_from=new_from, date_to=new_to,
label=f"{period.label} (previous)")
if mode == 'previous_year':
try:
new_from = period.date_from.replace(year=period.date_from.year - 1)
new_to = period.date_to.replace(year=period.date_to.year - 1)
except ValueError:
new_from = period.date_from.replace(year=period.date_from.year - 1, day=28)
new_to = period.date_to.replace(year=period.date_to.year - 1, day=28)
return Period(date_from=new_from, date_to=new_to,
label=f"{period.label} (prev year)")
raise ValueError(f"Unknown comparison mode: {mode}")

View File

@@ -0,0 +1,49 @@
"""Move-line aggregation primitives for report totaling.
Pure-Python helpers - callers pass dicts with debit/credit/balance/currency keys,
no Odoo recordsets needed. Keeps the math testable without an ORM."""
from dataclasses import dataclass
@dataclass
class TotalLine:
debit: float = 0.0
credit: float = 0.0
balance: float = 0.0
debit_currency: float = 0.0
credit_currency: float = 0.0
balance_currency: float = 0.0
line_count: int = 0
def aggregate(move_lines: list[dict]) -> TotalLine:
"""Aggregate a list of move-line dicts into a TotalLine.
Each dict must have: debit, credit, balance (signed). Optional:
debit_currency, credit_currency, balance_currency."""
out = TotalLine()
for ml in move_lines:
out.debit += ml.get('debit', 0.0)
out.credit += ml.get('credit', 0.0)
out.balance += ml.get('balance', 0.0)
out.debit_currency += ml.get('debit_currency', 0.0)
out.credit_currency += ml.get('credit_currency', 0.0)
out.balance_currency += ml.get('balance_currency', 0.0)
out.line_count += 1
return out
def aggregate_per_account(move_lines: list[dict]) -> dict[int, TotalLine]:
"""Group + aggregate by account_id. Returns {account_id: TotalLine}."""
grouped: dict[int, list[dict]] = {}
for ml in move_lines:
acct = ml['account_id']
grouped.setdefault(acct, []).append(ml)
return {acct: aggregate(lines) for acct, lines in grouped.items()}
def is_balanced(move_lines: list[dict], *, tolerance: float = 0.005) -> bool:
"""True if total debits == total credits (within tolerance for rounding)."""
agg = aggregate(move_lines)
return abs(agg.debit - agg.credit) <= tolerance

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,2 @@
from . import test_services_unit
from . import test_currency_conversion

View File

@@ -0,0 +1,53 @@
"""Unit tests for currency_conversion service."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.currency_conversion import (
convert_amount, fetch_rates,
)
@tagged('post_install', '-at_install')
class TestCurrencyConversion(TransactionCase):
def test_same_currency_returns_unchanged(self):
result = convert_amount(100, source_currency='USD',
target_currency='USD',
rate_date=date(2026, 4, 19), rates={})
self.assertEqual(result, 100)
def test_direct_rate(self):
rates = {('USD', 'CAD', date(2026, 4, 19)): 1.35}
result = convert_amount(100, source_currency='USD',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates=rates)
self.assertEqual(result, 135)
def test_inverse_rate(self):
rates = {('CAD', 'USD', date(2026, 4, 19)): 0.74}
result = convert_amount(100, source_currency='USD',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates=rates)
self.assertAlmostEqual(result, 100 / 0.74, places=2)
def test_falls_back_to_most_recent_rate(self):
rates = {
('USD', 'CAD', date(2026, 1, 1)): 1.30,
('USD', 'CAD', date(2026, 3, 1)): 1.32,
}
result = convert_amount(100, source_currency='USD',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates=rates)
self.assertEqual(result, 132)
def test_raises_when_no_rate(self):
with self.assertRaises(ValueError):
convert_amount(100, source_currency='EUR',
target_currency='CAD',
rate_date=date(2026, 4, 19), rates={})
def test_fetch_rates_from_env(self):
cad = self.env.ref('base.CAD')
rates = fetch_rates(self.env, target_currency_id=cad.id, as_of=date(2026, 4, 19))
self.assertIsInstance(rates, dict)

View File

@@ -0,0 +1,142 @@
"""Unit tests for date_periods, account_hierarchy, totaling services."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period, fiscal_year_bounds, month_bounds, quarter_bounds, comparison_period,
)
from odoo.addons.fusion_accounting_reports.services.account_hierarchy import (
build_tree, walk, filter_by_account_type,
)
from odoo.addons.fusion_accounting_reports.services.totaling import (
aggregate, aggregate_per_account, is_balanced,
)
@tagged('post_install', '-at_install')
class TestDatePeriods(TransactionCase):
def test_fiscal_year_calendar_default(self):
period = fiscal_year_bounds(date(2026, 6, 15))
self.assertEqual(period.date_from, date(2026, 1, 1))
self.assertEqual(period.date_to, date(2026, 12, 31))
def test_fiscal_year_april_start(self):
period = fiscal_year_bounds(date(2026, 6, 15), fy_start_month=4)
self.assertEqual(period.date_from, date(2026, 4, 1))
self.assertEqual(period.date_to, date(2027, 3, 31))
def test_fiscal_year_before_start_returns_prior(self):
period = fiscal_year_bounds(date(2026, 2, 15), fy_start_month=4)
self.assertEqual(period.date_from, date(2025, 4, 1))
self.assertEqual(period.date_to, date(2026, 3, 31))
def test_month_bounds(self):
period = month_bounds(date(2026, 4, 19))
self.assertEqual(period.date_from, date(2026, 4, 1))
self.assertEqual(period.date_to, date(2026, 4, 30))
def test_month_bounds_december(self):
period = month_bounds(date(2026, 12, 19))
self.assertEqual(period.date_from, date(2026, 12, 1))
self.assertEqual(period.date_to, date(2026, 12, 31))
def test_quarter_bounds_q2(self):
period = quarter_bounds(date(2026, 5, 15))
self.assertEqual(period.date_from, date(2026, 4, 1))
self.assertEqual(period.date_to, date(2026, 6, 30))
def test_comparison_previous_year(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'FY 2026')
comp = comparison_period(period, 'previous_year')
self.assertEqual(comp.date_from, date(2025, 1, 1))
self.assertEqual(comp.date_to, date(2025, 12, 31))
def test_comparison_previous_period_same_length(self):
period = Period(date(2026, 4, 1), date(2026, 4, 30), 'Apr 2026')
comp = comparison_period(period, 'previous_period')
self.assertEqual(comp.date_to, date(2026, 3, 31))
self.assertEqual(comp.days, period.days)
def test_period_validates_bounds(self):
with self.assertRaises(ValueError):
Period(date(2026, 12, 31), date(2026, 1, 1), 'invalid')
@tagged('post_install', '-at_install')
class TestAccountHierarchy(TransactionCase):
def setUp(self):
super().setUp()
self.flat = [
{'id': 1, 'code': '1', 'name': 'Assets', 'account_type': 'asset_root', 'parent_id': None},
{'id': 2, 'code': '11', 'name': 'Cash', 'account_type': 'asset_cash', 'parent_id': 1},
{'id': 3, 'code': '12', 'name': 'AR', 'account_type': 'asset_receivable', 'parent_id': 1},
{'id': 4, 'code': '2', 'name': 'Liabilities', 'account_type': 'liability_root', 'parent_id': None},
{'id': 5, 'code': '21', 'name': 'AP', 'account_type': 'liability_payable', 'parent_id': 4},
]
def test_build_tree_returns_two_roots(self):
roots = build_tree(self.flat)
self.assertEqual(len(roots), 2)
def test_walk_yields_all_nodes(self):
roots = build_tree(self.flat)
ids = [n.id for n, _, _ in walk(roots)]
self.assertEqual(set(ids), {1, 2, 3, 4, 5})
def test_walk_depth_correct(self):
roots = build_tree(self.flat)
depths = {n.id: depth for n, depth, _ in walk(roots)}
self.assertEqual(depths[1], 0)
self.assertEqual(depths[2], 1)
self.assertEqual(depths[3], 1)
def test_filter_by_type_prefix(self):
roots = build_tree(self.flat)
assets = filter_by_account_type(roots, 'asset_')
self.assertEqual(len(assets), 3)
@tagged('post_install', '-at_install')
class TestTotaling(TransactionCase):
def test_aggregate_empty(self):
result = aggregate([])
self.assertEqual(result.debit, 0.0)
self.assertEqual(result.line_count, 0)
def test_aggregate_simple(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
{'debit': 0, 'credit': 50, 'balance': -50, 'account_id': 1},
]
result = aggregate(lines)
self.assertEqual(result.debit, 100)
self.assertEqual(result.credit, 50)
self.assertEqual(result.balance, 50)
def test_aggregate_per_account_groups_correctly(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
{'debit': 50, 'credit': 0, 'balance': 50, 'account_id': 1},
{'debit': 0, 'credit': 25, 'balance': -25, 'account_id': 2},
]
result = aggregate_per_account(lines)
self.assertEqual(result[1].debit, 150)
self.assertEqual(result[2].credit, 25)
def test_is_balanced_true(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100},
{'debit': 0, 'credit': 100, 'balance': -100},
]
self.assertTrue(is_balanced(lines))
def test_is_balanced_false(self):
lines = [
{'debit': 100, 'credit': 0, 'balance': 100},
{'debit': 0, 'credit': 50, 'balance': -50},
]
self.assertFalse(is_balanced(lines))