diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index e1c84a5..bf37664 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -74,6 +74,7 @@ LINK_METADATA = frozenset([ 'receipt', 'rt-id', 'statement', + 'tax-reporting', 'tax-statement', ]) diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py index 42e1db5..9e06344 100644 --- a/conservancy_beancount/plugin/__init__.py +++ b/conservancy_beancount/plugin/__init__.py @@ -52,18 +52,19 @@ class HookRegistry: # Enforcing this hook would be premature as of May 2020. --brett # '.meta_payable_documentation': None, '.meta_paypal_id': ['MetaPayPalID'], - '.meta_project': None, - '.meta_receipt': None, - '.meta_receivable_documentation': None, - '.meta_repo_links': None, - '.meta_rt_links': ['MetaRTLinks'], - '.meta_tax_implication': None, '.meta_payroll_type': [ 'HealthInsuranceHook', 'OtherBenefitsHook', 'SalaryHook', 'TaxHook', ], + '.meta_project': None, + '.meta_receipt': None, + '.meta_receivable_documentation': None, + '.meta_repo_links': None, + '.meta_rt_links': ['MetaRTLinks'], + '.meta_tax_implication': None, + '.meta_tax_reporting': None, '.txn_date': ['TransactionDate'], } diff --git a/conservancy_beancount/plugin/meta_tax_reporting.py b/conservancy_beancount/plugin/meta_tax_reporting.py new file mode 100644 index 0000000..374e132 --- /dev/null +++ b/conservancy_beancount/plugin/meta_tax_reporting.py @@ -0,0 +1,54 @@ +"""meta_tax_reporting - Validate tax-reporting metadata links""" +# Copyright © 2021 Brett Smith +# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0 +# +# Full copyright and licensing details can be found at toplevel file +# LICENSE.txt in the repository. + +import datetime + +from . import core +from .. import config as configmod +from .. import data +from .. import errors as errormod +from ..beancount_types import ( + Transaction, +) + +from .meta_tax_implication import MetaTaxImplication +from ..ranges import DateRange + +class MetaTaxReporting(core._RequireLinksPostingMetadataHook): + CHECKED_IMPLICATIONS = frozenset( + # We load values through the MetadataEnum to future-proof against + # changes to tax-implication. This ensures that the set contains + # canonical values, or else this code will crash if canonical values + # can't be found. + MetaTaxImplication.VALUES_ENUM[value] for value in [ + '1099-MISC-Other', + '1099-NEC', + 'Foreign-Grantee', + 'Foreign-Individual-Contractor', + 'USA-501c3', + 'USA-Grantee', + ]) + CHECKED_METADATA = ['tax-reporting'] + SKIP_FLAGS = '!' + TXN_DATE_RANGE = DateRange(datetime.date(2020, 3, 1), datetime.date.max) + + def __init__(self, config: configmod.Config) -> None: + self._implication_hook = MetaTaxImplication(config) + # Yes, we create our own MetaTaxImplication hook. This is a little + # weird but it does two things for us: + # 1. We can check MetaTaxImplication._run_on_post() as part of our own + # implementation without duplicating the logic. + # 2. We can canonicalize values through the hook. We don't strictly + # need an instance for that, but we have it anyway so doing it this way + # is nicer. + + def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: + if not self._implication_hook._run_on_post(txn, post): + return False + implication = str(post.meta.get('tax-implication') or '') + normalized = self._implication_hook.VALUES_ENUM.get(implication) + return normalized in self.CHECKED_IMPLICATIONS diff --git a/setup.py b/setup.py index b126f4d..a29bfe1 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.17.1', + version='1.18.0', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/test_meta_tax_reporting.py b/tests/test_meta_tax_reporting.py new file mode 100644 index 0000000..cfae103 --- /dev/null +++ b/tests/test_meta_tax_reporting.py @@ -0,0 +1,184 @@ +"""test_meta_tax_reporting.py - Unit tests for tax-reporting metadata validation""" +# Copyright © 2021 Brett Smith +# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0 +# +# Full copyright and licensing details can be found at toplevel file +# LICENSE.txt in the repository. + +import pytest + +from . import testutil + +from conservancy_beancount.plugin import meta_tax_reporting + +TEST_KEY = 'tax-reporting' +IMPLICATION_KEY = 'tax-implication' + +REQUIRED_ACCOUNTS = { + 'Assets:Checking', + 'Assets:Bank:Savings', +} + +NON_REQUIRED_ACCOUNTS = { + 'Assets:Prepaid:Expenses', + 'Assets:Receivable:Accounts', + 'Liabilities:CreditCard', +} + +REQUIRED_AMOUNTS = {-50, -500} +NON_REQUIRED_AMOUNTS = {-5, 500} + +REQUIRED_IMPLICATIONS = { + '1099', + '1099-Misc-Other', + 'foreign-grantee', + 'Foreign-Individual-Contractor', + 'USA-501c3', + 'US-Grantee', +} + +NON_REQUIRED_IMPLICATIONS = { + 'Bank-Transfer', + 'chargeback', + 'Foreign-Corp', + 'Loan', + 'refund', + 'Reimbursement', + 'retirement-pretax', + 'Tax-Payment', + 'us-corp', + 'w2', +} + +@pytest.fixture(scope='module') +def hook(): + config = testutil.TestConfig(payment_threshold=10) + return meta_tax_reporting.MetaTaxReporting(config) + +@pytest.mark.parametrize('account,amount,implication,value', testutil.combine_values( + REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, + testutil.LINK_METADATA_STRINGS, +)) +def test_pass_on_txn(hook, account, amount, implication, value): + txn_meta = { + IMPLICATION_KEY: implication, + TEST_KEY: value, + } + txn = testutil.Transaction(**txn_meta, postings=[ + (account, amount), + ('Expenses:Other', -amount), + ]) + assert not list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication,value', testutil.combine_values( + REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, + testutil.LINK_METADATA_STRINGS, +)) +def test_pass_on_post(hook, account, amount, implication, value): + post_meta = { + IMPLICATION_KEY: implication, + TEST_KEY: value, + } + txn = testutil.Transaction(postings=[ + (account, amount, post_meta), + ('Expenses:Other', -amount), + ]) + assert not list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication', testutil.combine_values( + REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, +)) +def test_error_when_missing(hook, account, amount, implication): + txn = testutil.Transaction(postings=[ + (account, amount, {IMPLICATION_KEY: implication}), + ('Expenses:Other', -amount), + ]) + assert list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication,value', testutil.combine_values( + REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, + testutil.NON_LINK_METADATA_STRINGS, +)) +def test_error_when_empty(hook, account, amount, implication, value): + txn_meta = { + IMPLICATION_KEY: implication, + TEST_KEY: value, + } + txn = testutil.Transaction(**txn_meta, postings=[ + (account, amount), + ('Expenses:Other', -amount), + ]) + assert list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication,value', testutil.combine_values( + REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, + testutil.NON_STRING_METADATA_VALUES, +)) +def test_error_when_wrong_type(hook, account, amount, implication, value): + txn_meta = { + IMPLICATION_KEY: implication, + TEST_KEY: value, + } + txn = testutil.Transaction(**txn_meta, postings=[ + (account, amount), + ('Expenses:Other', -amount), + ]) + assert list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication', testutil.combine_values( + NON_REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, +)) +def test_skip_by_account(hook, account, amount, implication): + txn = testutil.Transaction(postings=[ + (account, amount, {IMPLICATION_KEY: implication}), + ('Expenses:Other', -amount), + ]) + assert not list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication', testutil.combine_values( + REQUIRED_ACCOUNTS, + NON_REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, +)) +def test_skip_by_amount(hook, account, amount, implication): + txn = testutil.Transaction(postings=[ + (account, amount, {IMPLICATION_KEY: implication}), + ('Expenses:Other', -amount), + ]) + assert not list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication', testutil.combine_values( + REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + NON_REQUIRED_IMPLICATIONS, +)) +def test_skip_by_implication(hook, account, amount, implication): + txn = testutil.Transaction(postings=[ + (account, amount, {IMPLICATION_KEY: implication}), + ('Expenses:Other', -amount), + ]) + assert not list(hook.run(txn)) + +@pytest.mark.parametrize('account,amount,implication', testutil.combine_values( + REQUIRED_ACCOUNTS, + REQUIRED_AMOUNTS, + REQUIRED_IMPLICATIONS, +)) +def test_skip_by_flag(hook, account, amount, implication): + txn = testutil.Transaction(flag='!', postings=[ + (account, amount, {IMPLICATION_KEY: implication}), + ('Expenses:Other', -amount), + ]) + assert not list(hook.run(txn))