opening_balances: New tool.
This commit is contained in:
parent
b038ec827c
commit
f3c68ff462
4 changed files with 411 additions and 1 deletions
0
conservancy_beancount/tools/__init__.py
Normal file
0
conservancy_beancount/tools/__init__.py
Normal file
259
conservancy_beancount/tools/opening_balances.py
Normal file
259
conservancy_beancount/tools/opening_balances.py
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
#!/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
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import copy
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
import locale
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
Dict,
|
||||||
|
Hashable,
|
||||||
|
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
|
||||||
|
# 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnFlag(enum.IntFlag):
|
||||||
|
LOAD_ERRORS = 1
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
returncode = 0
|
||||||
|
books_loader = config.books_loader()
|
||||||
|
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(0, args.as_of_date)
|
||||||
|
for error in load_errors:
|
||||||
|
bc_printer.print_error(error, file=stderr)
|
||||||
|
returncode |= ReturnFlag.LOAD_ERRORS
|
||||||
|
|
||||||
|
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
|
||||||
|
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 0 if returncode == 0 else 16 + returncode
|
||||||
|
|
||||||
|
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
exit(entry_point())
|
3
setup.py
3
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
||||||
setup(
|
setup(
|
||||||
name='conservancy_beancount',
|
name='conservancy_beancount',
|
||||||
description="Plugin, library, and reports for reading Conservancy's books",
|
description="Plugin, library, and reports for reading Conservancy's books",
|
||||||
version='1.4.1',
|
version='1.5.0',
|
||||||
author='Software Freedom Conservancy',
|
author='Software Freedom Conservancy',
|
||||||
author_email='info@sfconservancy.org',
|
author_email='info@sfconservancy.org',
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
@ -38,6 +38,7 @@ setup(
|
||||||
'accrual-report = conservancy_beancount.reports.accrual:entry_point',
|
'accrual-report = conservancy_beancount.reports.accrual:entry_point',
|
||||||
'fund-report = conservancy_beancount.reports.fund:entry_point',
|
'fund-report = conservancy_beancount.reports.fund:entry_point',
|
||||||
'ledger-report = conservancy_beancount.reports.ledger:entry_point',
|
'ledger-report = conservancy_beancount.reports.ledger:entry_point',
|
||||||
|
'opening-balances = conservancy_beancount.tools.opening_balances:entry_point',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
150
tests/test_opening_balances.py
Normal file
150
tests/test_opening_balances.py
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
"""test_tools_opening_balances.py - Unit tests for opening balance generation"""
|
||||||
|
# Copyright © 2020 Brett Smith
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import copy
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from . import testutil
|
||||||
|
|
||||||
|
from beancount import loader as bc_loader
|
||||||
|
from conservancy_beancount.tools import opening_balances as openbalmod
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
|
||||||
|
A_CHECKING = 'Assets:Checking'
|
||||||
|
A_EUR = 'Assets:EUR'
|
||||||
|
A_PREPAID = 'Assets:Prepaid:Expenses'
|
||||||
|
A_RECEIVABLE = 'Assets:Receivable:Accounts'
|
||||||
|
A_RESTRICTED = 'Equity:Funds:Restricted'
|
||||||
|
A_UNRESTRICTED = 'Equity:Funds:Unrestricted'
|
||||||
|
A_CURRCONV = 'Equity:Realized:CurrencyConversion'
|
||||||
|
A_CREDITCARD = 'Liabilities:CreditCard'
|
||||||
|
A_PAYABLE = 'Liabilities:Payable:Accounts'
|
||||||
|
A_UNEARNED = 'Liabilities:UnearnedIncome'
|
||||||
|
|
||||||
|
class FlatPosting(NamedTuple):
|
||||||
|
account: str
|
||||||
|
units_number: Decimal
|
||||||
|
units_currency: str
|
||||||
|
cost_number: Optional[Decimal]
|
||||||
|
cost_currency: Optional[str]
|
||||||
|
cost_date: Optional[datetime.date]
|
||||||
|
project: Optional[str]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
cost_s = f' {{{self.cost_number} {self.cost_currency}, {self.cost_date}}}'
|
||||||
|
if cost_s == ' {None None, None}':
|
||||||
|
cost_s = ''
|
||||||
|
return f'<{self.account} {self.units_number} {self.units_currency}{cost_s}>'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make(cls,
|
||||||
|
account,
|
||||||
|
units_number,
|
||||||
|
units_currency='USD',
|
||||||
|
cost_number=None,
|
||||||
|
cost_date=None,
|
||||||
|
project=None,
|
||||||
|
cost_currency=None,
|
||||||
|
):
|
||||||
|
if cost_number is not None:
|
||||||
|
cost_number = Decimal(cost_number)
|
||||||
|
if cost_currency is None:
|
||||||
|
cost_currency = 'USD'
|
||||||
|
if isinstance(cost_date, str):
|
||||||
|
cost_date = datetime.datetime.strptime(cost_date, '%Y-%m-%d').date()
|
||||||
|
return cls(account, Decimal(units_number), units_currency,
|
||||||
|
cost_number, cost_currency, cost_date, project)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_beancount(cls, posting):
|
||||||
|
units_number, units_currency = posting.units
|
||||||
|
if posting.cost is None:
|
||||||
|
cost_number = cost_currency = cost_date = None
|
||||||
|
else:
|
||||||
|
cost_number, cost_currency, cost_date, _ = posting.cost
|
||||||
|
try:
|
||||||
|
project = posting.meta['project']
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
project = None
|
||||||
|
return cls(posting.account, units_number, units_currency,
|
||||||
|
cost_number, cost_currency, cost_date, project)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_output(cls, output):
|
||||||
|
entries, _, _ = bc_loader.load_string(output.read())
|
||||||
|
return (cls.from_beancount(post) for post in entries[-1].postings)
|
||||||
|
|
||||||
|
|
||||||
|
def run_main(arglist, config=None):
|
||||||
|
if config is None:
|
||||||
|
config = testutil.TestConfig(
|
||||||
|
books_path=testutil.test_path('books/fund.beancount'),
|
||||||
|
)
|
||||||
|
output = io.StringIO()
|
||||||
|
errors = io.StringIO()
|
||||||
|
retcode = openbalmod.main(arglist, output, errors, config)
|
||||||
|
output.seek(0)
|
||||||
|
return retcode, output, errors
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('arg', ['2018', '2018-03-01'])
|
||||||
|
def test_2018_opening(arg):
|
||||||
|
retcode, output, errors = run_main([arg])
|
||||||
|
assert not errors.getvalue()
|
||||||
|
assert retcode == 0
|
||||||
|
assert list(FlatPosting.from_output(output)) == [
|
||||||
|
FlatPosting.make(A_CHECKING, 10000),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -3000, project='Alpha'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -2000, project='Bravo'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'),
|
||||||
|
FlatPosting.make(A_UNRESTRICTED, -4000, project='Conservancy'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('arg', ['2019', '2019-03-01'])
|
||||||
|
def test_2019_opening(arg):
|
||||||
|
retcode, output, errors = run_main([arg])
|
||||||
|
assert not errors.getvalue()
|
||||||
|
assert retcode == 0
|
||||||
|
assert list(FlatPosting.from_output(output)) == [
|
||||||
|
FlatPosting.make(A_CHECKING, 10050),
|
||||||
|
FlatPosting.make(A_PREPAID, 20, project='Alpha'),
|
||||||
|
FlatPosting.make(A_RECEIVABLE, 32, 'EUR', '1.25', '2018-03-03', 'Conservancy'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -3060, project='Alpha'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -1980, project='Bravo'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'),
|
||||||
|
FlatPosting.make(A_UNRESTRICTED, -4036, project='Conservancy'),
|
||||||
|
FlatPosting.make(A_PAYABLE, -4, project='Conservancy'),
|
||||||
|
FlatPosting.make(A_UNEARNED, -30, project='Alpha'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('arg', ['2020', '2020-12-31'])
|
||||||
|
def test_2020_opening(arg):
|
||||||
|
retcode, output, errors = run_main([arg])
|
||||||
|
assert not errors.getvalue()
|
||||||
|
assert retcode == 0
|
||||||
|
assert list(FlatPosting.from_output(output)) == [
|
||||||
|
FlatPosting.make(A_CHECKING, 10276),
|
||||||
|
FlatPosting.make(A_EUR, 32, 'EUR', '1.5', '2019-03-03'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -3064, project='Alpha'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -2180, project='Bravo'),
|
||||||
|
FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'),
|
||||||
|
FlatPosting.make(A_UNRESTRICTED, -4080, project='Conservancy'),
|
||||||
|
]
|
Loading…
Reference in a new issue