plugin: Skip enum value checks with a flag+FIXME.

We've long supported skipping documentation checks by flagging the
transaction. We haven't done the same for enumerated metadata because we
need it less often, and bad values tend to do more damage to reports.

However, occasionally when something very off-process happens, we do need it
as a matter of expediency. So support it.

In order to skip validation of these fields, the plugin requires that the
value start with the string "FIXME". This helps ensure that reports have a
consistent way to detect and warn about unfilled values in flagged
transactions.
This commit is contained in:
Brett Smith 2021-01-29 09:38:37 -05:00
parent c230f91ab7
commit fdd9f2847b
10 changed files with 71 additions and 10 deletions

View file

@ -160,6 +160,13 @@ class _PostingHook(TransactionHook, metaclass=abc.ABCMeta):
def __init_subclass__(cls) -> None: def __init_subclass__(cls) -> None:
cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['posting']) cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['posting'])
def _is_flagged_fixme(self, post: data.Posting, value: MetaValue) -> bool:
return (
post.meta.txn.flag == '!'
and isinstance(value, str)
and value.startswith('FIXME')
)
def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
return True return True
@ -205,9 +212,10 @@ class _NormalizePostingMetadataHook(_PostingHook):
try: try:
set_value = self.VALUES_ENUM[source_value] set_value = self.VALUES_ENUM[source_value]
except KeyError: except KeyError:
error = errormod.InvalidMetadataError( if not self._is_flagged_fixme(post, source_value):
txn, self.METADATA_KEY, source_value, post, error = errormod.InvalidMetadataError(
) txn, self.METADATA_KEY, source_value, post,
)
if error is None: if error is None:
post.meta[self.METADATA_KEY] = set_value post.meta[self.METADATA_KEY] = set_value
else: else:

View file

@ -39,5 +39,5 @@ class MetaPayPalID(core._PostingHook):
match = regexp.match(value) # type:ignore[arg-type] match = regexp.match(value) # type:ignore[arg-type]
except TypeError: except TypeError:
match = None match = None
if match is None: if match is None and not self._is_flagged_fixme(post, value):
yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, value, post) yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, value, post)

View file

@ -59,10 +59,6 @@ class MetaTaxImplication(core._NormalizePostingMetadataHook):
VALUES_ENUM = core.MetadataEnum('tax-implication', _STDNAMES, _ALIASES) VALUES_ENUM = core.MetadataEnum('tax-implication', _STDNAMES, _ALIASES)
del _STDNAMES, _ALIASES del _STDNAMES, _ALIASES
# Sometimes we accrue a payment before we have determined the recipient's
# tax status.
SKIP_FLAGS = '!'
def __init__(self, config: configmod.Config) -> None: def __init__(self, config: configmod.Config) -> None:
self.payment_threshold = -config.payment_threshold() self.payment_threshold = -config.payment_threshold()

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.16.1', version='1.16.2',
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

@ -157,3 +157,13 @@ def test_flagged_txn_checked(hook, src_value):
errors = list(hook.run(txn)) errors = list(hook.run(txn))
assert errors assert errors
testutil.check_post_meta(txn, None, {TEST_KEY: src_value}) testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
@pytest.mark.parametrize('src_value', testutil.FIXME_VALUES)
def test_flagged_fixme_ok(hook, src_value):
txn = testutil.Transaction(flag='!', postings=[
('Assets:Cash', -25),
('Expenses:General', 25, {TEST_KEY: src_value}),
])
errors = list(hook.run(txn))
assert not errors
testutil.check_post_meta(txn, None, {TEST_KEY: src_value})

View file

@ -151,3 +151,13 @@ def test_flagged_txn_checked(hook, src_value):
errors = list(hook.run(txn)) errors = list(hook.run(txn))
assert errors assert errors
testutil.check_post_meta(txn, None, {TEST_KEY: src_value}) testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
@pytest.mark.parametrize('src_value', testutil.FIXME_VALUES)
def test_flagged_fixme_ok(hook, src_value):
txn = testutil.Transaction(flag='!', postings=[
('Assets:Cash', 25),
('Income:Other', -25, {TEST_KEY: src_value}),
])
errors = list(hook.run(txn))
assert not errors
testutil.check_post_meta(txn, None, {TEST_KEY: src_value})

View file

@ -192,3 +192,15 @@ def test_still_required_on_flagged_txn(hook):
('Income:Donations', -1000), ('Income:Donations', -1000),
]) ])
assert list(hook.run(txn)) assert list(hook.run(txn))
@pytest.mark.parametrize('account,src_value', testutil.combine_values(
ACCOUNTS,
testutil.FIXME_VALUES,
))
def test_flagged_fixme_ok(hook, account, src_value):
txn = testutil.Transaction(flag='!', postings=[
(account, 200, {TEST_KEY: src_value}),
('Income:Donations', -200),
])
assert not list(hook.run(txn))
testutil.check_post_meta(txn, {TEST_KEY: src_value}, None)

View file

@ -201,3 +201,13 @@ def test_still_required_on_flagged_txn(hook, src_value):
errors = list(hook.run(txn)) errors = list(hook.run(txn))
assert errors assert errors
testutil.check_post_meta(txn, None, None) testutil.check_post_meta(txn, None, None)
@pytest.mark.parametrize('src_value', testutil.FIXME_VALUES)
def test_flagged_fixme_ok(hook, src_value):
txn = testutil.Transaction(flag='!', postings=[
('Assets:Cash', -25),
('Expenses:General', 25, {TEST_KEY: src_value}),
])
errors = list(hook.run(txn))
assert not errors
testutil.check_post_meta(txn, None, {TEST_KEY: src_value})

View file

@ -137,9 +137,18 @@ def test_validation_only_in_date_range(hook, date, need_value):
testutil.check_post_meta(txn, None, None) testutil.check_post_meta(txn, None, None)
@pytest.mark.parametrize('src_value', INVALID_VALUES) @pytest.mark.parametrize('src_value', INVALID_VALUES)
def test_flagged_txn_skipped(hook, src_value): def test_flagged_txn_checked(hook, src_value):
txn = testutil.Transaction(flag='!', **{TEST_KEY: src_value}, postings=[ txn = testutil.Transaction(flag='!', **{TEST_KEY: src_value}, postings=[
('Liabilities:Payable:Accounts', 25), ('Liabilities:Payable:Accounts', 25),
('Assets:Cash', -25), ('Assets:Cash', -25),
]) ])
assert list(hook.run(txn))
@pytest.mark.parametrize('src_value', testutil.FIXME_VALUES)
def test_flagged_fixme_ok(hook, src_value):
txn = testutil.Transaction(flag='!', postings=[
('Liabilities:Payable:Accounts', 25),
('Assets:Cash', -25, {TEST_KEY: src_value}),
])
assert not list(hook.run(txn)) assert not list(hook.run(txn))
testutil.check_post_meta(txn, None, {TEST_KEY: src_value})

View file

@ -194,6 +194,12 @@ NON_STRING_METADATA_VALUES = [
Amount(500, None), Amount(500, None),
] ]
FIXME_VALUES = [
'FIXME',
'FIXME loose comment',
'FIXME: comment with punctuation',
]
OPENING_EQUITY_ACCOUNTS = itertools.cycle([ OPENING_EQUITY_ACCOUNTS = itertools.cycle([
'Equity:Funds:Unrestricted', 'Equity:Funds:Unrestricted',
'Equity:Funds:Restricted', 'Equity:Funds:Restricted',