Revise chart of accounts used throughout.

The main impetus of this change is to rename accounts that were outside
Beancount's accepted five root accounts, to move them into that
structure. This includes:

  Accrued:*Payable: → Liabilities:Payable:*
  Accrued:*Receivable: → Assets:Receivable:*
  UneanedIncome:* → Liabilities:UnearnedIncome:*

Note the last change did inspire in a change to our validation rules. We no
longer require income-type on unearned income, because it's no longer
considered income at all. Once it's earned and converted to an Income
account, that has an income-type of course.

This did inspire another rename that was not required, but
provided more consistency with the other account names above:

  Assets:Prepaid* → Assets:Prepaid:*

Where applicable, I have generally extended tests to make sure one of each
of the five account types is tested. (This mostly meant adding an Equity
account to the tests.) I also added tests for key parts of the hierarchy,
like Assets:Receivable and Liabilities:Payable, where applicable.

As part of this change, Account.is_real_asset() got renamed to
Account.is_cash_equivalent(), to better self-document its purpose.
This commit is contained in:
Brett Smith 2020-04-03 10:34:10 -04:00
parent 21c7646b41
commit c712105bed
18 changed files with 156 additions and 115 deletions

View file

@ -58,7 +58,7 @@ LINK_METADATA = frozenset([
class Account(str):
"""Account name string
This is a string that names an account, like Accrued:AccountsPayable
This is a string that names an account, like Assets:Bank:Checking
or Income:Donations. This class provides additional methods for common
account name parsing and queries.
"""
@ -66,18 +66,18 @@ class Account(str):
SEP = bc_account.sep
def is_checking(self) -> bool:
return self.is_real_asset() and ':Check' in self
def is_income(self) -> bool:
return self.is_under('Income:', 'UnearnedIncome:') is not None
def is_real_asset(self) -> bool:
return bool(
self.is_under('Assets:')
and not self.is_under('Assets:PrepaidExpenses', 'Assets:PrepaidVacation')
def is_cash_equivalent(self) -> bool:
return (
self.is_under('Assets:') is not None
and self.is_under('Assets:Prepaid', 'Assets:Receivable') is None
)
def is_checking(self) -> bool:
return self.is_cash_equivalent() and ':Check' in self
def is_credit_card(self) -> bool:
return self.is_under('Liabilities:CreditCard') is not None
def is_under(self, *acct_seq: str) -> Optional[str]:
"""Return a match if this account is "under" a part of the hierarchy
@ -248,7 +248,7 @@ class Posting(BasePosting):
threshold: DecimalCompat=0,
default: Optional[bool]=None,
) -> Optional[bool]:
return self.account.is_real_asset() and self.is_debit(threshold, default)
return self.account.is_cash_equivalent() and self.is_debit(threshold, default)
def iter_postings(txn: Transaction) -> Iterator[Posting]:

View file

@ -59,7 +59,12 @@ class MetaEntity(core.TransactionHook):
if txn_entity_ok is False:
yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, txn_entity)
for post in data.iter_postings(txn):
if post.account.is_under('Assets', 'Equity', 'Liabilities'):
if not post.account.is_under(
'Assets:Receivable',
'Expenses',
'Income',
'Liabilities:Payable',
):
continue
entity = post.meta.get(self.METADATA_KEY)
if entity is None:

View file

@ -30,6 +30,8 @@ class MetaIncomeType(core._NormalizePostingMetadataHook):
'UBTI',
})
DEFAULT_VALUES = {
'Income:Conferences:Registrations': 'RBI',
'Income:Conferences:Sponsorship': 'RBI',
'Income:Donations': 'Donations',
'Income:Honoraria': 'RBI',
'Income:Interest': 'RBI',
@ -38,12 +40,10 @@ class MetaIncomeType(core._NormalizePostingMetadataHook):
'Income:Sales': 'RBI',
'Income:SoftwareDevelopment': 'RBI',
'Income:TrademarkLicensing': 'RBI',
'UnearnedIncome:Conferences:Registrations': 'RBI',
'UnearnedIncome:MatchPledges': 'Donations',
}
def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
return post.account.is_income()
return post.account.is_under('Income') is not None
def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
try:

View file

@ -26,4 +26,7 @@ class MetaInvoice(core._RequireLinksPostingMetadataHook):
METADATA_KEY = 'invoice'
def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
return post.account.is_under('Accrued') is not None
return post.account.is_under(
'Assets:Receivable',
'Liabilities:Payable',
) is not None

View file

@ -79,12 +79,19 @@ class MetaProject(core._NormalizePostingMetadataHook):
)
def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
return post.account.is_under('Assets', 'Equity', 'Liabilities') is None
if post.account.is_under('Liabilities'):
return not post.account.is_credit_card()
else:
return post.account.is_under(
'Assets:Receivable',
'Expenses',
'Income',
) is not None
def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
if post.account.is_under(
'Accrued:VacationPayable',
'Expenses:Payroll',
'Liabilities:Payable:Vacation',
):
return self.DEFAULT_PROJECT
else:

View file

@ -29,8 +29,8 @@ class MetaReceipt(core._RequireLinksPostingMetadataHook):
self.payment_threshold = abs(config.payment_threshold())
def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
return bool(
(post.account.is_real_asset() or post.account.is_under('Liabilities'))
return (
(post.account.is_cash_equivalent() or post.account.is_credit_card())
and post.units.number is not None
and abs(post.units.number) >= self.payment_threshold
)
@ -52,7 +52,7 @@ class MetaReceipt(core._RequireLinksPostingMetadataHook):
if post.account.is_checking():
fallback_key = 'check'
elif post.account.is_under('Liabilities:CreditCard') and post_amount == -1:
elif post.account.is_credit_card() and post_amount == -1:
fallback_key = 'invoice'
elif post.account.is_under('Assets:PayPal') and post_amount == 1:
fallback_key = 'paypal-id'

View file

@ -57,7 +57,7 @@ class MetaReceivableDocumentation(core._RequireLinksPostingMetadataHook):
self.rt = rt_wrapper
def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
if not post.account.is_under('Accrued:AccountsReceivable'):
if not post.account.is_under('Assets:Receivable'):
return False
# Get the first invoice, or return False if it doesn't exist.

View file

@ -26,55 +26,46 @@ from conservancy_beancount import data
('Expenses:Tax:Sales', 'Expenses:', True),
('Expenses:Tax:Sales', 'Expenses', True),
('Expenses:Tax:Sales', 'Expense', False),
('Expenses:Tax:Sales', 'Accrued:', False),
('Expenses:Tax:Sales', 'Accrued', 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', [
('Income:Other', 'Income'),
('UnearnedIncome:Other', 'UnearnedIncome'),
('Accrued:AccountsPayable', None),
('Expenses:General', None),
('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 data.Account(acct_name).is_under('Income', 'UnearnedIncome') == expected
assert expected == data.Account(acct_name).is_under(
'Assets:Prepaid', 'Assets:Receivable',
)
if expected:
expected += ':'
assert data.Account(acct_name).is_under('Income:', 'UnearnedIncome:') == expected
assert expected == data.Account(acct_name).is_under(
'Assets:Prepaid:', 'Assets:Receivable:',
)
@pytest.mark.parametrize('acct_name,expected', [
('Accrued:AccountsReceivable', False),
('Assets:Cash', False),
('Expenses:General', False),
('Income:Donations', True),
('Income:Sales', True),
('Income:Other', True),
('Liabilities:CreditCard', False),
('UnearnedIncome:MatchPledges', True),
])
def test_is_income(acct_name, expected):
assert data.Account(acct_name).is_income() == expected
@pytest.mark.parametrize('acct_name,expected', [
('Accrued:AccountsPayable', False),
('Accrued:AccountsReceivable', False),
('Assets:Bank:Checking', True),
('Assets:Cash', True),
('Assets:Cash:EUR', True),
('Assets:PrepaidExpenses', False),
('Assets:PrepaidVacation', False),
('Assets:Bank:Checking', True),
('Expenses:General', False),
('Income:Donations', False),
('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_real_asset(acct_name, expected):
assert data.Account(acct_name).is_real_asset() == expected
def test_is_cash_equivalent(acct_name, expected):
assert data.Account(acct_name).is_cash_equivalent() == expected
@pytest.mark.parametrize('acct_name,expected', [
('Accrued:AccountsPayable', False),
('Accrued:AccountsReceivable', False),
('Assets:Bank:Check9999', True),
('Assets:Bank:CheckCard', True),
('Assets:Bank:Checking', True),
@ -83,10 +74,27 @@ def test_is_real_asset(acct_name, expected):
('Assets:Check9999', True),
('Assets:CheckCard', True),
('Assets:Checking', True),
('Assets:PrepaidExpenses', False),
('Assets:Savings', False),
('Expenses:CheckingFees', False),
('Income:Interest:Checking', False),
('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

View file

@ -26,17 +26,17 @@ from conservancy_beancount import data
PAYMENT_ACCOUNTS = {
'Assets:Cash',
'Assets:Checking',
'Assets:Bank:Checking',
}
NON_PAYMENT_ACCOUNTS = {
'Accrued:AccountsReceivable',
'Assets:PrepaidExpenses',
'Assets:PrepaidVacation',
'Assets:Prepaid:Expenses',
'Assets:Prepaid:Vacation',
'Assets:Receivable:Accounts',
'Equity:OpeningBalance',
'Expenses:Other',
'Income:Other',
'Liabilities:CreditCard',
'UnearnedIncome:MatchPledges',
}
AMOUNTS = [

View file

@ -21,18 +21,18 @@ from . import testutil
from conservancy_beancount.plugin import meta_approval
REQUIRED_ACCOUNTS = {
'Assets:Bank:Checking',
'Assets:Cash',
'Assets:Checking',
'Assets:Savings',
}
NON_REQUIRED_ACCOUNTS = {
'Accrued:AccountsPayable',
'Assets:PrepaidExpenses',
'Assets:PrepaidVacation',
'Assets:Prepaid:Expenses',
'Assets:Receivable:Accounts',
'Equity:QpeningBalance',
'Expenses:Other',
'Income:Other',
'UnearnedIncome:Donations',
'Liabilities:Payable:Accounts',
}
CREDITCARD_ACCOUNT = 'Liabilities:CreditCard'

View file

@ -111,17 +111,21 @@ def test_invalid_values_on_transactions(hook, src_value):
for error in hook.run(txn))
@pytest.mark.parametrize('account,required', [
('Accrued:AccountsReceivable', True),
('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),
('UnearnedIncome:Donations', True),
('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),
('Assets:Checking', -25),
(account, 25),
])
errors = list(hook.run(txn))

View file

@ -83,11 +83,12 @@ def test_invalid_values_on_transactions(hook, src_value):
testutil.check_post_meta(txn, None, None)
@pytest.mark.parametrize('account', [
'Accrued:AccountsReceivable',
'Assets:Cash',
'Income:Donations',
'Assets:Receivable:Accounts',
'Equity:OpeningBalance',
'Income:Other',
'Liabilities:CreditCard',
'UnearnedIncome:Donations',
'Liabilities:Payable:Vacation',
])
def test_non_expense_accounts_skipped(hook, account):
meta = {TEST_KEY: 'program'}

View file

@ -83,10 +83,12 @@ def test_invalid_values_on_transactions(hook, src_value):
testutil.check_post_meta(txn, None, None)
@pytest.mark.parametrize('account', [
'Accrued:AccountsReceivable',
'Assets:Cash',
'Expenses:General',
'Assets:Receivable:Accounts',
'Equity:OpeningBalance',
'Expenses:Other',
'Liabilities:CreditCard',
'Liabilities:Payable:Vacation',
])
def test_non_income_accounts_skipped(hook, account):
meta = {TEST_KEY: 'RBI'}
@ -99,6 +101,8 @@ def test_non_income_accounts_skipped(hook, account):
testutil.check_post_meta(txn, None, meta)
@pytest.mark.parametrize('account,set_value', [
('Income:Conferences:Registrations', 'RBI'),
('Income:Conferences:Sponsorship', 'RBI'),
('Income:Donations', 'Donations'),
('Income:Honoraria', 'RBI'),
('Income:Interest', 'RBI'),
@ -107,8 +111,6 @@ def test_non_income_accounts_skipped(hook, account):
('Income:Sales', 'RBI'),
('Income:SoftwareDevelopment', 'RBI'),
('Income:TrademarkLicensing', 'RBI'),
('UnearnedIncome:Conferences:Registrations', 'RBI'),
('UnearnedIncome:MatchPledges', 'Donations'),
])
def test_default_values(hook, account, set_value):
txn = testutil.Transaction(postings=[

View file

@ -21,16 +21,18 @@ from . import testutil
from conservancy_beancount.plugin import meta_invoice
REQUIRED_ACCOUNTS = {
'Accrued:AccountsPayable',
'Accrued:AccountsReceivable',
'Assets:Receivable:Accounts',
'Assets:Receivable:Loans',
'Liabilities:Payable:Accounts',
'Liabilities:Payable:Vacation',
}
NON_REQUIRED_ACCOUNTS = {
'Assets:Cash',
'Equity:OpeningBalance',
'Expenses:Other',
'Income:Other',
'Liabilities:CreditCard',
'UnearnedIncome:Donations',
}
TEST_KEY = 'invoice'

View file

@ -86,13 +86,17 @@ def test_invalid_values_on_transactions(hook, src_value):
testutil.check_post_meta(txn, None, None)
@pytest.mark.parametrize('account,required', [
('Accrued:AccountsReceivable', True),
('Assets:Cash', False),
('Equity:Opening-Balances', False),
('Assets:Receivable:Accounts', True),
('Assets:Receivable:Loans', True),
('Equity:OpeningBalance', False),
('Expenses:General', True),
('Income:Donations', True),
('Liabilities:CreditCard', False),
('UnearnedIncome:Donations', True),
('Liabilities:Payable:Accounts', True),
# We do want a "project" for Lia:Pay:Vacation but it has a default value
('Liabilities:Payable:Vacation', False),
('Liabilities:UnearnedIncome:Donations', True),
])
def test_which_accounts_required_on(hook, account, required):
txn = testutil.Transaction(postings=[
@ -103,9 +107,9 @@ def test_which_accounts_required_on(hook, account, required):
assert required == any(errors)
@pytest.mark.parametrize('account', [
'Accrued:VacationPayable',
'Expenses:Payroll:Salary',
'Expenses:Payroll:Tax',
'Liabilities:Payable:Vacation',
])
def test_default_values(hook, account):
txn = testutil.Transaction(postings=[
@ -126,7 +130,7 @@ def test_default_values(hook, account):
def test_default_value_set_in_date_range(hook, date, required):
txn = testutil.Transaction(date=date, postings=[
('Expenses:Payroll:Benefits', 25),
('Accrued:VacationPayable', -25),
('Liabilities:Payable:Vacation', -25),
])
errors = list(hook.run(txn))
assert not errors

View file

@ -77,13 +77,13 @@ KNOWN_FALLBACKS = {acct.fallback_meta for acct in ACCOUNTS_WITH_FALLBACKS}
# doesn't require the decorated test to go over every value, which in turn
# trims unnecessary test time.
NOT_REQUIRED_ACCOUNTS = itertools.cycle([
'Accrued:AccountsPayable',
'Accrued:AccountsReceivable',
'Assets:PrepaidExpenses',
'Assets:PrepaidVacation',
'Assets:Prepaid:Expenses',
'Assets:Receivable:Accounts',
'Equity:OpeningBalance',
'Expenses:Other',
'Income:Other',
'UnearnedIncome:Donations',
'Liabilities:Payable:Accounts',
'Liabilities:UnearnedIncome:Donations',
])
def check(hook, test_acct, other_acct, expected, *,

View file

@ -23,7 +23,7 @@ from . import testutil
from conservancy_beancount import errors as errormod
from conservancy_beancount.plugin import meta_receivable_documentation
TEST_ACCT = 'Accrued:AccountsReceivable'
TEST_ACCT = 'Assets:Receivable:Accounts'
OTHER_ACCT = 'Income:Donations'
SUPPORTING_METADATA = [
@ -178,9 +178,17 @@ def test_type_errors_reported_with_valid_txn_docs(hook, invoice, support_key, su
def test_received_invoices_not_checked(hook, invoice, meta_type):
check(hook, None, **{meta_type: {'invoice': invoice}})
def test_does_not_apply_to_payables(hook):
@pytest.mark.parametrize('account', [
'Assets:Bank:Checking',
'Assets:Cash',
'Equity:OpeningBalance',
'Expenses:BankingFees',
'Liabilities:CreditCard',
'Liabilities:Payable:Accounts',
])
def test_does_not_apply_to_other_accounts(hook, account):
meta = seed_meta()
check(hook, None, 'Accrued:AccountsPayable', 'Expenses:Other', post_meta=meta)
check(hook, None, account, 'Expenses:Other', post_meta=meta)
def test_configuration_error_without_rt():
config = testutil.TestConfig()

View file

@ -57,7 +57,7 @@ def hook():
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
def test_valid_values_on_postings(hook, src_value, set_value):
txn = testutil.Transaction(postings=[
('Accrued:AccountsPayable', 25),
('Liabilities:Payable:Accounts', 25),
('Assets:Cash', -25, {TEST_KEY: src_value}),
])
errors = list(hook.run(txn))
@ -67,7 +67,7 @@ def test_valid_values_on_postings(hook, src_value, set_value):
@pytest.mark.parametrize('src_value', INVALID_VALUES)
def test_invalid_values_on_postings(hook, src_value):
txn = testutil.Transaction(postings=[
('Accrued:AccountsPayable', 25),
('Liabilities:Payable:Accounts', 25),
('Assets:Cash', -25, {TEST_KEY: src_value}),
])
errors = list(hook.run(txn))
@ -77,7 +77,7 @@ def test_invalid_values_on_postings(hook, src_value):
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
def test_valid_values_on_transactions(hook, src_value, set_value):
txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
('Accrued:AccountsPayable', 25),
('Liabilities:Payable:Accounts', 25),
('Assets:Cash', -25),
])
errors = list(hook.run(txn))
@ -87,37 +87,34 @@ def test_valid_values_on_transactions(hook, src_value, set_value):
@pytest.mark.parametrize('src_value', INVALID_VALUES)
def test_invalid_values_on_transactions(hook, src_value):
txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
('Accrued:AccountsPayable', 25),
('Liabilities:Payable:Accounts', 25),
('Assets:Cash', -25),
])
errors = list(hook.run(txn))
assert errors
testutil.check_post_meta(txn, None, None)
@pytest.mark.parametrize('account', [
'Accrued:AccountsPayable',
'Expenses:General',
@pytest.mark.parametrize('count,account', enumerate([
'Assets:Payable:Accounts',
'Assets:Prepaid:Expenses',
'Equity:OpeningBalance',
'Expenses:Other',
'Income:Other',
'Liabilities:CreditCard',
])
def test_non_asset_accounts_skipped(hook, account):
'Liabilities:Payable:Accounts',
'Liabilities:UnearnedIncome:Donations',
], 1))
def test_non_payment_accounts_skipped(hook, account, count):
amount = count * 100
meta = {TEST_KEY: 'USA-Corporation'}
txn = testutil.Transaction(postings=[
(account, 25),
('Assets:Cash', -25, meta.copy()),
(account, amount),
('Assets:Checking', -amount, meta.copy()),
])
errors = list(hook.run(txn))
assert not errors
testutil.check_post_meta(txn, None, meta)
def test_prepaid_expenses_skipped(hook, ):
txn = testutil.Transaction(postings=[
('Expenses:General', 25),
('Assets:PrepaidExpenses', -25),
])
errors = list(hook.run(txn))
assert not errors
testutil.check_post_meta(txn, None, None)
def test_asset_credits_skipped(hook, ):
txn = testutil.Transaction(postings=[
('Income:Donations', -25),