diff --git a/tests/test_opening_balances.py b/tests/test_opening_balances.py new file mode 100644 index 0000000000000000000000000000000000000000..8e7b66f3e2dfeb8a23458d5ed64d1bdbf2dbc44a --- /dev/null +++ b/tests/test_opening_balances.py @@ -0,0 +1,150 @@ +"""test_tools_opening_balances.py - Unit tests for opening balance generation""" +# 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 . + +import collections +import copy +import datetime +import io + +import pytest + +from . import testutil + +from beancount import loader as bc_loader +from conservancy_beancount.tools import opening_balances as openbalmod + +from decimal import Decimal +from typing import NamedTuple, Optional + +A_CHECKING = 'Assets:Checking' +A_EUR = 'Assets:EUR' +A_PREPAID = 'Assets:Prepaid:Expenses' +A_RECEIVABLE = 'Assets:Receivable:Accounts' +A_RESTRICTED = 'Equity:Funds:Restricted' +A_UNRESTRICTED = 'Equity:Funds:Unrestricted' +A_CURRCONV = 'Equity:Realized:CurrencyConversion' +A_CREDITCARD = 'Liabilities:CreditCard' +A_PAYABLE = 'Liabilities:Payable:Accounts' +A_UNEARNED = 'Liabilities:UnearnedIncome' + +class FlatPosting(NamedTuple): + account: str + units_number: Decimal + units_currency: str + cost_number: Optional[Decimal] + cost_currency: Optional[str] + cost_date: Optional[datetime.date] + project: Optional[str] + + def __repr__(self): + cost_s = f' {{{self.cost_number} {self.cost_currency}, {self.cost_date}}}' + if cost_s == ' {None None, None}': + cost_s = '' + return f'<{self.account} {self.units_number} {self.units_currency}{cost_s}>' + + @classmethod + def make(cls, + account, + units_number, + units_currency='USD', + cost_number=None, + cost_date=None, + project=None, + cost_currency=None, + ): + if cost_number is not None: + cost_number = Decimal(cost_number) + if cost_currency is None: + cost_currency = 'USD' + if isinstance(cost_date, str): + cost_date = datetime.datetime.strptime(cost_date, '%Y-%m-%d').date() + return cls(account, Decimal(units_number), units_currency, + cost_number, cost_currency, cost_date, project) + + @classmethod + def from_beancount(cls, posting): + units_number, units_currency = posting.units + if posting.cost is None: + cost_number = cost_currency = cost_date = None + else: + cost_number, cost_currency, cost_date, _ = posting.cost + try: + project = posting.meta['project'] + except (AttributeError, KeyError): + project = None + return cls(posting.account, units_number, units_currency, + cost_number, cost_currency, cost_date, project) + + @classmethod + def from_output(cls, output): + entries, _, _ = bc_loader.load_string(output.read()) + return (cls.from_beancount(post) for post in entries[-1].postings) + + +def run_main(arglist, config=None): + if config is None: + config = testutil.TestConfig( + books_path=testutil.test_path('books/fund.beancount'), + ) + output = io.StringIO() + errors = io.StringIO() + retcode = openbalmod.main(arglist, output, errors, config) + output.seek(0) + return retcode, output, errors + +@pytest.mark.parametrize('arg', ['2018', '2018-03-01']) +def test_2018_opening(arg): + retcode, output, errors = run_main([arg]) + assert not errors.getvalue() + assert retcode == 0 + assert list(FlatPosting.from_output(output)) == [ + FlatPosting.make(A_CHECKING, 10000), + FlatPosting.make(A_RESTRICTED, -3000, project='Alpha'), + FlatPosting.make(A_RESTRICTED, -2000, project='Bravo'), + FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'), + FlatPosting.make(A_UNRESTRICTED, -4000, project='Conservancy'), + ] + +@pytest.mark.parametrize('arg', ['2019', '2019-03-01']) +def test_2019_opening(arg): + retcode, output, errors = run_main([arg]) + assert not errors.getvalue() + assert retcode == 0 + assert list(FlatPosting.from_output(output)) == [ + FlatPosting.make(A_CHECKING, 10050), + FlatPosting.make(A_PREPAID, 20, project='Alpha'), + FlatPosting.make(A_RECEIVABLE, 32, 'EUR', '1.25', '2018-03-03', 'Conservancy'), + FlatPosting.make(A_RESTRICTED, -3060, project='Alpha'), + FlatPosting.make(A_RESTRICTED, -1980, project='Bravo'), + FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'), + FlatPosting.make(A_UNRESTRICTED, -4036, project='Conservancy'), + FlatPosting.make(A_PAYABLE, -4, project='Conservancy'), + FlatPosting.make(A_UNEARNED, -30, project='Alpha'), + ] + +@pytest.mark.parametrize('arg', ['2020', '2020-12-31']) +def test_2020_opening(arg): + retcode, output, errors = run_main([arg]) + assert not errors.getvalue() + assert retcode == 0 + assert list(FlatPosting.from_output(output)) == [ + FlatPosting.make(A_CHECKING, 10276), + FlatPosting.make(A_EUR, 32, 'EUR', '1.5', '2019-03-03'), + FlatPosting.make(A_RESTRICTED, -3064, project='Alpha'), + FlatPosting.make(A_RESTRICTED, -2180, project='Bravo'), + FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'), + FlatPosting.make(A_UNRESTRICTED, -4080, project='Conservancy'), + ]