241 lines
9.2 KiB
Python
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
|