# Copyright (c) 2026 Patrick Motsch # All rights reserved. """Unit tests for the local-fallback cumulative balance computation in ``AccountingDataSync._buildLocalBalanceFallback`` and the connector handoff in ``_persistBalances``. These tests exercise pure-logic paths -- no DB, no HTTP. We pass a ``FakeInterface`` that records the bulk-create rows so we can inspect what would have been written to ``TrusteeDataAccountBalance``. """ from datetime import datetime, timezone from typing import Any, Dict, List, Type from unittest.mock import MagicMock import pytest def _ts(isoDate: str) -> float: """Convert ``YYYY-MM-DD`` to UTC midnight unix timestamp for test fixtures.""" return datetime.strptime(isoDate, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() from modules.features.trustee.accounting.accountingConnectorBase import AccountingPeriodBalance from modules.features.trustee.accounting.accountingDataSync import ( AccountingDataSync, _isIncomeStatementAccount, _resolveBalanceYears, ) class _FakeDb: """Minimal db stub: records the rows handed to ``recordCreateBulk`` and returns canned recordsets for ``getRecordset``.""" def __init__(self, entries: List[Dict[str, Any]], lines: List[Dict[str, Any]]): self._entries = entries self._lines = lines self.createdRows: List[Dict[str, Any]] = [] def getRecordset(self, model, recordFilter=None): name = model.__name__ if "Entry" in name and "Line" not in name: return list(self._entries) if "Line" in name: return list(self._lines) return [] def recordDeleteWhere(self, model, where): return 0 def recordCreateBulk(self, model, rows): self.createdRows.extend(rows) return len(rows) def recordModify(self, model, recordId, payload): return None def recordCreate(self, model, row): self.createdRows.append(row) return row.get("id", "x") def recordDelete(self, model, rid): return 1 class _FakeInterface: def __init__(self, db): self.db = db class _FakeJournalEntry: pass class _FakeJournalLine: pass class _FakeBalance: pass class TestResolveBalanceYears: def test_singleYearWindow(self): assert _resolveBalanceYears("2025-01-01", "2025-12-31", None, None) == [2025] def test_multiYearWindow(self): assert _resolveBalanceYears("2024-01-01", "2026-12-31", None, None) == [2024, 2025, 2026] def test_fallsBackToObservedDates(self): assert _resolveBalanceYears(None, None, "2024-03-15", "2025-08-01") == [2024, 2025] def test_invertedWindowIsCorrected(self): assert _resolveBalanceYears("2026-12-31", "2024-01-01", None, None) == [2024, 2025, 2026] class TestIsIncomeStatementAccount: @pytest.mark.parametrize("accno,expected", [ ("1020", False), ("2010", False), ("3000", True), ("8500", True), ]) def test_classification(self, accno, expected): assert _isIncomeStatementAccount(accno) == expected class TestPersistBalancesConnectorPath: def test_connectorOutputPersistedVerbatim(self): db = _FakeDb([], []) sync = AccountingDataSync(_FakeInterface(db)) connectorRows = [ AccountingPeriodBalance( accountNumber="1020", periodYear=2025, periodMonth=12, openingBalance=47100.00, debitTotal=159374.89, creditTotal=79939.86, closingBalance=48507.41, currency="CHF", ), ] n = sync._persistBalances( "fi-1", "m-1", _FakeJournalEntry, _FakeJournalLine, _FakeBalance, connectorRows, "connector", ) assert n == 1 row = db.createdRows[0] assert row["accountNumber"] == "1020" assert row["closingBalance"] == 48507.41 assert row["openingBalance"] == 47100.00 assert row["featureInstanceId"] == "fi-1" assert row["mandateId"] == "m-1" def test_connectorBalancesEnrichedWithJournalMovements(self): """When connector provides closingBalance but no debit/credit (e.g. RMA /gl/saldo), the sync should enrich from journal lines.""" entries = [ {"id": "e1", "bookingDate": _ts("2025-06-15")}, {"id": "e2", "bookingDate": _ts("2025-06-20")}, ] lines = [ {"journalEntryId": "e1", "accountNumber": "1020", "debitAmount": 500.0, "creditAmount": 0.0}, {"journalEntryId": "e2", "accountNumber": "1020", "debitAmount": 0.0, "creditAmount": 200.0}, ] db = _FakeDb(entries, lines) sync = AccountingDataSync(_FakeInterface(db)) connectorRows = [ AccountingPeriodBalance( accountNumber="1020", periodYear=2025, periodMonth=6, openingBalance=10000.0, closingBalance=10300.0, currency="CHF", ), AccountingPeriodBalance( accountNumber="1020", periodYear=2025, periodMonth=0, openingBalance=10000.0, closingBalance=10300.0, currency="CHF", ), ] sync._persistBalances( "fi-1", "m-1", _FakeJournalEntry, _FakeJournalLine, _FakeBalance, connectorRows, "connector", ) byPeriod = {(r["accountNumber"], r["periodMonth"]): r for r in db.createdRows} assert byPeriod[("1020", 6)]["closingBalance"] == 10300.0 assert byPeriod[("1020", 6)]["debitTotal"] == 500.0 assert byPeriod[("1020", 6)]["creditTotal"] == 200.0 assert byPeriod[("1020", 0)]["debitTotal"] == 500.0 assert byPeriod[("1020", 0)]["creditTotal"] == 200.0 class TestLocalFallbackCumulative: """Replicates the BuHa SoHa scenario WITHOUT prior-year journal data: the local fallback can't recreate the prior-year carry-over (by design), but the cumulation across months within the imported window must be correct -- previously the closingBalance was just the per-period net movement. """ def test_balanceSheetAccount_cumulatesAcrossMonths(self): entries = [ {"id": "e1", "bookingDate": _ts("2025-01-15")}, {"id": "e2", "bookingDate": _ts("2025-02-10")}, {"id": "e3", "bookingDate": _ts("2025-12-20")}, ] lines = [ {"journalEntryId": "e1", "accountNumber": "1020", "debitAmount": 1000.0, "creditAmount": 0.0}, {"journalEntryId": "e2", "accountNumber": "1020", "debitAmount": 0.0, "creditAmount": 300.0}, {"journalEntryId": "e3", "accountNumber": "1020", "debitAmount": 500.0, "creditAmount": 0.0}, ] db = _FakeDb(entries, lines) sync = AccountingDataSync(_FakeInterface(db)) n = sync._persistBalances( "fi-1", "m-1", _FakeJournalEntry, _FakeJournalLine, _FakeBalance, [], "local-fallback", ) assert n > 0 byPeriod = {(r["accountNumber"], r["periodYear"], r["periodMonth"]): r for r in db.createdRows} assert byPeriod[("1020", 2025, 1)]["closingBalance"] == 1000.0 assert byPeriod[("1020", 2025, 2)]["closingBalance"] == 700.0 # December: previous closing (700) + 500 - 0 = 1200 assert byPeriod[("1020", 2025, 12)]["closingBalance"] == 1200.0 assert byPeriod[("1020", 2025, 0)]["closingBalance"] == 1200.0 assert byPeriod[("1020", 2025, 2)]["openingBalance"] == 1000.0 def test_incomeStatementAccount_resetsAtFiscalYearStart(self): entries = [ {"id": "e1", "bookingDate": _ts("2024-12-31")}, {"id": "e2", "bookingDate": _ts("2025-06-15")}, {"id": "e3", "bookingDate": _ts("2025-07-10")}, ] lines = [ {"journalEntryId": "e1", "accountNumber": "6000", "debitAmount": 99999.99, "creditAmount": 0.0}, {"journalEntryId": "e2", "accountNumber": "6000", "debitAmount": 250.0, "creditAmount": 0.0}, {"journalEntryId": "e3", "accountNumber": "6000", "debitAmount": 100.0, "creditAmount": 0.0}, ] db = _FakeDb(entries, lines) sync = AccountingDataSync(_FakeInterface(db)) sync._persistBalances( "fi-1", "m-1", _FakeJournalEntry, _FakeJournalLine, _FakeBalance, [], "local-fallback", ) byPeriod = {(r["accountNumber"], r["periodYear"], r["periodMonth"]): r for r in db.createdRows} # ER account 6000 must NOT carry 2024's 99999.99 into 2025 # 2024 year summary closes at 99999.99, but 2025 opening must be 0 assert byPeriod[("6000", 2024, 0)]["closingBalance"] == 99999.99 assert byPeriod[("6000", 2025, 0)]["openingBalance"] == 0.0 # June 2025: first activity in fiscal year, opening is the reset 0 assert byPeriod[("6000", 2025, 6)]["openingBalance"] == 0.0 assert byPeriod[("6000", 2025, 6)]["closingBalance"] == 250.0 # July 2025: opening is June's closing (cumulation within same fiscal year) assert byPeriod[("6000", 2025, 7)]["openingBalance"] == 250.0 assert byPeriod[("6000", 2025, 7)]["closingBalance"] == 350.0 # 2025 year summary: 250 + 100 = 350 assert byPeriod[("6000", 2025, 0)]["closingBalance"] == 350.0 assert byPeriod[("6000", 2025, 0)]["debitTotal"] == 350.0