reports: Add normalize_amount_func() function.

This commit is contained in:
Brett Smith 2020-06-06 11:30:44 -04:00
parent cd1b28ae3e
commit e26dffa214
3 changed files with 94 additions and 10 deletions

View file

@ -125,6 +125,7 @@ STANDARD_PATH = Path('-')
CompoundAmount = TypeVar('CompoundAmount', data.Amount, core.Balance)
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
RTObject = Mapping[str, str]
T = TypeVar('T')
logger = logging.getLogger('conservancy_beancount.reports.accrual')
@ -134,19 +135,14 @@ class Sentinel:
class Account(NamedTuple):
name: str
norm_func: Callable[[CompoundAmount], CompoundAmount]
aging_thresholds: Sequence[int]
class AccrualAccount(enum.Enum):
# Note the aging report uses the same order accounts are defined here.
# See AgingODS.start_spreadsheet().
RECEIVABLE = Account(
'Assets:Receivable', lambda bal: bal, [365, 120, 90, 60],
)
PAYABLE = Account(
'Liabilities:Payable', operator.neg, [365, 90, 60, 30],
)
RECEIVABLE = Account('Assets:Receivable', [365, 120, 90, 60])
PAYABLE = Account('Liabilities:Payable', [365, 90, 60, 30])
@classmethod
def account_names(cls) -> Iterator[str]:
@ -167,6 +163,10 @@ class AccrualAccount(enum.Enum):
return account
raise ValueError("unrecognized account set in related postings")
@property
def normalize_amount(self) -> Callable[[T], T]:
return core.normalize_amount_func(self.value.name)
class AccrualPostings(core.RelatedPostings):
def _meta_getter(key: MetaKey) -> Callable[[data.Posting], MetaValue]: # type:ignore[misc]
@ -221,8 +221,7 @@ class AccrualPostings(core.RelatedPostings):
self.paid_entities = self.accrued_entities
else:
self.accrual_type = AccrualAccount.classify(self)
accrual_acct: Account = self.accrual_type.value
norm_func = accrual_acct.norm_func
norm_func = self.accrual_type.normalize_amount
self.end_balance = norm_func(self.balance_at_cost())
self.accrued_entities = self._collect_entities(
lambda post: norm_func(post.units).number > 0,
@ -453,7 +452,7 @@ class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]):
return
raw_balance = row.balance()
if row.accrual_type is not None:
raw_balance = row.accrual_type.value.norm_func(raw_balance)
raw_balance = row.accrual_type.normalize_amount(raw_balance)
if raw_balance == row.end_balance:
amount_cell = odf.table.TableCell()
else:

View file

@ -73,6 +73,7 @@ LinkType = Union[str, Tuple[str, Optional[str]]]
RelatedType = TypeVar('RelatedType', bound='RelatedPostings')
RT = TypeVar('RT', bound=Sequence)
ST = TypeVar('ST')
T = TypeVar('T')
class Balance(Mapping[str, data.Amount]):
"""A collection of amounts mapped by currency
@ -898,3 +899,21 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta):
with path.open(f'{mode}b') as out_file:
out_file = cast(BinaryIO, out_file)
self.save_file(out_file)
def normalize_amount_func(account_name: str) -> Callable[[T], T]:
"""Get a function to normalize amounts for reporting
Given an account name, return a function that can be used on "amounts"
under that account (including numbers, Amount objects, and Balance objects)
to normalize them for reporting. Right now that means make flipping the
sign for accounts where "normal" postings are negative.
"""
if account_name.startswith(('Assets:', 'Expenses:')):
# We can't just return operator.pos because Beancount's Amount class
# doesn't implement __pos__.
return lambda amt: amt
elif account_name.startswith(('Equity:', 'Income:', 'Liabilities:')):
return operator.neg
else:
raise ValueError(f"unrecognized account name {account_name!r}")

View file

@ -0,0 +1,66 @@
"""test_reports_core - Unit tests for basic reports functions"""
# 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 decimal import Decimal
from . import testutil
from conservancy_beancount.reports import core
AMOUNTS = [
2,
Decimal('4.40'),
testutil.Amount('6.60', 'CHF'),
core.Balance([testutil.Amount('8.80')]),
]
@pytest.mark.parametrize('acct_name', [
'Assets:Checking',
'Assets:Receivable:Accounts',
'Expenses:Other',
'Expenses:FilingFees',
])
def test_normalize_amount_func_pos(acct_name):
actual = core.normalize_amount_func(acct_name)
for amount in AMOUNTS:
assert actual(amount) == amount
@pytest.mark.parametrize('acct_name', [
'Equity:Funds:Restricted',
'Equity:Realized:CurrencyConversion',
'Income:Donations',
'Income:Other',
'Liabilities:CreditCard',
'Liabilities:Payable:Accounts',
])
def test_normalize_amount_func_neg(acct_name):
actual = core.normalize_amount_func(acct_name)
for amount in AMOUNTS:
assert actual(amount) == -amount
@pytest.mark.parametrize('acct_name', [
'',
'Assets',
'Equity',
'Expenses',
'Income',
'Liabilities',
])
def test_normalize_amount_func_bad_acct_name(acct_name):
with pytest.raises(ValueError):
core.normalize_amount_func(acct_name)