Files @ 5a8da108b983
Branch filter:

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

bsturmfels
statement_reconciler: Add initial Chase bank CSV statement matching

We currently don't have many examples to work with, so haven't done any
significant testing of the matching accuracy between statement and books.
"""test_reports_core - Unit tests for basic reports functions"""
# 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 datetime

import pytest

from decimal import Decimal

from . import testutil

from conservancy_beancount import data
from conservancy_beancount.reports import core

AMOUNTS = [
    2,
    Decimal('4.40'),
    testutil.Amount('6.60', 'CHF'),
    core.Balance([testutil.Amount('8.80')]),
]

@pytest.fixture
def balance_postings():
    dates = testutil.date_seq(testutil.FY_MID_DATE)
    return data.Posting.from_entries([
        testutil.Transaction(date=next(dates), postings=[
            ('Equity:OpeningBalance', -1000),
            ('Assets:Checking', 1000),
        ]),
        testutil.Transaction(date=next(dates), postings=[
            ('Income:Donations', -10),
            ('Expenses:BankingFees', 1),
            ('Assets:Checking', 9),
        ]),
        testutil.Transaction(date=next(dates), postings=[
            ('Income:Donations', -20),
            ('Expenses:Services:Fundraising', 1),
            ('Equity:Realized:CurrencyConversion', 1),
            ('Assets:Checking', 18),
        ]),
    ])

@pytest.mark.parametrize('acct_name', [
    'Assets:Checking',
    'Assets:Receivable:Accounts',
    'Expenses:Other',
    'Expenses:FilingFees',
])
def test_normalize_amount_func_pos(acct_name):
    actual = core.normalize_amount_func(acct_name)
    for amount in AMOUNTS:
        assert actual(amount) == amount

@pytest.mark.parametrize('acct_name', [
    'Equity:Funds:Restricted',
    'Equity:Realized:CurrencyConversion',
    'Income:Donations',
    'Income:Other',
    'Liabilities:CreditCard',
    'Liabilities:Payable:Accounts',
])
def test_normalize_amount_func_neg(acct_name):
    actual = core.normalize_amount_func(acct_name)
    for amount in AMOUNTS:
        assert actual(amount) == -amount

@pytest.mark.parametrize('acct_name', [
    '',
    'Assets',
    'Equity',
    'Expenses',
    'Income',
    'Liabilities',
])
def test_normalize_amount_func_bad_acct_name(acct_name):
    with pytest.raises(ValueError):
        core.normalize_amount_func(acct_name)

def test_sort_and_filter_accounts():
    accounts = (data.Account(s) for s in [
        'Expenses:Services',
        'Assets:Receivable',
        'Income:Other',
        'Liabilities:Payable',
        'Equity:Funds:Unrestricted',
        'Income:Donations',
        'Expenses:Other',
    ])
    actual = core.sort_and_filter_accounts(accounts, ['Equity', 'Income', 'Expenses'])
    assert list(actual) == [
        (0, 'Equity:Funds:Unrestricted'),
        (1, 'Income:Donations'),
        (1, 'Income:Other'),
        (2, 'Expenses:Other'),
        (2, 'Expenses:Services'),
    ]

def test_sort_and_filter_accounts_unused_name():
    accounts = (data.Account(s) for s in [
        'Liabilities:CreditCard',
        'Assets:Cash',
        'Assets:Receivable:Accounts',
    ])
    actual = core.sort_and_filter_accounts(
        accounts, ['Assets:Receivable', 'Liabilities:Payable', 'Assets', 'Liabilities'],
    )
    assert list(actual) == [
        (0, 'Assets:Receivable:Accounts'),
        (2, 'Assets:Cash'),
        (3, 'Liabilities:CreditCard'),
    ]

