2020-06-25 14:51:37 +00:00
|
|
|
#!/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
|
2021-01-08 21:57:43 +00:00
|
|
|
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
|
2020-06-25 14:51:37 +00:00
|
|
|
#
|
2021-01-08 21:57:43 +00:00
|
|
|
# Full copyright and licensing details can be found at toplevel file
|
|
|
|
# LICENSE.txt in the repository.
|
2020-06-25 14:51:37 +00:00
|
|
|
|
|
|
|
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,
|
2021-02-26 21:13:02 +00:00
|
|
|
Sortable,
|
2020-06-25 14:51:37 +00:00
|
|
|
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]
|
|
|
|
|
2021-02-26 21:13:02 +00:00
|
|
|
def sortkey(self) -> Sortable:
|
2020-06-25 14:51:37 +00:00
|
|
|
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)
|
|
|
|
|
2021-02-19 23:52:44 +00:00
|
|
|
books_load = books.Loader.dispatch(config.books_loader(), 0, args.as_of_date)
|
|
|
|
returncode = books_load.returncode()
|
|
|
|
books_load.print_errors(stderr)
|
2020-06-25 14:51:37 +00:00
|
|
|
|
|
|
|
inventories: Mapping[AccountWithFund, Inventory] = collections.defaultdict(Inventory)
|
2021-02-19 23:52:44 +00:00
|
|
|
for post in books_load.iter_postings():
|
2020-06-25 14:51:37 +00:00
|
|
|
if post.meta.date >= args.as_of_date:
|
|
|
|
continue
|
|
|
|
account = post.account
|
2020-10-16 14:05:23 +00:00
|
|
|
fund_acct_match = post.account.is_under(*data.FUND_ACCOUNTS)
|
|
|
|
is_equity = account.root_part() in data.EQUITY_ACCOUNTS
|
2020-06-25 14:51:37 +00:00
|
|
|
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)
|
2020-07-30 19:53:31 +00:00
|
|
|
return returncode
|
2020-06-25 14:51:37 +00:00
|
|
|
|
|
|
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
exit(entry_point())
|