reports: Add account_balances() function.
This commit is contained in:
parent
6213bc1e5d
commit
5e9e11923e
3 changed files with 158 additions and 37 deletions
|
@ -68,6 +68,9 @@ from ..beancount_types import (
|
||||||
MetaValue,
|
MetaValue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OPENING_BALANCE_NAME = "OPENING BALANCE"
|
||||||
|
ENDING_BALANCE_NAME = "ENDING BALANCE"
|
||||||
|
|
||||||
DecimalCompat = data.DecimalCompat
|
DecimalCompat = data.DecimalCompat
|
||||||
BalanceType = TypeVar('BalanceType', bound='Balance')
|
BalanceType = TypeVar('BalanceType', bound='Balance')
|
||||||
ElementType = Callable[..., odf.element.Element]
|
ElementType = Callable[..., odf.element.Element]
|
||||||
|
@ -1104,6 +1107,50 @@ class BaseODS(BaseSpreadsheet[RT, ST], metaclass=abc.ABCMeta):
|
||||||
self.save_file(out_file)
|
self.save_file(out_file)
|
||||||
|
|
||||||
|
|
||||||
|
def account_balances(
|
||||||
|
groups: Mapping[data.Account, PeriodPostings],
|
||||||
|
order: Optional[Sequence[str]]=None,
|
||||||
|
) -> Iterator[Tuple[str, Balance]]:
|
||||||
|
"""Iterate account balances over a date range
|
||||||
|
|
||||||
|
1. ``subclass = PeriodPostings.with_start_date(start_date)``
|
||||||
|
2. ``groups = dict(subclass.group_by_account(postings))``
|
||||||
|
3. ``for acct, bal in account_balances(groups, [optional ordering]): ...``
|
||||||
|
|
||||||
|
This function returns an iterator of 2-tuples ``(account, balance)``
|
||||||
|
that you can use to generate a report in the style of ``ledger balance``.
|
||||||
|
The accounts are accounts in ``groups`` that appeared under one of the
|
||||||
|
account name strings in ``order``. ``balance`` is the corresponding
|
||||||
|
balance over the time period (``groups[key].period_bal``). Accounts are
|
||||||
|
iterated in the order provided by ``sort_and_filter_accounts()``.
|
||||||
|
|
||||||
|
The first 2-tuple is ``(OPENING_BALANCE_NAME, balance)`` with the balance of
|
||||||
|
all these accounts as of ``start_date``.
|
||||||
|
The final 2-tuple is ``(ENDING_BALANCE_NAME, balance)`` with the final
|
||||||
|
balance of all these accounts as of ``start_date``.
|
||||||
|
The iterator will always yield these special 2-tuples, even when there are
|
||||||
|
no accounts in the input or to report.
|
||||||
|
"""
|
||||||
|
if order is None:
|
||||||
|
order = ['Equity', 'Income', 'Expenses']
|
||||||
|
acct_seq = [account for _, account in sort_and_filter_accounts(groups, order)]
|
||||||
|
yield (OPENING_BALANCE_NAME, sum(
|
||||||
|
(groups[key].start_bal for key in acct_seq),
|
||||||
|
MutableBalance(),
|
||||||
|
))
|
||||||
|
for key in acct_seq:
|
||||||
|
postings = groups[key]
|
||||||
|
try:
|
||||||
|
in_date_range = postings[-1].meta.date >= postings.START_DATE
|
||||||
|
except IndexError:
|
||||||
|
in_date_range = False
|
||||||
|
if in_date_range:
|
||||||
|
yield (key, groups[key].period_bal)
|
||||||
|
yield (ENDING_BALANCE_NAME, sum(
|
||||||
|
(groups[key].stop_bal for key in acct_seq),
|
||||||
|
MutableBalance(),
|
||||||
|
))
|
||||||
|
|
||||||
def normalize_amount_func(account_name: str) -> Callable[[T], T]:
|
def normalize_amount_func(account_name: str) -> Callable[[T], T]:
|
||||||
"""Get a function to normalize amounts for reporting
|
"""Get a function to normalize amounts for reporting
|
||||||
|
|
||||||
|
|
|
@ -312,27 +312,7 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
for key in self.metadata_columns),
|
for key in self.metadata_columns),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _combined_balance_row(self,
|
|
||||||
balance_accounts: Sequence[str],
|
|
||||||
attr_name: str,
|
|
||||||
) -> None:
|
|
||||||
date = getattr(self.date_range, attr_name)
|
|
||||||
balance_attrname = f'{attr_name}_bal'
|
|
||||||
balance = -sum((
|
|
||||||
getattr(related, balance_attrname)
|
|
||||||
for account, related in self.account_groups.items()
|
|
||||||
if account.is_under(*balance_accounts)
|
|
||||||
), core.MutableBalance())
|
|
||||||
self.add_row(
|
|
||||||
self.string_cell(
|
|
||||||
f"Balance as of {date.isoformat()}",
|
|
||||||
stylename=self.merge_styles(self.style_bold, self.style_endtext),
|
|
||||||
),
|
|
||||||
self.balance_cell(balance, stylename=self.style_bold),
|
|
||||||
)
|
|
||||||
|
|
||||||
def write_balance_sheet(self) -> None:
|
def write_balance_sheet(self) -> None:
|
||||||
balance_accounts = ['Equity', 'Income', 'Expenses']
|
|
||||||
self.use_sheet("Balance")
|
self.use_sheet("Balance")
|
||||||
self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(3)))
|
self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(3)))
|
||||||
self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(1.5)))
|
self.sheet.addElement(odf.table.TableColumn(stylename=self.column_style(1.5)))
|
||||||
|
@ -349,17 +329,20 @@ class LedgerODS(core.BaseODS[data.Posting, data.Account]):
|
||||||
numbercolumnsspanned=2,
|
numbercolumnsspanned=2,
|
||||||
))
|
))
|
||||||
self.add_row()
|
self.add_row()
|
||||||
self._combined_balance_row(balance_accounts, 'start')
|
for account, balance in core.account_balances(self.account_groups):
|
||||||
for _, account in core.sort_and_filter_accounts(
|
if account is core.OPENING_BALANCE_NAME:
|
||||||
self.account_groups, balance_accounts,
|
text = f"Balance as of {self.date_range.start.isoformat()}"
|
||||||
):
|
style = self.merge_styles(self.style_bold, self.style_endtext)
|
||||||
balance = self.account_groups[account].period_bal
|
elif account is core.ENDING_BALANCE_NAME:
|
||||||
if not balance.is_zero():
|
text = f"Balance as of {self.date_range.stop.isoformat()}"
|
||||||
|
style = self.merge_styles(self.style_bold, self.style_endtext)
|
||||||
|
else:
|
||||||
|
text = account
|
||||||
|
style = self.style_endtext
|
||||||
self.add_row(
|
self.add_row(
|
||||||
self.string_cell(account, stylename=self.style_endtext),
|
self.string_cell(text, stylename=style),
|
||||||
self.balance_cell(-balance),
|
self.balance_cell(-balance, stylename=style),
|
||||||
)
|
)
|
||||||
self._combined_balance_row(balance_accounts, 'stop')
|
|
||||||
|
|
||||||
def write(self, rows: Iterable[data.Posting]) -> None:
|
def write(self, rows: Iterable[data.Posting]) -> None:
|
||||||
related_cls = core.PeriodPostings.with_start_date(self.date_range.start)
|
related_cls = core.PeriodPostings.with_start_date(self.date_range.start)
|
||||||
|
|
|
@ -14,16 +14,17 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from . import testutil
|
from . import testutil
|
||||||
|
|
||||||
|
from conservancy_beancount import data
|
||||||
from conservancy_beancount.reports import core
|
from conservancy_beancount.reports import core
|
||||||
|
|
||||||
from conservancy_beancount.data import Account
|
|
||||||
|
|
||||||
AMOUNTS = [
|
AMOUNTS = [
|
||||||
2,
|
2,
|
||||||
Decimal('4.40'),
|
Decimal('4.40'),
|
||||||
|
@ -31,6 +32,27 @@ AMOUNTS = [
|
||||||
core.Balance([testutil.Amount('8.80')]),
|
core.Balance([testutil.Amount('8.80')]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def balance_postings():
|
||||||
|
dates = testutil.date_seq(testutil.FY_MID_DATE)
|
||||||
|
return data.Posting.from_entries([
|
||||||
|
testutil.Transaction(date=next(dates), postings=[
|
||||||
|
('Equity:OpeningBalance', -1000),
|
||||||
|
('Assets:Checking', 1000),
|
||||||
|
]),
|
||||||
|
testutil.Transaction(date=next(dates), postings=[
|
||||||
|
('Income:Donations', -10),
|
||||||
|
('Expenses:BankingFees', 1),
|
||||||
|
('Assets:Checking', 9),
|
||||||
|
]),
|
||||||
|
testutil.Transaction(date=next(dates), postings=[
|
||||||
|
('Income:Donations', -20),
|
||||||
|
('Expenses:Services:Fundraising', 1),
|
||||||
|
('Equity:Realized:CurrencyConversion', 1),
|
||||||
|
('Assets:Checking', 18),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
|
||||||
@pytest.mark.parametrize('acct_name', [
|
@pytest.mark.parametrize('acct_name', [
|
||||||
'Assets:Checking',
|
'Assets:Checking',
|
||||||
'Assets:Receivable:Accounts',
|
'Assets:Receivable:Accounts',
|
||||||
|
@ -68,7 +90,7 @@ def test_normalize_amount_func_bad_acct_name(acct_name):
|
||||||
core.normalize_amount_func(acct_name)
|
core.normalize_amount_func(acct_name)
|
||||||
|
|
||||||
def test_sort_and_filter_accounts():
|
def test_sort_and_filter_accounts():
|
||||||
accounts = (Account(s) for s in [
|
accounts = (data.Account(s) for s in [
|
||||||
'Expenses:Services',
|
'Expenses:Services',
|
||||||
'Assets:Receivable',
|
'Assets:Receivable',
|
||||||
'Income:Other',
|
'Income:Other',
|
||||||
|
@ -87,7 +109,7 @@ def test_sort_and_filter_accounts():
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_sort_and_filter_accounts_unused_name():
|
def test_sort_and_filter_accounts_unused_name():
|
||||||
accounts = (Account(s) for s in [
|
accounts = (data.Account(s) for s in [
|
||||||
'Liabilities:CreditCard',
|
'Liabilities:CreditCard',
|
||||||
'Assets:Cash',
|
'Assets:Cash',
|
||||||
'Assets:Receivable:Accounts',
|
'Assets:Receivable:Accounts',
|
||||||
|
@ -102,7 +124,7 @@ def test_sort_and_filter_accounts_unused_name():
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_sort_and_filter_accounts_with_subaccounts():
|
def test_sort_and_filter_accounts_with_subaccounts():
|
||||||
accounts = (Account(s) for s in [
|
accounts = (data.Account(s) for s in [
|
||||||
'Assets:Checking',
|
'Assets:Checking',
|
||||||
'Assets:Receivable:Fraud',
|
'Assets:Receivable:Fraud',
|
||||||
'Assets:Cash',
|
'Assets:Cash',
|
||||||
|
@ -118,10 +140,79 @@ def test_sort_and_filter_accounts_with_subaccounts():
|
||||||
|
|
||||||
@pytest.mark.parametrize('empty_arg', ['accounts', 'order'])
|
@pytest.mark.parametrize('empty_arg', ['accounts', 'order'])
|
||||||
def test_sort_and_filter_accounts_empty_accounts(empty_arg):
|
def test_sort_and_filter_accounts_empty_accounts(empty_arg):
|
||||||
accounts = [Account(s) for s in ['Expenses:Other', 'Income:Other']]
|
accounts = [data.Account(s) for s in ['Expenses:Other', 'Income:Other']]
|
||||||
if empty_arg == 'accounts':
|
if empty_arg == 'accounts':
|
||||||
args = ([], accounts)
|
args = ([], accounts)
|
||||||
else:
|
else:
|
||||||
args = (accounts, [])
|
args = (accounts, [])
|
||||||
actual = core.sort_and_filter_accounts(*args)
|
actual = core.sort_and_filter_accounts(*args)
|
||||||
assert next(actual, None) is None
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
def check_account_balance(balance_seq, account, balance):
|
||||||
|
assert next(balance_seq, None) == (account, {'USD': testutil.Amount(balance)})
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('days_after', range(4))
|
||||||
|
def test_account_balances(balance_postings, days_after):
|
||||||
|
start_date = testutil.FY_MID_DATE + datetime.timedelta(days=days_after)
|
||||||
|
balance_cls = core.PeriodPostings.with_start_date(start_date)
|
||||||
|
groups = dict(balance_cls.group_by_account(balance_postings))
|
||||||
|
actual = core.account_balances(groups)
|
||||||
|
expect_opening = -1027
|
||||||
|
opening_acct, opening_bal = next(actual)
|
||||||
|
if days_after < 1:
|
||||||
|
check_account_balance(actual, 'Equity:OpeningBalance', -1000)
|
||||||
|
expect_opening += 1000
|
||||||
|
if days_after < 3:
|
||||||
|
check_account_balance(actual, 'Equity:Realized:CurrencyConversion', 1)
|
||||||
|
expect_opening -= 1
|
||||||
|
if days_after < 2:
|
||||||
|
check_account_balance(actual, 'Income:Donations', -30)
|
||||||
|
expect_opening += 30
|
||||||
|
elif days_after < 3:
|
||||||
|
check_account_balance(actual, 'Income:Donations', -20)
|
||||||
|
expect_opening += 20
|
||||||
|
if days_after < 2:
|
||||||
|
check_account_balance(actual, 'Expenses:BankingFees', 1)
|
||||||
|
expect_opening -= 1
|
||||||
|
if days_after < 3:
|
||||||
|
check_account_balance(actual, 'Expenses:Services:Fundraising', 1)
|
||||||
|
expect_opening -= 1
|
||||||
|
if expect_opening:
|
||||||
|
assert opening_bal == {'USD': testutil.Amount(expect_opening)}
|
||||||
|
else:
|
||||||
|
assert opening_bal.is_zero()
|
||||||
|
assert opening_acct == core.OPENING_BALANCE_NAME
|
||||||
|
check_account_balance(actual, core.ENDING_BALANCE_NAME, -1027)
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
def test_account_balances_order_arg(balance_postings):
|
||||||
|
start_date = testutil.FY_MID_DATE + datetime.timedelta(days=1)
|
||||||
|
balance_cls = core.PeriodPostings.with_start_date(start_date)
|
||||||
|
groups = dict(balance_cls.group_by_account(balance_postings))
|
||||||
|
actual = core.account_balances(groups, ['Income', 'Assets'])
|
||||||
|
check_account_balance(actual, core.OPENING_BALANCE_NAME, 1000)
|
||||||
|
check_account_balance(actual, 'Income:Donations', -30)
|
||||||
|
check_account_balance(actual, 'Assets:Checking', 27)
|
||||||
|
check_account_balance(actual, core.ENDING_BALANCE_NAME, 997)
|
||||||
|
assert next(actual, None) is None
|
||||||
|
|
||||||
|
def test_account_balances_order_filters_all(balance_postings):
|
||||||
|
start_date = testutil.FY_MID_DATE + datetime.timedelta(days=1)
|
||||||
|
balance_cls = core.PeriodPostings.with_start_date(start_date)
|
||||||
|
groups = dict(balance_cls.group_by_account(balance_postings))
|
||||||
|
actual = core.account_balances(groups, ['Liabilities'])
|
||||||
|
account, balance = next(actual)
|
||||||
|
assert account is core.OPENING_BALANCE_NAME
|
||||||
|
assert balance.is_zero()
|
||||||
|
account, balance = next(actual)
|
||||||
|
assert account is core.ENDING_BALANCE_NAME
|
||||||
|
assert balance.is_zero()
|
||||||
|
|
||||||
|
def test_account_balances_empty_postings():
|
||||||
|
actual = core.account_balances({})
|
||||||
|
account, balance = next(actual)
|
||||||
|
assert account is core.OPENING_BALANCE_NAME
|
||||||
|
assert balance.is_zero()
|
||||||
|
account, balance = next(actual)
|
||||||
|
assert account is core.ENDING_BALANCE_NAME
|
||||||
|
assert balance.is_zero()
|
||||||
|
|
Loading…
Reference in a new issue