Changeset - 73bbc1e4ec8a
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-10-16 14:05:23
brettcsmith@brettcsmith.org
data: Define EQUITY_ACCOUNTS and FUND_ACCOUNTS.
3 files changed with 18 insertions and 23 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/data.py
Show inline comments
...
 
@@ -33,64 +33,75 @@ from beancount.core import position as bc_position
 
from beancount.parser import options as bc_options
 

	
 
from typing import (
 
    cast,
 
    overload,
 
    Callable,
 
    Hashable,
 
    Iterable,
 
    Iterator,
 
    MutableMapping,
 
    Optional,
 
    Pattern,
 
    Sequence,
 
    TypeVar,
 
    Union,
 
)
 

	
 
from .beancount_types import (
 
    Close,
 
    Currency,
 
    Directive,
 
    Meta,
 
    MetaKey,
 
    MetaValue,
 
    Open,
 
    OptionsMap,
 
    Posting as BasePosting,
 
    Transaction,
 
)
 

	
 
DecimalCompat = Union[decimal.Decimal, int]
 

	
 
EQUITY_ACCOUNTS = frozenset([
 
    'Equity',
 
    'Expenses',
 
    'Income',
 
])
 
FUND_ACCOUNTS = EQUITY_ACCOUNTS | frozenset([
 
    'Assets:Prepaid',
 
    'Assets:Receivable',
 
    'Liabilities:Payable',
 
    'Liabilities:UnearnedIncome',
 
])
 
LINK_METADATA = frozenset([
 
    'approval',
 
    'bank-statement',
 
    'check',
 
    'contract',
 
    'invoice',
 
    'purchase-order',
 
    'receipt',
 
    'rt-id',
 
    'statement',
 
    'tax-statement',
 
])
 

	
 
class AccountMeta(MutableMapping[MetaKey, MetaValue]):
 
    """Access account metadata
 

	
 
    This class provides a consistent interface to all the metadata provided by
 
    Beancount's ``open`` and ``close`` directives: open and close dates,
 
    used currencies, booking method, and the metadata associated with each.
 

	
 
    For convenience, you can use this class as a Mapping to access the ``open``
 
    directive's metadata directly.
 
    """
 
    __slots__ = ('_opening', '_closing')
 

	
 
    def __init__(self, opening: Open, closing: Optional[Close]=None) -> None:
 
        self._opening = opening
 
        self._closing: Optional[Close] = None
 
        if closing is not None:
 
            self.add_closing(closing)
 

	
 
    def add_closing(self, closing: Close) -> None:
conservancy_beancount/reports/balance_sheet.py
Show inline comments
...
 
@@ -27,65 +27,64 @@ from pathlib import Path
 

	
 
from typing import (
 
    Any,
 
    Callable,
 
    Collection,
 
    Dict,
 
    Hashable,
 
    Iterable,
 
    Iterator,
 
    List,
 
    Mapping,
 
    NamedTuple,
 
    Optional,
 
    Sequence,
 
    TextIO,
 
    Tuple,
 
    Union,
 
)
 

	
 
import odf.style  # type:ignore[import]
 
import odf.table  # type:ignore[import]
 

	
 
from beancount.parser import printer as bc_printer
 

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

	
 
EQUITY_ACCOUNTS = frozenset(['Equity', 'Income', 'Expenses'])
 
PROGNAME = 'balance-sheet-report'
 
logger = logging.getLogger('conservancy_beancount.reports.balance_sheet')
 

	
 
KWArgs = Mapping[str, Any]
 

	
 
class Fund(enum.IntFlag):
 
    RESTRICTED = enum.auto()
 
    UNRESTRICTED = enum.auto()
 
    ANY = RESTRICTED | UNRESTRICTED
 

	
 

	
 
