accrual: Inconsistent entity is not an error.

This commit is contained in:
Brett Smith 2020-06-05 09:10:48 -04:00
parent 39fa977f71
commit 0b3eb1d1d3
3 changed files with 85 additions and 14 deletions

View file

@ -94,6 +94,7 @@ from typing import (
Set, Set,
TextIO, TextIO,
Tuple, Tuple,
TypeVar,
Union, Union,
) )
from ..beancount_types import ( from ..beancount_types import (
@ -121,6 +122,7 @@ from .. import rtutil
PROGNAME = 'accrual-report' PROGNAME = 'accrual-report'
STANDARD_PATH = Path('-') STANDARD_PATH = Path('-')
CompoundAmount = TypeVar('CompoundAmount', data.Amount, core.Balance)
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings'] PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
RTObject = Mapping[str, str] RTObject = Mapping[str, str]
@ -132,7 +134,7 @@ class Sentinel:
class Account(NamedTuple): class Account(NamedTuple):
name: str name: str
norm_func: Callable[[core.Balance], core.Balance] norm_func: Callable[[CompoundAmount], CompoundAmount]
aging_thresholds: Sequence[int] aging_thresholds: Sequence[int]
@ -175,21 +177,19 @@ class AccrualPostings(core.RelatedPostings):
_FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = { _FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = {
'account': operator.attrgetter('account'), 'account': operator.attrgetter('account'),
'contract': _meta_getter('contract'), 'contract': _meta_getter('contract'),
'entity': _meta_getter('entity'),
'invoice': _meta_getter('invoice'), 'invoice': _meta_getter('invoice'),
'purchase_order': _meta_getter('purchase-order'), 'purchase_order': _meta_getter('purchase-order'),
} }
INCONSISTENT = Sentinel() INCONSISTENT = Sentinel()
__slots__ = ( __slots__ = (
'accrual_type', 'accrual_type',
'accrued_entities',
'end_balance', 'end_balance',
'paid_entities',
'account', 'account',
'accounts', 'accounts',
'contract', 'contract',
'contracts', 'contracts',
'entity',
'entitys',
'entities',
'invoice', 'invoice',
'invoices', 'invoices',
'purchase_order', 'purchase_order',
@ -205,8 +205,6 @@ class AccrualPostings(core.RelatedPostings):
# The following type declarations tell mypy about values set in the for # The following type declarations tell mypy about values set in the for
# loop that are important enough to be referenced directly elsewhere. # loop that are important enough to be referenced directly elsewhere.
self.account: Union[data.Account, Sentinel] self.account: Union[data.Account, Sentinel]
self.entity: Union[MetaValue, Sentinel]
self.entitys: FrozenSet[MetaValue]
self.invoice: Union[MetaValue, Sentinel] self.invoice: Union[MetaValue, Sentinel]
for name, get_func in self._FIELDS.items(): for name, get_func in self._FIELDS.items():
values = frozenset(get_func(post) for post in self) values = frozenset(get_func(post) for post in self)
@ -216,14 +214,34 @@ class AccrualPostings(core.RelatedPostings):
else: else:
one_value = self.INCONSISTENT one_value = self.INCONSISTENT
setattr(self, name, one_value) 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: if self.account is self.INCONSISTENT:
self.accrual_type: Optional[AccrualAccount] = None self.accrual_type: Optional[AccrualAccount] = None
self.end_balance = self.balance_at_cost() self.end_balance = self.balance_at_cost()
self.accrued_entities = self._collect_entities()
self.paid_entities = self.accrued_entities
else: else:
self.accrual_type = AccrualAccount.classify(self) 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='<empty>',
) -> 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']]: def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]:
account_ok = isinstance(self.account, str) 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) amount_cell = self.balance_cell(raw_balance)
self.add_row( self.add_row(
self.date_cell(row[0].meta.date), self.date_cell(row[0].meta.date),
self.multiline_cell(row.entities), self.multiline_cell(row.entities()),
amount_cell, amount_cell,
self.balance_cell(row.end_balance), self.balance_cell(row.end_balance),
self.multilink_cell(self._link_seq(row, 'rt-id')), self.multilink_cell(self._link_seq(row, 'rt-id')),
@ -464,7 +482,7 @@ class AgingReport(BaseReport):
rows.sort(key=lambda related: ( rows.sort(key=lambda related: (
related.account, related.account,
related[0].meta.date, 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.write(rows)
self.ods.save_file(self.out_bin) self.ods.save_file(self.out_bin)

View file

@ -30,6 +30,27 @@
Liabilities:Payable:Accounts -75 USD Liabilities:Payable:Accounts -75 USD
Expenses:Travel 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" 2010-04-30 ! "Vendor" "Travel reimbursement"
rt-id: "rt:310" rt-id: "rt:310"
contract: "rt:310/3100" contract: "rt:310/3100"

View file

@ -63,7 +63,6 @@ ACCOUNTS = [
CONSISTENT_METADATA = [ CONSISTENT_METADATA = [
'contract', 'contract',
'entity',
'purchase-order', '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, attr_name) == meta_value
assert getattr(related, f'{attr_name}s') == {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(): def test_accrual_postings_inconsistent_account():
meta = {'invoice': 'invoice.pdf'} meta = {'invoice': 'invoice.pdf'}
txn = testutil.Transaction(postings=[ txn = testutil.Transaction(postings=[
@ -399,7 +431,7 @@ def test_consistency_check_when_consistent(meta_key, account):
assert not list(related.report_inconsistencies()) assert not list(related.report_inconsistencies())
@pytest.mark.parametrize('meta_key,account', testutil.combine_values( @pytest.mark.parametrize('meta_key,account', testutil.combine_values(
['approval', 'fx-rate', 'statement'], ['approval', 'entity', 'fx-rate', 'statement'],
ACCOUNTS, ACCOUNTS,
)) ))
def test_consistency_check_ignored_metadata(meta_key, account): def test_consistency_check_ignored_metadata(meta_key, account):