conservancy_beancount/tests/test_opening_balances.py
Brett Smith cc1767a09d fund: Incorporate Equity accounts into Release from Restrictions.
This matches what we do on our Statement of Activities in the
balance sheet report.
2020-08-22 09:25:53 -04:00

152 lines
5.7 KiB
Python

"""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 <https://www.gnu.org/licenses/>.
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.40'),
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_RESTRICTED, '-.40', project='Delta'),
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, 10281),
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, -900, project='Charlie'),
FlatPosting.make(A_RESTRICTED, -5, project='Delta'),
FlatPosting.make(A_UNRESTRICTED, -4180, project='Conservancy'),
]