accruals: Aging report shows all unpaid accruals color coded by age.

Some readers care about recent accruals, some don't. This presentation
accommmodates both audiences, providing the data while making it easy to
ignore or filter out recent accruals.
This commit is contained in:
Brett Smith 2020-07-01 11:56:28 -04:00
parent c0a2d1c070
commit 42b3e6ca17
3 changed files with 97 additions and 82 deletions

View file

@ -106,6 +106,7 @@ from ..beancount_types import (
Transaction, Transaction,
) )
import odf.element # type:ignore[import]
import odf.style # type:ignore[import] import odf.style # type:ignore[import]
import odf.table # type:ignore[import] import odf.table # type:ignore[import]
import rt import rt
@ -236,6 +237,13 @@ class BaseReport:
class AgingODS(core.BaseODS[AccrualPostings, data.Account]): class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
AGE_COLORS = [
'#ff00ff',
'#ff0000',
'#ff8800',
'#ffff00',
'#00ff00',
]
DOC_COLUMNS = [ DOC_COLUMNS = [
'rt-id', 'rt-id',
'invoice', 'invoice',
@ -283,16 +291,22 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
self.lock_first_row() self.lock_first_row()
def start_section(self, key: data.Account) -> None: def start_section(self, key: data.Account) -> None:
self.norm_func = core.normalize_amount_func(key) accrual_type = AccrualAccount.by_account(key)
self.age_thresholds = list(AccrualAccount.by_account(key).value.aging_thresholds) self.norm_func = accrual_type.normalize_amount
self.age_thresholds = list(accrual_type.value.aging_thresholds)
self.age_thresholds.append(-sys.maxsize)
self.age_balances = [core.MutableBalance() for _ in self.age_thresholds] self.age_balances = [core.MutableBalance() for _ in self.age_thresholds]
accrual_date = self.date - datetime.timedelta(days=self.age_thresholds[-1]) self.age_styles = [
self.merge_styles(self.style_date, self.border_style(
core.Border.LEFT, '10pt', 'solid', color,
)) for color in self.AGE_COLORS
]
acct_parts = key.slice_parts() acct_parts = key.slice_parts()
self.use_sheet(acct_parts[1]) self.use_sheet(acct_parts[1])
self.add_row() self.add_row()
self.add_row(self.string_cell( self.add_row(self.string_cell(
f"{' '.join(acct_parts[2:])} {acct_parts[1]} Aging Report" f"{' '.join(acct_parts[2:])} {acct_parts[1]} Aging Report"
f" Accrued by {accrual_date.isoformat()} Unpaid by {self.date.isoformat()}", f" for {self.date.isoformat()}",
stylename=self.merge_styles(self.style_bold, self.style_centertext), stylename=self.merge_styles(self.style_bold, self.style_centertext),
numbercolumnsspanned=self.COL_COUNT, numbercolumnsspanned=self.COL_COUNT,
)) ))
@ -300,11 +314,12 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
def end_section(self, key: data.Account) -> None: def end_section(self, key: data.Account) -> None:
total_balance = core.MutableBalance() total_balance = core.MutableBalance()
text_style = self.merge_styles(self.style_bold, self.style_endtext)
text_span = 4 text_span = 4
last_age_text: Optional[str] = None last_age_text: Optional[str] = None
self.add_row() self.add_row()
for threshold, balance in zip(self.age_thresholds, self.age_balances): for threshold, balance, style in zip(
self.age_thresholds, self.age_balances, self.age_styles,
):
years, days = divmod(threshold, 365) years, days = divmod(threshold, 365)
years_text = f"{years} {'Year' if years == 1 else 'Years'}" years_text = f"{years} {'Year' if years == 1 else 'Years'}"
days_text = f"{days} Days" days_text = f"{days} Days"
@ -316,12 +331,23 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
age_text = days_text age_text = days_text
if last_age_text is None: if last_age_text is None:
age_range = f"Over {age_text}" age_range = f"Over {age_text}"
elif threshold < 0:
self.add_row(
self.string_cell(
f"Total Unpaid Over {last_age_text}: ",
stylename=self.merge_styles(self.style_bold, self.style_endtext),
numbercolumnsspanned=text_span,
),
*(odf.table.TableCell() for _ in range(1, text_span)),
self.balance_cell(total_balance),
)
age_range = f"Under {last_age_text}"
else: else:
age_range = f"{age_text}{last_age_text}" age_range = f"{age_text}{last_age_text}"
self.add_row( self.add_row(
self.string_cell( self.string_cell(
f"Total Aged {age_range}: ", f"Total Aged {age_range}: ",
stylename=text_style, stylename=self.merge_styles(self.style_bold, self.style_endtext, style),
numbercolumnsspanned=text_span, numbercolumnsspanned=text_span,
), ),
*(odf.table.TableCell() for _ in range(1, text_span)), *(odf.table.TableCell() for _ in range(1, text_span)),
@ -332,7 +358,7 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
self.add_row( self.add_row(
self.string_cell( self.string_cell(
"Total Unpaid: ", "Total Unpaid: ",
stylename=text_style, stylename=self.merge_styles(self.style_bold, self.style_endtext),
numbercolumnsspanned=text_span, numbercolumnsspanned=text_span,
), ),
*(odf.table.TableCell() for _ in range(1, text_span)), *(odf.table.TableCell() for _ in range(1, text_span)),
@ -343,9 +369,9 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
row_date = row[0].meta.date row_date = row[0].meta.date
row_balance = self.norm_func(row.balance_at_cost()) row_balance = self.norm_func(row.balance_at_cost())
age = (self.date - row_date).days age = (self.date - row_date).days
if row_balance.ge_zero():
for index, threshold in enumerate(self.age_thresholds): for index, threshold in enumerate(self.age_thresholds):
if age >= threshold: if age >= threshold:
if row_balance.ge_zero():
self.age_balances[index] += row_balance self.age_balances[index] += row_balance
break break
else: else:
@ -360,7 +386,7 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
projects = row.meta_values('project') projects = row.meta_values('project')
projects.discard(None) projects.discard(None)
self.add_row( self.add_row(
self.date_cell(row_date), self.date_cell(row_date, stylename=self.age_styles[index]),
self.multiline_cell(sorted(entities)), self.multiline_cell(sorted(entities)),
amount_cell, amount_cell,
self.balance_cell(row_balance), self.balance_cell(row_balance),

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.5.4', version='1.5.5',
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

@ -61,6 +61,8 @@ ACCOUNTS = [
'Liabilities:Payable:Vacation', 'Liabilities:Payable:Vacation',
] ]
AGE_SUM_RE = re.compile(r'(?:\b(\d+) Years?)?(?: ?\b(\d+) Days?)?[:]')
class AgingRow(NamedTuple): class AgingRow(NamedTuple):
date: datetime.date date: datetime.date
entity: Sequence[str] entity: Sequence[str]
@ -166,57 +168,59 @@ def find_row_by_text(row_source, want_text):
found_row = False found_row = False
if found_row: if found_row:
return row return row
return None pytest.fail(f"did not find row with text {want_text!r}")
def check_aging_sheet(sheet, aging_rows, date, accrue_date): def check_age_sum(aging_rows, row, date):
text = row.firstChild.text
ages = [int(match.group(1) or 0) * 365 + int(match.group(2) or 0)
for match in AGE_SUM_RE.finditer(text)]
if len(ages) == 1:
# datetime only supports a 10K year range so this should cover all of it
if text.startswith('Total Aged Over '):
age_range = range(ages[0], 3650000)
else:
age_range = range(-3650000, ages[0])
elif len(ages) == 2:
age_range = range(*ages)
else:
pytest.fail(f"row has incorrect age matches: {ages!r}")
assert row.lastChild.value == sum(
row.at_cost.number
for row in aging_rows
if row.at_cost.number > 0
and (date - row.date).days in age_range
)
return row.lastChild.value
def check_aging_sheet(sheet, aging_rows, date):
if not aging_rows: if not aging_rows:
return return
if isinstance(accrue_date, int):
accrue_date = date + datetime.timedelta(days=accrue_date)
rows = iter(sheet.getElementsByType(odf.table.TableRow)) 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) expect_rows = iter(aging_rows)
row0 = find_row_by_text(rows, aging_rows[0].date.isoformat()) row0 = find_row_by_text(rows, aging_rows[0].date.isoformat())
next(expect_rows).check_row_match(row0) next(expect_rows).check_row_match(row0)
for actual, expected in zip(rows, expect_rows): for actual, expected in zip(rows, expect_rows):
expected.check_row_match(actual) expected.check_row_match(actual)
row0 = find_row_by_text(rows, "Total Aged Over 1 Year: ")
aging_sum = check_age_sum(aging_rows, row0, date)
sums = 0
for row in rows: for row in rows:
if row.text.startswith("Total Aged "): if not row.firstChild:
break pass
elif row.firstChild.text.startswith("Total Unpaid"):
assert row.lastChild.value == aging_sum
sums += 1
else: else:
assert None, "Totals rows not found" aging_sum += check_age_sum(aging_rows, row, date)
actual_sum = Decimal(row.childNodes[-1].value) assert sums > 1
for row in rows:
if row.text.startswith("Total Aged "):
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, def check_aging_ods(ods_file, date, recv_rows=AGING_AR, pay_rows=AGING_AP):
date=None,
recv_rows=AGING_AR,
pay_rows=AGING_AP,
):
if date is None:
date = datetime.date.today()
ods_file.seek(0) ods_file.seek(0)
ods = odf.opendocument.load(ods_file) ods = odf.opendocument.load(ods_file)
sheets = ods.spreadsheet.getElementsByType(odf.table.Table) sheets = ods.spreadsheet.getElementsByType(odf.table.Table)
assert len(sheets) == 2 assert len(sheets) == 2
check_aging_sheet(sheets[0], recv_rows, date, -60) check_aging_sheet(sheets[0], recv_rows, date)
check_aging_sheet(sheets[1], pay_rows, date, -30) check_aging_sheet(sheets[1], pay_rows, date)
@pytest.mark.parametrize('search_terms,expect_count,check_func', [ @pytest.mark.parametrize('search_terms,expect_count,check_func', [
([], ACCRUALS_COUNT, lambda post: post.account.is_under( ([], ACCRUALS_COUNT, lambda post: post.account.is_under(
@ -576,9 +580,7 @@ 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): def run_aging_report(postings, today):
if today is None:
today = datetime.date.today()
postings = ( postings = (
post for post in postings post for post in postings
if post.account.is_under('Assets:Receivable', 'Liabilities:Payable') if post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
@ -590,48 +592,35 @@ def run_aging_report(postings, today=None):
report.run(groups) report.run(groups)
return output return output
def test_aging_report(accrual_postings): @pytest.mark.parametrize('date', [
output = run_aging_report(accrual_postings) datetime.date(2010, 3, 1),
check_aging_ods(output)
@pytest.mark.parametrize('date,recv_end,pay_end', [
# Both these dates are chosen for their off-by-one potential: # Both these dates are chosen for their off-by-one potential:
# the first is exactly 30 days after the 2010-06-10 payable; # the first is exactly 30 days after the 2010-06-10 payable;
# the second is exactly 60 days after the 2010-05-15 receivable. # the second is exactly 60 days after the 2010-05-15 receivable.
(datetime.date(2010, 7, 10), 1, 5), datetime.date(2010, 7, 10),
(datetime.date(2010, 7, 14), 2, 5), datetime.date(2010, 7, 14),
# The remainder just shuffle the age buckets some.
datetime.date(2010, 12, 1),
datetime.date(2011, 6, 1),
datetime.date(2011, 12, 1),
datetime.date(2012, 3, 1),
]) ])
def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end): def test_aging_report_date_cutoffs(accrual_postings, date):
expect_recv = AGING_AR[:recv_end]
expect_pay = AGING_AP[:pay_end]
output = run_aging_report(accrual_postings, date) output = run_aging_report(accrual_postings, date)
check_aging_ods(output, date, expect_recv, expect_pay) check_aging_ods(output, date)
def test_aging_report_entity_consistency(accrual_postings): def test_aging_report_entity_consistency(accrual_postings):
date = datetime.date.today()
output = run_aging_report(( output = run_aging_report((
post for post in accrual_postings post for post in accrual_postings
if post.meta.get('rt-id') == 'rt:480' if post.meta.get('rt-id') == 'rt:480'
and post.units.number < 0 and post.units.number < 0
)) ), date)
check_aging_ods(output, None, [], [ check_aging_ods(output, date, [], [
AgingRow.make_simple('2010-04-15', 'MultiPartyA', 125, 'rt:480/4800'), AgingRow.make_simple('2010-04-15', 'MultiPartyA', 125, 'rt:480/4800'),
AgingRow.make_simple('2010-04-15', 'MultiPartyB', 125, 'rt:480/4800'), AgingRow.make_simple('2010-04-15', 'MultiPartyB', 125, 'rt:480/4800'),
]) ])
def test_aging_report_does_not_include_too_recent_postings(accrual_postings):
# This date is after the Q3 posting, but too soon after for that to be
# included in the aging report.
date = datetime.date(2010, 10, 1)
output = run_aging_report((
post for post in accrual_postings
if post.meta.get('rt-id') == 'rt:470'
), date)
# Date+amount are both from the Q2 posting only.
check_aging_ods(output, date, [
AgingRow.make_simple('2010-06-15', 'GrantCo', 5500, 'rt:470/4700',
project='Development Grant'),
], [])
def run_main(arglist, config=None, out_type=io.StringIO): def run_main(arglist, config=None, out_type=io.StringIO):
if config is None: if config is None:
config = testutil.TestConfig( config = testutil.TestConfig(
@ -735,7 +724,7 @@ def test_main_aging_report(arglist):
retcode, output, errors = run_main(arglist, out_type=io.BytesIO) retcode, output, errors = run_main(arglist, out_type=io.BytesIO)
assert not errors.getvalue() assert not errors.getvalue()
assert retcode == 0 assert retcode == 0
check_aging_ods(output, None, recv_rows, pay_rows) check_aging_ods(output, datetime.date.today(), recv_rows, pay_rows)
def test_main_no_books(): def test_main_no_books():
errors = check_main_fails([], testutil.TestConfig(), 1 | 8) errors = check_main_fails([], testutil.TestConfig(), 1 | 8)