diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py
index f7b438b..a6d8ae7 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 0000000..b9ee686
--- /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 9963f75..0106004 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'):