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 accrual-report checks accruals (postings under Assets:Receivable and
Liabilities:Payable) for errors and metadata consistency, and reports any Liabilities:Payable) for errors and metadata consistency, and reports any
problems on stderr. Then it writes a report about the status of those 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 If you run it with no arguments, it will generate an aging report in ODS format
argument:: 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: # Report all accruals associated with RT#1230:
accrual-report 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 # Report accruals associated with RT#1230 and Jane Doe
accrual-report 1230 entity=Doe-Jane accrual-report 1230 entity=Doe-Jane
accrual-report will automatically decide what kind of report to generate from accrual-report will automatically decide what kind of report to generate
the results of your search. If the search matches a single outstanding payable, from the search terms you provide and the results they return. If you pass
it will write an outgoing approval report; otherwise, it writes a basic balance no search terms, it generates an aging report. If your search terms match a
report. You can request a specific report type with the ``--report-type`` single outstanding payable, it writes an outgoing approval report.
option:: 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 # Write an outgoing approval report for all outstanding accruals for
# Jane Doe, even if there's more than one # Jane Doe, even if there's more than one
accrual-report --report-type outgoing entity=Doe-Jane 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 # Copyright © 2020 Brett Smith
# #
@ -66,9 +73,14 @@ import logging
import operator import operator
import re import re
import sys import sys
import urllib.parse as urlparse
from pathlib import Path
from typing import ( from typing import (
cast,
Any, Any,
BinaryIO,
Callable, Callable,
Dict, Dict,
Iterable, Iterable,
@ -85,12 +97,16 @@ from typing import (
Union, Union,
) )
from ..beancount_types import ( from ..beancount_types import (
Entries,
Error, Error,
Errors,
MetaKey, MetaKey,
MetaValue, MetaValue,
Transaction, Transaction,
) )
import odf.style # type:ignore[import]
import odf.table # type:ignore[import]
import rt import rt
from beancount.parser import printer as bc_printer from beancount.parser import printer as bc_printer
@ -103,6 +119,7 @@ from .. import filters
from .. import rtutil from .. import rtutil
PROGNAME = 'accrual-report' PROGNAME = 'accrual-report'
STANDARD_PATH = Path('-')
PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings'] PostGroups = Mapping[Optional[MetaValue], 'AccrualPostings']
RTObject = Mapping[str, str] RTObject = Mapping[str, str]
@ -116,16 +133,30 @@ class Sentinel:
class Account(NamedTuple): class Account(NamedTuple):
name: str name: str
norm_func: Callable[[core.Balance], core.Balance] norm_func: Callable[[core.Balance], core.Balance]
aging_thresholds: Sequence[int]
class AccrualAccount(enum.Enum): class AccrualAccount(enum.Enum):
PAYABLE = Account('Liabilities:Payable', operator.neg) # Note the aging report uses the same order accounts are defined here.
RECEIVABLE = Account('Assets:Receivable', lambda bal: bal) # 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 @classmethod
def account_names(cls) -> Iterator[str]: def account_names(cls) -> Iterator[str]:
return (acct.value.name for acct in cls) 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 @classmethod
def classify(cls, related: core.RelatedPostings) -> 'AccrualAccount': def classify(cls, related: core.RelatedPostings) -> 'AccrualAccount':
for account in cls: for account in cls:
@ -151,7 +182,7 @@ class AccrualPostings(core.RelatedPostings):
INCONSISTENT = Sentinel() INCONSISTENT = Sentinel()
__slots__ = ( __slots__ = (
'accrual_type', 'accrual_type',
'final_bal', 'end_balance',
'account', 'account',
'accounts', 'accounts',
'contract', 'contract',
@ -174,6 +205,7 @@ class AccrualPostings(core.RelatedPostings):
# The following type declarations tell mypy about values set in the for # The following type declarations tell mypy about values set in the for
# loop that are important enough to be referenced directly elsewhere. # loop that are important enough to be referenced directly elsewhere.
self.account: Union[data.Account, Sentinel] self.account: Union[data.Account, Sentinel]
self.entity: Union[MetaValue, Sentinel]
self.entitys: FrozenSet[MetaValue] self.entitys: FrozenSet[MetaValue]
self.invoice: Union[MetaValue, Sentinel] self.invoice: Union[MetaValue, Sentinel]
for name, get_func in self._FIELDS.items(): for name, get_func in self._FIELDS.items():
@ -188,10 +220,10 @@ class AccrualPostings(core.RelatedPostings):
self.entities = self.entitys self.entities = self.entitys
if self.account is self.INCONSISTENT: if self.account is self.INCONSISTENT:
self.accrual_type: Optional[AccrualAccount] = None self.accrual_type: Optional[AccrualAccount] = None
self.final_bal = self.balance() self.end_balance = self.balance_at_cost()
else: else:
self.accrual_type = AccrualAccount.classify(self) 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']]: def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]:
account_ok = isinstance(self.account, str) account_ok = isinstance(self.account, str)
@ -239,13 +271,13 @@ class AccrualPostings(core.RelatedPostings):
if self.accrual_type is None: if self.accrual_type is None:
return default return default
else: else:
return self.final_bal.le_zero() return self.end_balance.le_zero()
def is_zero(self, default: Optional[bool]=None) -> Optional[bool]: def is_zero(self, default: Optional[bool]=None) -> Optional[bool]:
if self.accrual_type is None: if self.accrual_type is None:
return default return default
else: else:
return self.final_bal.is_zero() return self.end_balance.is_zero()
def since_last_nonzero(self) -> 'AccrualPostings': def since_last_nonzero(self) -> 'AccrualPostings':
for index, (post, balance) in enumerate(self.iter_with_balance()): for index, (post, balance) in enumerate(self.iter_with_balance()):
@ -276,6 +308,168 @@ class BaseReport:
print(line, file=self.out_file) 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): class BalanceReport(BaseReport):
def _report(self, def _report(self,
invoice: str, invoice: str,
@ -283,12 +477,11 @@ class BalanceReport(BaseReport):
index: int, index: int,
) -> Iterable[str]: ) -> Iterable[str]:
posts = posts.since_last_nonzero() posts = posts.since_last_nonzero()
balance = posts.balance()
date_s = posts[0].meta.date.strftime('%Y-%m-%d') date_s = posts[0].meta.date.strftime('%Y-%m-%d')
if index: if index:
yield "" yield ""
yield f"{invoice}:" yield f"{invoice}:"
yield f" {balance} outstanding since {date_s}" yield f" {posts.balance_at_cost()} outstanding since {date_s}"
class OutgoingReport(BaseReport): class OutgoingReport(BaseReport):
@ -344,12 +537,10 @@ class OutgoingReport(BaseReport):
) )
requestor = f'{requestor_name} <{rt_requestor["EmailAddress"]}>'.strip() requestor = f'{requestor_name} <{rt_requestor["EmailAddress"]}>'.strip()
cost_balance = -posts.balance_at_cost() balance_s = posts.end_balance.format(None)
cost_balance_s = cost_balance.format(None) raw_balance = -posts.balance()
if posts.final_bal == cost_balance: if raw_balance != posts.end_balance:
balance_s = cost_balance_s balance_s = f'{raw_balance} ({balance_s})'
else:
balance_s = f'{posts.final_bal} ({cost_balance_s})'
contract_links = posts.all_meta_links('contract') contract_links = posts.all_meta_links('contract')
if contract_links: if contract_links:
@ -380,8 +571,10 @@ class OutgoingReport(BaseReport):
class ReportType(enum.Enum): class ReportType(enum.Enum):
AGING = AgingReport
BALANCE = BalanceReport BALANCE = BalanceReport
OUTGOING = OutgoingReport OUTGOING = OutgoingReport
AGE = AGING
BAL = BALANCE BAL = BALANCE
OUT = OUTGOING OUT = OUTGOING
OUTGOINGS = OUTGOING OUTGOINGS = OUTGOING
@ -460,9 +653,10 @@ def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace
'--report-type', '-t', '--report-type', '-t',
metavar='NAME', metavar='NAME',
type=ReportType.by_name, type=ReportType.by_name,
help="""The type of report to generate, either `balance` or `outgoing`. help="""The type of report to generate, one of `aging`, `balance`, or
If not specified, the default is `outgoing` for search criteria that return a `outgoing`. If not specified, the default is `aging` when no search terms are
single outstanding payable, and `balance` any other time. given, `outgoing` for search terms that return a single outstanding payable,
and `balance` any other time.
""") """)
parser.add_argument( parser.add_argument(
'--since', '--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 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 fiscal year, to start loading entries from. The default is -1 (start from the
previous fiscal year). 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) cliutil.add_loglevel_argument(parser)
parser.add_argument( parser.add_argument(
@ -486,8 +688,32 @@ metadata to match. A single ticket number is a shortcut for
""") """)
args = parser.parse_args(arglist) args = parser.parse_args(arglist)
args.search_terms = [SearchTerm.parse(s) for s in args.search] 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 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, def main(arglist: Optional[Sequence[str]]=None,
stdout: TextIO=sys.stdout, stdout: TextIO=sys.stdout,
stderr: TextIO=sys.stderr, stderr: TextIO=sys.stderr,
@ -502,21 +728,24 @@ def main(arglist: Optional[Sequence[str]]=None,
if config is None: if config is None:
config = configmod.Config() config = configmod.Config()
config.load_file() config.load_file()
books_loader = config.books_loader() books_loader = config.books_loader()
if books_loader is not None: if books_loader is None:
entries, load_errors, _ = books_loader.load_fy_range(args.since) entries: Entries = []
else:
entries = []
source = { source = {
'filename': str(config.config_file_path()), 'filename': str(config.config_file_path()),
'lineno': 1, '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) filters.remove_opening_balance_txn(entries)
returncode = 0
postings = filter_search(data.Posting.from_entries(entries), args.search_terms) postings = filter_search(data.Posting.from_entries(entries), args.search_terms)
groups: PostGroups = dict(AccrualPostings.group_by_meta(postings, 'invoice')) 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: for error in load_errors:
bc_printer.print_error(error, file=stderr) bc_printer.print_error(error, file=stderr)
returncode |= ReturnFlag.LOAD_ERRORS returncode |= ReturnFlag.LOAD_ERRORS
@ -524,24 +753,53 @@ def main(arglist: Optional[Sequence[str]]=None,
for error in related.report_inconsistencies(): for error in related.report_inconsistencies():
bc_printer.print_error(error, file=stderr) bc_printer.print_error(error, file=stderr)
returncode |= ReturnFlag.CONSISTENCY_ERRORS returncode |= ReturnFlag.CONSISTENCY_ERRORS
if args.report_type is None:
args.report_type = ReportType.default_for(groups)
if not groups: if not groups:
logger.warning("no matching entries found to report") logger.warning("no matching entries found to report")
returncode |= ReturnFlag.NOTHING_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 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() rt_client = config.rt_client()
if rt_client is None: if rt_client is None:
logger.error("unable to generate outgoing report: RT client is required") logger.error("unable to generate outgoing report: RT client is required")
else: 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: 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: if report is None:
returncode |= ReturnFlag.REPORT_ERRORS returncode |= ReturnFlag.REPORT_ERRORS
else: else:
report.run(groups) 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 return 0 if returncode == 0 else 16 + returncode
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -5,7 +5,7 @@ from setuptools import setup
setup( setup(
name='conservancy_beancount', name='conservancy_beancount',
description="Plugin, library, and reports for reading Conservancy's books", description="Plugin, library, and reports for reading Conservancy's books",
version='1.0.9', version='1.1.0',
author='Software Freedom Conservancy', author='Software Freedom Conservancy',
author_email='info@sfconservancy.org', author_email='info@sfconservancy.org',
license='GNU AGPLv3+', license='GNU AGPLv3+',

View file

@ -1,70 +1,70 @@
2020-01-01 open Assets:Checking 2010-01-01 open Assets:Checking
2020-01-01 open Assets:Receivable:Accounts 2010-01-01 open Assets:Receivable:Accounts
2020-01-01 open Expenses:FilingFees 2010-01-01 open Expenses:FilingFees
2020-01-01 open Expenses:Services:Legal 2010-01-01 open Expenses:Services:Legal
2020-01-01 open Expenses:Travel 2010-01-01 open Expenses:Travel
2020-01-01 open Income:Donations 2010-01-01 open Income:Donations
2020-01-01 open Liabilities:Payable:Accounts 2010-01-01 open Liabilities:Payable:Accounts
2020-01-01 open Equity:Funds:Opening 2010-01-01 open Equity:Funds:Opening
2020-03-01 * "Opening balances" 2010-03-01 * "Opening balances"
Equity:Funds:Opening -1000 USD Equity:Funds:Opening -1000 USD
Assets:Receivable:Accounts 6000 USD Assets:Receivable:Accounts 6000 USD
Liabilities:Payable:Accounts -5000 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" rt-id: "rt:40"
invoice: "rt:40/400" invoice: "rt:40/400"
Assets:Receivable:Accounts -500 USD Assets:Receivable:Accounts -500 USD
Assets:Checking 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" rt-id: "rt:44"
invoice: "rt:44/440" invoice: "rt:44/440"
Liabilities:Payable:Accounts 125 USD Liabilities:Payable:Accounts 125 USD
Assets:Checking -125 USD Assets:Checking -125 USD
2020-03-30 * "EarlyBird" "Travel reimbursement" 2010-03-30 * "EarlyBird" "Travel reimbursement"
rt-id: "rt:490" rt-id: "rt:490"
invoice: "rt:490/4900" invoice: "rt:490/4900"
Liabilities:Payable:Accounts -75 USD Liabilities:Payable:Accounts -75 USD
Expenses:Travel 75 USD Expenses:Travel 75 USD
2020-04-30 ! "Vendor" "Travel reimbursement" 2010-04-30 ! "Vendor" "Travel reimbursement"
rt-id: "rt:310" rt-id: "rt:310"
contract: "rt:310/3100" contract: "rt:310/3100"
invoice: "FIXME" ; still waiting on them to send it invoice: "FIXME" ; still waiting on them to send it
Liabilities:Payable:Accounts -200 USD Liabilities:Payable:Accounts -200 USD
Expenses:Travel 200 USD Expenses:Travel 200 USD
2020-05-05 * "DonorA" "Donation pledge" 2010-05-05 * "DonorA" "Donation pledge"
rt-id: "rt:505" rt-id: "rt:505"
invoice: "rt:505/5050" invoice: "rt:505/5050"
approval: "rt:505/5040" approval: "rt:505/5040"
Income:Donations -2,500 EUR {1.100 USD} Income:Donations -2,500 EUR {1.100 USD}
Assets:Receivable:Accounts 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" rt-id: "rt:510"
invoice: "rt:510/5100" invoice: "rt:510/5100"
contract: "rt:510/4000" contract: "rt:510/4000"
Expenses:Services:Legal 200.00 USD Expenses:Services:Legal 200.00 USD
Liabilities:Payable:Accounts -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" invoice: "rt://ticket/515/attachments/5150"
approval: "rt://ticket/515/attachments/5140" approval: "rt://ticket/515/attachments/5140"
Income:Donations -1500.00 USD Income:Donations -1500.00 USD
Assets:Receivable:Accounts 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" rt-id: "rt:505"
invoice: "rt:505/5050" invoice: "rt:505/5050"
Assets:Receivable:Accounts -2,750.00 USD Assets:Receivable:Accounts -2,750.00 USD
Assets:Checking 2,750.00 USD Assets:Checking 2,750.00 USD
receipt: "DonorAWire.pdf" receipt: "DonorAWire.pdf"
2020-05-25 * "Lawyer" "May payment" 2010-05-25 * "Lawyer" "May payment"
rt-id: "rt:510" rt-id: "rt:510"
invoice: "rt:510/5100" invoice: "rt:510/5100"
Liabilities:Payable:Accounts 200.00 USD Liabilities:Payable:Accounts 200.00 USD
@ -72,21 +72,21 @@
Assets:Checking -200.00 USD Assets:Checking -200.00 USD
receipt: "rt:510/5105" receipt: "rt:510/5105"
2020-06-10 * "Lawyer" "May legal services" 2010-06-10 * "Lawyer" "May legal services"
rt-id: "rt:510" rt-id: "rt:510"
invoice: "rt:510/6100" invoice: "rt:510/6100"
contract: "rt:510/4000" contract: "rt:510/4000"
Expenses:Services:Legal 220.00 USD Expenses:Services:Legal 220.00 USD
Liabilities:Payable:Accounts -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" rt-id: "rt:510"
invoice: "rt:510/6100" invoice: "rt:510/6100"
contract: "rt:510/4000" contract: "rt:510/4000"
Expenses:FilingFees 60.00 USD Expenses:FilingFees 60.00 USD
Liabilities:Payable:Accounts -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" rt-id: "rt:520"
invoice: "rt:520/5200" invoice: "rt:520/5200"
contract: "rt:520/5220" contract: "rt:520/5220"

View file

@ -22,10 +22,19 @@ import itertools
import logging import logging
import re import re
import babel.numbers
import odf.opendocument
import odf.table
import odf.text
import pytest import pytest
from . import testutil 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 beancount import loader as bc_loader
from conservancy_beancount import data from conservancy_beancount import data
from conservancy_beancount import rtutil from conservancy_beancount import rtutil
@ -58,6 +67,58 @@ CONSISTENT_METADATA = [
'purchase-order', '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): class RTClient(testutil.RTClient):
TICKET_DATA = { TICKET_DATA = {
'40': [ '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') 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', [ @pytest.mark.parametrize('link_fmt', [
'{}', '{}',
'rt:{}', 'rt:{}',
@ -180,8 +301,10 @@ def test_filter_search(accrual_postings, search_terms, expect_count, check_func)
assert check_func(post) assert check_func(post)
@pytest.mark.parametrize('arg,expected', [ @pytest.mark.parametrize('arg,expected', [
('aging', accrual.AgingReport),
('balance', accrual.BalanceReport), ('balance', accrual.BalanceReport),
('outgoing', accrual.OutgoingReport), ('outgoing', accrual.OutgoingReport),
('age', accrual.AgingReport),
('bal', accrual.BalanceReport), ('bal', accrual.BalanceReport),
('out', accrual.OutgoingReport), ('out', accrual.OutgoingReport),
('outgoings', accrual.OutgoingReport), ('outgoings', accrual.OutgoingReport),
@ -399,10 +522,10 @@ def run_outgoing(invoice, postings, rt_client=None):
return output return output
@pytest.mark.parametrize('invoice,expected', [ @pytest.mark.parametrize('invoice,expected', [
('rt:505/5050', "Zero balance outstanding since 2020-05-05"), ('rt:505/5050', "Zero balance outstanding since 2010-05-05"),
('rt:510/5100', "Zero balance outstanding since 2020-05-10"), ('rt:510/5100', "Zero balance outstanding since 2010-05-10"),
('rt:510/6100', "-280.00 USD outstanding since 2020-06-10"), ('rt:510/6100', "-280.00 USD outstanding since 2010-06-10"),
('rt://ticket/515/attachments/5150', "1,500.00 USD outstanding since 2020-05-15",), ('rt://ticket/515/attachments/5150', "1,500.00 USD outstanding since 2010-05-15",),
]) ])
def test_balance_report(accrual_postings, invoice, expected, caplog): def test_balance_report(accrual_postings, invoice, expected, caplog):
related = accruals_by_meta(accrual_postings, invoice, wrap_type=accrual.AccrualPostings) 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:$', r'^BEANCOUNT ENTRIES:$',
# For each transaction, check for the date line, a metadata, and the # For each transaction, check for the date line, a metadata, and the
# Expenses posting. # Expenses posting.
r'^\s*2020-06-10\s', r'^\s*2010-06-10\s',
fr'^\s+rt-id: "{rt_id_url}"$', fr'^\s+rt-id: "{rt_id_url}"$',
r'^\s+Expenses:Services:Legal\s+220\.00 USD$', 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}"$', fr'^\s+contract: "{contract_url}"$',
r'^\s+Expenses:FilingFees\s+60\.00 USD$', 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() 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): def run_main(arglist, config=None):
if config is None: if config is None:
config = testutil.TestConfig( config = testutil.TestConfig(
@ -527,7 +685,7 @@ def test_main_outgoing_report(arglist):
check_output(output, [ check_output(output, [
r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$', r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
r'^TOTAL TO PAY: \$280\.00$', 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$', r'^\s+Expenses:FilingFees\s+60\.00 USD$',
]) ])
@ -542,9 +700,29 @@ def test_main_balance_report(arglist):
assert retcode == 0 assert retcode == 0
check_output(output, [ check_output(output, [
r'\brt://ticket/515/attachments/5150:$', 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(): def test_main_no_books():
check_main_fails([], testutil.TestConfig(), 1 | 8, [ check_main_fails([], testutil.TestConfig(), 1 | 8, [
r':1: +no books to load in configuration\b', r':1: +no books to load in configuration\b',

View file

@ -217,9 +217,12 @@ class TestBooksLoader(books.Loader):
def __init__(self, source): def __init__(self, source):
self.source = 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) return bc_loader.load_file(self.source)
def load_fy_range(self, from_fy, to_fy=None):
return self.load_all()
class TestConfig: class TestConfig:
def __init__(self, *, def __init__(self, *,