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
|
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__':
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -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+',
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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, *,
|
||||||
|
|
Loading…
Reference in a new issue