def test_sort_and_filter_accounts_with_subaccounts():
    accounts = (data.Account(s) for s in [
        'Assets:Checking',
        'Assets:Receivable:Fraud',
        'Assets:Cash',
        'Assets:Receivable:Accounts',
    ])
    actual = core.sort_and_filter_accounts(accounts, ['Assets:Receivable', 'Assets'])
    assert list(actual) == [
        (0, 'Assets:Receivable:Accounts'),
        (0, 'Assets:Receivable:Fraud'),
        (1, 'Assets:Cash'),
        (1, 'Assets:Checking'),
    ]

@pytest.mark.parametrize('empty_arg', ['accounts', 'order'])
def test_sort_and_filter_accounts_empty_accounts(empty_arg):
    accounts = [data.Account(s) for s in ['Expenses:Other', 'Income:Other']]
    if empty_arg == 'accounts':
        args = ([], accounts)
    else:
        args = (accounts, [])
    actual = core.sort_and_filter_accounts(*args)
    assert next(actual, None) is None

def check_account_balance(balance_seq, account, balance):
    assert next(balance_seq, None) == (account, {'USD': testutil.Amount(balance)})

@pytest.mark.parametrize('days_after', range(4))
def test_account_balances(balance_postings, days_after):
    start_date = testutil.FY_MID_DATE + datetime.timedelta(days=days_after)
    balance_cls = core.PeriodPostings.with_start_date(start_date)
    groups = dict(balance_cls.group_by_account(balance_postings))
    actual = core.account_balances(groups)
    expect_opening = -1027
    opening_acct, opening_bal = next(actual)
    if days_after < 1:
        check_account_balance(actual, 'Equity:OpeningBalance', -1000)
        expect_opening += 1000
    if days_after < 3:
        check_account_balance(actual, 'Equity:Realized:CurrencyConversion', 1)
        expect_opening -= 1
    if days_after < 2:
        check_account_balance(actual, 'Income:Donations', -30)
        expect_opening += 30
    elif days_after < 3:
        check_account_balance(actual, 'Income:Donations', -20)
        expect_opening += 20
    if days_after < 2:
        check_account_balance(actual, 'Expenses:BankingFees', 1)
        expect_opening -= 1
    if days_after < 3:
        check_account_balance(actual, 'Expenses:Services:Fundraising', 1)
        expect_opening -= 1
    if expect_opening:
        assert opening_bal == {'USD': testutil.Amount(expect_opening)}
    else:
        assert opening_bal.is_zero()
    assert opening_acct == core.OPENING_BALANCE_NAME
    check_account_balance(actual, core.ENDING_BALANCE_NAME, -1027)
    assert next(actual, None) is None

def test_account_balances_order_arg(balance_postings):
    start_date = testutil.FY_MID_DATE + datetime.timedelta(days=1)
    balance_cls = core.PeriodPostings.with_start_date(start_date)
    groups = dict(balance_cls.group_by_account(balance_postings))
    actual = core.account_balances(groups, ['Income', 'Assets'])
    check_account_balance(actual, core.OPENING_BALANCE_NAME, 1000)
    check_account_balance(actual, 'Income:Donations', -30)
    check_account_balance(actual, 'Assets:Checking', 27)
    check_account_balance(actual, core.ENDING_BALANCE_NAME, 997)
    assert next(actual, None) is None

def test_account_balances_order_filters_all(balance_postings):
    start_date = testutil.FY_MID_DATE + datetime.timedelta(days=1)
    balance_cls = core.PeriodPostings.with_start_date(start_date)
    groups = dict(balance_cls.group_by_account(balance_postings))
    actual = core.account_balances(groups, ['Liabilities'])
    account, balance = next(actual)
    assert account is core.OPENING_BALANCE_NAME
    assert balance.is_zero()
    account, balance = next(actual)
    assert account is core.ENDING_BALANCE_NAME
    assert balance.is_zero()

def test_account_balances_empty_postings():
    actual = core.account_balances({})
    account, balance = next(actual)
    assert account is core.OPENING_BALANCE_NAME
    assert balance.is_zero()
    account, balance = next(actual)
    assert account is core.ENDING_BALANCE_NAME
    assert balance.is_zero()