data: Add Posting.is_payment() method.

This commit is contained in:
Brett Smith 2020-03-29 10:18:51 -04:00
parent 2909c405e6
commit 93feb2f4a3
3 changed files with 115 additions and 1 deletions

View file

@ -20,6 +20,7 @@ throughout Conservancy tools.
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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"""

View file

@ -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 <https://www.gnu.org/licenses/>.
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)

View file

@ -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'):