Changeset - d66ba8773f5e
[Not reviewed]
0 4 0
Brett Smith - 4 years ago 2020-04-09 18:13:07
brettcsmith@brettcsmith.org
data: Make balance_of currency-aware.
4 files changed with 48 insertions and 19 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/data.py
Show inline comments
...
 
@@ -16,24 +16,25 @@ throughout Conservancy tools.
 
# 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 collections
 
import decimal
 
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,
 
    Callable,
 
    Iterable,
 
    Iterator,
 
    MutableMapping,
 
    Optional,
 
    Sequence,
 
    Union,
 
)
 

	
...
 
@@ -248,34 +249,44 @@ class Posting(BasePosting):
 
    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
 
    # declaration should also use MutableMapping, because it would be very
 
    # unusual for code to specifically require a Dict over that.
 
    # If it did, this declaration would pass without issue.
 
    meta: Metadata  # type:ignore[assignment]
 

	
 

	
 
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"""
 
    for index, source in enumerate(txn.postings):
 
        yield Posting(
 
            Account(source.account),
 
            *source[1:5],
 
            # see rationale above about Posting.meta
 
            PostingMeta(txn, index, source), # type:ignore[arg-type]
 
        )
conservancy_beancount/plugin/meta_approval.py
Show inline comments
...
 
@@ -31,17 +31,17 @@ class MetaApproval(core._RequireLinksPostingMetadataHook):
 
        self.payment_threshold = -config.payment_threshold()
 

	
 
    def _run_on_txn(self, txn: Transaction) -> bool:
 
        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,
 
            )
 
            ).number
 
        )
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return post.account.is_cash_equivalent() and post.units.number < 0
tests/test_data_balance_of.py
Show inline comments
...
 
@@ -15,53 +15,68 @@
 
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
 

	
 
from decimal import Decimal
 
from operator import methodcaller
 

	
 
import pytest
 

	
 
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():
 
    return testutil.Transaction(postings=[
 
        ('Liabilities:Payable:Accounts', 50),
 
        ('Assets:Checking', -50),
 
        ('Expenses:BankingFees', 5),
 
        ('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)
 

	
 
def test_balance_of_simple_txn():
 
    txn = testutil.Transaction(postings=[
 
        ('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=[
 
        ('Liabilities:CreditCard', 650),
 
        ('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)
tests/testutil.py
Show inline comments
...
 
@@ -60,34 +60,37 @@ def parse_date(s, fmt='%Y-%m-%d'):
 

	
 
def test_path(s):
 
    if s is None:
 
        return s
 
    s = Path(s)
 
    if not s.is_absolute():
 
        s = TESTS_DIR / s
 
    return 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,
 
        meta,
 
    )
 

	
 
LINK_METADATA_STRINGS = {
 
    'Invoices/304321.pdf',
 
    'rt:123/456',
 
    'rt://ticket/234',
 
}
 

	
0 comments (0 inline, 0 general)