core: Balances lets the caller specify posting metadata to load.

This commit is contained in:
Brett Smith 2020-10-16 11:56:38 -04:00
parent ffc20b6899
commit 5e147dc0b5
3 changed files with 59 additions and 28 deletions

View file

@ -299,10 +299,10 @@ class Report(core.BaseODS[Sequence[None], None]):
("Fundraising", 'fundraising'), ("Fundraising", 'fundraising'),
]: ]:
period_bal = self.balances.total( period_bal = self.balances.total(
account='Expenses', period=Period.PERIOD, post_type=type_value, account='Expenses', period=Period.PERIOD, post_meta=type_value,
) )
prior_bal = self.balances.total( prior_bal = self.balances.total(
account='Expenses', period=Period.PRIOR, post_type=type_value, account='Expenses', period=Period.PRIOR, post_meta=type_value,
) )
self.write_totals_row(text, [ self.write_totals_row(text, [
period_bal, period_bal,
@ -359,9 +359,9 @@ class Report(core.BaseODS[Sequence[None], None]):
totals_prefix=[f"Total {self.period_desc} Ended"], totals_prefix=[f"Total {self.period_desc} Ended"],
) )
totals = self.write_classifications_by_account('Expenses', [ totals = self.write_classifications_by_account('Expenses', [
{'period': Period.PERIOD, 'post_type': 'program'}, {'period': Period.PERIOD, 'post_meta': 'program'},
{'period': Period.PERIOD, 'post_type': 'management'}, {'period': Period.PERIOD, 'post_meta': 'management'},
{'period': Period.PERIOD, 'post_type': 'fundraising'}, {'period': Period.PERIOD, 'post_meta': 'fundraising'},
{'period': Period.PERIOD}, {'period': Period.PERIOD},
{'period': Period.PRIOR}, {'period': Period.PRIOR},
]) ])
@ -562,6 +562,7 @@ def main(arglist: Optional[Sequence[str]]=None,
postings, postings,
args.start_date, args.start_date,
args.stop_date, args.stop_date,
'expense-type',
args.fund_metadata_key, args.fund_metadata_key,
args.unrestricted_fund, args.unrestricted_fund,
) )

View file

@ -298,7 +298,7 @@ class BalanceKey(NamedTuple):
classification: data.Account classification: data.Account
period: Period period: Period
fund: Fund fund: Fund
post_type: Optional[str] post_meta: Optional[str]
class Balances: class Balances:
@ -316,7 +316,8 @@ class Balances:
postings: Iterable[data.Posting], postings: Iterable[data.Posting],
start_date: datetime.date, start_date: datetime.date,
stop_date: datetime.date, stop_date: datetime.date,
fund_key: str='project', post_meta_key: MetaKey,
fund_key: MetaKey='project',
unrestricted_fund_value: str='Conservancy', unrestricted_fund_value: str='Conservancy',
) -> None: ) -> None:
year_diff = (stop_date - start_date).days // 365 year_diff = (stop_date - start_date).days // 365
@ -360,11 +361,8 @@ class Balances:
raise TypeError() raise TypeError()
except (KeyError, TypeError): except (KeyError, TypeError):
classification = post.account classification = post.account
if post.account.root_part() == 'Expenses': post_meta = post.meta.get(post_meta_key)
post_type = post.meta.get('expense-type') key = BalanceKey(post.account, classification, period, fund, post_meta)
else:
post_type = None
key = BalanceKey(post.account, classification, period, fund, post_type)
self.balances[key] += post.at_cost() self.balances[key] += post.at_cost()
def total(self, def total(self,
@ -372,7 +370,7 @@ class Balances:
classification: Optional[str]=None, classification: Optional[str]=None,
period: int=Period.ANY, period: int=Period.ANY,
fund: int=Fund.ANY, fund: int=Fund.ANY,
post_type: Optional[str]=None, post_meta: Optional[str]=None,
*, *,
account_exact: bool=False, account_exact: bool=False,
) -> Balance: ) -> Balance:
@ -383,7 +381,7 @@ class Balances:
pass ``account_exact=True``, the postings must have exactly the pass ``account_exact=True``, the postings must have exactly the
``account`` you specify instead. ``account`` you specify instead.
Given ``period``, ``fund``, or ``post_type`` criteria, limits to Given ``period``, ``fund``, or ``post_meta`` criteria, limits to
reporting the balance of postings that match that reporting period, reporting the balance of postings that match that reporting period,
fund type, or metadata value, respectively. fund type, or metadata value, respectively.
""" """
@ -411,7 +409,7 @@ class Balances:
pass pass
elif not fund & key.fund: elif not fund & key.fund:
pass pass
elif not (post_type is None or post_type == key.post_type): elif not (post_meta is None or post_meta == key.post_meta):
pass pass
else: else:
retval += balance retval += balance

View file

