Changeset - 9ae974009b13
[Not reviewed]
0 2 0
Brett Smith - 4 years ago 2020-06-27 20:31:27
brettcsmith@brettcsmith.org
fund: Add outstanding balances to text fund report.
2 files changed with 60 insertions and 33 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/fund.py
Show inline comments
...
 
@@ -71,25 +71,31 @@ import odf.table  # type:ignore[import]
 

	
 
from beancount.parser import printer as bc_printer
 

	
 
from . import core
 
from .. import books
 
from .. import cliutil
 
from .. import config as configmod
 
from .. import data
 

	
 
AccountsMap = Mapping[data.Account, core.PeriodPostings]
 
FundPosts = Tuple[MetaValue, AccountsMap]
 

	
 
BALANCE_ACCOUNTS = ['Equity', 'Income', 'Expenses']
 
EQUITY_ACCOUNTS = ['Equity', 'Income', 'Expenses']
 
INFO_ACCOUNTS = [
 
    'Assets:Receivable',
 
    'Assets:Prepaid',
 
    'Liabilities:Payable',
 
    'Liabilities:UnearnedIncome',
 
]
 
PROGNAME = 'fund-report'
 
UNRESTRICTED_FUND = 'Conservancy'
 
logger = logging.getLogger('conservancy_beancount.reports.fund')
 

	
 
class ODSReport(core.BaseODS[FundPosts, None]):
 
    def __init__(self, start_date: datetime.date, stop_date: datetime.date) -> None:
 
        super().__init__()
 
        self.start_date = start_date
 
        self.stop_date = stop_date
 
        self.unrestricted: AccountsMap = {}
 

	
 
    def section_key(self, row: FundPosts) -> None:
...
 
