ledger: Add fund ledger report type.
See the FundLedgerODS docstring for details.
This commit is contained in:
		
							parent
							
								
									4188dc6a64
								
							
						
					
					
						commit
						55b347271c
					
				
					 2 changed files with 139 additions and 89 deletions
				
			
		|  | @ -46,6 +46,7 @@ from typing import ( | |||
|     Any, | ||||
|     Callable, | ||||
|     Dict, | ||||
|     Hashable, | ||||
|     Iterable, | ||||
|     Iterator, | ||||
|     List, | ||||
|  | @ -55,7 +56,9 @@ from typing import ( | |||
|     Set, | ||||
|     TextIO, | ||||
|     Tuple, | ||||
|     Type, | ||||
|     Union, | ||||
|     cast, | ||||
| ) | ||||
| from ..beancount_types import ( | ||||
|     Transaction, | ||||
|  | @ -64,6 +67,7 @@ from ..beancount_types import ( | |||
| from pathlib import Path | ||||
| 
 | ||||
| import odf.table  # type:ignore[import] | ||||
| import odf.text  # type:ignore[import] | ||||
| 
 | ||||
| from beancount.core import data as bc_data | ||||
| from beancount.parser import printer as bc_printer | ||||
|  | @ -366,40 +370,6 @@ class LedgerODS(core.BaseODS[data.Posting, None]): | |||
|             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: | ||||
|         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): | ||||
|             self.account_groups[empty_acct] = related_cls() | ||||
|         self.write_balance_sheet() | ||||
|         self.start_spreadsheet() | ||||
|         tally_by_account_iter = ( | ||||
|             (account, self._account_tally(account)) | ||||
|             for account in self.accounts | ||||
|  | @ -472,29 +442,97 @@ class LedgerODS(core.BaseODS[data.Posting, None]): | |||
|             self.start_sheet(sheet_names[index]) | ||||
| 
 | ||||
| 
 | ||||
| class TransactionFilter(enum.IntFlag): | ||||
|     ZERO = 1 | ||||
|     CREDIT = 2 | ||||
|     DEBIT = 4 | ||||
|     ALL = ZERO | CREDIT | DEBIT | ||||
| class FundLedgerODS(LedgerODS): | ||||
|     """Streamlined ledger report for a specific project fund | ||||
| 
 | ||||
|     @classmethod | ||||
|     def from_arg(cls, s: str) -> 'TransactionFilter': | ||||
|         try: | ||||
|             return cls[s.upper()] | ||||
|         except KeyError: | ||||
|             raise ValueError(f"unknown transaction filter {s!r}") | ||||
|     This report is more appropriate to share with people who are interested in | ||||
|     the project fund. Differences from the main ledger report: | ||||
| 
 | ||||
|     * It adds a cover sheet with a high level overview of the fund balance. | ||||
|     * It only reports accounts that belong to funds. | ||||
|     * 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 | ||||
|     def post_flag(cls, post: data.Posting) -> int: | ||||
|         norm_func = core.normalize_amount_func(post.account) | ||||
|         number = norm_func(post.units.number) | ||||
|         if not number: | ||||
|             return cls.ZERO | ||||
|             return cls.ZERO_TRANSACTIONS | ||||
|         elif number > 0: | ||||
|             return cls.CREDIT | ||||
|             return cls.CREDIT_TRANSACTIONS | ||||
|         else: | ||||
|             return cls.DEBIT | ||||
|             return cls.DEBIT_TRANSACTIONS | ||||
| 
 | ||||
|     def _choices_sortkey(self) -> Hashable: | ||||
|         subtype, _, maintype = self.name.partition('_') | ||||
|         return (maintype, subtype) | ||||
| 
 | ||||
| 
 | ||||
| class TransactionODS(LedgerODS): | ||||
|  | @ -527,7 +565,7 @@ class TransactionODS(LedgerODS): | |||
|                  sheet_size: Optional[int]=None, | ||||
|                  totals_with_entries: Optional[Sequence[str]]=None, | ||||
|                  totals_without_entries: Optional[Sequence[str]]=None, | ||||
|                  txn_filter: int=TransactionFilter.ALL, | ||||
|                  txn_filter: int=ReportType.ALL_TRANSACTIONS, | ||||
|     ) -> None: | ||||
|         super().__init__( | ||||
|             start_date, | ||||
|  | @ -539,9 +577,9 @@ class TransactionODS(LedgerODS): | |||
|             totals_without_entries, | ||||
|         ) | ||||
|         self.txn_filter = txn_filter | ||||
|         if self.txn_filter == TransactionFilter.CREDIT: | ||||
|         if self.txn_filter == ReportType.CREDIT_TRANSACTIONS: | ||||
|             self.report_name = "Receipts" | ||||
|         elif self.txn_filter == TransactionFilter.DEBIT: | ||||
|         elif self.txn_filter == ReportType.DEBIT_TRANSACTIONS: | ||||
|             self.report_name = "Disbursements" | ||||
|         else: | ||||
|             self.report_name = "Transactions" | ||||
|  | @ -551,18 +589,15 @@ class TransactionODS(LedgerODS): | |||
|         for post in postings: | ||||
|             txn = post.meta.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 | ||||
|                 last_txn = txn | ||||
| 
 | ||||
|     def metadata_columns_for(self, sheet_name: str) -> Sequence[str]: | ||||
|         return self.METADATA_COLUMNS | ||||
| 
 | ||||
|     def write_balance_sheet(self) -> None: | ||||
|         return | ||||
| 
 | ||||
|     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) | ||||
|         elif date_key == 'stop': | ||||
|             balance = core.Balance( | ||||
|  | @ -638,7 +673,7 @@ class CashReportAction(argparse.Action): | |||
|                  values: Union[Sequence[Any], str, None]=None, | ||||
|                  option_string: Optional[str]=None, | ||||
|     ) -> None: | ||||
|         namespace.txn_filter = self.const | ||||
|         namespace.report_type = self.const | ||||
|         if namespace.accounts is None: | ||||
|             namespace.accounts = [] | ||||
|         namespace.accounts.append('Assets:PayPal') | ||||
|  | @ -653,7 +688,7 @@ def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace | |||
|     parser.add_argument( | ||||
|         '--disbursements', | ||||
|         action=CashReportAction, | ||||
|         const=TransactionFilter.DEBIT, | ||||
|         const=ReportType.DEBIT_TRANSACTIONS, | ||||
|         nargs=0, | ||||
|         help="""Shortcut to set all the necessary options to generate a cash | ||||
| disbursements report. | ||||
|  | @ -661,7 +696,7 @@ disbursements report. | |||
|     parser.add_argument( | ||||
|         '--receipts', | ||||
|         action=CashReportAction, | ||||
|         const=TransactionFilter.CREDIT, | ||||
|         const=ReportType.CREDIT_TRANSACTIONS, | ||||
|         nargs=0, | ||||
|         help="""Shortcut to set all the necessary options to generate a cash | ||||
| 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 | ||||
| date was also not specified. | ||||
| """) | ||||
|     parser.add_argument( | ||||
|         '--transactions', '-t', | ||||
|         dest='txn_filter', | ||||
|     report_type = cliutil.EnumArgument(ReportType) | ||||
|     report_type_default = ReportType.FULL_LEDGER | ||||
|     report_type_arg = parser.add_argument( | ||||
|         '--report-type', '-t', | ||||
|         metavar='TYPE', | ||||
|         type=TransactionFilter.from_arg, | ||||
|         help="""Report whole transactions rather than individual postings. | ||||
| The type argument selects which type of transactions to report. Choices are | ||||
| credit, debit, or all. | ||||
|         type=report_type.enum_type, | ||||
|         default=report_type_default, | ||||
|         help=f"""The type of report to generate. Choices are | ||||
| {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( | ||||
|         '--account', '-a', | ||||
|         dest='accounts', | ||||
|  | @ -814,18 +857,10 @@ def main(arglist: Optional[Sequence[str]]=None, | |||
|     rt_wrapper = config.rt_wrapper() | ||||
|     if rt_wrapper is None: | ||||
|         logger.warning("could not initialize RT client; spreadsheet links will be broken") | ||||
|     report: LedgerODS | ||||
|     report_cls: Type[LedgerODS] | ||||
|     try: | ||||
|         if args.txn_filter is None: | ||||
|             report = LedgerODS( | ||||
|                 args.start_date, | ||||
|                 args.stop_date, | ||||
|                 args.accounts, | ||||
|                 rt_wrapper, | ||||
|                 args.sheet_size, | ||||
|                 args.show_totals, | ||||
|                 args.add_totals, | ||||
|             ) | ||||
|         else: | ||||
|         if args.report_type & ReportType.ALL_TRANSACTIONS: | ||||
|             report = TransactionODS( | ||||
|                 args.start_date, | ||||
|                 args.stop_date, | ||||
|  | @ -834,7 +869,21 @@ def main(arglist: Optional[Sequence[str]]=None, | |||
|                 args.sheet_size, | ||||
|                 args.show_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: | ||||
|         logger.error("%s: %r", *error.args) | ||||
|  |  | |||
|  | @ -32,7 +32,6 @@ Acct = data.Account | |||
| 
 | ||||
| _ledger_load = bc_loader.load_file(testutil.test_path('books/ledger.beancount')) | ||||
| DEFAULT_REPORT_SHEETS = [ | ||||
|     'Balance', | ||||
|     'Income', | ||||
|     'Expenses:Payroll', | ||||
|     'Expenses', | ||||
|  | @ -44,8 +43,9 @@ DEFAULT_REPORT_SHEETS = [ | |||
|     'Liabilities', | ||||
| ] | ||||
| PROJECT_REPORT_SHEETS = [ | ||||
|     *DEFAULT_REPORT_SHEETS[:2], | ||||
|     *DEFAULT_REPORT_SHEETS[3:6], | ||||
|     'Balance', | ||||
|     'Income', | ||||
|     *DEFAULT_REPORT_SHEETS[2:5], | ||||
|     'Assets:Prepaid', | ||||
|     'Liabilities:UnearnedIncome', | ||||
|     'Liabilities:Payable', | ||||
|  | @ -60,7 +60,7 @@ STOP_DATE = datetime.date(2020, 3, 1) | |||
| REPORT_KWARGS = [ | ||||
|     {'report_class': ledger.LedgerODS}, | ||||
|     *({'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 | ||||
|  | @ -189,7 +189,7 @@ class ExpectedPostings(core.RelatedPostings): | |||
|         period_bal = core.MutableBalance() | ||||
|         rows = self.find_section(ods, account) | ||||
|         if (expect_totals | ||||
|             and txn_filter == ledger.TransactionFilter.ALL | ||||
|             and txn_filter == ledger.ReportType.ALL_TRANSACTIONS | ||||
|             and account.is_under('Assets', 'Liabilities')): | ||||
|             opening_row = testutil.ODSCell.from_row(next(rows)) | ||||
|             assert opening_row[0].value == start_date | ||||
|  | @ -198,7 +198,7 @@ class ExpectedPostings(core.RelatedPostings): | |||
|         last_txn = None | ||||
|         for post in expect_posts: | ||||
|             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): | ||||
|                 continue | ||||
|             last_txn = txn | ||||
|  | @ -462,7 +462,7 @@ def test_main_account_limit(ledger_entries, acct_arg): | |||
|     assert not errors.getvalue() | ||||
|     assert retcode == 0 | ||||
|     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) | ||||
|     for account, expected in ExpectedPostings.group_by_account(postings): | ||||
|         if account == 'Liabilities:UnearnedIncome': | ||||
|  | @ -485,7 +485,7 @@ def test_main_account_classification_splits_hierarchy(ledger_entries): | |||
|     assert not errors.getvalue() | ||||
|     assert retcode == 0 | ||||
|     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) | ||||
|     for account, expected in ExpectedPostings.group_by_account(postings): | ||||
|         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([ | ||||
|         f'--begin={start_date.isoformat()}', | ||||
|         f'--end={stop_date.isoformat()}', | ||||
|         '--report-type=fund_ledger', | ||||
|         project, | ||||
|     ]) | ||||
|     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): | ||||
|     if flag == '--receipts': | ||||
|         txn_filter = ledger.TransactionFilter.CREDIT | ||||
|         txn_filter = ledger.ReportType.CREDIT_TRANSACTIONS | ||||
|     else: | ||||
|         txn_filter = ledger.TransactionFilter.DEBIT | ||||
|         txn_filter = ledger.ReportType.DEBIT_TRANSACTIONS | ||||
|     retcode, output, errors = run_main([ | ||||
|         flag, | ||||
|         '-b', START_DATE.isoformat(), | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue
	
	 Brett Smith
						Brett Smith