@ -32,11 +32,11 @@ Period = core.Period
clean_account_meta = pytest.fixture(scope='module')(testutil.clean_account_meta) clean_account_meta = pytest.fixture(scope='module')(testutil.clean_account_meta)
@pytest.fixture(scope='module') @pytest.fixture(scope='module')
def income_expense_balances(): def income_expense_entries():
txns = [] txns = []
prior_date = datetime.date(2019, 2, 2) prior_date = datetime.date(2019, 2, 2)
period_date = datetime.date(2019, 4, 4) period_date = datetime.date(2019, 4, 4)
for (acct, post_type), fund in itertools.product([ for (acct, post_meta), fund in itertools.product([
('Income:Donations', 'Donations'), ('Income:Donations', 'Donations'),
('Income:Sales', 'RBI'), ('Income:Sales', 'RBI'),
('Expenses:Postage', 'fundraising'), ('Expenses:Postage', 'fundraising'),
@ -56,7 +56,7 @@ def income_expense_balances():
)) ))
meta = { meta = {
'project': fund, 'project': fund,
f'{root_acct.lower().rstrip("s")}-type': post_type, f'{root_acct.lower().rstrip("s")}-type': post_meta,
} }
sign = '' if root_acct == 'Expenses' else '-' sign = '' if root_acct == 'Expenses' else '-'
txns.append(testutil.Transaction(date=prior_date, postings=[ txns.append(testutil.Transaction(date=prior_date, postings=[
@ -65,10 +65,24 @@ def income_expense_balances():
txns.append(testutil.Transaction(date=period_date, postings=[ txns.append(testutil.Transaction(date=period_date, postings=[
(acct, f'{sign}2.60', meta), (acct, f'{sign}2.60', meta),
])) ]))
return txns
@pytest.fixture(scope='module')
def expense_balances(income_expense_entries):
return core.Balances( return core.Balances(
data.Posting.from_entries(txns), data.Posting.from_entries(income_expense_entries),
datetime.date(2019, 3, 1), datetime.date(2019, 3, 1),
datetime.date(2020, 3, 1), datetime.date(2020, 3, 1),
'expense-type',
)
@pytest.fixture(scope='module')
def income_balances(income_expense_entries):
return core.Balances(
data.Posting.from_entries(income_expense_entries),
datetime.date(2019, 3, 1),
datetime.date(2020, 3, 1),
'income-type',
) )
@pytest.mark.parametrize('kwargs,expected', [ @pytest.mark.parametrize('kwargs,expected', [
@ -82,18 +96,36 @@ def income_expense_balances():
({'period': Period.PERIOD, 'account': 'Expenses'}, 26), ({'period': Period.PERIOD, 'account': 'Expenses'}, 26),
({'fund': Fund.RESTRICTED, 'account': 'Income'}, -10), ({'fund': Fund.RESTRICTED, 'account': 'Income'}, -10),
({'fund': Fund.UNRESTRICTED, 'account': 'Expenses'}, 25), ({'fund': Fund.UNRESTRICTED, 'account': 'Expenses'}, 25),
({'post_type': 'fundraising'}, 20), ({'post_meta': 'fundraising'}, 20),
({'post_type': 'management'}, 10), ({'post_meta': 'management'}, 10),
({'post_type': 'Nonexistent'}, None), ({'post_meta': 'Donations'}, None),
({'period': Period.PRIOR, 'post_type': 'fundraising'}, '9.60'), ({'post_meta': 'RBI'}, None),
({'fund': Fund.RESTRICTED, 'post_type': 'program'}, 10), ({'period': Period.PRIOR, 'post_meta': 'fundraising'}, '9.60'),
({'period': Period.PRIOR, 'fund': Fund.RESTRICTED, 'post_type': 'program'}, '4.80'), ({'fund': Fund.RESTRICTED, 'post_meta': 'program'}, 10),
({'period': Period.PERIOD, 'fund': Fund.RESTRICTED, 'post_type': 'ø'}, None), ({'period': Period.PRIOR, 'fund': Fund.RESTRICTED, 'post_meta': 'program'}, '4.80'),
({'period': Period.PERIOD, 'fund': Fund.RESTRICTED, 'post_meta': 'ø'}, None),
({'account': ('Income', 'Expenses')}, 30), ({'account': ('Income', 'Expenses')}, 30),
({'account': ('Income', 'Expenses'), 'fund': Fund.UNRESTRICTED}, 15), ({'account': ('Income', 'Expenses'), 'fund': Fund.UNRESTRICTED}, 15),
]) ])
def test_balance_total(income_expense_balances, kwargs, expected): def test_expense_balance_total(expense_balances, kwargs, expected):
actual = income_expense_balances.total(**kwargs) actual = expense_balances.total(**kwargs)
if expected is None:
assert not actual
else:
assert actual == {'USD': testutil.Amount(expected)}
@pytest.mark.parametrize('kwargs,expected', [
({'post_meta': 'fundraising'}, None),
({'post_meta': 'management'}, None),
({'post_meta': 'Donations'}, -10),
({'post_meta': 'RBI'}, -10),
({'period': Period.PRIOR, 'post_meta': 'Donations'}, '-4.80'),
({'fund': Fund.RESTRICTED, 'post_meta': 'RBI'}, -5),
({'period': Period.PRIOR, 'fund': Fund.RESTRICTED, 'post_meta': 'Donations'}, '-2.40'),
({'period': Period.PERIOD, 'fund': Fund.RESTRICTED, 'post_meta': 'ø'}, None),
])
def test_income_balance_total(income_balances, kwargs, expected):
actual = income_balances.total(**kwargs)
if expected is None: if expected is None:
assert not actual assert not actual
else: else: