From 0b3eb1d1d377c83089bf36b5100e3ecf31dd509d 2020-06-05 13:10:48 From: Brett Smith Date: 2020-06-05 13:10:48 Subject: [PATCH] accrual: Inconsistent entity is not an error. --- diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index 6f77a255a7ade4bf97d4f7179c3b1926dbbe6482..38101ee6ef2887fd5b614efc8b639054e6c95499 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -94,6 +94,7 @@ from typing import ( Set, TextIO, Tuple, + TypeVar, Union, ) from ..beancount_types import ( @@ -121,6 +122,7 @@ from .. import rtutil PROGNAME = 'accrual-report' STANDARD_PATH = Path('-') +CompoundAmount = TypeVar('CompoundAmount', data.Amount, core.Balance) PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings'] RTObject = Mapping[str, str] @@ -132,7 +134,7 @@ class Sentinel: class Account(NamedTuple): name: str - norm_func: Callable[[core.Balance], core.Balance] + norm_func: Callable[[CompoundAmount], CompoundAmount] aging_thresholds: Sequence[int] @@ -175,21 +177,19 @@ class AccrualPostings(core.RelatedPostings): _FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = { 'account': operator.attrgetter('account'), 'contract': _meta_getter('contract'), - 'entity': _meta_getter('entity'), 'invoice': _meta_getter('invoice'), 'purchase_order': _meta_getter('purchase-order'), } INCONSISTENT = Sentinel() __slots__ = ( 'accrual_type', + 'accrued_entities', 'end_balance', + 'paid_entities', 'account', 'accounts', 'contract', 'contracts', - 'entity', - 'entitys', - 'entities', 'invoice', 'invoices', 'purchase_order', @@ -205,8 +205,6 @@ class AccrualPostings(core.RelatedPostings): # The following type declarations tell mypy about values set in the for # loop that are important enough to be referenced directly elsewhere. self.account: Union[data.Account, Sentinel] - self.entity: Union[MetaValue, Sentinel] - self.entitys: FrozenSet[MetaValue] self.invoice: Union[MetaValue, Sentinel] for name, get_func in self._FIELDS.items(): values = frozenset(get_func(post) for post in self) @@ -216,14 +214,34 @@ class AccrualPostings(core.RelatedPostings): else: one_value = self.INCONSISTENT setattr(self, name, one_value) - # Correct spelling = bug prevention for future users of this class. - self.entities = self.entitys if self.account is self.INCONSISTENT: self.accrual_type: Optional[AccrualAccount] = None self.end_balance = self.balance_at_cost() + self.accrued_entities = self._collect_entities() + self.paid_entities = self.accrued_entities else: self.accrual_type = AccrualAccount.classify(self) - self.end_balance = self.accrual_type.value.norm_func(self.balance_at_cost()) + norm_func = self.accrual_type.value.norm_func + self.end_balance = norm_func(self.balance_at_cost()) + self.accrued_entities = self._collect_entities( + lambda post: norm_func(post.units).number > 0, # type:ignore[no-any-return] + ) + self.paid_entities = self._collect_entities( + lambda post: norm_func(post.units).number < 0, # type:ignore[no-any-return] + ) + + def _collect_entities(self, + pred: Callable[[data.Posting], bool]=bool, + default: str='', + ) -> FrozenSet[MetaValue]: + return frozenset( + post.meta.get('entity') or default + for post in self if pred(post) + ) + + def entities(self) -> Iterator[MetaValue]: + yield from self.accrued_entities + yield from self.paid_entities.difference(self.accrued_entities) def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]: account_ok = isinstance(self.account, str) @@ -439,7 +457,7 @@ class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]): amount_cell = self.balance_cell(raw_balance) self.add_row( self.date_cell(row[0].meta.date), - self.multiline_cell(row.entities), + self.multiline_cell(row.entities()), amount_cell, self.balance_cell(row.end_balance), self.multilink_cell(self._link_seq(row, 'rt-id')), @@ -464,7 +482,7 @@ class AgingReport(BaseReport): rows.sort(key=lambda related: ( related.account, related[0].meta.date, - min(related.entities) if related.entities else '', + min(related.entities()) if related.accrued_entities else '', )) self.ods.write(rows) self.ods.save_file(self.out_bin) diff --git a/tests/books/accruals.beancount b/tests/books/accruals.beancount index 563d4da7be9de44f64cc7cbc0d692c48899492f8..9789f0c3fdd82b31c633c74666fa1e6b5c9a017b 100644 --- a/tests/books/accruals.beancount +++ b/tests/books/accruals.beancount @@ -30,6 +30,27 @@ Liabilities:Payable:Accounts -75 USD Expenses:Travel 75 USD +2010-04-15 * "Multiparty invoice" + rt-id: "rt:480" + invoice: "rt:480/4800" + Expenses:Travel 250 USD + Liabilities:Payable:Accounts -125 USD + entity: "MultiPartyA" + Liabilities:Payable:Accounts -125 USD + entity: "MultiPartyB" + +2010-04-18 * "MultiPartyA" "Payment for 480" + rt-id: "rt:480" + invoice: "rt:480/4800" + Liabilities:Payable:Accounts 125 USD + Assets:Checking -125 USD + +2010-04-20 * "MultiPartyB" "Payment for 480" + rt-id: "rt:480" + invoice: "rt:480/4800" + Liabilities:Payable:Accounts 125 USD + Assets:Checking -125 USD + 2010-04-30 ! "Vendor" "Travel reimbursement" rt-id: "rt:310" contract: "rt:310/3100" diff --git a/tests/test_reports_accrual.py b/tests/test_reports_accrual.py index e1ea4c65d4b09a06bd74df27bcdcc0b715595575..3a16dfc3105b9f5fb9a3bdca5dca02bcd4a71da2 100644 --- a/tests/test_reports_accrual.py +++ b/tests/test_reports_accrual.py @@ -63,7 +63,6 @@ ACCOUNTS = [ CONSISTENT_METADATA = [ 'contract', - 'entity', 'purchase-order', ] @@ -354,6 +353,39 @@ def test_accrual_postings_consistent_metadata(meta_key, acct_name): assert getattr(related, attr_name) == meta_value assert getattr(related, f'{attr_name}s') == {meta_value} +def test_accrual_postings_entity(): + txn = testutil.Transaction(postings=[ + (ACCOUNTS[0], 25, {'entity': 'Accruee'}), + (ACCOUNTS[0], -15, {'entity': 'Payee15'}), + (ACCOUNTS[0], -10, {'entity': 'Payee10'}), + ]) + related = accrual.AccrualPostings(data.Posting.from_txn(txn)) + assert related.accrued_entities == {'Accruee'} + assert related.paid_entities == {'Payee10', 'Payee15'} + +def test_accrual_postings_entities(): + txn = testutil.Transaction(postings=[ + (ACCOUNTS[0], 25, {'entity': 'Accruee'}), + (ACCOUNTS[0], -15, {'entity': 'Payee15'}), + (ACCOUNTS[0], -10, {'entity': 'Payee10'}), + ]) + related = accrual.AccrualPostings(data.Posting.from_txn(txn)) + actual = related.entities() + assert next(actual, None) == 'Accruee' + assert set(actual) == {'Payee10', 'Payee15'} + +def test_accrual_postings_entities_no_duplicates(): + txn = testutil.Transaction(postings=[ + (ACCOUNTS[0], 25, {'entity': 'Accruee'}), + (ACCOUNTS[0], -15, {'entity': 'Accruee'}), + (ACCOUNTS[0], -10, {'entity': 'Other'}), + ]) + related = accrual.AccrualPostings(data.Posting.from_txn(txn)) + actual = related.entities() + assert next(actual, None) == 'Accruee' + assert next(actual, None) == 'Other' + assert next(actual, None) is None + def test_accrual_postings_inconsistent_account(): meta = {'invoice': 'invoice.pdf'} txn = testutil.Transaction(postings=[ @@ -399,7 +431,7 @@ def test_consistency_check_when_consistent(meta_key, account): assert not list(related.report_inconsistencies()) @pytest.mark.parametrize('meta_key,account', testutil.combine_values( - ['approval', 'fx-rate', 'statement'], + ['approval', 'entity', 'fx-rate', 'statement'], ACCOUNTS, )) def test_consistency_check_ignored_metadata(meta_key, account):