reports.core: Start Balance class.
This commit is contained in:
parent
219cd4bc37
commit
5aa30e5456
4 changed files with 141 additions and 16 deletions
|
@ -23,11 +23,60 @@ from .. import data
|
||||||
from typing import (
|
from typing import (
|
||||||
overload,
|
overload,
|
||||||
Dict,
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
List,
|
List,
|
||||||
|
Mapping,
|
||||||
|
Optional,
|
||||||
Sequence,
|
Sequence,
|
||||||
|
Tuple,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Balance(Mapping[str, data.Amount]):
|
||||||
|
"""A collection of amounts mapped by currency
|
||||||
|
|
||||||
|
Each key is a Beancount currency string, and each value represents the
|
||||||
|
balance in that currency.
|
||||||
|
"""
|
||||||
|
__slots__ = ('_currency_map',)
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
source: Union[Iterable[Tuple[str, data.Amount]],
|
||||||
|
Mapping[str, data.Amount]]=(),
|
||||||
|
) -> None:
|
||||||
|
if isinstance(source, Mapping):
|
||||||
|
source = source.items()
|
||||||
|
self._currency_map = {
|
||||||
|
currency: amount.number for currency, amount in source
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"{type(self).__name__}({self._currency_map!r})"
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> data.Amount:
|
||||||
|
return data.Amount(self._currency_map[key], key)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
return iter(self._currency_map)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._currency_map)
|
||||||
|
|
||||||
|
def is_zero(self) -> bool:
|
||||||
|
return all(number == 0 for number in self._currency_map.values())
|
||||||
|
|
||||||
|
|
||||||
|
class MutableBalance(Balance):
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def add_amount(self, amount: data.Amount) -> None:
|
||||||
|
try:
|
||||||
|
self._currency_map[amount.currency] += amount.number
|
||||||
|
except KeyError:
|
||||||
|
self._currency_map[amount.currency] = amount.number
|
||||||
|
|
||||||
|
|
||||||
class RelatedPostings(Sequence[data.Posting]):
|
class RelatedPostings(Sequence[data.Posting]):
|
||||||
"""Collect and query related postings
|
"""Collect and query related postings
|
||||||
|
|
||||||
|
@ -72,8 +121,8 @@ class RelatedPostings(Sequence[data.Posting]):
|
||||||
def add(self, post: data.Posting) -> None:
|
def add(self, post: data.Posting) -> None:
|
||||||
self._postings.append(post)
|
self._postings.append(post)
|
||||||
|
|
||||||
def balance(self) -> Sequence[data.Amount]:
|
def balance(self) -> Balance:
|
||||||
currency_balance: Dict[str, Decimal] = collections.defaultdict(Decimal)
|
balance = MutableBalance()
|
||||||
for post in self:
|
for post in self:
|
||||||
currency_balance[post.units.currency] += post.units.number
|
balance.add_amount(post.units)
|
||||||
return [data.Amount(number, key) for key, number in currency_balance.items()]
|
return balance
|
||||||
|
|
68
tests/test_reports_balance.py
Normal file
68
tests/test_reports_balance.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
"""test_reports_balance - Unit tests for reports.core.Balance"""
|
||||||
|
# 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 itertools
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from . import testutil
|
||||||
|
|
||||||
|
from conservancy_beancount.reports import core
|
||||||
|
|
||||||
|
def test_empty_balance():
|
||||||
|
balance = core.Balance()
|
||||||
|
assert not balance
|
||||||
|
assert len(balance) == 0
|
||||||
|
assert balance.is_zero()
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
balance['USD']
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('currencies', [
|
||||||
|
'USD',
|
||||||
|
'EUR GBP',
|
||||||
|
'JPY INR BRL',
|
||||||
|
])
|
||||||
|
def test_zero_balance(currencies):
|
||||||
|
keys = currencies.split()
|
||||||
|
balance = core.Balance(testutil.balance_map((key, 0) for key in keys))
|
||||||
|
assert balance
|
||||||
|
assert len(balance) == len(keys)
|
||||||
|
assert balance.is_zero()
|
||||||
|
assert all(balance[key].number == 0 for key in keys)
|
||||||
|
assert all(balance[key].currency == key for key in keys)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('currencies', [
|
||||||
|
'USD',
|
||||||
|
'EUR GBP',
|
||||||
|
'JPY INR BRL',
|
||||||
|
])
|
||||||
|
def test_nonzero_balance(currencies):
|
||||||
|
amounts = testutil.balance_map(zip(currencies.split(), itertools.count(110, 100)))
|
||||||
|
balance = core.Balance(amounts.items())
|
||||||
|
assert balance
|
||||||
|
assert len(balance) == len(amounts)
|
||||||
|
assert not balance.is_zero()
|
||||||
|
assert all(balance[key] == amt for key, amt in amounts.items())
|
||||||
|
|
||||||
|
def test_mixed_balance():
|
||||||
|
amounts = testutil.balance_map(USD=0, EUR=120)
|
||||||
|
balance = core.Balance(amounts.items())
|
||||||
|
assert balance
|
||||||
|
assert len(balance) == 2
|
||||||
|
assert not balance.is_zero()
|
||||||
|
assert all(balance[key] == amt for key, amt in amounts.items())
|
|
@ -17,6 +17,8 @@
|
||||||
import datetime
|
import datetime
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from . import testutil
|
from . import testutil
|
||||||
|
@ -53,17 +55,17 @@ def donation(amount, currency='USD', date=None, other_acct='Assets:Cash', **meta
|
||||||
def test_balance():
|
def test_balance():
|
||||||
related = core.RelatedPostings()
|
related = core.RelatedPostings()
|
||||||
related.add(data.Posting.from_beancount(donation(10), 0))
|
related.add(data.Posting.from_beancount(donation(10), 0))
|
||||||
assert related.balance() == [testutil.Amount(-10)]
|
assert related.balance() == testutil.balance_map(USD=-10)
|
||||||
related.add(data.Posting.from_beancount(donation(15), 0))
|
related.add(data.Posting.from_beancount(donation(15), 0))
|
||||||
assert related.balance() == [testutil.Amount(-25)]
|
assert related.balance() == testutil.balance_map(USD=-25)
|
||||||
related.add(data.Posting.from_beancount(donation(20), 0))
|
related.add(data.Posting.from_beancount(donation(20), 0))
|
||||||
assert related.balance() == [testutil.Amount(-45)]
|
assert related.balance() == testutil.balance_map(USD=-45)
|
||||||
|
|
||||||
def test_balance_zero():
|
def test_balance_zero():
|
||||||
related = core.RelatedPostings()
|
related = core.RelatedPostings()
|
||||||
related.add(data.Posting.from_beancount(donation(10), 0))
|
related.add(data.Posting.from_beancount(donation(10), 0))
|
||||||
related.add(data.Posting.from_beancount(donation(-10), 0))
|
related.add(data.Posting.from_beancount(donation(-10), 0))
|
||||||
assert related.balance() == [testutil.Amount(0)]
|
assert related.balance().is_zero()
|
||||||
|
|
||||||
def test_balance_multiple_currencies():
|
def test_balance_multiple_currencies():
|
||||||
related = core.RelatedPostings()
|
related = core.RelatedPostings()
|
||||||
|
@ -71,17 +73,11 @@ def test_balance_multiple_currencies():
|
||||||
related.add(data.Posting.from_beancount(donation(15, 'GBP'), 0))
|
related.add(data.Posting.from_beancount(donation(15, 'GBP'), 0))
|
||||||
related.add(data.Posting.from_beancount(donation(20, 'EUR'), 0))
|
related.add(data.Posting.from_beancount(donation(20, 'EUR'), 0))
|
||||||
related.add(data.Posting.from_beancount(donation(25, 'EUR'), 0))
|
related.add(data.Posting.from_beancount(donation(25, 'EUR'), 0))
|
||||||
assert set(related.balance()) == {
|
assert related.balance() == testutil.balance_map(EUR=-45, GBP=-25)
|
||||||
testutil.Amount(-25, 'GBP'),
|
|
||||||
testutil.Amount(-45, 'EUR'),
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_balance_multiple_currencies_one_zero():
|
def test_balance_multiple_currencies_one_zero():
|
||||||
related = core.RelatedPostings()
|
related = core.RelatedPostings()
|
||||||
related.add(data.Posting.from_beancount(donation(10, 'EUR'), 0))
|
related.add(data.Posting.from_beancount(donation(10, 'EUR'), 0))
|
||||||
related.add(data.Posting.from_beancount(donation(15, 'USD'), 0))
|
related.add(data.Posting.from_beancount(donation(15, 'USD'), 0))
|
||||||
related.add(data.Posting.from_beancount(donation(-10, 'EUR'), 0))
|
related.add(data.Posting.from_beancount(donation(-10, 'EUR'), 0))
|
||||||
assert set(related.balance()) == {
|
assert related.balance() == testutil.balance_map(EUR=0, USD=-15)
|
||||||
testutil.Amount(-15, 'USD'),
|
|
||||||
testutil.Amount(0, 'EUR'),
|
|
||||||
}
|
|
||||||
|
|
|
@ -113,6 +113,18 @@ OPENING_EQUITY_ACCOUNTS = itertools.cycle([
|
||||||
'Equity:OpeningBalance',
|
'Equity:OpeningBalance',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def balance_map(source=None, **kwargs):
|
||||||
|
# The source and/or kwargs should map currency name strings to
|
||||||
|
# things you can pass to Decimal (a decimal string, an int, etc.)
|
||||||
|
# This returns a dict that maps currency name strings to Amount instances.
|
||||||
|
retval = {}
|
||||||
|
if source is not None:
|
||||||
|
retval.update((currency, Amount(number, currency))
|
||||||
|
for currency, number in source)
|
||||||
|
if kwargs:
|
||||||
|
retval.update(balance_map(kwargs.items()))
|
||||||
|
return retval
|
||||||
|
|
||||||
class Transaction:
|
class Transaction:
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
date=FY_MID_DATE, flag='*', payee=None,
|
date=FY_MID_DATE, flag='*', payee=None,
|
||||||
|
|
Loading…
Reference in a new issue