meta_tax_reporting: New plugin validation.
This commit is contained in:
		
							parent
							
								
									328f59231c
								
							
						
					
					
						commit
						fe3560b748
					
				
					 5 changed files with 247 additions and 7 deletions
				
			
		| 
						 | 
					@ -74,6 +74,7 @@ LINK_METADATA = frozenset([
 | 
				
			||||||
    'receipt',
 | 
					    'receipt',
 | 
				
			||||||
    'rt-id',
 | 
					    'rt-id',
 | 
				
			||||||
    'statement',
 | 
					    'statement',
 | 
				
			||||||
 | 
					    'tax-reporting',
 | 
				
			||||||
    'tax-statement',
 | 
					    'tax-statement',
 | 
				
			||||||
])
 | 
					])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -52,18 +52,19 @@ class HookRegistry:
 | 
				
			||||||
        # Enforcing this hook would be premature as of May 2020.  --brett
 | 
					        # Enforcing this hook would be premature as of May 2020.  --brett
 | 
				
			||||||
        # '.meta_payable_documentation': None,
 | 
					        # '.meta_payable_documentation': None,
 | 
				
			||||||
        '.meta_paypal_id': ['MetaPayPalID'],
 | 
					        '.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': [
 | 
					        '.meta_payroll_type': [
 | 
				
			||||||
            'HealthInsuranceHook',
 | 
					            'HealthInsuranceHook',
 | 
				
			||||||
            'OtherBenefitsHook',
 | 
					            'OtherBenefitsHook',
 | 
				
			||||||
            'SalaryHook',
 | 
					            'SalaryHook',
 | 
				
			||||||
            'TaxHook',
 | 
					            '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'],
 | 
					        '.txn_date': ['TransactionDate'],
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										54
									
								
								conservancy_beancount/plugin/meta_tax_reporting.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								conservancy_beancount/plugin/meta_tax_reporting.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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
 | 
				
			||||||
							
								
								
									
										2
									
								
								setup.py
									
										
									
									
									
								
							
							
						
						
									
										2
									
								
								setup.py
									
										
									
									
									
								
							| 
						 | 
					@ -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.17.1',
 | 
					    version='1.18.0',
 | 
				
			||||||
    author='Software Freedom Conservancy',
 | 
					    author='Software Freedom Conservancy',
 | 
				
			||||||
    author_email='info@sfconservancy.org',
 | 
					    author_email='info@sfconservancy.org',
 | 
				
			||||||
    license='GNU AGPLv3+',
 | 
					    license='GNU AGPLv3+',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										184
									
								
								tests/test_meta_tax_reporting.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								tests/test_meta_tax_reporting.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -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))
 | 
				
			||||||
		Loading…
	
	Add table
		
		Reference in a new issue