From 9ae974009b13453dae0a6745b30487e8ca5e626e Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sat, 27 Jun 2020 16:10:27 -0400 Subject: [PATCH] fund: Add outstanding balances to text fund report. --- conservancy_beancount/reports/fund.py | 16 ++++-- tests/test_reports_fund.py | 77 ++++++++++++++++----------- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/conservancy_beancount/reports/fund.py b/conservancy_beancount/reports/fund.py index f55e2ab..1b1f168 100644 --- a/conservancy_beancount/reports/fund.py +++ b/conservancy_beancount/reports/fund.py @@ -80,7 +80,13 @@ from .. import data AccountsMap = Mapping[data.Account, core.PeriodPostings] FundPosts = Tuple[MetaValue, AccountsMap] -BALANCE_ACCOUNTS = ['Equity', 'Income', 'Expenses'] +EQUITY_ACCOUNTS = ['Equity', 'Income', 'Expenses'] +INFO_ACCOUNTS = [ + 'Assets:Receivable', + 'Assets:Prepaid', + 'Liabilities:Payable', + 'Liabilities:UnearnedIncome', +] PROGNAME = 'fund-report' UNRESTRICTED_FUND = 'Conservancy' logger = logging.getLogger('conservancy_beancount.reports.fund') @@ -170,12 +176,17 @@ class TextReport: account_map: AccountsMap, ) -> Iterator[Tuple[str, Sequence[str]]]: total_fmt = f'{fund} balance as of {{}}' - for acct_s, balance in core.account_balances(account_map, BALANCE_ACCOUNTS): + for acct_s, balance in core.account_balances(account_map, EQUITY_ACCOUNTS): if acct_s is core.OPENING_BALANCE_NAME: acct_s = total_fmt.format(self.start_date.isoformat()) elif acct_s is core.ENDING_BALANCE_NAME: acct_s = total_fmt.format(self.stop_date.isoformat()) yield acct_s, (-balance).format(None, sep='\0').split('\0') + for _, account in core.sort_and_filter_accounts(account_map, INFO_ACCOUNTS): + balance = account_map[account].stop_bal + if not balance.is_zero(): + balance = core.normalize_amount_func(account)(balance) + yield account, balance.format(None, sep='\0').split('\0') def write(self, rows: Iterable[FundPosts]) -> None: output = [ @@ -319,7 +330,6 @@ def main(arglist: Optional[Sequence[str]]=None, post for post in data.Posting.from_entries(entries) if post.meta.date < args.stop_date - and post.account.is_under(*BALANCE_ACCOUNTS) ) for search_term in args.search_terms: postings = search_term.filter_postings(postings) diff --git a/tests/test_reports_fund.py b/tests/test_reports_fund.py index da2ac89..41691a3 100644 --- a/tests/test_reports_fund.py +++ b/tests/test_reports_fund.py @@ -40,6 +40,8 @@ START_DATE = datetime.date(2018, 3, 1) MID_DATE = datetime.date(2019, 3, 1) STOP_DATE = datetime.date(2020, 3, 1) +EQUITY_ROOT_ACCOUNTS = ('Expenses:', 'Equity:', 'Income:') + OPENING_BALANCES = { 'Alpha': 3000, 'Bravo': 2000, @@ -51,18 +53,26 @@ BALANCES_BY_YEAR = { ('Conservancy', 2018): [ ('Income:Other', 40), ('Expenses:Other', -4), + ('Assets:Receivable:Accounts', 40), + ('Liabilities:Payable:Accounts', 4), ], ('Conservancy', 2019): [ ('Income:Other', 42), ('Expenses:Other', Decimal('-4.20')), ('Equity:Realized:CurrencyConversion', Decimal('6.20')), + ('Assets:Receivable:Accounts', -40), + ('Liabilities:Payable:Accounts', -4), ], ('Alpha', 2018): [ ('Income:Other', 60), + ('Liabilities:UnearnedIncome', 30), + ('Assets:Prepaid:Expenses', 20), ], ('Alpha', 2019): [ ('Income:Other', 30), ('Expenses:Other', -26), + ('Assets:Prepaid:Expenses', -20), + ('Liabilities:UnearnedIncome', -30), ], ('Bravo', 2018): [ ('Expenses:Other', -20), @@ -76,14 +86,6 @@ BALANCES_BY_YEAR = { def fund_entries(): return copy.deepcopy(_ledger_load[0]) -def fund_postings(entries, project, stop_date): - return ( - post for post in data.Posting.from_entries(entries) - if post.meta.date < stop_date - and post.account.is_under('Equity', 'Income', 'Expenses') - and post.meta.get('project') == project - ) - def split_text_lines(output): for line in output: account, amount = line.rsplit(None, 1) @@ -94,6 +96,17 @@ def format_amount(amount, currency='USD'): amount, currency, format_type='accounting', ) +def check_text_balances(actual, expected, *expect_accounts): + balance = Decimal() + for expect_account in expect_accounts: + expect_amount = expected[expect_account] + if expect_amount: + actual_account, actual_amount = next(actual) + assert actual_account == expect_account + assert actual_amount == format_amount(expect_amount) + balance += expect_amount + return balance + def check_text_report(output, project, start_date, stop_date): _, _, project = project.rpartition('=') balance_amount = Decimal(OPENING_BALANCES[project]) @@ -105,11 +118,10 @@ def check_text_report(output, project, start_date, stop_date): pass else: for account, amount in amounts: - if year < start_date.year: + if year < start_date.year and account.startswith(EQUITY_ROOT_ACCOUNTS): balance_amount += amount else: expected[account] += amount - expected.default_factory = None actual = split_text_lines(output) next(actual); next(actual) # Discard headers open_acct, open_amt = next(actual) @@ -117,25 +129,24 @@ def check_text_report(output, project, start_date, stop_date): project, start_date.isoformat(), ) assert open_amt == format_amount(balance_amount) - for expect_account in [ - 'Equity:Realized:CurrencyConversion', - 'Income:Other', - 'Expenses:Other', - ]: - try: - expect_amount = expected[expect_account] - except KeyError: - continue - else: - actual_account, actual_amount = next(actual) - assert actual_account == expect_account - assert actual_amount == format_amount(expect_amount) - balance_amount += expect_amount + balance_amount += check_text_balances( + actual, expected, + 'Equity:Realized:CurrencyConversion', + 'Income:Other', + 'Expenses:Other', + ) end_acct, end_amt = next(actual) assert end_acct == "{} balance as of {}".format( project, stop_date.isoformat(), ) assert end_amt == format_amount(balance_amount) + balance_amount += check_text_balances( + actual, expected, + 'Assets:Receivable:Accounts', + 'Assets:Prepaid:Expenses', + 'Liabilities:Payable:Accounts', + 'Liabilities:UnearnedIncome', + ) assert next(actual, None) is None def check_ods_report(ods, start_date, stop_date): @@ -143,7 +154,11 @@ def check_ods_report(ods, start_date, stop_date): 'opening': Decimal(amount), 'Income': Decimal(0), 'Expenses': Decimal(0), - 'Equity': Decimal(0), + 'Equity:Realized': Decimal(0), + 'Assets:Receivable': Decimal(0), + 'Assets:Prepaid': Decimal(0), + 'Liabilities:Payable': Decimal(0), + 'Liabilities': Decimal(0), # UnearnedIncome }) for key, amount in sorted(OPENING_BALANCES.items())) for fund, year in itertools.product(account_bals, range(2018, stop_date.year)): try: @@ -152,10 +167,10 @@ def check_ods_report(ods, start_date, stop_date): pass else: for account, amount in amounts: - if year < start_date.year: + if year < start_date.year and account.startswith(EQUITY_ROOT_ACCOUNTS): acct_key = 'opening' else: - acct_key, _, _ = account.partition(':') + acct_key, _, _ = account.rpartition(':') account_bals[fund][acct_key] += amount account_bals['Unrestricted'] = account_bals.pop('Conservancy') for row in ods.getElementsByType(odf.table.TableRow): @@ -169,11 +184,13 @@ def check_ods_report(ods, start_date, stop_date): assert next(cells).value == balances['opening'] assert next(cells).value == balances['Income'] assert next(cells).value == -balances['Expenses'] - if balances['Equity']: - assert next(cells).value == balances['Equity'] + if balances['Equity:Realized']: + assert next(cells).value == balances['Equity:Realized'] else: assert not next(cells).value - assert next(cells).value == sum(balances.values()) + assert next(cells).value == sum(balances[key] for key in [ + 'opening', 'Income', 'Expenses', 'Equity:Realized', + ]) assert not account_bals, "did not see all funds in report" def run_main(out_type, arglist, config=None):