Changeset - 71f50a6cf864
[Not reviewed]
0 4 0
Brett Smith - 4 years ago 2020-12-29 17:20:53
brettcsmith@brettcsmith.org
data: Bugfix is_opening_balance_txn() for donations from equity. RT#13516

Opening balance transactions should only include opening equity
accounts and non-equity accounts. Reflect that in the test.
4 files changed with 21 insertions and 6 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/data.py
Show inline comments
...
 
@@ -642,55 +642,55 @@ class _SizedDict(collections.OrderedDict, MutableMapping[_KT, _VT]):
 
    def __init__(self, maxsize: int=128) -> None:
 
        self.maxsize = maxsize
 
        super().__init__()
 

	
 
    def __setitem__(self, key: _KT, value: _VT) -> None:
 
        super().__setitem__(key, value)
 
        for _ in range(self.maxsize, len(self)):
 
            self.popitem(last=False)
 

	
 

	
 
def balance_of(txn: Transaction,
 
               *preds: Callable[[Account], Optional[bool]],
 
) -> Amount:
 
    """Return the balance of specified postings in a transaction.
 

	
 
    Given a transaction and a series of account predicates, balance_of
 
    returns the balance of the amounts of all postings with accounts that
 
    match any of the predicates.
 

	
 
    balance_of uses the "weight" of each posting, so the return value will
 
    use the currency of the postings' cost when available.
 
    """
 
    match_posts = [post for post in Posting.from_txn(txn)
 
                   if any(pred(post.account) for pred in preds)]
 
    number = decimal.Decimal(0)
 
    if not match_posts:
 
        currency = ''
 
    else:
 
        weights: Sequence[Amount] = [
 
            bc_convert.get_weight(post) for post in match_posts
 
        ]
 
        number = sum((wt.number for wt in weights), number)
 
        currency = weights[0].currency
 
    return Amount(number, currency)
 

	
 
_opening_balance_cache: MutableMapping[str, bool] = _SizedDict()
 
def is_opening_balance_txn(txn: Transaction) -> bool:
 
    key = '\0'.join(
 
        f'{post.account}={post.units}' for post in txn.postings
 
    )
 
    try:
 
        return _opening_balance_cache[key]
 
    except KeyError:
 
        pass
 
    opening_equity = balance_of(txn, Account.is_opening_equity)
 
    if not opening_equity.currency:
 
        retval = False
 
    else:
 
        rest = balance_of(txn, lambda acct: not acct.is_opening_equity())
 
        if not rest.currency:
 
            retval = False
 
        else:
 
            retval = abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
 
        rest = balance_of(txn, lambda acct: not acct.is_under(*EQUITY_ACCOUNTS))
 
        retval = (
 
            opening_equity.currency == rest.currency
 
            and abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
 
        )
 
    _opening_balance_cache[key] = retval
 
    return retval
setup.py
Show inline comments
 
#!/usr/bin/env python3
 

	
 
from setuptools import setup
 

	
 
setup(
 
    name='conservancy_beancount',
 
    description="Plugin, library, and reports for reading Conservancy's books",
 
    version='1.14.2',
 
    version='1.14.3',
 
    author='Software Freedom Conservancy',
 
    author_email='info@sfconservancy.org',
 
    license='GNU AGPLv3+',
 

	
 
    install_requires=[
 
        'babel>=2.6',  # Debian:python3-babel
 
        'beancount>=2.2',  # Debian:beancount
 
        'GitPython>=2.0',  # Debian:python3-git
 
        # 1.4.1 crashes when trying to save some documents.
 
        'odfpy>=1.4.0,!=1.4.1',  # Debian:python3-odf
 
        'PyYAML>=3.0',  # Debian:python3-yaml
 
        'regex',  # Debian:python3-regex
 
        'rt>=2.0',
 
    ],
 
    setup_requires=[
 
        'pytest-mypy',
 
        'pytest-runner',  # Debian:python3-pytest-runner
 
    ],
 
    tests_require=[
 
        'mypy>=0.770',  # Debian:python3-mypy
 
        'pytest',  # Debian:python3-pytest
 
    ],
 

	
 
    packages=[
 
        'conservancy_beancount',
 
        'conservancy_beancount.plugin',
 
        'conservancy_beancount.reports',
 
        'conservancy_beancount.tools',
 
    ],
 
    entry_points={
 
        'console_scripts': [
 
            'accrual-report = conservancy_beancount.reports.accrual:entry_point',
 
            'assemble-audit-reports = conservancy_beancount.tools.audit_report:entry_point',
 
            'balance-sheet-report = conservancy_beancount.reports.balance_sheet:entry_point',
 
            'budget-report = conservancy_beancount.reports.budget:entry_point',
 
            'bean-sort = conservancy_beancount.tools.sort_entries:entry_point',
 
            'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point',
 
            'fund-report = conservancy_beancount.reports.fund:entry_point',
 
            'ledger-report = conservancy_beancount.reports.ledger:entry_point',
 
            'opening-balances = conservancy_beancount.tools.opening_balances:entry_point',
 
            'split-ods-links = conservancy_beancount.tools.split_ods_links:entry_point',
 
        ],
 
    },
 
)
tests/test_data_is_opening_balance_txn.py
Show inline comments
...
 
@@ -5,55 +5,57 @@
 
# it under the terms of the GNU Affero General Public License as published by
 
# the Free Software Foundation, either version 3 of the License, or
 
# (at your option) any later version.
 
#
 
# This program is distributed in the hope that it will be useful,
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
# GNU Affero General Public License for more details.
 
#
 
# You should have received a copy of the GNU Affero General Public License
 
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
 

	
 
from decimal import Decimal
 

	
 
import pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount import data
 

	
 
def test_typical_opening():
 
    txn = testutil.OpeningBalance()
 
    assert data.is_opening_balance_txn(txn)
 

	
 
def test_multiacct_opening():
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Receivable:Accounts', 100),
 
        (next(testutil.OPENING_EQUITY_ACCOUNTS), -100),
 
        ('Liabilities:Payable:Accounts', -150),
 
        (next(testutil.OPENING_EQUITY_ACCOUNTS), 150),
 
    ])
 
    assert data.is_opening_balance_txn(txn)
 

	
 
def test_opening_with_fx():
 
    txn = testutil.OpeningBalance()
 
    equity_post = txn.postings[-1]
 
    txn.postings[-1] = equity_post._replace(
 
        units=testutil.Amount(equity_post.units.number * Decimal('.9'), 'EUR'),
 
        cost=testutil.Cost('1.11111'),
 
    )
 
    assert data.is_opening_balance_txn(txn)
 

	
 
@pytest.mark.parametrize('acct1,acct2,number', [
 
    ('Assets:Receivable:Accounts', 'Income:Donations', 100),
 
    ('Expenses:Other', 'Liabilities:Payable:Accounts', 200),
 
    ('Expenses:Other', 'Equity:Retained:Costs', 300),
 
    # Release from restriction
 
    ('Equity:Funds:Unrestricted', 'Equity:Funds:Restricted', 400),
 
    # Donation from project fund
 
    ('Equity:Funds:Restricted', 'Income:Donations', 500),
 
])
 
def test_not_opening_balance(acct1, acct2, number):
 
    txn = testutil.Transaction(postings=[
 
        (acct1, number),
 
        (acct2, -number),
 
    ])
 
    assert not data.is_opening_balance_txn(txn)
tests/test_meta_entity.py
Show inline comments
...
 
@@ -143,96 +143,109 @@ def test_invalid_values_on_transactions(hook, src_value):
 
    ])
 
    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):
0 comments (0 inline, 0 general)