diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index b93a2c7..7c016e0 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -25,6 +25,7 @@ import operator from beancount.core import account as bc_account from beancount.core import amount as bc_amount +from beancount.core import convert as bc_convert from typing import ( cast, @@ -257,18 +258,28 @@ class Posting(BasePosting): def balance_of(txn: Transaction, *preds: Callable[[Account], Optional[bool]], -) -> decimal.Decimal: +) -> Amount: """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. + + balance_of uses the "weight" of each posting, so the return value will + use the currency of the postings' cost when available. """ - return sum( - (post.units.number for post in iter_postings(txn) - if any(pred(post.account) for pred in preds)), - decimal.Decimal(0), - ) + match_posts = [post for post in iter_postings(txn) + if any(pred(post.account) for pred in preds)] + number = decimal.Decimal(0) + if not match_posts: + currency = '' + else: + weights: Sequence[Amount] = [ + bc_convert.get_weight(post) for post in match_posts # type:ignore[no-untyped-call] + ] + number = sum((wt.number for wt in weights), number) + currency = weights[0].currency + return Amount._make((number, currency)) 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 ca09b4b..31fdc88 100644 --- a/conservancy_beancount/plugin/meta_approval.py +++ b/conservancy_beancount/plugin/meta_approval.py @@ -40,7 +40,7 @@ class MetaApproval(core._RequireLinksPostingMetadataHook): txn, data.Account.is_cash_equivalent, data.Account.is_credit_card, - ) + ).number ) def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool: diff --git a/tests/test_data_balance_of.py b/tests/test_data_balance_of.py index 91e63b7..6a44fd6 100644 --- a/tests/test_data_balance_of.py +++ b/tests/test_data_balance_of.py @@ -24,6 +24,7 @@ from . import testutil from conservancy_beancount import data is_cash_eq = data.Account.is_cash_equivalent +USD = testutil.Amount @pytest.fixture def payable_payment_txn(): @@ -34,6 +35,14 @@ def payable_payment_txn(): ('Assets:Checking', -5), ]) +@pytest.fixture +def fx_donation_txn(): + return testutil.Transaction(postings=[ + ('Income:Donations', -500, 'EUR', ('.9', 'USD')), + ('Assets:Checking', 445), + ('Expenses:BankingFees', 5), + ]) + def balance_under(txn, *accts): pred = methodcaller('is_under', *accts) return data.balance_of(txn, pred) @@ -43,17 +52,18 @@ def test_balance_of_simple_txn(): ('Assets:Cash', 50), ('Income:Donations', -50), ]) - assert balance_under(txn, 'Assets') == 50 - assert balance_under(txn, 'Income') == -50 + assert balance_under(txn, 'Assets') == USD(50) + assert balance_under(txn, 'Income') == USD(-50) def test_zero_balance_of(payable_payment_txn): - assert balance_under(payable_payment_txn, 'Equity') == 0 - assert balance_under(payable_payment_txn, 'Assets:Cash') == 0 - assert balance_under(payable_payment_txn, 'Liabilities:CreditCard') == 0 + expected = testutil.Amount(0, '') + assert balance_under(payable_payment_txn, 'Equity') == expected + assert balance_under(payable_payment_txn, 'Assets:Cash') == expected + assert balance_under(payable_payment_txn, 'Liabilities:CreditCard') == expected def test_nonzero_balance_of(payable_payment_txn): - assert balance_under(payable_payment_txn, 'Assets', 'Expenses') == -50 - assert balance_under(payable_payment_txn, 'Assets', 'Liabilities') == -5 + assert balance_under(payable_payment_txn, 'Assets', 'Expenses') == USD(-50) + assert balance_under(payable_payment_txn, 'Assets', 'Liabilities') == USD(-5) def test_multiarg_balance_of(): txn = testutil.Transaction(postings=[ @@ -61,7 +71,12 @@ def test_multiarg_balance_of(): ('Expenses:BankingFees', 5), ('Assets:Checking', -655), ]) - assert data.balance_of(txn, is_cash_eq, data.Account.is_credit_card) == -5 + assert data.balance_of(txn, is_cash_eq, data.Account.is_credit_card) == USD(-5) def test_balance_of_multipost_txn(payable_payment_txn): - assert data.balance_of(payable_payment_txn, is_cash_eq) == -55 + assert data.balance_of(payable_payment_txn, is_cash_eq) == USD(-55) + +def test_balance_of_multicurrency_txn(fx_donation_txn): + assert balance_under(fx_donation_txn, 'Income') == USD(-450) + assert balance_under(fx_donation_txn, 'Income', 'Assets') == USD(-5) + assert balance_under(fx_donation_txn, 'Income', 'Expenses') == USD(-445) diff --git a/tests/testutil.py b/tests/testutil.py index 583de75..c1b4580 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -69,16 +69,19 @@ def test_path(s): def Amount(number, currency='USD'): return bc_amount.Amount(Decimal(number), currency) +def Cost(number, currency='USD', date=FY_MID_DATE, label=None): + return bc_data.Cost(Decimal(number), currency, date, label) + def Posting(account, number, currency='USD', cost=None, price=None, flag=None, **meta): - if not (number is None or isinstance(number, Decimal)): - number = Decimal(number) + if cost is not None: + cost = Cost(*cost) if meta is None: meta = None return bc_data.Posting( account, - bc_amount.Amount(number, currency), + Amount(number, currency), cost, price, flag,