fund: Add outstanding balances to text fund report.

This commit is contained in:
Brett Smith 2020-06-27 16:10:27 -04:00
parent b1a46d6ef6
commit 9ae974009b
2 changed files with 60 additions and 33 deletions

View file

@ -80,7 +80,13 @@ from .. import data
AccountsMap = Mapping[data.Account, core.PeriodPostings] AccountsMap = Mapping[data.Account, core.PeriodPostings]
FundPosts = Tuple[MetaValue, AccountsMap] 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' PROGNAME = 'fund-report'
UNRESTRICTED_FUND = 'Conservancy' UNRESTRICTED_FUND = 'Conservancy'
logger = logging.getLogger('conservancy_beancount.reports.fund') logger = logging.getLogger('conservancy_beancount.reports.fund')
@ -170,12 +176,17 @@ class TextReport:
account_map: AccountsMap, account_map: AccountsMap,
) -> Iterator[Tuple[str, Sequence[str]]]: ) -> Iterator[Tuple[str, Sequence[str]]]:
total_fmt = f'{fund} balance as of {{}}' 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: if acct_s is core.OPENING_BALANCE_NAME:
acct_s = total_fmt.format(self.start_date.isoformat()) acct_s = total_fmt.format(self.start_date.isoformat())
elif acct_s is core.ENDING_BALANCE_NAME: elif acct_s is core.ENDING_BALANCE_NAME:
acct_s = total_fmt.format(self.stop_date.isoformat()) acct_s = total_fmt.format(self.stop_date.isoformat())
yield acct_s, (-balance).format(None, sep='\0').split('\0') 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: def write(self, rows: Iterable[FundPosts]) -> None:
output = [ output = [
@ -319,7 +330,6 @@ def main(arglist: Optional[Sequence[str]]=None,
post post
for post in data.Posting.from_entries(entries) for post in data.Posting.from_entries(entries)
if post.meta.date < args.stop_date if post.meta.date < args.stop_date
and post.account.is_under(*BALANCE_ACCOUNTS)
) )
for search_term in args.search_terms: for search_term in args.search_terms:
postings = search_term.filter_postings(postings) postings = search_term.filter_postings(postings)

View file

@ -40,6 +40,8 @@ START_DATE = datetime.date(2018, 3, 1)
MID_DATE = datetime.date(2019, 3, 1) MID_DATE = datetime.date(2019, 3, 1)
STOP_DATE = datetime.date(2020, 3, 1) STOP_DATE = datetime.date(2020, 3, 1)
EQUITY_ROOT_ACCOUNTS = ('Expenses:', 'Equity:', 'Income:')
OPENING_BALANCES = { OPENING_BALANCES = {
'Alpha': 3000, 'Alpha': 3000,
'Bravo': 2000, 'Bravo': 2000,
@ -51,18 +53,26 @@ BALANCES_BY_YEAR = {
('Conservancy', 2018): [ ('Conservancy', 2018): [
('Income:Other', 40), ('Income:Other', 40),
('Expenses:Other', -4), ('Expenses:Other', -4),
('Assets:Receivable:Accounts', 40),
('Liabilities:Payable:Accounts', 4),
], ],
('Conservancy', 2019): [ ('Conservancy', 2019): [
('Income:Other', 42), ('Income:Other', 42),
('Expenses:Other', Decimal('-4.20')), ('Expenses:Other', Decimal('-4.20')),
('Equity:Realized:CurrencyConversion', Decimal('6.20')), ('Equity:Realized:CurrencyConversion', Decimal('6.20')),
('Assets:Receivable:Accounts', -40),
('Liabilities:Payable:Accounts', -4),
], ],
('Alpha', 2018): [ ('Alpha', 2018): [
('Income:Other', 60), ('Income:Other', 60),
('Liabilities:UnearnedIncome', 30),
('Assets:Prepaid:Expenses', 20),
], ],
('Alpha', 2019): [ ('Alpha', 2019): [
('Income:Other', 30), ('Income:Other', 30),
('Expenses:Other', -26), ('Expenses:Other', -26),
('Assets:Prepaid:Expenses', -20),
('Liabilities:UnearnedIncome', -30),
], ],
('Bravo', 2018): [ ('Bravo', 2018): [
('Expenses:Other', -20), ('Expenses:Other', -20),
@ -76,14 +86,6 @@ BALANCES_BY_YEAR = {
def fund_entries(): def fund_entries():
return copy.deepcopy(_ledger_load[0]) 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): def split_text_lines(output):
for line in output: for line in output:
account, amount = line.rsplit(None, 1) account, amount = line.rsplit(None, 1)
@ -94,6 +96,17 @@ def format_amount(amount, currency='USD'):
amount, currency, format_type='accounting', 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): def check_text_report(output, project, start_date, stop_date):
_, _, project = project.rpartition('=') _, _, project = project.rpartition('=')
balance_amount = Decimal(OPENING_BALANCES[project]) balance_amount = Decimal(OPENING_BALANCES[project])
@ -105,11 +118,10 @@ def check_text_report(output, project, start_date, stop_date):
pass pass
else: else:
for account, amount in amounts: for account, amount in amounts:
if year < start_date.year: if year < start_date.year and account.startswith(EQUITY_ROOT_ACCOUNTS):
balance_amount += amount balance_amount += amount
else: else:
expected[account] += amount expected[account] += amount
expected.default_factory = None
actual = split_text_lines(output) actual = split_text_lines(output)
next(actual); next(actual) # Discard headers next(actual); next(actual) # Discard headers
open_acct, open_amt = next(actual) open_acct, open_amt = next(actual)
@ -117,25 +129,24 @@ def check_text_report(output, project, start_date, stop_date):
project, start_date.isoformat(), project, start_date.isoformat(),
) )
assert open_amt == format_amount(balance_amount) assert open_amt == format_amount(balance_amount)
for expect_account in [ balance_amount += check_text_balances(
'Equity:Realized:CurrencyConversion', actual, expected,
'Income:Other', 'Equity:Realized:CurrencyConversion',
'Expenses:Other', '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
end_acct, end_amt = next(actual) end_acct, end_amt = next(actual)
assert end_acct == "{} balance as of {}".format( assert end_acct == "{} balance as of {}".format(
project, stop_date.isoformat(), project, stop_date.isoformat(),
) )
assert end_amt == format_amount(balance_amount) 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 assert next(actual, None) is None
def check_ods_report(ods, start_date, stop_date): 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), 'opening': Decimal(amount),
'Income': Decimal(0), 'Income': Decimal(0),
'Expenses': 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 key, amount in sorted(OPENING_BALANCES.items()))
for fund, year in itertools.product(account_bals, range(2018, stop_date.year)): for fund, year in itertools.product(account_bals, range(2018, stop_date.year)):
try: try:
@ -152,10 +167,10 @@ def check_ods_report(ods, start_date, stop_date):
pass pass
else: else:
for account, amount in amounts: for account, amount in amounts:
if year < start_date.year: if year < start_date.year and account.startswith(EQUITY_ROOT_ACCOUNTS):
acct_key = 'opening' acct_key = 'opening'
else: else:
acct_key, _, _ = account.partition(':') acct_key, _, _ = account.rpartition(':')
account_bals[fund][acct_key] += amount account_bals[fund][acct_key] += amount
account_bals['Unrestricted'] = account_bals.pop('Conservancy') account_bals['Unrestricted'] = account_bals.pop('Conservancy')
for row in ods.getElementsByType(odf.table.TableRow): 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['opening']
assert next(cells).value == balances['Income'] assert next(cells).value == balances['Income']
assert next(cells).value == -balances['Expenses'] assert next(cells).value == -balances['Expenses']
if balances['Equity']: if balances['Equity:Realized']:
assert next(cells).value == balances['Equity'] assert next(cells).value == balances['Equity:Realized']
else: else:
assert not next(cells).value 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" assert not account_bals, "did not see all funds in report"
def run_main(out_type, arglist, config=None): def run_main(out_type, arglist, config=None):