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…
Reference in a new issue