accrual: Remove the consistency checker.

Everything it said was a problem has been done legitimately in our books at
one point or another.

* Variation in contract can happen in different line items of an invoice or
  "group of contractor" situations.

* Variation in cost can happen because one invoice spans a period of time,
  like donation matching programs. There is probably still value in a tool
  that checks to make sure we use consistent rates each day, but that
  affects all kinds of transactions, not just accruals, so it would be
  done better in a separate tool.

* Variation in account happens because invoices legitimately span accrual
  accounts, like donation matching programs with fees payable.

So: it's gone, good riddance.
This commit is contained in:
Brett Smith 2020-06-10 14:01:12 -04:00
parent 8250f0a8ef
commit 5859421a15
3 changed files with 54 additions and 224 deletions

View file

@ -169,32 +169,14 @@ class AccrualAccount(enum.Enum):
class AccrualPostings(core.RelatedPostings): class AccrualPostings(core.RelatedPostings):
def _meta_getter(key: MetaKey) -> Callable[[data.Posting], MetaValue]: # type:ignore[misc]
def meta_getter(post: data.Posting) -> MetaValue:
return post.meta.get(key)
return meta_getter
_FIELDS: Dict[str, Callable[[data.Posting], MetaValue]] = {
'account': operator.attrgetter('account'),
'contract': _meta_getter('contract'),
'invoice': _meta_getter('invoice'),
'purchase_order': _meta_getter('purchase-order'),
}
INCONSISTENT = Sentinel()
__slots__ = ( __slots__ = (
'accrual_type', 'accrual_type',
'accrued_entities',
'end_balance', 'end_balance',
'paid_entities',
'account', 'account',
'accounts', 'entity',
'contract',
'contracts',
'invoice', 'invoice',
'invoices',
'purchase_order',
'purchase_orders',
) )
INCONSISTENT = Sentinel()
def __init__(self, def __init__(self,
source: Iterable[data.Posting]=(), source: Iterable[data.Posting]=(),
@ -204,56 +186,57 @@ class AccrualPostings(core.RelatedPostings):
super().__init__(source, _can_own=_can_own) super().__init__(source, _can_own=_can_own)
# 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 = self._single_item(post.account for post in self)
self.invoice: Union[MetaValue, Sentinel] if isinstance(self.account, Sentinel):
for name, get_func in self._FIELDS.items():
values = frozenset(get_func(post) for post in self)
setattr(self, f'{name}s', values)
if len(values) == 1:
one_value = next(iter(values))
else:
one_value = self.INCONSISTENT
setattr(self, name, one_value)
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() norm_func: Callable[[T], T] = lambda x: x
self.accrued_entities = self._collect_entities() entity_pred: Callable[[data.Posting], bool] = bool
self.paid_entities = self.accrued_entities
else: else:
self.accrual_type = AccrualAccount.classify(self) self.accrual_type = AccrualAccount.by_account(self.account)
norm_func = self.accrual_type.normalize_amount norm_func = self.accrual_type.normalize_amount
self.end_balance = norm_func(self.balance_at_cost()) entity_pred = lambda post: norm_func(post.units).number > 0
self.accrued_entities = self._collect_entities( self.entity = self._single_item(self.entities(entity_pred))
lambda post: norm_func(post.units).number > 0, self.invoice = self._single_item(self.first_links('invoice'))
) self.end_balance = norm_func(self.balance_at_cost())
self.paid_entities = self._collect_entities(
lambda post: norm_func(post.units).number < 0,
)
def _collect_entities(self, def _single_item(self, seq: Iterable[T]) -> Union[T, Sentinel]:
pred: Callable[[data.Posting], bool]=bool, items = iter(seq)
default: str='<empty>', try:
) -> FrozenSet[MetaValue]: item1 = next(items)
return frozenset( except StopIteration:
post.meta.get('entity') or default all_same = False
for post in self if pred(post) else:
) all_same = all(item == item1 for item in items)
return item1 if all_same else self.INCONSISTENT
def entities(self) -> Iterator[MetaValue]: def entities(self, pred: Callable[[data.Posting], bool]=bool) -> Iterator[MetaValue]:
yield from self.accrued_entities seen: Set[MetaValue] = set()
yield from self.paid_entities.difference(self.accrued_entities) for post in self:
if pred(post):
try:
entity = post.meta['entity']
except KeyError:
pass
else:
if entity not in seen:
yield entity
seen.add(entity)
def first_links(self, key: MetaKey, default: Optional[str]=None) -> Iterator[Optional[str]]:
for post in self:
try:
yield post.meta.get_links(key)[0]
except (IndexError, TypeError):
yield default
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)
if len(self.accrued_entities) == 1: entity_ok = isinstance(self.entity, str)
entity = next(iter(self.accrued_entities))
else:
entity = None
# `'/' in self.invoice` is just our heuristic to ensure that the # `'/' in self.invoice` is just our heuristic to ensure that the
# invoice metadata is "unique enough," and not just a placeholder # invoice metadata is "unique enough," and not just a placeholder
# value like "FIXME". It can be refined if needed. # value like "FIXME". It can be refined if needed.
invoice_ok = isinstance(self.invoice, str) and '/' in self.invoice invoice_ok = isinstance(self.invoice, str) and '/' in self.invoice
if account_ok and entity is not None and invoice_ok: if account_ok and entity_ok and invoice_ok:
yield (self.invoice, self) yield (self.invoice, self)
return return
groups = collections.defaultdict(list) groups = collections.defaultdict(list)
@ -261,7 +244,7 @@ class AccrualPostings(core.RelatedPostings):
post_invoice = self.invoice if invoice_ok else ( post_invoice = self.invoice if invoice_ok else (
post.meta.get('invoice') or 'BlankInvoice' post.meta.get('invoice') or 'BlankInvoice'
) )
post_entity = entity if entity is not None else ( post_entity = self.entity if entity_ok else (
post.meta.get('entity') or 'BlankEntity' post.meta.get('entity') or 'BlankEntity'
) )
groups[f'{post.account} {post_invoice} {post_entity}'].append(post) groups[f'{post.account} {post_invoice} {post_entity}'].append(post)
@ -269,28 +252,6 @@ class AccrualPostings(core.RelatedPostings):
for group_key, posts in groups.items(): for group_key, posts in groups.items():
yield group_key, type_self(posts, _can_own=True) yield group_key, type_self(posts, _can_own=True)
def report_inconsistencies(self) -> Iterable[Error]:
for field_name, get_func in self._FIELDS.items():
if getattr(self, field_name) is self.INCONSISTENT:
for post in self:
errmsg = 'inconsistent {} for invoice {}: {}'.format(
field_name.replace('_', '-'),
self.invoice or "<none>",
get_func(post),
)
yield Error(post.meta, errmsg, post.meta.txn)
costs = collections.defaultdict(set)
for post in self:
costs[post.units.currency].add(post.cost)
for code, currency_costs in costs.items():
if len(currency_costs) > 1:
for post in self:
if post.units.currency == code:
errmsg = 'inconsistent cost for invoice {}: {}'.format(
self.invoice or "<none>", post.cost,
)
yield Error(post.meta, errmsg, post.meta.txn)
def is_paid(self, default: Optional[bool]=None) -> Optional[bool]: def is_paid(self, default: Optional[bool]=None) -> Optional[bool]:
if self.accrual_type is None: if self.accrual_type is None:
return default return default
@ -533,7 +494,9 @@ 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.accrued_entities else '', ('\0'.join(related.entities())
if related.entity is related.INCONSISTENT
else related.entity),
)) ))
self.ods.write(rows) self.ods.write(rows)
self.ods.save_file(self.out_bin) self.ods.save_file(self.out_bin)
@ -556,12 +519,7 @@ class OutgoingReport(BaseReport):
self.rt_wrapper = rtutil.RT(rt_client) self.rt_wrapper = rtutil.RT(rt_client)
def _primary_rt_id(self, posts: AccrualPostings) -> rtutil.TicketAttachmentIds: def _primary_rt_id(self, posts: AccrualPostings) -> rtutil.TicketAttachmentIds:
rt_ids: Set[str] = set() rt_ids = {url for url in posts.first_links('rt-id') if url is not None}
for post in posts:
try:
rt_ids.add(post.meta.get_links('rt-id')[0])
except (IndexError, TypeError):
pass
rt_ids_count = len(rt_ids) rt_ids_count = len(rt_ids)
if rt_ids_count != 1: if rt_ids_count != 1:
raise ValueError(f"{rt_ids_count} rt-id links found") raise ValueError(f"{rt_ids_count} rt-id links found")
@ -666,10 +624,10 @@ class ReportType(enum.Enum):
class ReturnFlag(enum.IntFlag): class ReturnFlag(enum.IntFlag):
LOAD_ERRORS = 1 LOAD_ERRORS = 1
CONSISTENCY_ERRORS = 2
REPORT_ERRORS = 4 REPORT_ERRORS = 4
NOTHING_TO_REPORT = 8 NOTHING_TO_REPORT = 8
def filter_search(postings: Iterable[data.Posting], def filter_search(postings: Iterable[data.Posting],
search_terms: Iterable[cliutil.SearchTerm], search_terms: Iterable[cliutil.SearchTerm],
) -> Iterable[data.Posting]: ) -> Iterable[data.Posting]:
@ -756,10 +714,6 @@ def main(arglist: Optional[Sequence[str]]=None,
for error in load_errors: for error in load_errors:
bc_printer.print_error(error, file=stderr) bc_printer.print_error(error, file=stderr)
returncode |= ReturnFlag.LOAD_ERRORS returncode |= ReturnFlag.LOAD_ERRORS
for related in groups.values():
for error in related.report_inconsistencies():
bc_printer.print_error(error, file=stderr)
returncode |= ReturnFlag.CONSISTENCY_ERRORS
if not groups: if not groups:
logger.warning("no matching entries found to report") logger.warning("no matching entries found to report")
returncode |= ReturnFlag.NOTHING_TO_REPORT returncode |= ReturnFlag.NOTHING_TO_REPORT

View file

@ -5,7 +5,7 @@ from setuptools import setup
setup( setup(
name='conservancy_beancount', name='conservancy_beancount',
description="Plugin, library, and reports for reading Conservancy's books", description="Plugin, library, and reports for reading Conservancy's books",
version='1.1.7', version='1.1.8',
author='Software Freedom Conservancy', author='Software Freedom Conservancy',
author_email='info@sfconservancy.org', author_email='info@sfconservancy.org',
license='GNU AGPLv3+', license='GNU AGPLv3+',

View file

@ -63,11 +63,6 @@ ACCOUNTS = [
'Liabilities:Payable:Vacation', 'Liabilities:Payable:Vacation',
] ]
CONSISTENT_METADATA = [
'contract',
'purchase-order',
]
class AgingRow(NamedTuple): class AgingRow(NamedTuple):
date: datetime.date date: datetime.date
entity: Sequence[str] entity: Sequence[str]
@ -271,26 +266,6 @@ def test_accrual_postings_consistent_account(acct_name):
]) ])
related = accrual.AccrualPostings(data.Posting.from_txn(txn)) related = accrual.AccrualPostings(data.Posting.from_txn(txn))
assert related.account == acct_name assert related.account == acct_name
assert related.accounts == {acct_name}
@pytest.mark.parametrize('meta_key,acct_name', testutil.combine_values(
CONSISTENT_METADATA,
ACCOUNTS,
))
def test_accrual_postings_consistent_metadata(meta_key, acct_name):
meta_value = f'{meta_key}.pdf'
meta = {
meta_key: meta_value,
'invoice': f'invoice with {meta_key}.pdf',
}
txn = testutil.Transaction(postings=[
(acct_name, 70, meta),
(acct_name, 35, meta),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
attr_name = meta_key.replace('-', '_')
assert getattr(related, attr_name) == meta_value
assert getattr(related, f'{attr_name}s') == {meta_value}
def test_accrual_postings_entity(): def test_accrual_postings_entity():
txn = testutil.Transaction(postings=[ txn = testutil.Transaction(postings=[
@ -299,8 +274,8 @@ def test_accrual_postings_entity():
(ACCOUNTS[0], -10, {'entity': 'Payee10'}), (ACCOUNTS[0], -10, {'entity': 'Payee10'}),
]) ])
related = accrual.AccrualPostings(data.Posting.from_txn(txn)) related = accrual.AccrualPostings(data.Posting.from_txn(txn))
assert related.accrued_entities == {'Accruee'} assert related.entity == 'Accruee'
assert related.paid_entities == {'Payee10', 'Payee15'} assert set(related.entities()) == {'Accruee', 'Payee10', 'Payee15'}
def test_accrual_postings_entities(): def test_accrual_postings_entities():
txn = testutil.Transaction(postings=[ txn = testutil.Transaction(postings=[
@ -333,107 +308,6 @@ def test_accrual_postings_inconsistent_account():
]) ])
related = accrual.AccrualPostings(data.Posting.from_txn(txn)) related = accrual.AccrualPostings(data.Posting.from_txn(txn))
assert related.account is related.INCONSISTENT assert related.account is related.INCONSISTENT
assert related.accounts == set(ACCOUNTS)
@pytest.mark.parametrize('meta_key,acct_name', testutil.combine_values(
CONSISTENT_METADATA,
ACCOUNTS,
))
def test_accrual_postings_inconsistent_metadata(meta_key, acct_name):
invoice = 'invoice with {meta_key}.pdf'
meta_value = f'{meta_key}.pdf'
txn = testutil.Transaction(postings=[
(acct_name, 20, {'invoice': invoice, meta_key: meta_value}),
(acct_name, 35, {'invoice': invoice}),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
attr_name = meta_key.replace('-', '_')
assert getattr(related, attr_name) is related.INCONSISTENT
assert getattr(related, f'{attr_name}s') == {meta_value, None}
@pytest.mark.parametrize('meta_key,account', testutil.combine_values(
CONSISTENT_METADATA,
ACCOUNTS,
))
def test_consistency_check_when_consistent(meta_key, account):
invoice = f'test-{meta_key}-invoice'
meta_value = f'test-{meta_key}-value'
meta = {
'invoice': invoice,
meta_key: meta_value,
}
txn = testutil.Transaction(postings=[
(account, 100, meta),
(account, -100, meta),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
assert not list(related.report_inconsistencies())
@pytest.mark.parametrize('meta_key,account', testutil.combine_values(
['approval', 'entity', 'fx-rate', 'statement'],
ACCOUNTS,
))
def test_consistency_check_ignored_metadata(meta_key, account):
invoice = f'test-{meta_key}-invoice'
txn = testutil.Transaction(postings=[
(account, 100, {'invoice': invoice, meta_key: 'credit'}),
(account, -100, {'invoice': invoice, meta_key: 'debit'}),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
assert not list(related.report_inconsistencies())
@pytest.mark.parametrize('meta_key,account', testutil.combine_values(
CONSISTENT_METADATA,
ACCOUNTS,
))
def test_consistency_check_when_inconsistent(meta_key, account):
invoice = f'test-{meta_key}-invoice'
txn = testutil.Transaction(postings=[
(account, 100, {'invoice': invoice, meta_key: 'credit', 'lineno': 1}),
(account, -100, {'invoice': invoice, meta_key: 'debit', 'lineno': 2}),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
errors = list(related.report_inconsistencies())
for exp_lineno, (actual, exp_msg) in enumerate(itertools.zip_longest(errors, [
f'inconsistent {meta_key} for invoice {invoice}: credit',
f'inconsistent {meta_key} for invoice {invoice}: debit',
]), 1):
assert actual.message == exp_msg
assert actual.entry is txn
assert actual.source.get('lineno') == exp_lineno
def test_consistency_check_cost():
account = ACCOUNTS[0]
invoice = 'test-cost-invoice'
txn = testutil.Transaction(postings=[
(account, 100, 'EUR', ('1.1251', 'USD'), {'invoice': invoice, 'lineno': 1}),
(account, -100, 'EUR', ('1.125', 'USD'), {'invoice': invoice, 'lineno': 2}),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
errors = list(related.report_inconsistencies())
for post, err in itertools.zip_longest(txn.postings, errors):
assert err.message == f'inconsistent cost for invoice {invoice}: {post.cost}'
assert err.entry is txn
assert err.source.get('lineno') == post.meta['lineno']
def test_make_consistent_not_needed():
main_meta = {
'entity': 'ConsistentTest',
'invoice': 'Invoices/ConsistentDoc.pdf',
}
other_meta = {key: f'{key}.pdf' for key in CONSISTENT_METADATA}
# We intentionally make inconsistencies in "minor" metadata that shouldn't
# split out the group.
txn = testutil.Transaction(postings=[
(ACCOUNTS[0], 20, {**main_meta, **other_meta}),
(ACCOUNTS[0], 25, {**main_meta}),
])
related = accrual.AccrualPostings(data.Posting.from_txn(txn))
consistent = related.make_consistent()
actual_key, actual_postings = next(consistent)
assert actual_key == main_meta['invoice']
assert actual_postings is related
assert next(consistent, None) is None
@pytest.mark.parametrize('acct_name,invoice,day', testutil.combine_values( @pytest.mark.parametrize('acct_name,invoice,day', testutil.combine_values(
ACCOUNTS, ACCOUNTS,
@ -495,8 +369,10 @@ def test_make_consistent_across_entity(acct_name):
assert len(consistent) == 3 assert len(consistent) == 3
for key, posts in consistent.items(): for key, posts in consistent.items():
assert len(posts) == 1 assert len(posts) == 1
assert len(posts.accrued_entities) == 1 entities = posts.entities()
assert next(posts.entities()) in key assert next(entities, None) == posts.entity
assert next(entities, None) is None
assert posts.entity in key
@pytest.mark.parametrize('acct_name', ACCOUNTS) @pytest.mark.parametrize('acct_name', ACCOUNTS)
def test_make_consistent_entity_differs_accrual_payment(acct_name): def test_make_consistent_entity_differs_accrual_payment(acct_name):