conservancy_beancount/conservancy_beancount/tools/opening_balances.py
Brett Smith ca12496880 typing: Updates to pass type checking under mypy>=0.800.
Most of these account for the fact that mypy now reports that Hashable is
not an allowed return type for sort key functions.

That, plus the new ignore for the regression in config.py.
2021-02-26 16:13:02 -05:00

224 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""opening_balances.py - Tool to generate opening balances transactions
This tool generates an opening balances transaction for a given date and writes
it to stdout. Use this when you close the books for a year to record the final
balances for that year.
Run it without arguments to generate opening balances for the current fiscal
year. You can also specify a fiscal year to generate opening balances for, or
even a specific date (which can be helpful for testing or debugging).
"""
# SPDX-FileCopyrightText: © 2020 Martin Michlmayr <tbm@cyrius.com>
# SPDX-FileCopyrightText: © 2020 Brett Smith
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
#
# Full copyright and licensing details can be found at toplevel file
# LICENSE.txt in the repository.
import argparse
import collections
import copy
import datetime
import enum
import locale
import logging
import sys
from typing import (
Dict,
Iterable,
Iterator,
Mapping,
NamedTuple,
Optional,
Sequence,
TextIO,
Tuple,
)
from ..beancount_types import (
Error,
MetaKey,
MetaValue,
Sortable,
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
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) -> Sortable:
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
# post without a cost and see what it returns. Hence the ignore.
return units.currency # type:ignore[unreachable]
else:
return f'{units.currency} {cost.currency} {cost.date.isoformat()}'
@classmethod
def build_opening(
cls,
key: AccountWithFund,
meta_key: MetaKey,
inventory: Inventory,
) -> Iterator[bc_data.Posting]:
account, project = key
if project is None:
meta: Optional[Dict[MetaKey, MetaValue]] = None
else:
meta = {meta_key: project}
for units, cost in sorted(inventory, key=cls._position_sortkey):
if cost is None:
units = quantize_amount(units)
yield bc_data.Posting(
account, units, cost, None, None, copy.copy(meta),
)
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog=PROGNAME)
cliutil.add_version_argument(parser)
cliutil.add_loglevel_argument(parser)
parser.add_argument(
'--fund-metadata-key', '-m',
metavar='KEY',
dest='meta_key',
default='project',
help="""Name of the fund metadata key. Default %(default)s.
""")
parser.add_argument(
'--unrestricted-fund', '-u',
metavar='PROJECT',
default='Conservancy',
help="""Name of the unrestricted fund. Default %(default)s.
""")
parser.add_argument(
'as_of_date',
metavar='YEAR_OR_DATE',
type=cliutil.year_or_date_arg,
nargs='?',
help="""Date to generate opening balances for. You can provide just
a year to generate balances for the start of that fiscal year. Defaults to the
current fiscal year.
""")
return parser.parse_args(arglist)
def main(arglist: Optional[Sequence[str]]=None,
stdout: TextIO=sys.stdout,
stderr: TextIO=sys.stderr,
config: Optional[configmod.Config]=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)
books_load = books.Loader.dispatch(config.books_loader(), 0, args.as_of_date)
returncode = books_load.returncode()
books_load.print_errors(stderr)
inventories: Mapping[AccountWithFund, Inventory] = collections.defaultdict(Inventory)
for post in books_load.iter_postings():
if post.meta.date >= args.as_of_date:
continue
account = post.account
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])
])
balance = Balance(get_cost(get_position(post))
for post in opening.postings)
for amount in balance.clean_copy().values():
opening.postings.append(bc_data.Posting(
UNRESTRICTED_ACCOUNT, quantize_amount(-amount), None, None, None,
{args.meta_key: args.unrestricted_fund},
))
dcontext = bc_dcontext.DisplayContext()
dcontext.set_commas(True)
bc_printer.print_entry(opening, dcontext, file=stdout)
return returncode
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
if __name__ == '__main__':
exit(entry_point())