diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index 589308342db0be07661c24b67f394f7783289d5e..b93a2c7e854b4beba00ff94d7062980142f599bb 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -24,6 +24,7 @@ import decimal import operator from beancount.core import account as bc_account +from beancount.core import amount as bc_amount from typing import ( cast, @@ -114,6 +115,22 @@ class Account(str): return None +class Amount(bc_amount.Amount): + """Beancount amount after processing + + Beancount's native Amount class declares number to be Optional[Decimal], + because the number is None when Beancount first parses a posting that does + not have an amount, because the user wants it to be automatically balanced. + + As part of the loading process, Beancount replaces those None numbers + with the calculated amount, so it will always be a Decimal. This class + overrides the type declaration accordingly, so the type checker knows + that our code doesn't have to consider the possibility that number is + None. + """ + number: decimal.Decimal + + class Metadata(MutableMapping[MetaKey, MetaValue]): """Transaction or posting metadata @@ -221,11 +238,14 @@ class Posting(BasePosting): specific fields are replaced with enhanced versions: * The `account` field is an Account object + * The `units` field is our Amount object (which simply declares that the + number is always a Decimal—see that docstring for details) * The `meta` field is a PostingMeta object """ __slots__ = () account: Account + units: Amount # mypy correctly complains that our MutableMapping is not compatible # with Beancount's meta type declaration of Optional[Dict]. IMO # Beancount's type declaration is a smidge too specific: I think its type @@ -234,56 +254,21 @@ class Posting(BasePosting): # If it did, this declaration would pass without issue. meta: Metadata # type:ignore[assignment] - def _compare_amount(self, - op: Callable[[decimal.Decimal], decimal.Decimal], - threshold: DecimalCompat, - default: Optional[bool], - ) -> Optional[bool]: - if self.units.number is None: - return default - else: - return op(self.units.number) > threshold - - def is_credit(self, - threshold: DecimalCompat=0, - default: Optional[bool]=None, - ) -> Optional[bool]: - return self._compare_amount(operator.pos, threshold, default) - - def is_debit(self, - threshold: DecimalCompat=0, - default: Optional[bool]=None, - ) -> Optional[bool]: - return self._compare_amount(operator.neg, threshold, default) - - def is_payment(self, - threshold: DecimalCompat=0, - default: Optional[bool]=None, - ) -> Optional[bool]: - return self.account.is_cash_equivalent() and self.is_debit(threshold, default) - def balance_of(txn: Transaction, *preds: Callable[[Account], Optional[bool]], - default: Optional[DecimalCompat]=None, -) -> Optional[decimal.Decimal]: +) -> decimal.Decimal: """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. - - If any of the postings have no amount, returns default. """ - retval = decimal.Decimal(0) - for post in txn.postings: - acct = Account(post.account) - if any(p(acct) for p in preds): - if post.units.number is None: - return None if default is None else decimal.Decimal(default) - else: - retval += post.units.number - return retval + return sum( + (post.units.number for post in iter_postings(txn) + if any(pred(post.account) for pred in preds)), + decimal.Decimal(0), + ) def iter_postings(txn: Transaction) -> Iterator[Posting]: """Yield an enhanced Posting object for every posting in the transaction""" diff --git a/conservancy_beancount/plugin/meta_approval.py b/conservancy_beancount/plugin/meta_approval.py index d9704eb7990c2ea6c73931f356b88b8247561b1f..ca09b4ba31c69b59c418d1d7228936bd26e199ea 100644 --- a/conservancy_beancount/plugin/meta_approval.py +++ b/conservancy_beancount/plugin/meta_approval.py @@ -31,17 +31,17 @@ class MetaApproval(core._RequireLinksPostingMetadataHook): self.payment_threshold = -config.payment_threshold() def _run_on_txn(self, txn: Transaction) -> bool: - if not super()._run_on_txn(txn): - return False - # approval is required when funds leave a cash equivalent asset, - # UNLESS that transaction is a transfer to another asset, - # or paying off a credit card. - balance = data.balance_of( - txn, - data.Account.is_cash_equivalent, - data.Account.is_credit_card, + return ( + super()._run_on_txn(txn) + # approval is required when funds leave a cash equivalent asset, + # UNLESS that transaction is a transfer to another asset, + # or paying off a credit card. + and self.payment_threshold > data.balance_of( + txn, + data.Account.is_cash_equivalent, + data.Account.is_credit_card, + ) ) - return balance is None or balance < self.payment_threshold def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: - return post.account.is_cash_equivalent() and not post.is_credit(0) + return post.account.is_cash_equivalent() and post.units.number < 0 diff --git a/conservancy_beancount/plugin/meta_payable_documentation.py b/conservancy_beancount/plugin/meta_payable_documentation.py index 8f77611066f1f316804445186f619b4fbf122988..fd37651a66bfd23514d6811a05fa44f7fa68d3ef 100644 --- a/conservancy_beancount/plugin/meta_payable_documentation.py +++ b/conservancy_beancount/plugin/meta_payable_documentation.py @@ -27,6 +27,6 @@ class MetaPayableDocumentation(core._RequireLinksPostingMetadataHook): def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: if post.account.is_under('Liabilities:Payable:Accounts'): - return not post.is_credit() + return post.units.number < 0 else: return False diff --git a/conservancy_beancount/plugin/meta_receipt.py b/conservancy_beancount/plugin/meta_receipt.py index 423cfe9cbcf8945f77439991773f8b5f157bb1f7..c620579addb6139635e5024739f308bb056ed6ae 100644 --- a/conservancy_beancount/plugin/meta_receipt.py +++ b/conservancy_beancount/plugin/meta_receipt.py @@ -39,7 +39,6 @@ class MetaReceipt(core._RequireLinksPostingMetadataHook): return ( (post.account.is_cash_equivalent() or post.account.is_credit_card()) and not post.account.is_under('Assets:PayPal') - and post.units.number is not None and abs(post.units.number) >= self.payment_threshold ) @@ -66,10 +65,10 @@ class MetaReceipt(core._RequireLinksPostingMetadataHook): def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: keys = list(self.CHECKED_METADATA) is_checking = post.account.is_checking() - if is_checking and post.is_debit(): + if is_checking and post.units.number < 0: return self._run_checking_debit(txn, post) elif is_checking: keys.append('check') - elif post.account.is_credit_card() and not post.is_credit(): + elif post.account.is_credit_card() and post.units.number <= 0: keys.append('invoice') return self._check_metadata(txn, post, keys) diff --git a/conservancy_beancount/plugin/meta_receivable_documentation.py b/conservancy_beancount/plugin/meta_receivable_documentation.py index e95e577630f9bfa7a23f2cc9cb4236708b1b608a..df7c21bdca946871a1c69afd5c5ac8063cc8c1d2 100644 --- a/conservancy_beancount/plugin/meta_receivable_documentation.py +++ b/conservancy_beancount/plugin/meta_receivable_documentation.py @@ -54,7 +54,7 @@ class MetaReceivableDocumentation(core._RequireLinksPostingMetadataHook): def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: if not post.account.is_under('Assets:Receivable'): return False - elif post.is_debit(): + elif post.units.number < 0: return False # Get the first invoice, or return False if it doesn't exist. diff --git a/conservancy_beancount/plugin/meta_tax_implication.py b/conservancy_beancount/plugin/meta_tax_implication.py index 154fb101f3c7ade705601e99e3f8623a873e00df..7a2fe2b86938140d3ada608a63fb156495c6b0ce 100644 --- a/conservancy_beancount/plugin/meta_tax_implication.py +++ b/conservancy_beancount/plugin/meta_tax_implication.py @@ -45,7 +45,10 @@ class MetaTaxImplication(core._NormalizePostingMetadataHook): ]) def __init__(self, config: configmod.Config) -> None: - self.payment_threshold = config.payment_threshold() + self.payment_threshold = -config.payment_threshold() def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: - return post.is_payment(self.payment_threshold) is not False + return ( + post.account.is_cash_equivalent() + and post.units.number < self.payment_threshold + ) diff --git a/tests/test_data_balance_of.py b/tests/test_data_balance_of.py index 4d6e3084ebb99ebc5835ce387548a35f1cd288e1..91e63b7d68780c7cefe7986c57531eda5a93452f 100644 --- a/tests/test_data_balance_of.py +++ b/tests/test_data_balance_of.py @@ -34,23 +34,6 @@ def payable_payment_txn(): ('Assets:Checking', -5), ]) -@pytest.fixture -def none_posting_txn(): - return testutil.Transaction(postings=[ - ('Income:Donations', -30), - ('Expenses:BankingFees', 3), - ('Assets:Checking', None), - ]) - -@pytest.fixture -def multipost_one_none_txn(): - return testutil.Transaction(postings=[ - ('Liabilities:Payable:Accounts', 50), - ('Assets:Checking', -50), - ('Expenses:BankingFees', 5), - ('Assets:Checking', None), - ]) - def balance_under(txn, *accts): pred = methodcaller('is_under', *accts) return data.balance_of(txn, pred) @@ -82,25 +65,3 @@ def test_multiarg_balance_of(): def test_balance_of_multipost_txn(payable_payment_txn): assert data.balance_of(payable_payment_txn, is_cash_eq) == -55 - -def test_balance_of_none_posting(none_posting_txn): - assert data.balance_of(none_posting_txn, is_cash_eq) is None - -def test_balance_of_none_posting_with_default(none_posting_txn): - expected = Decimal('Infinity') - assert expected == data.balance_of( - none_posting_txn, is_cash_eq, default=expected, - ) - -def test_balance_of_other_side_of_none_posting(none_posting_txn): - assert balance_under(none_posting_txn, 'Income') == -30 - assert balance_under(none_posting_txn, 'Expenses') == 3 - -def test_balance_of_multi_postings_one_none(multipost_one_none_txn): - assert data.balance_of(multipost_one_none_txn, is_cash_eq) is None - -def test_balance_of_multi_postings_one_none(multipost_one_none_txn): - expected = Decimal('Infinity') - assert expected == data.balance_of( - multipost_one_none_txn, is_cash_eq, default=expected, - ) diff --git a/tests/test_data_posting.py b/tests/test_data_posting.py deleted file mode 100644 index 708c940fc8b4e88fb8271d1bf32746fbe17c385d..0000000000000000000000000000000000000000 --- a/tests/test_data_posting.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Test Posting class""" -# Copyright © 2020 Brett Smith -# -# This program is free software: you can redistribute it and/or modify -# 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 . - -import pytest - -from . import testutil - -from decimal import Decimal - -import beancount.core.amount as bc_amount - -from conservancy_beancount import data - -PAYMENT_ACCOUNTS = { - 'Assets:Cash', - 'Assets:Bank:Checking', -} - -NON_PAYMENT_ACCOUNTS = { - 'Assets:Prepaid:Expenses', - 'Assets:Prepaid:Vacation', - 'Assets:Receivable:Accounts', - 'Equity:OpeningBalance', - 'Expenses:Other', - 'Income:Other', - 'Liabilities:CreditCard', -} - -AMOUNTS = [ - None, - '-25.50', - 0, - '25.75', -] - -def Posting(account, number, - currency='USD', cost=None, price=None, flag=None, - **meta): - if not meta: - meta = None - if number is not None: - number = Decimal(number) - return data.Posting( - data.Account(account), - bc_amount.Amount(number, currency), - cost, - price, - flag, - meta, - ) - -def check_all_thresholds(expected, method, threshold, *args): - assert method(threshold, *args) is expected - assert method(Decimal(threshold), *args) is expected - -@pytest.mark.parametrize('amount', AMOUNTS) -def test_is_credit(amount): - expected = None if amount is None else float(amount) > 0 - assert Posting('Assets:Cash', amount).is_credit() is expected - -def test_is_credit_threshold(): - post = Posting('Assets:Cash', 25) - check_all_thresholds(True, post.is_credit, 0) - check_all_thresholds(True, post.is_credit, 20) - check_all_thresholds(False, post.is_credit, 40) - -def test_is_credit_default(): - post = Posting('Assets:Cash', None) - assert post.is_credit(default=True) is True - assert post.is_credit(default=False) is False - -@pytest.mark.parametrize('amount', AMOUNTS) -def test_is_debit(amount): - expected = None if amount is None else float(amount) < 0 - assert Posting('Assets:Cash', amount).is_debit() is expected - -def test_is_debit_threshold(): - post = Posting('Assets:Cash', -25) - check_all_thresholds(True, post.is_debit, 0) - check_all_thresholds(True, post.is_debit, 20) - check_all_thresholds(False, post.is_debit, 40) - -def test_is_debit_default(): - post = Posting('Assets:Cash', None) - assert post.is_debit(default=True) is True - assert post.is_debit(default=False) is False - -@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS) -def test_is_payment(acct): - assert Posting(acct, -500).is_payment() - -@pytest.mark.parametrize('acct,amount,threshold', testutil.combine_values( - NON_PAYMENT_ACCOUNTS, - range(5, 20, 5), - range(0, 30, 10), -)) -def test_is_not_payment_account(acct, amount, threshold): - post = Posting(acct, -amount) - assert not post.is_payment() - check_all_thresholds(False, post.is_payment, threshold) - -@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS) -def test_is_payment_with_threshold(acct): - threshold = len(acct) * 10 - post = Posting(acct, -500) - check_all_thresholds(True, post.is_payment, threshold) - -@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS) -def test_is_not_payment_by_threshold(acct): - threshold = len(acct) * 10 - post = Posting(acct, -9) - check_all_thresholds(False, post.is_payment, threshold) - -@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS) -def test_is_not_payment_but_credit(acct): - post = Posting(acct, 9) - assert not post.is_payment() - check_all_thresholds(False, post.is_payment, 0) - check_all_thresholds(False, post.is_payment, 5) - check_all_thresholds(False, post.is_payment, 10)