accrual: Introduce aging report. RT#10694.
This commit is contained in:
parent
70057fe383
commit
f8f57428aa
5 changed files with 508 additions and 69 deletions
|
@ -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__':
|
||||
|
|
2
setup.py
2
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
|||
setup(
|
||||
name='conservancy_beancount',
|
||||
description="Plugin, library, and reports for reading Conservancy's books",
|
||||
version='1.0.9',
|
||||
version='1.1.0',
|
||||
author='Software Freedom Conservancy',
|
||||
author_email='info@sfconservancy.org',
|
||||
license='GNU AGPLv3+',
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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, *,
|
||||
|
|
Loading…
Reference in a new issue