diff --git a/conservancy_beancount/reports/accrual.py b/conservancy_beancount/reports/accrual.py index 2bb819c..9a9070e 100644 --- a/conservancy_beancount/reports/accrual.py +++ b/conservancy_beancount/reports/accrual.py @@ -4,10 +4,14 @@ accrual-report checks accruals (postings under Assets:Receivable and Liabilities:Payable) for errors and metadata consistency, and reports any problems on stderr. Then it writes a report about the status of those -accruals on stdout. +accruals. -The typical way to run it is to pass an RT ticket number or invoice link as an -argument:: +If you run it with no arguments, it will generate an aging report in ODS format +in the current directory. + +Otherwise, the typical way to run it is to pass an RT ticket number or +invoice link as an argument, to report about accruals that match those +criteria:: # Report all accruals associated with RT#1230: accrual-report 1230 @@ -33,15 +37,18 @@ You can pass any number of search terms. For example:: # Report accruals associated with RT#1230 and Jane Doe accrual-report 1230 entity=Doe-Jane -accrual-report will automatically decide what kind of report to generate from -the results of your search. If the search matches a single outstanding payable, -it will write an outgoing approval report; otherwise, it writes a basic balance -report. You can request a specific report type with the ``--report-type`` -option:: +accrual-report will automatically decide what kind of report to generate +from the search terms you provide and the results they return. If you pass +no search terms, it generates an aging report. If your search terms match a +single outstanding payable, it writes an outgoing approval report. +Otherwise, it writes a basic balance report. You can specify what report +type you want with the ``--report-type`` option:: # Write an outgoing approval report for all outstanding accruals for # Jane Doe, even if there's more than one accrual-report --report-type outgoing entity=Doe-Jane + # Write an aging report for a specific project + accrual-report --report-type aging project=ProjectName """ # Copyright © 2020 Brett Smith # @@ -66,9 +73,14 @@ import logging import operator import re import sys +import urllib.parse as urlparse + +from pathlib import Path from typing import ( + cast, Any, + BinaryIO, Callable, Dict, Iterable, @@ -85,12 +97,16 @@ from typing import ( Union, ) from ..beancount_types import ( + Entries, Error, + Errors, MetaKey, MetaValue, Transaction, ) +import odf.style # type:ignore[import] +import odf.table # type:ignore[import] import rt from beancount.parser import printer as bc_printer @@ -103,6 +119,7 @@ from .. import filters from .. import rtutil PROGNAME = 'accrual-report' +STANDARD_PATH = Path('-') PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings'] RTObject = Mapping[str, str] @@ -116,16 +133,30 @@ class Sentinel: class Account(NamedTuple): name: str norm_func: Callable[[core.Balance], core.Balance] + aging_thresholds: Sequence[int] class AccrualAccount(enum.Enum): - PAYABLE = Account('Liabilities:Payable', operator.neg) - RECEIVABLE = Account('Assets:Receivable', lambda bal: bal) + # 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], + ) @classmethod def account_names(cls) -> Iterator[str]: return (acct.value.name for acct in cls) + @classmethod + def by_account(cls, name: data.Account) -> 'AccrualAccount': + for account in cls: + if name.is_under(account.value.name): + return account + raise ValueError(f"unrecognized account {name!r}") + @classmethod def classify(cls, related: core.RelatedPostings) -> 'AccrualAccount': for account in cls: @@ -151,7 +182,7 @@ class AccrualPostings(core.RelatedPostings): INCONSISTENT = Sentinel() __slots__ = ( 'accrual_type', - 'final_bal', + 'end_balance', 'account', 'accounts', 'contract', @@ -174,6 +205,7 @@ class AccrualPostings(core.RelatedPostings): # The following type declarations tell mypy about values set in the for # loop that are important enough to be referenced directly elsewhere. self.account: Union[data.Account, Sentinel] + self.entity: Union[MetaValue, Sentinel] self.entitys: FrozenSet[MetaValue] self.invoice: Union[MetaValue, Sentinel] for name, get_func in self._FIELDS.items(): @@ -188,10 +220,10 @@ class AccrualPostings(core.RelatedPostings): self.entities = self.entitys if self.account is self.INCONSISTENT: self.accrual_type: Optional[AccrualAccount] = None - self.final_bal = self.balance() + self.end_balance = self.balance_at_cost() else: self.accrual_type = AccrualAccount.classify(self) - self.final_bal = self.accrual_type.value.norm_func(self.balance()) + self.end_balance = self.accrual_type.value.norm_func(self.balance_at_cost()) def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]: account_ok = isinstance(self.account, str) @@ -239,13 +271,13 @@ class AccrualPostings(core.RelatedPostings): if self.accrual_type is None: return default else: - return self.final_bal.le_zero() + return self.end_balance.le_zero() def is_zero(self, default: Optional[bool]=None) -> Optional[bool]: if self.accrual_type is None: return default else: - return self.final_bal.is_zero() + return self.end_balance.is_zero() def since_last_nonzero(self) -> 'AccrualPostings': for index, (post, balance) in enumerate(self.iter_with_balance()): @@ -276,6 +308,168 @@ class BaseReport: print(line, file=self.out_file) +class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]): + COLUMNS = [ + 'Date', + 'Entity', + 'Invoice Amount', + 'Booked Amount', + 'Ticket', + 'Invoice', + ] + COL_COUNT = len(COLUMNS) + + def __init__(self, + rt_client: rt.Rt, + date: datetime.date, + logger: logging.Logger, + ) -> None: + super().__init__() + self.rt_client = rt_client + self.rt_wrapper = rtutil.RT(self.rt_client) + self.date = date + self.logger = logger + + def init_styles(self) -> None: + super().init_styles() + self.style_widecol = self.replace_child( + self.document.automaticstyles, + odf.style.Style, + name='WideCol', + ) + self.style_widecol.setAttribute('family', 'table-column') + self.style_widecol.addElement(odf.style.TableColumnProperties( + columnwidth='1.25in', + )) + + def section_key(self, row: AccrualPostings) -> Optional[data.Account]: + if isinstance(row.account, str): + return row.account + else: + return None + + def start_spreadsheet(self) -> None: + for accrual_type in AccrualAccount: + self.use_sheet(accrual_type.name.title()) + for index in range(self.COL_COUNT): + stylename = self.style_widecol if index else '' + self.sheet.addElement(odf.table.TableColumn(stylename=stylename)) + self.add_row(*( + self.string_cell(name, stylename=self.style_bold) + for name in self.COLUMNS + )) + self.lock_first_row() + + def start_section(self, key: Optional[data.Account]) -> None: + if key is None: + return + self.age_thresholds = list(AccrualAccount.by_account(key).value.aging_thresholds) + self.age_balances = [core.MutableBalance() for _ in self.age_thresholds] + accrual_date = self.date - datetime.timedelta(days=self.age_thresholds[-1]) + acct_parts = key.split(':') + self.use_sheet(acct_parts[1]) + self.add_row() + self.add_row(self.string_cell( + f"{' '.join(acct_parts[2:])} {acct_parts[1]} Aging Report" + f" Accrued by {accrual_date.isoformat()} Unpaid by {self.date.isoformat()}", + stylename=self.merge_styles(self.style_bold, self.style_centertext), + numbercolumnsspanned=self.COL_COUNT, + )) + self.add_row() + + def end_section(self, key: Optional[data.Account]) -> None: + if key is None: + return + self.add_row() + text_style = self.merge_styles(self.style_bold, self.style_endtext) + text_span = self.COL_COUNT - 1 + for threshold, balance in zip(self.age_thresholds, self.age_balances): + years, days = divmod(threshold, 365) + years_text = f"{years} {'Year' if years == 1 else 'Years'}" + days_text = f"{days} Days" + if years and days: + age_text = f"{years_text} {days_text}" + elif years: + age_text = years_text + else: + age_text = days_text + self.add_row( + self.string_cell( + f"Total Aged Over {age_text}: ", + stylename=text_style, + numbercolumnsspanned=text_span, + ), + *(odf.table.TableCell() for _ in range(1, text_span)), + self.balance_cell(balance), + ) + + def _link_seq(self, row: AccrualPostings, key: MetaKey) -> Iterator[Tuple[str, str]]: + for href in row.all_meta_links(key): + text: Optional[str] = None + rt_ids = self.rt_wrapper.parse(href) + if rt_ids is not None: + ticket_id, attachment_id = rt_ids + if attachment_id is None: + text = f'RT#{ticket_id}' + href = self.rt_wrapper.url(ticket_id, attachment_id) or href + else: + # '..' pops the ODS filename off the link path. In other words, + # make the link relative to the directory the ODS is in. + href = f'../{href}' + if text is None: + href_path = Path(urlparse.urlparse(href).path) + text = urlparse.unquote(href_path.name) + yield (href, text) + + def write_row(self, row: AccrualPostings) -> None: + age = (self.date - row[0].meta.date).days + if row.end_balance.ge_zero(): + for index, threshold in enumerate(self.age_thresholds): + if age >= threshold: + self.age_balances[index] += row.end_balance + break + else: + return + raw_balance = row.balance() + if row.accrual_type is not None: + raw_balance = row.accrual_type.value.norm_func(raw_balance) + if raw_balance == row.end_balance: + amount_cell = odf.table.TableCell() + else: + amount_cell = self.balance_cell(raw_balance) + self.add_row( + self.date_cell(row[0].meta.date), + self.multiline_cell(row.entities), + amount_cell, + self.balance_cell(row.end_balance), + self.multilink_cell(self._link_seq(row, 'rt-id')), + self.multilink_cell(self._link_seq(row, 'invoice')), + ) + + +class AgingReport(BaseReport): + def __init__(self, + rt_client: rt.Rt, + out_file: BinaryIO, + date: Optional[datetime.date]=None, + ) -> None: + if date is None: + date = datetime.date.today() + self.out_bin = out_file + self.logger = logger.getChild(type(self).__name__) + self.ods = AgingODS(rt_client, date, self.logger) + + def run(self, groups: PostGroups) -> None: + rows = list(group for group in groups.values() if not group.is_zero()) + rows.sort(key=lambda related: ( + related.account, + related[0].meta.date, + min(related.entities) if related.entities else '', + )) + self.ods.write(rows) + self.ods.save_file(self.out_bin) + + class BalanceReport(BaseReport): def _report(self, invoice: str, @@ -283,12 +477,11 @@ class BalanceReport(BaseReport): index: int, ) -> Iterable[str]: posts = posts.since_last_nonzero() - balance = posts.balance() date_s = posts[0].meta.date.strftime('%Y-%m-%d') if index: yield "" yield f"{invoice}:" - yield f" {balance} outstanding since {date_s}" + yield f" {posts.balance_at_cost()} outstanding since {date_s}" class OutgoingReport(BaseReport): @@ -344,12 +537,10 @@ class OutgoingReport(BaseReport): ) requestor = f'{requestor_name} <{rt_requestor["EmailAddress"]}>'.strip() - cost_balance = -posts.balance_at_cost() - cost_balance_s = cost_balance.format(None) - if posts.final_bal == cost_balance: - balance_s = cost_balance_s - else: - balance_s = f'{posts.final_bal} ({cost_balance_s})' + balance_s = posts.end_balance.format(None) + raw_balance = -posts.balance() + if raw_balance != posts.end_balance: + balance_s = f'{raw_balance} ({balance_s})' contract_links = posts.all_meta_links('contract') if contract_links: @@ -380,8 +571,10 @@ class OutgoingReport(BaseReport): class ReportType(enum.Enum): + AGING = AgingReport BALANCE = BalanceReport OUTGOING = OutgoingReport + AGE = AGING BAL = BALANCE OUT = OUTGOING OUTGOINGS = OUTGOING @@ -460,9 +653,10 @@ def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace '--report-type', '-t', metavar='NAME', type=ReportType.by_name, - help="""The type of report to generate, either `balance` or `outgoing`. -If not specified, the default is `outgoing` for search criteria that return a -single outstanding payable, and `balance` any other time. + help="""The type of report to generate, one of `aging`, `balance`, or +`outgoing`. If not specified, the default is `aging` when no search terms are +given, `outgoing` for search terms that return a single outstanding payable, +and `balance` any other time. """) parser.add_argument( '--since', @@ -473,6 +667,14 @@ single outstanding payable, and `balance` any other time. You can either specify a fiscal year, or a negative offset from the current fiscal year, to start loading entries from. The default is -1 (start from the previous fiscal year). +""") + parser.add_argument( + '--output-file', '-O', + metavar='PATH', + type=Path, + help="""Write the report to this file, or stdout when PATH is `-`. +The default is stdout for the balance and outgoing reports, and a generated +filename for other reports. """) cliutil.add_loglevel_argument(parser) parser.add_argument( @@ -486,8 +688,32 @@ metadata to match. A single ticket number is a shortcut for """) args = parser.parse_args(arglist) args.search_terms = [SearchTerm.parse(s) for s in args.search] + if args.report_type is None and not args.search_terms: + args.report_type = ReportType.AGING return args +def get_output_path(output_path: Optional[Path], + default_path: Path=STANDARD_PATH, +) -> Optional[Path]: + if output_path is None: + output_path = default_path + if output_path == STANDARD_PATH: + return None + else: + return output_path + +def get_output_bin(path: Optional[Path], stdout: TextIO) -> BinaryIO: + if path is None: + return open(stdout.fileno(), 'wb') + else: + return path.open('wb') + +def get_output_text(path: Optional[Path], stdout: TextIO) -> TextIO: + if path is None: + return stdout + else: + return path.open('w') + def main(arglist: Optional[Sequence[str]]=None, stdout: TextIO=sys.stdout, stderr: TextIO=sys.stderr, @@ -502,21 +728,24 @@ def main(arglist: Optional[Sequence[str]]=None, if config is None: config = configmod.Config() config.load_file() + books_loader = config.books_loader() - if books_loader is not None: - entries, load_errors, _ = books_loader.load_fy_range(args.since) - else: - entries = [] + if books_loader is None: + entries: Entries = [] source = { 'filename': str(config.config_file_path()), 'lineno': 1, } - load_errors = [Error(source, "no books to load in configuration", None)] + load_errors: Errors = [Error(source, "no books to load in configuration", None)] + elif args.report_type is ReportType.AGING: + entries, load_errors, _ = books_loader.load_all() + else: + entries, load_errors, _ = books_loader.load_fy_range(args.since) filters.remove_opening_balance_txn(entries) + + returncode = 0 postings = filter_search(data.Posting.from_entries(entries), args.search_terms) groups: PostGroups = dict(AccrualPostings.group_by_meta(postings, 'invoice')) - groups = {key: group for key, group in groups.items() if not group.is_paid()} or groups - returncode = 0 for error in load_errors: bc_printer.print_error(error, file=stderr) returncode |= ReturnFlag.LOAD_ERRORS @@ -524,24 +753,53 @@ def main(arglist: Optional[Sequence[str]]=None, for error in related.report_inconsistencies(): bc_printer.print_error(error, file=stderr) returncode |= ReturnFlag.CONSISTENCY_ERRORS - if args.report_type is None: - args.report_type = ReportType.default_for(groups) if not groups: logger.warning("no matching entries found to report") returncode |= ReturnFlag.NOTHING_TO_REPORT + + groups = { + key: posts + for source_posts in groups.values() + for key, posts in source_posts.make_consistent() + } + if args.report_type is not ReportType.AGING: + groups = { + key: posts for key, posts in groups.items() if not posts.is_paid() + } or groups + + if args.report_type is None: + args.report_type = ReportType.default_for(groups) report: Optional[BaseReport] = None - if args.report_type is ReportType.OUTGOING: + output_path: Optional[Path] = None + if args.report_type is ReportType.AGING: + rt_client = config.rt_client() + if rt_client is None: + logger.error("unable to generate aging report: RT client is required") + else: + now = datetime.datetime.now() + default_path = Path(now.strftime('AgingReport_%Y-%m-%d_%H:%M.ods')) + output_path = get_output_path(args.output_file, default_path) + out_bin = get_output_bin(output_path, stdout) + report = AgingReport(rt_client, out_bin) + elif args.report_type is ReportType.OUTGOING: rt_client = config.rt_client() if rt_client is None: logger.error("unable to generate outgoing report: RT client is required") else: - report = OutgoingReport(rt_client, stdout) + output_path = get_output_path(args.output_file) + out_file = get_output_text(output_path, stdout) + report = OutgoingReport(rt_client, out_file) else: - report = args.report_type.value(stdout) + output_path = get_output_path(args.output_file) + out_file = get_output_text(output_path, stdout) + report = args.report_type.value(out_file) + if report is None: returncode |= ReturnFlag.REPORT_ERRORS else: report.run(groups) + if args.output_file != output_path: + logger.info("Report saved to %s", output_path) return 0 if returncode == 0 else 16 + returncode if __name__ == '__main__': diff --git a/setup.py b/setup.py index e310157..287ceea 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='conservancy_beancount', description="Plugin, library, and reports for reading Conservancy's books", - version='1.0.9', + version='1.1.0', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/books/accruals.beancount b/tests/books/accruals.beancount index 282e399..563d4da 100644 --- a/tests/books/accruals.beancount +++ b/tests/books/accruals.beancount @@ -1,70 +1,70 @@ -2020-01-01 open Assets:Checking -2020-01-01 open Assets:Receivable:Accounts -2020-01-01 open Expenses:FilingFees -2020-01-01 open Expenses:Services:Legal -2020-01-01 open Expenses:Travel -2020-01-01 open Income:Donations -2020-01-01 open Liabilities:Payable:Accounts -2020-01-01 open Equity:Funds:Opening +2010-01-01 open Assets:Checking +2010-01-01 open Assets:Receivable:Accounts +2010-01-01 open Expenses:FilingFees +2010-01-01 open Expenses:Services:Legal +2010-01-01 open Expenses:Travel +2010-01-01 open Income:Donations +2010-01-01 open Liabilities:Payable:Accounts +2010-01-01 open Equity:Funds:Opening -2020-03-01 * "Opening balances" +2010-03-01 * "Opening balances" Equity:Funds:Opening -1000 USD Assets:Receivable:Accounts 6000 USD Liabilities:Payable:Accounts -5000 USD -2020-03-05 * "EarlyBird" "Payment for receivable from previous FY" +2010-03-05 * "EarlyBird" "Payment for receivable from previous FY" rt-id: "rt:40" invoice: "rt:40/400" Assets:Receivable:Accounts -500 USD Assets:Checking 500 USD -2020-03-06 * "EarlyBird" "Payment for payment from previous FY" +2010-03-06 * "EarlyBird" "Payment for payment from previous FY" rt-id: "rt:44" invoice: "rt:44/440" Liabilities:Payable:Accounts 125 USD Assets:Checking -125 USD -2020-03-30 * "EarlyBird" "Travel reimbursement" +2010-03-30 * "EarlyBird" "Travel reimbursement" rt-id: "rt:490" invoice: "rt:490/4900" Liabilities:Payable:Accounts -75 USD Expenses:Travel 75 USD -2020-04-30 ! "Vendor" "Travel reimbursement" +2010-04-30 ! "Vendor" "Travel reimbursement" rt-id: "rt:310" contract: "rt:310/3100" invoice: "FIXME" ; still waiting on them to send it Liabilities:Payable:Accounts -200 USD Expenses:Travel 200 USD -2020-05-05 * "DonorA" "Donation pledge" +2010-05-05 * "DonorA" "Donation pledge" rt-id: "rt:505" invoice: "rt:505/5050" approval: "rt:505/5040" Income:Donations -2,500 EUR {1.100 USD} Assets:Receivable:Accounts 2,500 EUR {1.100 USD} -2020-05-10 * "Lawyer" "April legal services" +2010-05-10 * "Lawyer" "April legal services" rt-id: "rt:510" invoice: "rt:510/5100" contract: "rt:510/4000" Expenses:Services:Legal 200.00 USD Liabilities:Payable:Accounts -200.00 USD -2020-05-15 * "MatchingProgram" "May matched donations" +2010-05-15 * "MatchingProgram" "May matched donations" invoice: "rt://ticket/515/attachments/5150" approval: "rt://ticket/515/attachments/5140" Income:Donations -1500.00 USD Assets:Receivable:Accounts 1500.00 USD -2020-05-20 * "DonorA" "Donation made" +2010-05-20 * "DonorA" "Donation made" rt-id: "rt:505" invoice: "rt:505/5050" Assets:Receivable:Accounts -2,750.00 USD Assets:Checking 2,750.00 USD receipt: "DonorAWire.pdf" -2020-05-25 * "Lawyer" "May payment" +2010-05-25 * "Lawyer" "May payment" rt-id: "rt:510" invoice: "rt:510/5100" Liabilities:Payable:Accounts 200.00 USD @@ -72,21 +72,21 @@ Assets:Checking -200.00 USD receipt: "rt:510/5105" -2020-06-10 * "Lawyer" "May legal services" +2010-06-10 * "Lawyer" "May legal services" rt-id: "rt:510" invoice: "rt:510/6100" contract: "rt:510/4000" Expenses:Services:Legal 220.00 USD Liabilities:Payable:Accounts -220.00 USD -2020-06-12 * "Lawyer" "Additional legal fees for May" +2010-06-12 * "Lawyer" "Additional legal fees for May" rt-id: "rt:510" invoice: "rt:510/6100" contract: "rt:510/4000" Expenses:FilingFees 60.00 USD Liabilities:Payable:Accounts -60.00 USD -2020-06-18 * "EuroGov" "European legal fees" +2010-06-18 * "EuroGov" "European legal fees" rt-id: "rt:520" invoice: "rt:520/5200" contract: "rt:520/5220" diff --git a/tests/test_reports_accrual.py b/tests/test_reports_accrual.py index 5493e2c..e1ea4c6 100644 --- a/tests/test_reports_accrual.py +++ b/tests/test_reports_accrual.py @@ -22,10 +22,19 @@ import itertools import logging import re +import babel.numbers +import odf.opendocument +import odf.table +import odf.text + import pytest from . import testutil +from decimal import Decimal +from typing import NamedTuple, Optional, Sequence + +from beancount.core import data as bc_data from beancount import loader as bc_loader from conservancy_beancount import data from conservancy_beancount import rtutil @@ -58,6 +67,58 @@ CONSISTENT_METADATA = [ 'purchase-order', ] +class AgingRow(NamedTuple): + date: datetime.date + entity: Sequence[str] + amount: Optional[Sequence[bc_data.Amount]] + at_cost: bc_data.Amount + rt_id: Sequence[str] + invoice: Sequence[str] + + @classmethod + def make_simple(cls, date, entity, at_cost, invoice, rt_id=None, orig_amount=None): + if isinstance(date, str): + date = datetime.datetime.strptime(date, '%Y-%m-%d').date() + if not isinstance(at_cost, tuple): + at_cost = testutil.Amount(at_cost) + if rt_id is None: + rt_id, _, _ = invoice.partition('/') + return cls(date, [entity], orig_amount, at_cost, [rt_id], [invoice]) + + def check_row_match(self, sheet_row): + cells = testutil.ODSCell.from_row(sheet_row) + assert len(cells) == len(self) + cells = iter(cells) + assert next(cells).value == self.date + assert next(cells).text == '\0'.join(self.entity) + assert next(cells).text == '\0'.join( + babel.numbers.format_currency(number, currency, format_type='accounting') + for number, currency in self.amount or () + ) + usd_cell = next(cells) + assert usd_cell.value_type == 'currency' + assert usd_cell.value == self.at_cost.number + for index, cell in enumerate(cells): + links = cell.getElementsByType(odf.text.A) + assert len(links) == len(cell.childNodes) + assert index >= 1 + + +AGING_AP = [ + AgingRow.make_simple('2010-03-06', 'EarlyBird', -125, 'rt:44/440'), + AgingRow.make_simple('2010-03-30', 'EarlyBird', 75, 'rt:490/4900'), + AgingRow.make_simple('2010-04-30', 'Vendor', 200, 'FIXME'), + AgingRow.make_simple('2010-06-10', 'Lawyer', 280, 'rt:510/6100'), + AgingRow.make_simple('2010-06-18', 'EuroGov', 1100, 'rt:520/5200', + orig_amount=[testutil.Amount(1000, 'EUR')]), +] + +AGING_AR = [ + AgingRow.make_simple('2010-03-05', 'EarlyBird', -500, 'rt:40/400'), + AgingRow.make_simple('2010-05-15', 'MatchingProgram', 1500, + 'rt://ticket/515/attachments/5150'), +] + class RTClient(testutil.RTClient): TICKET_DATA = { '40': [ @@ -102,6 +163,66 @@ def accruals_by_meta(postings, value, key='invoice', wrap_type=iter): and post.account.is_under('Assets:Receivable', 'Liabilities:Payable') ) +def find_row_by_text(row_source, want_text): + for row in row_source: + try: + found_row = row.childNodes[0].text == want_text + except IndexError: + found_row = False + if found_row: + return row + return None + +def check_aging_sheet(sheet, aging_rows, date, accrue_date): + if not aging_rows: + return + if isinstance(accrue_date, int): + accrue_date = date + datetime.timedelta(days=accrue_date) + rows = iter(sheet.getElementsByType(odf.table.TableRow)) + for row in rows: + if "Aging Report" in row.text: + break + else: + assert None, "Header row not found" + assert f"Accrued by {accrue_date.isoformat()}" in row.text + assert f"Unpaid by {date.isoformat()}" in row.text + expect_rows = iter(aging_rows) + row0 = find_row_by_text(rows, aging_rows[0].date.isoformat()) + next(expect_rows).check_row_match(row0) + for actual, expected in zip(rows, expect_rows): + expected.check_row_match(actual) + for row in rows: + if row.text.startswith("Total Aged Over "): + break + else: + assert None, "Totals rows not found" + actual_sum = Decimal(row.childNodes[-1].value) + for row in rows: + if row.text.startswith("Total Aged Over "): + actual_sum += Decimal(row.childNodes[-1].value) + else: + break + assert actual_sum == sum( + row.at_cost.number + for row in aging_rows + if row.date <= accrue_date + and row.at_cost.number > 0 + ) + +def check_aging_ods(ods_file, + date=None, + recv_rows=AGING_AR, + pay_rows=AGING_AP, +): + if date is None: + date = datetime.date.today() + ods_file.seek(0) + ods = odf.opendocument.load(ods_file) + sheets = ods.spreadsheet.getElementsByType(odf.table.Table) + assert len(sheets) == 2 + check_aging_sheet(sheets[0], recv_rows, date, -60) + check_aging_sheet(sheets[1], pay_rows, date, -30) + @pytest.mark.parametrize('link_fmt', [ '{}', 'rt:{}', @@ -180,8 +301,10 @@ def test_filter_search(accrual_postings, search_terms, expect_count, check_func) assert check_func(post) @pytest.mark.parametrize('arg,expected', [ + ('aging', accrual.AgingReport), ('balance', accrual.BalanceReport), ('outgoing', accrual.OutgoingReport), + ('age', accrual.AgingReport), ('bal', accrual.BalanceReport), ('out', accrual.OutgoingReport), ('outgoings', accrual.OutgoingReport), @@ -399,10 +522,10 @@ def run_outgoing(invoice, postings, rt_client=None): return output @pytest.mark.parametrize('invoice,expected', [ - ('rt:505/5050', "Zero balance outstanding since 2020-05-05"), - ('rt:510/5100', "Zero balance outstanding since 2020-05-10"), - ('rt:510/6100', "-280.00 USD outstanding since 2020-06-10"), - ('rt://ticket/515/attachments/5150', "1,500.00 USD outstanding since 2020-05-15",), + ('rt:505/5050', "Zero balance outstanding since 2010-05-05"), + ('rt:510/5100', "Zero balance outstanding since 2010-05-10"), + ('rt:510/6100', "-280.00 USD outstanding since 2010-06-10"), + ('rt://ticket/515/attachments/5150', "1,500.00 USD outstanding since 2010-05-15",), ]) def test_balance_report(accrual_postings, invoice, expected, caplog): related = accruals_by_meta(accrual_postings, invoice, wrap_type=accrual.AccrualPostings) @@ -429,10 +552,10 @@ def test_outgoing_report(accrual_postings, caplog): r'^BEANCOUNT ENTRIES:$', # For each transaction, check for the date line, a metadata, and the # Expenses posting. - r'^\s*2020-06-10\s', + r'^\s*2010-06-10\s', fr'^\s+rt-id: "{rt_id_url}"$', r'^\s+Expenses:Services:Legal\s+220\.00 USD$', - r'^\s*2020-06-12\s', + r'^\s*2010-06-12\s', fr'^\s+contract: "{contract_url}"$', r'^\s+Expenses:FilingFees\s+60\.00 USD$', ]) @@ -469,6 +592,41 @@ def test_outgoing_report_without_rt_id(accrual_postings, caplog): ) assert not output.getvalue() +def run_aging_report(postings, today=None): + if today is None: + today = datetime.date.today() + postings = ( + post for post in postings + if post.account.is_under('Assets:Receivable', 'Liabilities:Payable') + ) + groups = { + key: group + for _, related in accrual.AccrualPostings.group_by_meta(postings, 'invoice') + for key, group in related.make_consistent() + } + output = io.BytesIO() + rt_client = RTClient() + report = accrual.AgingReport(rt_client, output, today) + report.run(groups) + return output + +def test_aging_report(accrual_postings): + output = run_aging_report(accrual_postings) + check_aging_ods(output) + +@pytest.mark.parametrize('date,recv_end,pay_end', [ + # Both these dates are chosen for their off-by-one potential: + # the first is exactly 30 days after the 2010-06-10 payable; + # the second is exactly 60 days after the 2010-05-15 receivable. + (datetime.date(2010, 7, 10), 1, 4), + (datetime.date(2010, 7, 14), 2, 4), +]) +def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end): + expect_recv = AGING_AR[:recv_end] + expect_pay = AGING_AP[:pay_end] + output = run_aging_report(accrual_postings, date) + check_aging_ods(output, date, expect_recv, expect_pay) + def run_main(arglist, config=None): if config is None: config = testutil.TestConfig( @@ -527,7 +685,7 @@ def test_main_outgoing_report(arglist): check_output(output, [ r'^REQUESTOR: Mx\. 510 $', r'^TOTAL TO PAY: \$280\.00$', - r'^\s*2020-06-12\s', + r'^\s*2010-06-12\s', r'^\s+Expenses:FilingFees\s+60\.00 USD$', ]) @@ -542,9 +700,29 @@ def test_main_balance_report(arglist): assert retcode == 0 check_output(output, [ r'\brt://ticket/515/attachments/5150:$', - r'^\s+1,500\.00 USD outstanding since 2020-05-15$', + r'^\s+1,500\.00 USD outstanding since 2010-05-15$', ]) +@pytest.mark.parametrize('arglist', [ + [], + ['-t', 'aging', 'entity=Lawyer'], +]) +def test_main_aging_report(tmp_path, arglist): + if arglist: + recv_rows = [row for row in AGING_AR if 'Lawyer' in row.entity] + pay_rows = [row for row in AGING_AP if 'Lawyer' in row.entity] + else: + recv_rows = AGING_AR + pay_rows = AGING_AP + output_path = tmp_path / 'AgingReport.ods' + arglist.insert(0, f'--output-file={output_path}') + retcode, output, errors = run_main(arglist) + assert not errors.getvalue() + assert retcode == 0 + assert not output.getvalue() + with output_path.open('rb') as ods_file: + check_aging_ods(ods_file, None, recv_rows, pay_rows) + def test_main_no_books(): check_main_fails([], testutil.TestConfig(), 1 | 8, [ r':1: +no books to load in configuration\b', diff --git a/tests/testutil.py b/tests/testutil.py index 18b4195..b5eea67 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -217,9 +217,12 @@ class TestBooksLoader(books.Loader): def __init__(self, source): self.source = source - def load_fy_range(self, from_fy, to_fy=None): + def load_all(self): return bc_loader.load_file(self.source) + def load_fy_range(self, from_fy, to_fy=None): + return self.load_all() + class TestConfig: def __init__(self, *,