"""Test Account class""" # 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 . import testutil from beancount.core.data import Open, Close, Booking from beancount.parser import options as bc_options from conservancy_beancount import data Date = datetime.date clean_account_meta = pytest.fixture()(testutil.clean_account_meta) @pytest.fixture def asset_hierarchy(): entries = [ Open({'classification': 'Investment'}, Date(2002, 2, 1), 'Assets:Bank:CD', None, None), Open({'classification': 'Cash'}, Date(2002, 2, 1), 'Assets:Bank:Checking', None, None), Open({'classification': 'Cash'}, Date(2002, 2, 1), 'Assets:Bank:Savings', None, None), Open({'classification': 'Cash'}, Date(2002, 2, 1), 'Assets:Cash', None, None), Open({'classification': 'Investment'}, Date(2002, 2, 1), 'Assets:Investment:Commodities', None, None), Open({'classification': 'Investment'}, Date(2002, 2, 1), 'Assets:Investment:Stocks', None, None), ] data.Account.load_openings_and_closings(entries) yield from testutil.clean_account_meta() def check_account_meta(acct_meta, opening, closing=None): if isinstance(acct_meta, str): acct_meta = data.Account(acct_meta).meta assert acct_meta == opening.meta assert acct_meta.account == opening.account assert acct_meta.booking == opening.booking assert acct_meta.currencies == opening.currencies assert acct_meta.open_date == opening.date assert acct_meta.open_meta == opening.meta if closing is None: assert acct_meta.close_date is None assert acct_meta.close_meta is None else: assert acct_meta.close_date == closing.date assert acct_meta.close_meta == closing.meta @pytest.mark.parametrize('acct_name,under_arg,expected', [ ('Expenses:Tax:Sales', 'Expenses:Tax:Sales:', False), ('Expenses:Tax:Sales', 'Expenses:Tax:Sales', True), ('Expenses:Tax:Sales', 'Expenses:Tax:', True), ('Expenses:Tax:Sales', 'Expenses:Tax', True), ('Expenses:Tax:Sales', 'Expenses:', True), ('Expenses:Tax:Sales', 'Expenses', True), ('Expenses:Tax:Sales', 'Expense', False), ('Expenses:Tax:Sales', 'Equity:', False), ('Expenses:Tax:Sales', 'Equity', False), ]) def test_is_under_one_arg(acct_name, under_arg, expected): expected = under_arg if expected else None assert data.Account(acct_name).is_under(under_arg) == expected @pytest.mark.parametrize('acct_name,expected', [ ('Assets:Cash', None), ('Assets:Checking', None), ('Assets:Prepaid:Expenses', 'Assets:Prepaid'), ('Assets:Receivable:Accounts', 'Assets:Receivable'), ]) def test_is_under_multi_arg(acct_name, expected): assert expected == data.Account(acct_name).is_under( 'Assets:Prepaid', 'Assets:Receivable', ) if expected: expected += ':' assert expected == data.Account(acct_name).is_under( 'Assets:Prepaid:', 'Assets:Receivable:', ) @pytest.mark.parametrize('acct_name,expected', [ ('Assets:Bank:Checking', True), ('Assets:Cash', True), ('Assets:Cash:EUR', True), ('Assets:Prepaid:Expenses', False), ('Assets:Prepaid:Vacation', False), ('Assets:Receivable:Accounts', False), ('Assets:Receivable:Fraud', False), ('Expenses:Other', False), ('Equity:OpeningBalance', False), ('Income:Other', False), ('Liabilities:CreditCard', False), ]) def test_is_cash_equivalent(acct_name, expected): assert data.Account(acct_name).is_cash_equivalent() == expected @pytest.mark.parametrize('acct_name,expected', [ ('Assets:Bank:Check9999', True), ('Assets:Bank:CheckCard', True), ('Assets:Bank:Checking', True), ('Assets:Bank:Savings', False), ('Assets:Cash', False), ('Assets:Check9999', True), ('Assets:CheckCard', True), ('Assets:Checking', True), ('Assets:Prepaid:Expenses', False), ('Assets:Receivable:Accounts', False), ('Expenses:Other', False), ('Equity:OpeningBalance', False), ('Income:Other', False), ('Liabilities:CreditCard', False), ]) def test_is_checking(acct_name, expected): assert data.Account(acct_name).is_checking() == expected @pytest.mark.parametrize('acct_name,expected', [ ('Assets:Cash', False), ('Assets:Prepaid:Expenses', False), ('Assets:Receivable:Accounts', False), ('Expenses:Other', False), ('Equity:OpeningBalance', False), ('Income:Other', False), ('Liabilities:CreditCard', True), ('Liabilities:CreditCard:Visa', True), ('Liabilities:Payable:Accounts', False), ('Liabilities:UnearnedIncome:Donations', False), ]) def test_is_credit_card(acct_name, expected): assert data.Account(acct_name).is_credit_card() == expected @pytest.mark.parametrize('acct_name,expected', [ ('Assets:Cash', False), ('Assets:Prepaid:Expenses', False), ('Assets:Receivable:Accounts', False), ('Expenses:Other', False), ('Equity:Funds:Restricted', True), ('Equity:Funds:Unrestricted', True), ('Equity:OpeningBalance', True), ('Equity:Retained:Costs', False), ('Income:Other', False), ('Liabilities:CreditCard', False), ('Liabilities:Payable:Accounts', False), ('Liabilities:UnearnedIncome:Donations', False), ]) def test_is_opening_equity(acct_name, expected): assert data.Account(acct_name).is_opening_equity() == expected @pytest.mark.parametrize('date', [ testutil.PAST_DATE, testutil.FY_START_DATE, testutil.FY_MID_DATE, testutil.FUTURE_DATE, ]) def test_is_open_on_date_without_opening(date): account = data.Account('Assets:Cash') assert account.is_open_on_date(date) is None @pytest.mark.parametrize('days_diff', range(-2, 3)) def test_is_open_on_date_without_closing(clean_account_meta, days_diff): open_date = testutil.FY_START_DATE acct_name = 'Assets:Checking' data.Account.load_opening(Open({}, open_date, acct_name, None, None)) account = data.Account(acct_name) check_date = open_date + datetime.timedelta(days=days_diff) assert account.is_open_on_date(check_date) == (days_diff >= 0) @pytest.mark.parametrize('close_diff,check_diff', [ (30, -30), (30, -1), (30, 0), (30, 1), (30, 29), (30, 30), (30, 60), (60, 30), (60, 59), (60, 60), (60, 90), (60, -60), ]) def test_is_open_on_date_with_closing(clean_account_meta, close_diff, check_diff): open_date = testutil.FY_START_DATE acct_name = 'Assets:Savings' data.Account.load_opening(Open({}, open_date, acct_name, None, None)) close_date = open_date + datetime.timedelta(days=close_diff) data.Account.load_closing(Close({}, close_date, acct_name)) account = data.Account(acct_name) check_date = open_date + datetime.timedelta(days=check_diff) expected = (0 <= check_diff < close_diff) assert account.is_open_on_date(check_date) == expected @pytest.mark.parametrize('acct_name', [ 'Assets:Cash', 'Assets:Prepaid:Expenses', 'Assets:Receivable:Accounts', 'Expenses:Other', 'Equity:Funds:Restricted', 'Equity:OpeningBalance', 'Income:Other', 'Liabilities:CreditCard', 'Liabilities:Payable:Accounts', 'Liabilities:UnearnedIncome:Donations', ]) def test_keeps_balance(acct_name): expected = acct_name.startswith(('Assets:', 'Liabilities:')) assert data.Account(acct_name).keeps_balance() == expected def test_keeps_balance_uses_options(clean_account_meta): config = bc_options.OPTIONS_DEFAULTS.copy() config['name_liabilities'] = 'Debts' data.Account.load_options_map(config) assert not data.Account('Liabilities:CreditCard').keeps_balance() assert data.Account('Debts:Payable').keeps_balance() assert data.Account('Assets:Receivable').keeps_balance() @pytest.mark.parametrize('acct_name', [ 'Assets:Cash', 'Assets:Receivable:Accounts', 'Expenses:Other', 'Equity:Funds:Restricted', 'Income:Other', 'Liabilities:CreditCard', 'Liabilities:Payable:Accounts', ]) def test_slice_parts_no_args(acct_name): account = data.Account(acct_name) assert account.slice_parts() == acct_name.split(':') @pytest.mark.parametrize('acct_name', [ 'Assets:Cash', 'Assets:Receivable:Accounts', 'Expenses:Other', 'Equity:Funds:Restricted', 'Income:Other', 'Liabilities:CreditCard', 'Liabilities:Payable:Accounts', ]) def test_slice_parts_index(acct_name): account = data.Account(acct_name) parts = acct_name.split(':') for index, expected in enumerate(parts): assert account.slice_parts(index) == expected with pytest.raises(IndexError): account.slice_parts(index + 1) @pytest.mark.parametrize('acct_name', [ 'Assets:Cash', 'Assets:Receivable:Accounts', 'Expenses:Other', 'Equity:Funds:Restricted', 'Income:Other', 'Liabilities:CreditCard', 'Liabilities:Payable:Accounts', ]) def test_slice_parts_range(acct_name): account = data.Account(acct_name) parts = acct_name.split(':') for start, stop in zip([0, 0, 1, 1], [2, 3, 2, 3]): assert account.slice_parts(start, stop) == parts[start:stop] @pytest.mark.parametrize('acct_name', [ 'Assets:Cash', 'Assets:Receivable:Accounts', 'Expenses:Other', 'Equity:Funds:Restricted', 'Income:Other', 'Liabilities:CreditCard', 'Liabilities:Payable:Accounts', ]) def test_slice_parts_slice(acct_name): account = data.Account(acct_name) parts = acct_name.split(':') for start, stop in zip([0, 0, 1, 1], [2, 3, 2, 3]): sl = slice(start, stop) assert account.slice_parts(sl) == parts[start:stop] @pytest.mark.parametrize('acct_name', [ 'Assets:Cash', 'Assets:Receivable:Accounts', 'Expenses:Other', 'Equity:Funds:Restricted', 'Income:Other', 'Liabilities:CreditCard', 'Liabilities:Payable:Accounts', ]) def test_count_parts(acct_name): account = data.Account(acct_name) assert account.count_parts() == acct_name.count(':') + 1 @pytest.mark.parametrize('acct_name', [ 'Assets:Cash', 'Assets:Receivable:Accounts', 'Expenses:Other', 'Equity:Funds:Restricted', 'Income:Other', 'Liabilities:CreditCard', 'Liabilities:Payable:Accounts', ]) def test_root_part(acct_name): account = data.Account(acct_name) parts = acct_name.split(':') assert account.root_part() == parts[0] assert account.root_part(1) == parts[0] assert account.root_part(2) == ':'.join(parts[:2]) def test_load_opening(clean_account_meta): opening = Open({'lineno': 210}, Date(2010, 2, 1), 'Assets:Cash', None, None) data.Account.load_opening(opening) check_account_meta('Assets:Cash', opening) def test_load_closing(clean_account_meta): name = 'Assets:Checking' opening = Open({'lineno': 230}, Date(2010, 10, 1), name, None, None) closing = Close({'lineno': 235}, Date(2010, 11, 1), name) data.Account.load_opening(opening) data.Account.load_closing(closing) check_account_meta(name, opening, closing) def test_load_closing_without_opening(clean_account_meta): closing = Close({'lineno': 245}, Date(2010, 3, 1), 'Assets:Cash') with pytest.raises(ValueError): data.Account.load_closing(closing) def test_load_openings_and_closings(clean_account_meta): entries = [ Open({'lineno': 1, 'income-type': 'Donations'}, Date(2000, 3, 1), 'Income:Donations', None, None), Open({'lineno': 2}, Date(2000, 3, 1), 'Income:Other', None, None), Open({'lineno': 3, 'asset-type': 'Cash equivalent'}, Date(2000, 4, 1), 'Assets:Checking', ['USD', 'EUR'], Booking.STRICT), testutil.Transaction(date=Date(2000, 4, 10), postings=[ ('Income:Donations', -10), ('Assets:Checking', 10), ]), Close({'lineno': 30, 'why': 'Changed banks'}, Date(2000, 5, 1), 'Assets:Checking') ] data.Account.load_openings_and_closings(iter(entries)) check_account_meta('Income:Donations', entries[0]) check_account_meta('Income:Other', entries[1]) check_account_meta('Assets:Checking', entries[2], entries[-1]) @pytest.mark.parametrize('account_s', [ 'Assets:Bank:Checking', 'Equity:Funds:Restricted', 'Expenses:Other', 'Income:Donations', 'Liabilities:CreditCard:Visa', ]) def test_is_account(account_s): assert data.Account.is_account(account_s) @pytest.mark.parametrize('account_s', [ 'Assets:Bank:12-345', 'Equity:Funds:Restricted', 'Expenses:Other', 'Income:Donations', 'Liabilities:CreditCard:Visa0123', ]) def test_is_account(account_s): assert data.Account.is_account(account_s) @pytest.mark.parametrize('account_s', [ 'Assets:checking', 'Assets::Cash', 'Equity', 'Liabilities:Credit Card', 'income:Donations', 'Expenses:Banking_Fees', 'Revenue:Grants', ]) def test_is_not_account(account_s): assert not data.Account.is_account(account_s) @pytest.mark.parametrize('account_s,expected', [ ('Revenue:Donations', True), ('Costs:Other', True), ('Income:Donations', False), ('Expenses:Other', False), ]) def test_is_account_respects_configured_roots(clean_account_meta, account_s, expected): config = bc_options.OPTIONS_DEFAULTS.copy() config['name_expenses'] = 'Costs' config['name_income'] = 'Revenue' data.Account.load_options_map(config) assert data.Account.is_account(account_s) == expected def test_load_from_books(clean_account_meta): entries = [ Open({'lineno': 310}, Date(2001, 1, 1), 'Assets:Bank:Checking', ['USD'], None), Open({'lineno': 315}, Date(2001, 2, 1), 'Revenue:Donations', None, Booking.STRICT), testutil.Transaction(date=Date(2001, 2, 10), postings=[ ('Revenue:Donations', -10), ('Assets:Bank:Checking', 10), ]), Close({'lineno': 320}, Date(2001, 3, 1), 'Assets:Bank:Checking'), ] config = bc_options.OPTIONS_DEFAULTS.copy() config['name_expenses'] = 'Costs' config['name_income'] = 'Revenue' data.Account.load_from_books(entries, config) for post in entries[2].postings: assert data.Account.is_account(post.account) check_meta = data.Account(entries[0].account).meta assert check_meta.open_date == entries[0].date assert check_meta.close_date == entries[-1].date @pytest.mark.parametrize('arg,expect_subaccts', [ ('Assets', ['Bank:CD', 'Bank:Checking', 'Bank:Savings', 'Cash', 'Investment:Commodities', 'Investment:Stocks']), ('Assets:Bank', ['CD', 'Checking', 'Savings']), ('Assets:Investment', ['Commodities', 'Stocks']), ('Equity', []), ]) def test_iter_accounts_by_hierarchy(asset_hierarchy, arg, expect_subaccts): assert set(data.Account.iter_accounts_by_hierarchy(arg)) == { f'{arg}:{sub}' for sub in expect_subaccts } @pytest.mark.parametrize('arg,expect_subaccts', [ ('Cash', ['Bank:Checking', 'Bank:Savings', 'Cash']), ('Investment', ['Bank:CD', 'Investment:Commodities', 'Investment:Stocks']), ('Equity', []), ]) def test_iter_accounts_by_classification(asset_hierarchy, arg, expect_subaccts): assert set(data.Account.iter_accounts_by_classification(arg)) == { f'Assets:{sub}' for sub in expect_subaccts } @pytest.mark.parametrize('arg,expect_subaccts', [ (None, ['Bank:CD', 'Bank:Checking', 'Bank:Savings', 'Cash', 'Investment:Commodities', 'Investment:Stocks']), ('Assets', ['Bank:CD', 'Bank:Checking', 'Bank:Savings', 'Cash', 'Investment:Commodities', 'Investment:Stocks']), ('Assets:Bank', ['CD', 'Checking', 'Savings']), ('Assets:Investment', ['Commodities', 'Stocks']), ('Cash', ['Bank:Checking', 'Bank:Savings', 'Cash']), ('Investment', ['Bank:CD', 'Investment:Commodities', 'Investment:Stocks']), ('Equity', []), ('Unused classification', []), ]) def test_iter_accounts(asset_hierarchy, arg, expect_subaccts): if arg and arg.startswith('Assets'): prefix = arg else: prefix = 'Assets' assert set(data.Account.iter_accounts(arg)) == { f'{prefix}:{sub}' for sub in expect_subaccts }