@@ -161,30 +167,35 @@ class TextReport:
 
                 start_date: datetime.date,
 
                 stop_date: datetime.date,
 
                 out_file: TextIO) -> None:
 
        self.start_date = start_date
 
        self.stop_date = stop_date
 
        self.out_file = out_file
 

	
 
    def _account_balances(self,
 
                          fund: str,
 
                          account_map: AccountsMap,
 
    ) -> Iterator[Tuple[str, Sequence[str]]]:
 
        total_fmt = f'{fund} balance as of {{}}'
 
        for acct_s, balance in core.account_balances(account_map, BALANCE_ACCOUNTS):
 
        for acct_s, balance in core.account_balances(account_map, EQUITY_ACCOUNTS):
 
            if acct_s is core.OPENING_BALANCE_NAME:
 
                acct_s = total_fmt.format(self.start_date.isoformat())
 
            elif acct_s is core.ENDING_BALANCE_NAME:
 
                acct_s = total_fmt.format(self.stop_date.isoformat())
 
            yield acct_s, (-balance).format(None, sep='\0').split('\0')
 
        for _, account in core.sort_and_filter_accounts(account_map, INFO_ACCOUNTS):
 
            balance = account_map[account].stop_bal
 
            if not balance.is_zero():
 
                balance = core.normalize_amount_func(account)(balance)
 
                yield account, balance.format(None, sep='\0').split('\0')
 

	
 
    def write(self, rows: Iterable[FundPosts]) -> None:
 
        output = [
 
            line
 
            for fund, account_map in rows
 
            for line in self._account_balances(fund, account_map)
 
        ]
 
        acct_width = max(len(acct_s) for acct_s, _ in output) + 2
 
        bal_width = max(len(s) for _, bal_s in output for s in bal_s)
 
        bal_width = max(bal_width, 8)
 
        line_fmt = f'{{:>{acct_width}}}  {{:>{bal_width}}}'
 
        print(line_fmt.replace('{:>', '{:^').format("ACCOUNT", "BALANCE"),
...
 
@@ -310,25 +321,24 @@ def main(arglist: Optional[Sequence[str]]=None,
 
    if books_loader is None:
 
        entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
 
    else:
 
        entries, load_errors, _ = books_loader.load_fy_range(args.start_date, args.stop_date)
 
    for error in load_errors:
 
        bc_printer.print_error(error, file=stderr)
 
        returncode |= ReturnFlag.LOAD_ERRORS
 

	
 
    postings = (
 
        post
 
        for post in data.Posting.from_entries(entries)
 
        if post.meta.date < args.stop_date
 
        and post.account.is_under(*BALANCE_ACCOUNTS)
 
    )
 
    for search_term in args.search_terms:
 
        postings = search_term.filter_postings(postings)
 
    fund_postings = {
 
        key: related
 
        for key, related in core.RelatedPostings.group_by_meta(postings, 'project')
 
        if isinstance(key, str)
 
    }
 
    period_cls = core.PeriodPostings.with_start_date(args.start_date)
 
    fund_map = collections.OrderedDict(
 
        (fund, dict(period_cls.group_by_account(fund_postings[fund])))
 
        for fund in sorted(fund_postings, key=lambda s: locale.strxfrm(s.casefold()))
tests/test_reports_fund.py
Show inline comments
...
 
@@ -31,158 +31,175 @@ import odf.table
 
from beancount import loader as bc_loader
 
from conservancy_beancount import data
 
from conservancy_beancount.reports import core
 
from conservancy_beancount.reports import fund
 

	
 
from decimal import Decimal
 

	
 
_ledger_load = bc_loader.load_file(testutil.test_path('books/fund.beancount'))
 
START_DATE = datetime.date(2018, 3, 1)
 
MID_DATE = datetime.date(2019, 3, 1)
 
STOP_DATE = datetime.date(2020, 3, 1)
 

	
 
EQUITY_ROOT_ACCOUNTS = ('Expenses:', 'Equity:', 'Income:')
 

	
 
OPENING_BALANCES = {
 
    'Alpha': 3000,
 
    'Bravo': 2000,
 
    'Charlie': 1000,
 
    'Conservancy': 4000,
 
}
 

	
 
BALANCES_BY_YEAR = {
 
    ('Conservancy', 2018): [
 
        ('Income:Other', 40),
 
        ('Expenses:Other', -4),
 
        ('Assets:Receivable:Accounts', 40),
 
        ('Liabilities:Payable:Accounts', 4),
 
    ],
 
    ('Conservancy', 2019): [
 
        ('Income:Other', 42),
 
        ('Expenses:Other', Decimal('-4.20')),
 
        ('Equity:Realized:CurrencyConversion', Decimal('6.20')),
 
        ('Assets:Receivable:Accounts', -40),
 
        ('Liabilities:Payable:Accounts', -4),
 
    ],
 
    ('Alpha', 2018): [
 
        ('Income:Other', 60),
 
        ('Liabilities:UnearnedIncome', 30),
 
        ('Assets:Prepaid:Expenses', 20),
 
    ],
 
    ('Alpha', 2019): [
 
        ('Income:Other', 30),
 
        ('Expenses:Other', -26),
 
        ('Assets:Prepaid:Expenses', -20),
 
        ('Liabilities:UnearnedIncome', -30),
 
    ],
 
    ('Bravo', 2018): [
 
        ('Expenses:Other', -20),
 
    ],
 
    ('Bravo', 2019): [
 
        ('Income:Other', 200),
 
    ],
 
}
 

	
 
@pytest.fixture
 
def fund_entries():
 
    return copy.deepcopy(_ledger_load[0])
 

	
 
def fund_postings(entries, project, stop_date):
 
    return (
 
        post for post in data.Posting.from_entries(entries)
 
        if post.meta.date < stop_date
 
        and post.account.is_under('Equity', 'Income', 'Expenses')
 
        and post.meta.get('project') == project
 
    )
 

	
 
def split_text_lines(output):
 
    for line in output:
 
        account, amount = line.rsplit(None, 1)
 
        yield account.strip(), amount
 

	
 
def format_amount(amount, currency='USD'):
 
    return babel.numbers.format_currency(
 
        amount, currency, format_type='accounting',
 
    )
 

	
 
def check_text_balances(actual, expected, *expect_accounts):
 
    balance = Decimal()
 
    for expect_account in expect_accounts:
 
        expect_amount = expected[expect_account]
 
        if expect_amount:
 
            actual_account, actual_amount = next(actual)
 
            assert actual_account == expect_account
 
            assert actual_amount == format_amount(expect_amount)
 
            balance += expect_amount
 
    return balance
 

	
 
def check_text_report(output, project, start_date, stop_date):
 
    _, _, project = project.rpartition('=')
 
    balance_amount = Decimal(OPENING_BALANCES[project])
 
    expected = collections.defaultdict(Decimal)
 
    for year in range(2018, stop_date.year):
 
        try:
 
            amounts = BALANCES_BY_YEAR[(project, year)]
 
        except KeyError:
 
            pass
 
        else:
 
            for account, amount in amounts:
 
                if year < start_date.year:
 
                if year < start_date.year and account.startswith(EQUITY_ROOT_ACCOUNTS):
 
                    balance_amount += amount
 
                else:
 
                    expected[account] += amount
 
    expected.default_factory = None
 
    actual = split_text_lines(output)
 
    next(actual); next(actual)  # Discard headers
 
    open_acct, open_amt = next(actual)
 
    assert open_acct == "{} balance as of {}".format(
 
        project, start_date.isoformat(),
 
    )
 
    assert open_amt == format_amount(balance_amount)
 
    for expect_account in [
 
            'Equity:Realized:CurrencyConversion',
 
            'Income:Other',
 
            'Expenses:Other',
 
    ]:
 
        try:
 
            expect_amount = expected[expect_account]
 
        except KeyError:
 
            continue
 
        else:
 
            actual_account, actual_amount = next(actual)
 
            assert actual_account == expect_account
 
            assert actual_amount == format_amount(expect_amount)
 
            balance_amount += expect_amount
 
    balance_amount += check_text_balances(
 
        actual, expected,
 
        'Equity:Realized:CurrencyConversion',
 
        'Income:Other',
 
        'Expenses:Other',
 
    )
 
    end_acct, end_amt = next(actual)
 
    assert end_acct == "{} balance as of {}".format(
 
        project, stop_date.isoformat(),
 
    )
 
    assert end_amt == format_amount(balance_amount)
 
    balance_amount += check_text_balances(
 
        actual, expected,
 
        'Assets:Receivable:Accounts',
 
        'Assets:Prepaid:Expenses',
 
        'Liabilities:Payable:Accounts',
 
        'Liabilities:UnearnedIncome',
 
    )
 
    assert next(actual, None) is None
 

	
 
def check_ods_report(ods, start_date, stop_date):
 
    account_bals = collections.OrderedDict((key, {
 
        'opening': Decimal(amount),
 
        'Income': Decimal(0),
 
        'Expenses': Decimal(0),
 
        'Equity': Decimal(0),
 
        'Equity:Realized': Decimal(0),
 
        'Assets:Receivable': Decimal(0),
 
        'Assets:Prepaid': Decimal(0),
 
        'Liabilities:Payable': Decimal(0),
 
        'Liabilities': Decimal(0),  # UnearnedIncome
 
    }) for key, amount in sorted(OPENING_BALANCES.items()))
 
    for fund, year in itertools.product(account_bals, range(2018, stop_date.year)):
 
        try:
 
            amounts = BALANCES_BY_YEAR[(fund, year)]
 
        except KeyError:
 
            pass
 
        else:
 
            for account, amount in amounts:
 
                if year < start_date.year:
 
                if year < start_date.year and account.startswith(EQUITY_ROOT_ACCOUNTS):
 
                    acct_key = 'opening'
 
                else:
 
                    acct_key, _, _ = account.partition(':')
 
                    acct_key, _, _ = account.rpartition(':')
 
                account_bals[fund][acct_key] += amount
 
    account_bals['Unrestricted'] = account_bals.pop('Conservancy')
 
    for row in ods.getElementsByType(odf.table.TableRow):
 
        cells = iter(testutil.ODSCell.from_row(row))
 
        try:
 
            fund = next(cells).firstChild.text
 
        except (AttributeError, StopIteration):
 
            fund = None
 
        if fund in account_bals:
 
            balances = account_bals.pop(fund)
 
            assert next(cells).value == balances['opening']
 
            assert next(cells).value == balances['Income']
 
            assert next(cells).value == -balances['Expenses']
 
            if balances['Equity']:
 
                assert next(cells).value == balances['Equity']
 
            if balances['Equity:Realized']:
 
                assert next(cells).value == balances['Equity:Realized']
 
            else:
 
                assert not next(cells).value
 
            assert next(cells).value == sum(balances.values())
 
            assert next(cells).value == sum(balances[key] for key in [
 
                'opening', 'Income', 'Expenses', 'Equity:Realized',
 
            ])
 
    assert not account_bals, "did not see all funds in report"
 

	
 
def run_main(out_type, arglist, config=None):
 
    if config is None:
 
        config = testutil.TestConfig(
 
            books_path=testutil.test_path('books/fund.beancount'),
 
        )
 
    arglist.insert(0, '--output-file=-')
 
    output = out_type()
 
    errors = io.StringIO()
 
    retcode = fund.main(arglist, output, errors, config)
 
    output.seek(0)
0 comments (0 inline, 0 general)