Files @ 5784068904e8
Branch filter:

Location: NPO-Accounting/conservancy_beancount/tests/test_meta_entity.py

bkuhn
payroll-type — US:403b:Employee:Roth — needed separate since taxable

Since Roth contributions are taxable, there are some reports that
need to include these amounts in total salary (i.e., when running a
report that seeks to show total taxable income for an employee). As
such, we need a `payroll-type` specifically for Roth 403(b)
contributions.
"""Test validation of entity metadata"""
# Copyright © 2020  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_entity

VALID_VALUES = {
    # Classic entity: LastName-FirstName
    'Smith-Alex',
    # Various people and companies have one-word names
    # Digits are allowed, as part of a name or standalone
    'Company19',
    'Company-19',
    # No case requirements
    'boyd-danah',
    # No limit on the number of parts of the name
    'B-van-der-A',
    # Names that have no ASCII are allowed, with or without dash separators
    '田中流星',
    '田中-流星',
    'スミスダコタ',
    'スミス-ダコタ',
    'Яшин-Данила',
    # Governments, using : as a hierarchy separator
    'BE',
    'US:KY',
    'CA:ON',
    # The PayPal importer allows ASCII punctuation in entity metadata
    'Du-Bois-W.-E.-B.',
    "O'Malley-Thomas",
    'O`Malley-Thomas',
    # import2ledger produces entities that end with -
    # That's probably a bug, but allow it for now.
    'foo-',
}

INVALID_VALUES = {
    # Starting with a - is not allowed
    '-foo',
    # Names that can be reduced to ASCII should be
    # Producers should change this to Uberentity or Ueberentity
    # I am not wild about this rule and would like to relax it—it's mostly
    # based on an expectation that entities are typed in by an American. That's
    # true less and less and it seems like we should reduce the amount of
    # mangling producers are expected to do. But it's the rule for today.
    'Überentity',
    # Whitespace is never allowed
    'Alex Smith',
    '田中\u00A0流星',  # Non-breaking space
    # Non-ASCII punctuation is not allowed
    'Яшин—Данила',  # em dash
    'O’Malley-Thomas',  # Right-angled apostrophe
    'Du-Bois-W。-E。-B。',  # Japanese period
}

ANONYMOUS_VALUES = {
    # Values produced by various importers that should be translated to
    # Anonymous.
    '',
    ' ',
    '-',
    '--',
    '-----',
    '_',
    ' _ ',
    '.',
}

TEST_KEY = 'entity'

@pytest.fixture(scope='module')
def hook():
    config = testutil.TestConfig()
    return meta_entity.MetaEntity(config)

@pytest.mark.parametrize('src_value', VALID_VALUES)
def test_valid_values_on_postings(hook, src_value):
    txn = testutil.Transaction(postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25, {TEST_KEY: src_value}),
    ])
    assert not any(hook.run(txn))

@pytest.mark.parametrize('src_value', ANONYMOUS_VALUES)
def test_anonymous_values_on_postings(hook, src_value):
    txn = testutil.Transaction(postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25, {TEST_KEY: src_value}),
    ])
    assert not any(hook.run(txn))
    assert txn.postings[-1].meta[TEST_KEY] == 'Anonymous'

@pytest.mark.parametrize('src_value', INVALID_VALUES)
def test_invalid_values_on_postings(hook, src_value):
    txn = testutil.Transaction(postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25, {TEST_KEY: src_value}),
    ])
    errors = list(hook.run(txn))
    assert len(errors) == 1
    assert errors[0].message == "Expenses:General has invalid entity: {}".format(src_value)

@pytest.mark.parametrize('src_value', VALID_VALUES)
def test_valid_values_on_transactions(hook, src_value):
    txn = testutil.Transaction(payee='Payee', **{TEST_KEY: src_value}, postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25),
    ])
    assert not any(hook.run(txn))
    # Make sure payee doesn't overwrite metadata. See payee test below.
    assert txn.meta[TEST_KEY] == src_value

@pytest.mark.parametrize('src_value', ANONYMOUS_VALUES)
def test_anonymous_values_on_transactions(hook, src_value):
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25),
    ])
    assert not any(hook.run(txn))
    assert txn.meta[TEST_KEY] == 'Anonymous'

@pytest.mark.parametrize('src_value', INVALID_VALUES)
def test_invalid_values_on_transactions(hook, src_value):
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25),
    ])
    errors = list(hook.run(txn))
    assert 1 <= len(errors) <= 2
    assert all(error.message == "transaction has invalid entity: {}".format(src_value)
               for error in hook.run(txn))

