gateway/tests/unit/features/trustee/test_accountingDataSync_balances.py
2026-04-26 18:11:42 +02:00

241 lines
9.2 KiB
Python

# 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