ledger: Add fund ledger report type.

See the FundLedgerODS docstring for details.
This commit is contained in:
Brett Smith 2021-02-17 14:20:58 -05:00
parent 4188dc6a64
commit 55b347271c
2 changed files with 139 additions and 89 deletions

View file

@ -46,6 +46,7 @@ from typing import (
Any, Any,
Callable, Callable,
Dict, Dict,
Hashable,
Iterable, Iterable,
Iterator, Iterator,
List, List,
@ -55,7 +56,9 @@ from typing import (
Set, Set,
TextIO, TextIO,
Tuple, Tuple,
Type,
Union, Union,
cast,
) )
from ..beancount_types import ( from ..beancount_types import (
Transaction, Transaction,
@ -64,6 +67,7 @@ from ..beancount_types import (
from pathlib import Path from pathlib import Path
import odf.table # type:ignore[import] import odf.table # type:ignore[import]
import odf.text # type:ignore[import]
from beancount.core import data as bc_data from beancount.core import data as bc_data
from beancount.parser import printer as bc_printer from beancount.parser import printer as bc_printer
@ -366,40 +370,6 @@ class LedgerODS(core.BaseODS[data.Posting, None]):
classification_cell, classification_cell,
) )
def write_balance_sheet(self) -> None:
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(1.5)))
self.add_row(
self.string_cell("Account", stylename=self.style_bold),
self.string_cell("Balance", stylename=self.style_bold),
)
self.lock_first_row()
self.add_row()
self.add_row(self.string_cell(
f"Ledger From {self.date_range.start.isoformat()}"
f" To {self.date_range.stop.isoformat()}",
stylename=self.merge_styles(self.style_centertext, self.style_bold),
numbercolumnsspanned=2,
))
self.add_row()
for account, balance in core.account_balances(self.account_groups):
if account is core.OPENING_BALANCE_NAME:
text = f"Balance as of {self.date_range.start.isoformat()}"
style = self.merge_styles(self.style_bold, self.style_endtext)
elif account is core.ENDING_BALANCE_NAME:
text = f"Balance as of {self.date_range.stop.isoformat()}"
style = self.merge_styles(
self.style_bottomline, self.style_bold, self.style_endtext,
)
else:
text = account
style = self.style_endtext
self.add_row(
self.string_cell(text, stylename=style),
self.balance_cell(-balance, stylename=style),
)
def _account_tally(self, account: data.Account) -> int: def _account_tally(self, account: data.Account) -> int:
return len(self.account_groups[account]) return len(self.account_groups[account])
@ -430,7 +400,7 @@ class LedgerODS(core.BaseODS[data.Posting, None]):
)) ))
for empty_acct in self.accounts.difference(self.account_groups): for empty_acct in self.accounts.difference(self.account_groups):
self.account_groups[empty_acct] = related_cls() self.account_groups[empty_acct] = related_cls()
self.write_balance_sheet() self.start_spreadsheet()
tally_by_account_iter = ( tally_by_account_iter = (
(account, self._account_tally(account)) (account, self._account_tally(account))
for account in self.accounts for account in self.accounts
@ -472,29 +442,97 @@ class LedgerODS(core.BaseODS[data.Posting, None]):
self.start_sheet(sheet_names[index]) self.start_sheet(sheet_names[index])
class TransactionFilter(enum.IntFlag): class FundLedgerODS(LedgerODS):
ZERO = 1 """Streamlined ledger report for a specific project fund
CREDIT = 2
DEBIT = 4
ALL = ZERO | CREDIT | DEBIT
@classmethod This report is more appropriate to share with people who are interested in
def from_arg(cls, s: str) -> 'TransactionFilter': the project fund. Differences from the main ledger report:
try:
return cls[s.upper()] * It adds a cover sheet with a high level overview of the fund balance.
except KeyError: * It only reports accounts that belong to funds.
raise ValueError(f"unknown transaction filter {s!r}") * It does not use any links, since recipients likely won't have access to
be able to follow them. (It does include RT IDs as a reference point to
correspond about specific transactions.)
"""
ACCOUNT_COLUMNS = collections.OrderedDict([
('Income', ['program', 'rt-id', 'income-type', 'memo']),
('Expenses', ['program', 'rt-id', 'expense-type']),
('Equity', ['program', 'rt-id']),
('Assets:Receivable', ['program', 'rt-id']),
('Liabilities:Payable', ['program', 'rt-id']),
('Assets', ['rt-id']),
('Liabilities', ['rt-id']),
])
def multilink_cell(self, links: Iterable[core.LinkType], **attrs: Any) -> odf.table.TableCell:
source = super().multilink_cell(links, **attrs)
return self.multiline_cell(
''.join(child.data for child in link.childNodes)
for link in source.getElementsByType(odf.text.A)
)
def start_spreadsheet(self) -> None:
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(1.5)))
self.add_row(
self.string_cell("Account", stylename=self.style_bold),
self.string_cell("Balance", stylename=self.style_bold),
)
self.lock_first_row()
self.add_row()
self.add_row(self.string_cell(
f"Ledger From {self.date_range.start.isoformat()}"
f" To {self.date_range.stop.isoformat()}",
stylename=self.merge_styles(self.style_centertext, self.style_bold),
numbercolumnsspanned=2,
))
self.add_row()
for account, balance in core.account_balances(self.account_groups):
if account is core.OPENING_BALANCE_NAME:
text = f"Balance as of {self.date_range.start.isoformat()}"
style = self.merge_styles(self.style_bold, self.style_endtext)
elif account is core.ENDING_BALANCE_NAME:
text = f"Balance as of {self.date_range.stop.isoformat()}"
style = self.merge_styles(
self.style_bottomline, self.style_bold, self.style_endtext,
)
else:
text = account
style = self.style_endtext
self.add_row(
self.string_cell(text, stylename=style),
self.balance_cell(-balance, stylename=style),
)
class ReportType(enum.IntFlag):
ZERO_TRANSACTIONS = enum.auto()
ZERO_TXNS = ZERO_TRANSACTIONS
CREDIT_TRANSACTIONS = enum.auto()
CREDIT_TXNS = CREDIT_TRANSACTIONS
DEBIT_TRANSACTIONS = enum.auto()
DEBIT_TXNS = DEBIT_TRANSACTIONS
ALL_TRANSACTIONS = ZERO_TRANSACTIONS | CREDIT_TRANSACTIONS | DEBIT_TRANSACTIONS
ALL_TXNS = ALL_TRANSACTIONS
FULL_LEDGER = enum.auto()
FUND_LEDGER = enum.auto()
PROJECT_LEDGER = FUND_LEDGER
@classmethod @classmethod
def post_flag(cls, post: data.Posting) -> int: def post_flag(cls, post: data.Posting) -> int:
norm_func = core.normalize_amount_func(post.account) norm_func = core.normalize_amount_func(post.account)
number = norm_func(post.units.number) number = norm_func(post.units.number)
if not number: if not number:
return cls.ZERO return cls.ZERO_TRANSACTIONS
elif number > 0: elif number > 0:
return cls.CREDIT return cls.CREDIT_TRANSACTIONS
else: else:
return cls.DEBIT return cls.DEBIT_TRANSACTIONS
def _choices_sortkey(self) -> Hashable:
subtype, _, maintype = self.name.partition('_')
return (maintype, subtype)
class TransactionODS(LedgerODS): class TransactionODS(LedgerODS):
@ -527,7 +565,7 @@ class TransactionODS(LedgerODS):
sheet_size: Optional[int]=None, sheet_size: Optional[int]=None,
totals_with_entries: Optional[Sequence[str]]=None, totals_with_entries: Optional[Sequence[str]]=None,
totals_without_entries: Optional[Sequence[str]]=None, totals_without_entries: Optional[Sequence[str]]=None,
txn_filter: int=TransactionFilter.ALL, txn_filter: int=ReportType.ALL_TRANSACTIONS,
) -> None: ) -> None:
super().__init__( super().__init__(
start_date, start_date,
@ -539,9 +577,9 @@ class TransactionODS(LedgerODS):
totals_without_entries, totals_without_entries,
) )
self.txn_filter = txn_filter self.txn_filter = txn_filter
if self.txn_filter == TransactionFilter.CREDIT: if self.txn_filter == ReportType.CREDIT_TRANSACTIONS:
self.report_name = "Receipts" self.report_name = "Receipts"
elif self.txn_filter == TransactionFilter.DEBIT: elif self.txn_filter == ReportType.DEBIT_TRANSACTIONS:
self.report_name = "Disbursements" self.report_name = "Disbursements"
else: else:
self.report_name = "Transactions" self.report_name = "Transactions"
@ -551,18 +589,15 @@ class TransactionODS(LedgerODS):
for post in postings: for post in postings:
txn = post.meta.txn txn = post.meta.txn
if (txn is not last_txn if (txn is not last_txn
and TransactionFilter.post_flag(post) & self.txn_filter): and ReportType.post_flag(post) & self.txn_filter):
yield txn yield txn
last_txn = txn last_txn = txn
def metadata_columns_for(self, sheet_name: str) -> Sequence[str]: def metadata_columns_for(self, sheet_name: str) -> Sequence[str]:
return self.METADATA_COLUMNS return self.METADATA_COLUMNS
def write_balance_sheet(self) -> None:
return
def _report_section_balance(self, key: data.Account, date_key: str) -> None: def _report_section_balance(self, key: data.Account, date_key: str) -> None:
if self.txn_filter == TransactionFilter.ALL: if self.txn_filter == ReportType.ALL_TRANSACTIONS:
super()._report_section_balance(key, date_key) super()._report_section_balance(key, date_key)
elif date_key == 'stop': elif date_key == 'stop':
balance = core.Balance( balance = core.Balance(
@ -638,7 +673,7 @@ class CashReportAction(argparse.Action):
values: Union[Sequence[Any], str, None]=None, values: Union[Sequence[Any], str, None]=None,
option_string: Optional[str]=None, option_string: Optional[str]=None,
) -> None: ) -> None:
namespace.txn_filter = self.const namespace.report_type = self.const
if namespace.accounts is None: if namespace.accounts is None:
namespace.accounts = [] namespace.accounts = []
namespace.accounts.append('Assets:PayPal') namespace.accounts.append('Assets:PayPal')
@ -653,7 +688,7 @@ def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace
parser.add_argument( parser.add_argument(
'--disbursements', '--disbursements',
action=CashReportAction, action=CashReportAction,
const=TransactionFilter.DEBIT, const=ReportType.DEBIT_TRANSACTIONS,
nargs=0, nargs=0,
help="""Shortcut to set all the necessary options to generate a cash help="""Shortcut to set all the necessary options to generate a cash
disbursements report. disbursements report.
@ -661,7 +696,7 @@ disbursements report.
parser.add_argument( parser.add_argument(
'--receipts', '--receipts',
action=CashReportAction, action=CashReportAction,
const=TransactionFilter.CREDIT, const=ReportType.CREDIT_TRANSACTIONS,
nargs=0, nargs=0,
help="""Shortcut to set all the necessary options to generate a cash help="""Shortcut to set all the necessary options to generate a cash
receipts report. receipts report.
@ -683,15 +718,23 @@ The default is one year ago.
The default is a year after the start date, or 30 days from today if the start The default is a year after the start date, or 30 days from today if the start
date was also not specified. date was also not specified.
""") """)
parser.add_argument( report_type = cliutil.EnumArgument(ReportType)
'--transactions', '-t', report_type_default = ReportType.FULL_LEDGER
dest='txn_filter', report_type_arg = parser.add_argument(
'--report-type', '-t',
metavar='TYPE', metavar='TYPE',
type=TransactionFilter.from_arg, type=report_type.enum_type,
help="""Report whole transactions rather than individual postings. default=report_type_default,
The type argument selects which type of transactions to report. Choices are help=f"""The type of report to generate. Choices are
credit, debit, or all. {report_type.choices_str()}. Default is {report_type_default.name.lower()!r}.
""") """)
# --transactions got merged into --report-type; this is backwards compatibility.
parser.add_argument(
'--transactions',
dest=report_type_arg.dest,
type=cast(Callable, report_type_arg.type),
help=argparse.SUPPRESS,
)
parser.add_argument( parser.add_argument(
'--account', '-a', '--account', '-a',
dest='accounts', dest='accounts',
@ -814,18 +857,10 @@ def main(arglist: Optional[Sequence[str]]=None,
rt_wrapper = config.rt_wrapper() rt_wrapper = config.rt_wrapper()
if rt_wrapper is None: if rt_wrapper is None:
logger.warning("could not initialize RT client; spreadsheet links will be broken") logger.warning("could not initialize RT client; spreadsheet links will be broken")
report: LedgerODS
report_cls: Type[LedgerODS]
try: try:
if args.txn_filter is None: if args.report_type & ReportType.ALL_TRANSACTIONS:
report = LedgerODS(
args.start_date,
args.stop_date,
args.accounts,
rt_wrapper,
args.sheet_size,
args.show_totals,
args.add_totals,
)
else:
report = TransactionODS( report = TransactionODS(
args.start_date, args.start_date,
args.stop_date, args.stop_date,
@ -834,7 +869,21 @@ def main(arglist: Optional[Sequence[str]]=None,
args.sheet_size, args.sheet_size,
args.show_totals, args.show_totals,
args.add_totals, args.add_totals,
args.txn_filter, args.report_type,
)
else:
if args.report_type is ReportType.FUND_LEDGER:
report_cls = FundLedgerODS
else:
report_cls = LedgerODS
report = report_cls(
args.start_date,
args.stop_date,
args.accounts,
rt_wrapper,
args.sheet_size,
args.show_totals,
args.add_totals,
) )
except ValueError as error: except ValueError as error:
logger.error("%s: %r", *error.args) logger.error("%s: %r", *error.args)

View file

@ -32,7 +32,6 @@ Acct = data.Account
_ledger_load = bc_loader.load_file(testutil.test_path('books/ledger.beancount')) _ledger_load = bc_loader.load_file(testutil.test_path('books/ledger.beancount'))
DEFAULT_REPORT_SHEETS = [ DEFAULT_REPORT_SHEETS = [
'Balance',
'Income', 'Income',
'Expenses:Payroll', 'Expenses:Payroll',
'Expenses', 'Expenses',
@ -44,8 +43,9 @@ DEFAULT_REPORT_SHEETS = [
'Liabilities', 'Liabilities',
] ]
PROJECT_REPORT_SHEETS = [ PROJECT_REPORT_SHEETS = [
*DEFAULT_REPORT_SHEETS[:2], 'Balance',
*DEFAULT_REPORT_SHEETS[3:6], 'Income',
*DEFAULT_REPORT_SHEETS[2:5],
'Assets:Prepaid', 'Assets:Prepaid',
'Liabilities:UnearnedIncome', 'Liabilities:UnearnedIncome',
'Liabilities:Payable', 'Liabilities:Payable',
@ -60,7 +60,7 @@ STOP_DATE = datetime.date(2020, 3, 1)
REPORT_KWARGS = [ REPORT_KWARGS = [
{'report_class': ledger.LedgerODS}, {'report_class': ledger.LedgerODS},
*({'report_class': ledger.TransactionODS, 'txn_filter': flags} *({'report_class': ledger.TransactionODS, 'txn_filter': flags}
for flags in ledger.TransactionFilter), for flags in ledger.ReportType if flags & ledger.ReportType.ALL_TRANSACTIONS),
] ]
@pytest.fixture @pytest.fixture
@ -189,7 +189,7 @@ class ExpectedPostings(core.RelatedPostings):
period_bal = core.MutableBalance() period_bal = core.MutableBalance()
rows = self.find_section(ods, account) rows = self.find_section(ods, account)
if (expect_totals if (expect_totals
and txn_filter == ledger.TransactionFilter.ALL and txn_filter == ledger.ReportType.ALL_TRANSACTIONS
and account.is_under('Assets', 'Liabilities')): and account.is_under('Assets', 'Liabilities')):
opening_row = testutil.ODSCell.from_row(next(rows)) opening_row = testutil.ODSCell.from_row(next(rows))
assert opening_row[0].value == start_date assert opening_row[0].value == start_date
@ -198,7 +198,7 @@ class ExpectedPostings(core.RelatedPostings):
last_txn = None last_txn = None
for post in expect_posts: for post in expect_posts:
txn = post.meta.txn txn = post.meta.txn
post_flag = ledger.TransactionFilter.post_flag(post) post_flag = ledger.ReportType.post_flag(post)
if txn is last_txn or (not txn_filter & post_flag): if txn is last_txn or (not txn_filter & post_flag):
continue continue
last_txn = txn last_txn = txn
@ -462,7 +462,7 @@ def test_main_account_limit(ledger_entries, acct_arg):
assert not errors.getvalue() assert not errors.getvalue()
assert retcode == 0 assert retcode == 0
ods = odf.opendocument.load(output) ods = odf.opendocument.load(output)
assert get_sheet_names(ods) == ['Balance', 'Liabilities'] assert get_sheet_names(ods) == ['Liabilities']
postings = data.Posting.from_entries(ledger_entries) postings = data.Posting.from_entries(ledger_entries)
for account, expected in ExpectedPostings.group_by_account(postings): for account, expected in ExpectedPostings.group_by_account(postings):
if account == 'Liabilities:UnearnedIncome': if account == 'Liabilities:UnearnedIncome':
@ -485,7 +485,7 @@ def test_main_account_classification_splits_hierarchy(ledger_entries):
assert not errors.getvalue() assert not errors.getvalue()
assert retcode == 0 assert retcode == 0
ods = odf.opendocument.load(output) ods = odf.opendocument.load(output)
assert get_sheet_names(ods) == ['Balance', 'Assets'] assert get_sheet_names(ods) == ['Assets']
postings = data.Posting.from_entries(ledger_entries) postings = data.Posting.from_entries(ledger_entries)
for account, expected in ExpectedPostings.group_by_account(postings): for account, expected in ExpectedPostings.group_by_account(postings):
should_find = (account == 'Assets:Checking' or account == 'Assets:PayPal') should_find = (account == 'Assets:Checking' or account == 'Assets:PayPal')
@ -509,6 +509,7 @@ def test_main_project_report(ledger_entries, project, start_date, stop_date):
retcode, output, errors = run_main([ retcode, output, errors = run_main([
f'--begin={start_date.isoformat()}', f'--begin={start_date.isoformat()}',
f'--end={stop_date.isoformat()}', f'--end={stop_date.isoformat()}',
'--report-type=fund_ledger',
project, project,
]) ])
assert not errors.getvalue() assert not errors.getvalue()
@ -528,9 +529,9 @@ def test_main_project_report(ledger_entries, project, start_date, stop_date):
]) ])
def test_main_cash_report(ledger_entries, flag): def test_main_cash_report(ledger_entries, flag):
if flag == '--receipts': if flag == '--receipts':
txn_filter = ledger.TransactionFilter.CREDIT txn_filter = ledger.ReportType.CREDIT_TRANSACTIONS
else: else:
txn_filter = ledger.TransactionFilter.DEBIT txn_filter = ledger.ReportType.DEBIT_TRANSACTIONS
retcode, output, errors = run_main([ retcode, output, errors = run_main([
flag, flag,
'-b', START_DATE.isoformat(), '-b', START_DATE.isoformat(),