@pytest.mark.parametrize('src_value', VALID_VALUES)
def test_valid_values_on_payee(hook, src_value):
    txn = testutil.Transaction(payee=src_value, postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25),
    ])
    assert not any(hook.run(txn))
    # In this case, we want the hook to set metadata to make it easier to
    # write bean-queries.
    assert txn.meta[TEST_KEY] == src_value

@pytest.mark.parametrize('src_value', ANONYMOUS_VALUES)
def test_anonymous_values_on_payee(hook, src_value):
    txn = testutil.Transaction(payee=src_value, postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25),
    ])
    assert not any(hook.run(txn))
    assert txn.meta[TEST_KEY] == 'Anonymous'

@pytest.mark.parametrize('src_value', INVALID_VALUES)
def test_invalid_values_on_payee(hook, src_value):
    txn = testutil.Transaction(payee=src_value, postings=[
        ('Assets:Cash', -25),
        ('Expenses:General', 25),
    ])
    errors = list(hook.run(txn))
    assert 1 <= len(errors) <= 2
    assert all(error.message == "transaction has invalid entity: {}".format(src_value)
               for error in hook.run(txn))

@pytest.mark.parametrize('payee,src_value', testutil.combine_values(
    INVALID_VALUES,
    VALID_VALUES,
))
def test_invalid_payee_but_valid_metadata(hook, payee, src_value):
    txn = testutil.Transaction(**{'payee': payee, TEST_KEY: src_value}, postings=[
        ('Assets:Cash', -25),
        ('Expenses:Other', 25),
    ])
    assert not any(hook.run(txn))

def test_mixed_sources(hook):
    txn = testutil.Transaction(payee='Payee', postings=[
        ('Income:Donations', -5),
        ('Equity:Funds:Restricted', 5, {TEST_KEY: 'Entity'}),
    ])
    assert not any(hook.run(txn))
    assert txn.postings[-1].meta[TEST_KEY] == 'Entity'
    assert txn.meta[TEST_KEY] == 'Payee'
    try:
        assert txn.postings[0].meta[TEST_KEY] == 'Payee'
    except (KeyError, TypeError):
        pass

@pytest.mark.parametrize('account,required', [
    ('Assets:Bank:Checking', False),
    ('Assets:Cash', False),
    ('Assets:Receivable:Accounts', True),
    ('Assets:Receivable:Loans', True),
    ('Equity:OpeningBalances', False),
    ('Expenses:General', True),
    ('Income:Donations', True),
    ('Liabilities:CreditCard', False),
    ('Liabilities:Payable:Accounts', True),
    ('Liabilities:Payable:Vacation', True),
    ('Liabilities:UnearnedIncome:Donations', False),
])
def test_which_accounts_required_on(hook, account, required):
    txn = testutil.Transaction(postings=[
        ('Assets:Checking', -25),
        (account, 25),
    ])
    errors = list(hook.run(txn))
    if not required:
        assert not errors
    else:
        assert errors
        assert any(error.message == "{} missing entity".format(account)
                   for error in errors)

def test_dont_set_entity_none(hook):
    txn = testutil.Transaction(postings=[
        ('Expenses:Other', 5),
        ('Assets:Cash', -5),
    ])
    assert any(hook.run(txn))
    assert 'entity' not in txn.meta
    for post in txn.postings:
        assert post.meta is None or 'entity' not in post.meta

def test_not_required_on_opening(hook):
    txn = testutil.OpeningBalance()
    assert not list(hook.run(txn))

@pytest.mark.parametrize('date,need_value', [
    (testutil.EXTREME_FUTURE_DATE, False),
    (testutil.FUTURE_DATE, True),
    (testutil.FY_START_DATE, True),
    (testutil.FY_MID_DATE, True),
    (testutil.PAST_DATE, False),
])
def test_required_by_date(hook, date, need_value):
    txn = testutil.Transaction(date=date, postings=[
        ('Income:Donations', -10),
        ('Assets:Checking', 10),
    ])
    assert any(hook.run(txn)) == need_value

def test_still_required_on_flagged(hook):
    txn = testutil.Transaction(flag='!', postings=[
        ('Income:Donations', -10),
        ('Assets:Checking', 10),
    ])
    assert list(hook.run(txn))