Changeset - 0b3eb1d1d377
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-06-05 13:10:48
brettcsmith@brettcsmith.org
accrual: Inconsistent entity is not an error.
3 files changed with 85 insertions and 14 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -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='<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']]:
 
        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)
tests/books/accruals.beancount
Show inline comments
...
 
@@ -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"
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -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):
0 comments (0 inline, 0 general)