From 93feb2f4a3b33e3e2b191a6173210d2d62e75263 2020-03-29 14:18:51 From: Brett Smith Date: 2020-03-29 14:18:51 Subject: [PATCH] data: Add Posting.is_payment() method. --- diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index f7b438b6da38f8d99b8b4f21af865e16094796df..a6d8ae79dd7d2bc2bd2b9e0f79c3e5524ebeb62a 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -20,6 +20,7 @@ throughout Conservancy tools. # along with this program. If not, see . import collections +import decimal from beancount.core import account as bc_account @@ -29,6 +30,7 @@ from typing import ( MutableMapping, Optional, Sequence, + Union, ) from .beancount_types import ( @@ -38,6 +40,8 @@ from .beancount_types import ( Transaction, ) +DecimalCompat = Union[decimal.Decimal, int] + LINK_METADATA = frozenset([ 'approval', 'check', @@ -207,6 +211,16 @@ class Posting(BasePosting): # If it did, this declaration would pass without issue. meta: Metadata # type:ignore[assignment] + def is_payment(self, threshold: DecimalCompat=0) -> bool: + return ( + self.account.is_real_asset() + and self.units.number is not None + # mypy says abs returns an object and we can't negate that. + # Since we know threshold is numeric, it seems safe to assume + # the return value of abs is numeric as well. + and self.units.number < -abs(threshold) # type:ignore[operator] + ) + def iter_postings(txn: Transaction) -> Iterator[Posting]: """Yield an enhanced Posting object for every posting in the transaction""" diff --git a/tests/test_data_posting.py b/tests/test_data_posting.py new file mode 100644 index 0000000000000000000000000000000000000000..b9ee686cf755e80fed4dd72d0ce161ac316428ac --- /dev/null +++ b/tests/test_data_posting.py @@ -0,0 +1,94 @@ +"""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:Checking', +} + +NON_PAYMENT_ACCOUNTS = { + 'Accrued:AccountsReceivable', + 'Assets:PrepaidExpenses', + 'Assets:PrepaidVacation', + 'Expenses:Other', + 'Income:Other', + 'Liabilities:CreditCard', + 'UnearnedIncome:MatchPledges', +} + +def Posting(account, number, + currency='USD', cost=None, price=None, flag=None, + **meta): + if not meta: + meta = None + return data.Posting( + data.Account(account), + bc_amount.Amount(Decimal(number), currency), + cost, + price, + flag, + meta, + ) + +def check_all_thresholds(post, threshold, expected): + assert post.is_payment(threshold) is expected + assert post.is_payment(-threshold) is expected + assert post.is_payment(Decimal(threshold)) is expected + assert post.is_payment(Decimal(-threshold)) is expected + +@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(post, threshold, False) + +@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS) +def test_is_payment_with_threshold(acct): + threshold = len(acct) * 10 + post = Posting(acct, -500) + check_all_thresholds(post, threshold, True) + +@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(post, threshold, False) + +@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(post, 0, False) + check_all_thresholds(post, 5, False) + check_all_thresholds(post, 10, False) diff --git a/tests/testutil.py b/tests/testutil.py index 9963f75ed7d5fe5fde361683b6cdce7ff42defe7..01060048b347633db284e18db050ae8a22e4ce92 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -44,9 +44,15 @@ def check_post_meta(txn, *expected_meta, default=None): assert actual == expected def combine_values(*value_seqs): + stop = 0 + for seq in value_seqs: + try: + stop = max(stop, len(seq)) + except TypeError: + pass return itertools.islice( zip(*(itertools.cycle(seq) for seq in value_seqs)), - max(len(seq) for seq in value_seqs), + stop, ) def parse_date(s, fmt='%Y-%m-%d'):