class Period(enum.IntFlag):
 
    OPENING = enum.auto()
 
    PRIOR = enum.auto()
 
    MIDDLE = enum.auto()
 
    PERIOD = enum.auto()
 
    THRU_PRIOR = OPENING | PRIOR
 
    THRU_MIDDLE = THRU_PRIOR | MIDDLE
 
    ANY = THRU_MIDDLE | PERIOD
 

	
 

	
 
class BalanceKey(NamedTuple):
 
    account: data.Account
 
    classification: data.Account
 
    period: Period
 
    fund: Fund
 
    post_type: Optional[str]
 

	
 

	
 
class Balances:
 
    def __init__(self,
 
                 postings: Iterable[data.Posting],
...
 
@@ -157,65 +156,65 @@ class Balances:
 
        if account is None:
 
            acct_pred = lambda acct: True
 
        elif account_exact:
 
            # At this point, between this isinstance() above and the earlier
 
            # `account is None` check, we've collapsed the type of `account` to
 
            # `Collection[str]`. Unfortunately the logic is too involved for
 
            # mypy to follow, so ignore the type problem.
 
            acct_pred = lambda acct: acct in account  # type:ignore[operator]
 
        else:
 
            acct_pred = lambda acct: acct.is_under(*account) is not None  # type:ignore[misc]
 
        retval = core.MutableBalance()
 
        for key, balance in self.balances.items():
 
            if not acct_pred(key.account):
 
                pass
 
            elif not (classification is None
 
                      or key.classification.is_under(classification)):
 
                pass
 
            elif not period & key.period:
 
                pass
 
            elif not fund & key.fund:
 
                pass
 
            elif not (post_type is None or post_type == key.post_type):
 
                pass
 
            else:
 
                retval += balance
 
        return retval
 

	
 
    def classifications(self,
 
                        account: str,
 
                        sort_period: Optional[int]=None,
 
    ) -> Sequence[data.Account]:
 
        if sort_period is None:
 
            if account in EQUITY_ACCOUNTS:
 
            if account in data.EQUITY_ACCOUNTS:
 
                sort_period = Period.PERIOD
 
            else:
 
                sort_period = Period.ANY
 
        class_bals: Mapping[data.Account, core.MutableBalance] \
 
            = collections.defaultdict(core.MutableBalance)
 
        for key, balance in self.balances.items():
 
            if not key.account.is_under(account):
 
                pass
 
            elif key.period & sort_period:
 
                class_bals[key.classification] += balance
 
            else:
 
                # Ensure the balance exists in the mapping
 
                class_bals[key.classification]
 
        norm_func = core.normalize_amount_func(f'{account}:RootsOK')
 
        def sortkey(acct: data.Account) -> Hashable:
 
            prefix, _, _ = acct.rpartition(':')
 
            balance = norm_func(class_bals[acct])
 
            try:
 
                max_bal = max(amount.number for amount in balance.values())
 
            except ValueError:
 
                max_bal = Decimal(0)
 
            return prefix, -max_bal
 
        return sorted(class_bals, key=sortkey)
 

	
 
    def iter_accounts(self, root: Optional[str]=None) -> Sequence[data.Account]:
 
        start_date = self.period_range.start
 
        stop_date = self.period_range.stop
 
        return sorted(
 
            account
 
            for account in data.Account.iter_accounts(root)
 
            if account.meta.open_date < stop_date
 
            and (account.meta.close_date is None
...
 
@@ -370,65 +369,65 @@ class Report(core.BaseODS[Sequence[None], None]):
 
            ) for sum_bals in zip(*balances)),
 
        )
 
        return self.last_totals_row
 

	
 
    def write_financial_position(self) -> None:
 
        self.start_sheet("Financial Position")
 
        balance_kwargs: Sequence[KWArgs] = [
 
            {'period': Period.ANY},
 
            {'period': Period.THRU_PRIOR},
 
        ]
 

	
 
        asset_totals = self.write_classifications_by_account('Assets', balance_kwargs)
 
        self.write_totals_row(
 
            "Total Assets", asset_totals, stylename=self.style_bottomline,
 
        )
 
        self.add_row()
 
        self.add_row()
 

	
 
        liabilities = self.write_classifications_by_account('Liabilities', balance_kwargs)
 
        self.write_totals_row(
 
            "Total Liabilities", liabilities, stylename=self.style_endtotal,
 
        )
 
        self.add_row()
 
        self.add_row()
 

	
 
        equity_totals = [core.MutableBalance() for _ in balance_kwargs]
 
        self.add_row(self.string_cell("Net Assets", stylename=self.style_bold))
 
        self.add_row()
 
        for fund in [Fund.UNRESTRICTED, Fund.RESTRICTED]:
 
            preposition = "Without" if fund is Fund.UNRESTRICTED else "With"
 
            row = self.add_row(self.string_cell(f"{preposition} donor restrictions"))
 
            for kwargs, total_bal in zip(balance_kwargs, equity_totals):
 
                balance = -self.balances.total(account=EQUITY_ACCOUNTS, fund=fund, **kwargs)
 
                balance = -self.balances.total(account=data.EQUITY_ACCOUNTS, fund=fund, **kwargs)
 
                row.addElement(self.balance_cell(balance))
 
                total_bal += balance
 
        self.write_totals_row(
 
            "Total Net Assets", equity_totals, stylename=self.style_total,
 
        )
 
        self.write_totals_row(
 
            "Total Liabilities and Net Assets",
 
            liabilities, equity_totals,
 
            stylename=self.style_bottomline,
 
        )
 

	
 
    def write_activities(self) -> None:
 
        self.start_sheet(
 
            "Activities",
 
            ["Without Donor", "Restrictions"],
 
            ["With Donor", "Restrictions"],
 
            totals_prefix=[f"Total {self.period_desc} Ended"],
 
        )
 
        bal_kwargs: Sequence[Dict[str, Any]] = [
 
            {'period': Period.PERIOD, 'fund': Fund.UNRESTRICTED},
 
            {'period': Period.PERIOD, 'fund': Fund.RESTRICTED},
 
            {'period': Period.PERIOD},
 
            {'period': Period.PRIOR},
 
        ]
 

	
 
        self.add_row(self.string_cell("Support and Revenue", stylename=self.style_bold))
 
        self.add_row()
 
        income_totals = self.write_classifications_by_account('Income', bal_kwargs)
 
        self.write_totals_row("", income_totals, stylename=self.style_total)
 
        self.add_row()
 
        self.add_row(
 
            self.string_cell("Net Assets released from restrictions:"),
...
 
@@ -471,170 +470,170 @@ class Report(core.BaseODS[Sequence[None], None]):
 
                prior_bal,
 
            ], leading_rows=0)
 
            period_expenses += period_bal
 
            prior_expenses += prior_bal
 
        period_bal = self.balances.total(account='Expenses', period=Period.PERIOD)
 
        if (period_expenses - period_bal).clean_copy(1).is_zero():
 
            period_bal = period_expenses
 
        else:
 
            logger.warning("Period functional expenses do not match total; math in columns B+D is wrong")
 
        prior_bal = self.balances.total(account='Expenses', period=Period.PRIOR)
 
        if (prior_expenses - prior_bal).clean_copy(1).is_zero():
 
            prior_bal = prior_expenses
 
        else:
 
            logger.warning("Prior functional expenses do not match total; math in column E is wrong")
 
        self.write_totals_row("Total Expenses", [
 
            period_bal,
 
            self.NO_BALANCE,
 
            period_bal,
 
            prior_bal,
 
        ], stylename=self.style_endtotal, leading_rows=0)
 

	
 
        other_totals[0] -= period_bal
 
        other_totals[2] -= period_bal
 
        other_totals[3] -= prior_bal
 
        self.write_totals_row("Change in Net Assets", income_totals, other_totals)
 

	
 
        for kwargs in bal_kwargs:
 
            if kwargs['period'] is Period.PERIOD:
 
                kwargs['period'] = Period.THRU_MIDDLE
 
            else:
 
                kwargs['period'] = Period.OPENING
 
        equity_totals = [
 
            -self.balances.total(account=EQUITY_ACCOUNTS, **kwargs)
 
            -self.balances.total(account=data.EQUITY_ACCOUNTS, **kwargs)
 
            for kwargs in bal_kwargs
 
        ]
 
        self.write_totals_row("Beginning Net Assets", equity_totals)
 
        self.write_totals_row(
 
            "Ending Net Assets",
 
            income_totals, other_totals, equity_totals,
 
            stylename=self.style_bottomline,
 
        )
 

	
 
    def write_functional_expenses(self) -> None:
 
        self.start_sheet(
 
            "Functional Expenses",
 
            ["Program", "Services"],
 
            ["Management and", "Administrative"],
 
            ["Fundraising"],
 
            totals_prefix=[f"Total {self.period_desc} Ended"],
 
        )
 
        totals = self.write_classifications_by_account('Expenses', [
 
            {'period': Period.PERIOD, 'post_type': 'program'},
 
            {'period': Period.PERIOD, 'post_type': 'management'},
 
            {'period': Period.PERIOD, 'post_type': 'fundraising'},
 
            {'period': Period.PERIOD},
 
            {'period': Period.PRIOR},
 
        ])
 
        self.write_totals_row(
 
            "Total Expenses",
 
            totals,
 
            stylename=self.style_bottomline,
 
        )
 

	
 
    def write_cash_flows(self) -> None:
 
        self.start_sheet("Cash Flows")
 
        bal_kwargs: Sequence[Dict[str, Any]] = [
 
            {'period': Period.PERIOD},
 
            {'period': Period.PRIOR},
 
        ]
 
        norm_func = operator.neg
 

	
 
        self.add_row(self.string_cell(
 
            "Cash Flows from Operating Activities",
 
            stylename=self.style_bold,
 
        ))
 
        equity_totals = [
 
            -self.balances.total(account=EQUITY_ACCOUNTS, **kwargs)
 
            -self.balances.total(account=data.EQUITY_ACCOUNTS, **kwargs)
 
            for kwargs in bal_kwargs
 
        ]
 
        self.write_totals_row("Change in Net Assets", equity_totals, leading_rows=1)
 
        self.add_row(self.string_cell(
 
            "(Increase) decrease in operating assets:",
 
        ))
 
        asset_totals = self.write_classifications_by_account(
 
            'Assets', bal_kwargs, (self.C_CASH,), self.SPACE, norm_func,
 
        )
 
        self.add_row(self.string_cell(
 
            "Increase (decrease) in operating liabilities:",
 
        ))
 
        liabilities = self.write_classifications_by_account(
 
            'Liabilities', bal_kwargs, (), self.SPACE, norm_func,
 
        )
 
        period_totals = [
 
            sum(bals, core.MutableBalance())
 
            for bals in zip(equity_totals, asset_totals, liabilities)
 
        ]
 
        self.write_totals_row(
 
            "Net cash provided by operating activities",
 
            period_totals,
 
            stylename=self.style_endtotal,
 
        )
 
        self.write_totals_row("Net Increase in Cash", period_totals)
 
        begin_totals = [
 
            self.balances.total(classification=self.C_CASH, period=period)
 
            for period in [Period.THRU_MIDDLE, Period.OPENING]
 
        ]
 
        self.write_totals_row("Beginning Cash", begin_totals)
 
        self.write_totals_row(
 
            "Ending Cash",
 
            period_totals, begin_totals,
 
            stylename=self.style_bottomline,
 
        )
 

	
 
    def write_trial_balances(self) -> None:
 
        self.start_sheet(
 
            "Trial Balances",
 
            ["Account Name"],
 
            ["Classification"],
 
            ["Account Code"],
 
            ["Balance Beginning", self.balances.period_range.start.strftime(self.date_fmt)],
 
            totals_prefix=["Change During", f"{self.period_desc} Ending"],
 
            title_fmt="Chart of Accounts with DRAFT {sheet_name}",
 
        )
 
        # Widen text columns
 
        col_style = self.column_style(3.5)
 
        for col in self.sheet.childNodes[:2]:
 
            col.setAttribute('stylename', col_style)
 
        # Patch up header row text
 
        header_row = self.sheet.lastChild
 
        header_row.removeChild(header_row.firstChild)
 
        header_row.insertBefore(self.multiline_cell(
 
            ["Balance Ending", self.period_name],
 
            stylename=header_row.lastChild.getAttribute('stylename'),
 
        ), header_row.lastChild)
 

	
 
        for acct_root in ['Assets', 'Liabilities', 'Income', 'Expenses', 'Equity']:
 
            norm_func = core.normalize_amount_func(f'{acct_root}:Dummy')
 
            want_balance = acct_root not in EQUITY_ACCOUNTS
 
            want_balance = acct_root not in data.EQUITY_ACCOUNTS
 
            self.add_row()
 
            for account in self.balances.iter_accounts(acct_root):
 
                period_bal = self.balances.total(
 
                    account=account, period=Period.PERIOD, account_exact=True,
 
                )
 
                prior_bal = self.balances.total(
 
                    account=account, period=Period.PRIOR, account_exact=True,
 
                )
 
                if want_balance:
 
                    close_bal = self.balances.total(account=account, account_exact=True)
 
                    close_cell = self.balance_cell(norm_func(close_bal))
 
                    open_cell = self.balance_cell(norm_func(close_bal - period_bal))
 
                else:
 
                    close_cell = odf.table.TableCell()
 
                    open_cell = odf.table.TableCell()
 
                self.add_row(
 
                    self.string_cell(account),
 
                    self.string_cell(account.meta.get('classification', '')),
 
                    self.string_cell(account.meta.get('account-code', '')),
 
                    open_cell,
 
                    self.balance_cell(norm_func(period_bal)),
 
                    close_cell,
 
                    self.balance_cell(norm_func(prior_bal)),
 
                )
 

	
 

	
 
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
 
    parser = argparse.ArgumentParser(prog=PROGNAME)
 
    cliutil.add_version_argument(parser)
 
    parser.add_argument(
 
        '--begin', '--start', '-b',
 
        dest='start_date',
conservancy_beancount/tools/opening_balances.py
Show inline comments
...
 
@@ -41,79 +41,64 @@ from typing import (
 
    Iterable,
 
    Iterator,
 
    Mapping,
 
    NamedTuple,
 
    Optional,
 
    Sequence,
 
    TextIO,
 
    Tuple,
 
)
 
from ..beancount_types import (
 
    Error,
 
    MetaKey,
 
    MetaValue,
 
    Transaction,
 
)
 

	
 
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
 

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

	
 
from beancount.core import data as bc_data
 
from beancount.core import display_context as bc_dcontext
 
from beancount.parser import printer as bc_printer
 

	
 
from beancount.core.convert import get_cost
 
from beancount.core.inventory import Inventory
 
from beancount.core.position import Position, get_position
 

	
 
EQUITY_ACCOUNTS = frozenset([
 
    'Equity',
 
    'Expenses',
 
    'Income',
 
])
 
FUND_ACCOUNTS = frozenset([
 
    'Assets:Prepaid',
 
    'Assets:Receivable',
 
    'Equity:Funds',
 
    'Equity:Realized',
 
    'Expenses',
 
    'Income',
 
    'Liabilities:Payable',
 
    'Liabilities:UnearnedIncome',
 
])
 
RESTRICTED_ACCOUNT = data.Account('Equity:Funds:Restricted')
 
UNRESTRICTED_ACCOUNT = data.Account('Equity:Funds:Unrestricted')
 
PROGNAME = 'opening-balances'
 
logger = logging.getLogger('conservancy_beancount.tools.opening_balances')
 

	
 
def quantize_amount(
 
        amount: data.Amount,
 
        exp: Decimal=Decimal('.01'),
 
        rounding: str=ROUND_HALF_EVEN,
 
) -> data.Amount:
 
    return amount._replace(number=amount.number.quantize(exp, rounding=rounding))
 

	
 
class AccountWithFund(NamedTuple):
 
    account: data.Account
 
    fund: Optional[MetaValue]
 

	
 
    def sortkey(self) -> Hashable:
 
        account, fund = self
 
        return (
 
            0 if fund is None else 1,
 
            locale.strxfrm(account),
 
            locale.strxfrm(str(fund).casefold()),
 
        )
 

	
 

	
 
class Posting(data.Posting):
 
    @staticmethod
 
    def _position_sortkey(position: Position) -> str:
 
        units, cost = position
 
        if cost is None:
 
            # Beancount type-declares that position.cost must be a Cost, but
 
            # in practice that's not true. Call get_position(post) on any
...
 
@@ -177,66 +162,66 @@ def main(arglist: Optional[Sequence[str]]=None,
 
) -> int:
 
    args = parse_arguments(arglist)
 
    cliutil.set_loglevel(logger, args.loglevel)
 
    if config is None:
 
        config = configmod.Config()
 
        config.load_file()
 

	
 
    fy = config.fiscal_year_begin()
 
    if args.as_of_date is None:
 
        args.as_of_date = fy.for_date()
 
    if isinstance(args.as_of_date, int):
 
        args.as_of_date = fy.first_date(args.as_of_date)
 

	
 
    returncode = 0
 
    books_loader = config.books_loader()
 
    if books_loader is None:
 
        entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
 
        returncode = cliutil.ExitCode.NoConfiguration
 
    else:
 
        entries, load_errors, _ = books_loader.load_fy_range(0, args.as_of_date)
 
        if load_errors:
 
            returncode = cliutil.ExitCode.BeancountErrors
 
        elif not entries:
 
            returncode = cliutil.ExitCode.NoDataLoaded
 
    for error in load_errors:
 
        bc_printer.print_error(error, file=stderr)
 

	
 
    inventories: Mapping[AccountWithFund, Inventory] = collections.defaultdict(Inventory)
 
    for post in Posting.from_entries(entries):
 
        if post.meta.date >= args.as_of_date:
 
            continue
 
        account = post.account
 
        fund_acct_match = post.account.is_under(*FUND_ACCOUNTS)
 
        is_equity = account.root_part() in EQUITY_ACCOUNTS
 
        fund_acct_match = post.account.is_under(*data.FUND_ACCOUNTS)
 
        is_equity = account.root_part() in data.EQUITY_ACCOUNTS
 
        if fund_acct_match is None:
 
            project: MetaValue = None
 
        else:
 
            project = post.meta.get(args.meta_key)
 
            if project is None:
 
                bc_printer.print_error(Error(
 
                    post.meta, "no fund specified", post.meta.txn,
 
                ), file=stderr)
 
                project = args.unrestricted_fund
 
            if is_equity:
 
                if project == args.unrestricted_fund:
 
                    account = UNRESTRICTED_ACCOUNT
 
                else:
 
                    account = RESTRICTED_ACCOUNT
 
        inventory = inventories[AccountWithFund(account, project)]
 
        if is_equity:
 
            inventory.add_amount(post.at_cost())
 
        else:
 
            inventory.add_position(get_position(post))
 

	
 
    opening_date = args.as_of_date - datetime.timedelta(1)
 
    opening = bc_data.Transaction(  # type:ignore[operator]
 
        None,  # meta
 
        opening_date,
 
        '*',
 
        None,  # payee
 
        f"Opening balances for FY{fy.for_date(args.as_of_date)}",
 
        frozenset(),  # tags
 
        frozenset(),  # links
 
        [post
 
         for key in sorted(inventories, key=AccountWithFund.sortkey)
 
         for post in Posting.build_opening(key, args.meta_key, inventories[key])
0 comments (0 inline, 0 general)