From 4eaba1ebf645e43c383168e0fdde0ddf2ba9bc49 Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Thu, 9 Apr 2020 15:11:16 -0400 Subject: [PATCH] data: Add is_opening_balance_txn function. --- conservancy_beancount/data.py | 12 ++++- tests/test_data_is_opening_balance_txn.py | 59 +++++++++++++++++++++++ tests/testutil.py | 18 +++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/test_data_is_opening_balance_txn.py diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index 7c016e0..2e0e217 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -21,7 +21,7 @@ throughout Conservancy tools. import collections import decimal -import operator +import functools from beancount.core import account as bc_account from beancount.core import amount as bc_amount @@ -281,6 +281,16 @@ def balance_of(txn: Transaction, currency = weights[0].currency return Amount._make((number, currency)) +@functools.lru_cache() +def is_opening_balance_txn(txn: Transaction) -> bool: + opening_equity = balance_of(txn, Account.is_opening_equity) + if not opening_equity.currency: + return False + rest = balance_of(txn, lambda acct: not acct.is_opening_equity()) + if not rest.currency: + return False + return abs(opening_equity.number + rest.number) < decimal.Decimal('.01') + def iter_postings(txn: Transaction) -> Iterator[Posting]: """Yield an enhanced Posting object for every posting in the transaction""" for index, source in enumerate(txn.postings): diff --git a/tests/test_data_is_opening_balance_txn.py b/tests/test_data_is_opening_balance_txn.py new file mode 100644 index 0000000..8d229d0 --- /dev/null +++ b/tests/test_data_is_opening_balance_txn.py @@ -0,0 +1,59 @@ +"""Test data.is_opening_balance_txn function""" +# Copyright © 2020 Brett Smith +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from decimal import Decimal + +import pytest + +from . import testutil + +from conservancy_beancount import data + +def test_typical_opening(): + txn = testutil.Transaction.opening_balance() + assert data.is_opening_balance_txn(txn) + +def test_multiacct_opening(): + txn = testutil.Transaction(postings=[ + ('Assets:Receivable:Accounts', 100), + (next(testutil.OPENING_EQUITY_ACCOUNTS), -100), + ('Liabilities:Payable:Accounts', -150), + (next(testutil.OPENING_EQUITY_ACCOUNTS), 150), + ]) + assert data.is_opening_balance_txn(txn) + +def test_opening_with_fx(): + txn = testutil.Transaction.opening_balance() + equity_post = txn.postings[-1] + txn.postings[-1] = equity_post._replace( + units=testutil.Amount(equity_post.units.number * Decimal('.9'), 'EUR'), + cost=testutil.Cost('1.11111'), + ) + assert data.is_opening_balance_txn(txn) + +@pytest.mark.parametrize('acct1,acct2,number', [ + ('Assets:Receivable:Accounts', 'Income:Donations', 100), + ('Expenses:Other', 'Liabilities:Payable:Accounts', 200), + ('Expenses:Other', 'Equity:Retained:Costs', 300), + # Release from restriction + ('Equity:Funds:Unrestricted', 'Equity:Funds:Restricted', 400), +]) +def test_not_opening_balance(acct1, acct2, number): + txn = testutil.Transaction(postings=[ + (acct1, number), + (acct2, -number), + ]) + assert not data.is_opening_balance_txn(txn) diff --git a/tests/testutil.py b/tests/testutil.py index c1b4580..87e68fc 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -107,6 +107,12 @@ NON_STRING_METADATA_VALUES = [ Amount(500, None), ] +OPENING_EQUITY_ACCOUNTS = itertools.cycle([ + 'Equity:Funds:Unrestricted', + 'Equity:Funds:Restricted', + 'Equity:OpeningBalance', +]) + class Transaction: def __init__(self, date=FY_MID_DATE, flag='*', payee=None, @@ -147,6 +153,18 @@ class Transaction: posting = arg self.postings.append(posting) + @classmethod + def opening_balance(cls, acct=None, **txn_meta): + if acct is None: + acct = next(OPENING_EQUITY_ACCOUNTS) + return cls(**txn_meta, postings=[ + ('Assets:Receivable:Accounts', 100), + ('Assets:Receivable:Loans', 200), + ('Liabilities:Payable:Accounts', -15), + ('Liabilities:Payable:Vacation', -25), + (acct, -260), + ]) + class TestConfig: def __init__(self, *,