Changeset - 42b3e6ca1742
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-07-01 16:00:17
brettcsmith@brettcsmith.org
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.
3 files changed with 97 insertions and 82 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -108,2 +108,3 @@ from ..beancount_types import (
 

	
 
import odf.element  # type:ignore[import]
 
import odf.style  # type:ignore[import]
...
 
@@ -238,2 +239,9 @@ class BaseReport:
 
class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
    AGE_COLORS = [
 
        '#ff00ff',
 
        '#ff0000',
 
        '#ff8800',
 
        '#ffff00',
 
        '#00ff00',
 
    ]
 
    DOC_COLUMNS = [
...
 
@@ -285,6 +293,12 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
    def start_section(self, key: data.Account) -> None:
 
        self.norm_func = core.normalize_amount_func(key)
 
        self.age_thresholds = list(AccrualAccount.by_account(key).value.aging_thresholds)
 
        accrual_type = AccrualAccount.by_account(key)
 
        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]
 
        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()
...
 
@@ -294,3 +308,3 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
            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),
...
 
@@ -302,3 +316,2 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
        total_balance = core.MutableBalance()
 
        text_style = self.merge_styles(self.style_bold, self.style_endtext)
 
        text_span = 4
...
 
@@ -306,3 +319,5 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
        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)
...
 
@@ -318,2 +333,13 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
                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:
...
 
@@ -323,3 +349,3 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
                    f"Total Aged {age_range}: ",
 
                    stylename=text_style,
 
                    stylename=self.merge_styles(self.style_bold, self.style_endtext, style),
 
                    numbercolumnsspanned=text_span,
...
 
@@ -334,3 +360,3 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
                "Total Unpaid: ",
 
                stylename=text_style,
 
                stylename=self.merge_styles(self.style_bold, self.style_endtext),
 
                numbercolumnsspanned=text_span,
...
 
@@ -345,9 +371,9 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
        age = (self.date - row_date).days
 
        if row_balance.ge_zero():
 
            for index, threshold in enumerate(self.age_thresholds):
 
                if age >= threshold:
 
        for index, threshold in enumerate(self.age_thresholds):
 
            if age >= threshold:
 
                if row_balance.ge_zero():
 
                    self.age_balances[index] += row_balance
 
                    break
 
            else:
 
                return
 
                break
 
        else:
 
            return
 
        raw_balance = self.norm_func(row.balance())
...
 
@@ -362,3 +388,3 @@ class AgingODS(core.BaseODS[AccrualPostings, data.Account]):
 
        self.add_row(
 
            self.date_cell(row_date),
 
            self.date_cell(row_date, stylename=self.age_styles[index]),
 
            self.multiline_cell(sorted(entities)),
setup.py
Show inline comments
...
 
@@ -7,3 +7,3 @@ setup(
 
    description="Plugin, library, and reports for reading Conservancy's books",
 
    version='1.5.4',
 
    version='1.5.5',
 
    author='Software Freedom Conservancy',
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -63,2 +63,4 @@ ACCOUNTS = [
 

	
 
AGE_SUM_RE = re.compile(r'(?:\b(\d+) Years?)?(?: ?\b(\d+) Days?)?[–:]')
 

	
 
class AgingRow(NamedTuple):
...
 
@@ -168,17 +170,30 @@ def find_row_by_text(row_source, want_text):
 
            return row
 
    return None
 
    pytest.fail(f"did not find row with text {want_text!r}")
 

	
 
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, accrue_date):
 
def check_aging_sheet(sheet, aging_rows, 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)
...
 
@@ -188,27 +203,16 @@ def check_aging_sheet(sheet, aging_rows, date, accrue_date):
 
        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:
 
        if row.text.startswith("Total Aged "):
 
            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 "):
 
            actual_sum += Decimal(row.childNodes[-1].value)
 
        if not row.firstChild:
 
            pass
 
        elif row.firstChild.text.startswith("Total Unpaid"):
 
            assert row.lastChild.value == aging_sum
 
            sums += 1
 
        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
 
    )
 
            aging_sum += check_age_sum(aging_rows, row, date)
 
    assert sums > 1
 

	
 
def check_aging_ods(ods_file,
 
                    date=None,
 
                    recv_rows=AGING_AR,
 
                    pay_rows=AGING_AP,
 
):
 
    if date is None:
 
        date = datetime.date.today()
 
def check_aging_ods(ods_file, date, recv_rows=AGING_AR, pay_rows=AGING_AP):
 
    ods_file.seek(0)
...
 
@@ -217,4 +221,4 @@ def check_aging_ods(ods_file,
 
    assert len(sheets) == 2
 
    check_aging_sheet(sheets[0], recv_rows, date, -60)
 
    check_aging_sheet(sheets[1], pay_rows, date, -30)
 
    check_aging_sheet(sheets[0], recv_rows, date)
 
    check_aging_sheet(sheets[1], pay_rows, date)
 

	
...
 
@@ -578,5 +582,3 @@ def test_outgoing_report_without_rt_id(accrual_postings, caplog):
 

	
 
def run_aging_report(postings, today=None):
 
    if today is None:
 
        today = datetime.date.today()
 
def run_aging_report(postings, today):
 
    postings = (
...
 
@@ -592,7 +594,4 @@ def run_aging_report(postings, today=None):
 

	
 
def test_aging_report(accrual_postings):
 
    output = run_aging_report(accrual_postings)
 
    check_aging_ods(output)
 

	
 
@pytest.mark.parametrize('date,recv_end,pay_end', [
 
@pytest.mark.parametrize('date', [
 
    datetime.date(2010, 3, 1),
 
    # Both these dates are chosen for their off-by-one potential:
...
 
@@ -600,12 +599,16 @@ def test_aging_report(accrual_postings):
 
    # the second is exactly 60 days after the 2010-05-15 receivable.
 
    (datetime.date(2010, 7, 10), 1, 5),
 
    (datetime.date(2010, 7, 14), 2, 5),
 
    datetime.date(2010, 7, 10),
 
    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):
 
    expect_recv = AGING_AR[:recv_end]
 
    expect_pay = AGING_AP[:pay_end]
 
def test_aging_report_date_cutoffs(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):
 
    date = datetime.date.today()
 
    output = run_aging_report((
...
 
@@ -614,4 +617,4 @@ def test_aging_report_entity_consistency(accrual_postings):
 
        and post.units.number < 0
 
    ))
 
    check_aging_ods(output, None, [], [
 
    ), date)
 
    check_aging_ods(output, date, [], [
 
        AgingRow.make_simple('2010-04-15', 'MultiPartyA', 125, 'rt:480/4800'),
...
 
@@ -620,16 +623,2 @@ def test_aging_report_entity_consistency(accrual_postings):
 

	
 
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):
...
 
@@ -737,3 +726,3 @@ def test_main_aging_report(arglist):
 
    assert retcode == 0
 
    check_aging_ods(output, None, recv_rows, pay_rows)
 
    check_aging_ods(output, datetime.date.today(), recv_rows, pay_rows)
 

	
0 comments (0 inline, 0 general)