Changeset - f8f57428aa9f
[Not reviewed]
0 5 0
Brett Smith - 4 years ago 2020-06-04 02:20:37
brettcsmith@brettcsmith.org
accrual: Introduce aging report. RT#10694.
5 files changed with 508 insertions and 69 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -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__':
setup.py
Show inline comments
...
 
@@ -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+',
tests/books/accruals.beancount
Show inline comments
 
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"
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -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 <mx510@example\.org>$',
 
        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',
tests/testutil.py
Show inline comments
...
 
@@ -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, *,
0 comments (0 inline, 0 general)