accrual: Introduce aging report. RT#10694.

This commit is contained in:
Brett Smith 2020-06-03 16:54:22 -04:00
parent 70057fe383
commit f8f57428aa
5 changed files with 508 additions and 69 deletions

View file

@ -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__':

View file

@ -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+',

View file

@ -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"

View file

@ -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',

View file

@ -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, *,