fund: New report.
This commit is contained in:
parent
baf0b2c5ff
commit
887102ea92
4 changed files with 659 additions and 1 deletions
359
conservancy_beancount/reports/fund.py
Normal file
359
conservancy_beancount/reports/fund.py
Normal file
|
@ -0,0 +1,359 @@
|
||||||
|
"""funds.py - Funds report from Beancount
|
||||||
|
|
||||||
|
This tool produces a report of account balances for restricted funds,
|
||||||
|
including income, expenses, and realized gain/loss over a given time period.
|
||||||
|
Restricted funds are designated with the "project" metadata.
|
||||||
|
|
||||||
|
Specify the date range you want to report with the ``--begin`` and ``--end``
|
||||||
|
options.
|
||||||
|
|
||||||
|
Select the accounts you want to report with the ``--account`` option. You can
|
||||||
|
specify this option multiple times. The report will include at least one sheet
|
||||||
|
for each account you specify. Subaccounts will be reported on that sheet as
|
||||||
|
well.
|
||||||
|
|
||||||
|
Select the postings you want to report by passing metadata search terms in
|
||||||
|
``name=value`` format.
|
||||||
|
|
||||||
|
Run ``ledger-report --help`` for abbreviations and other options.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
|
||||||
|
Generate a report of all restricted funds in ODS format over the last year::
|
||||||
|
|
||||||
|
fund-report
|
||||||
|
|
||||||
|
Query a specific restricted fund and get a quick balance on the terminal::
|
||||||
|
|
||||||
|
fund-report <PROJECT>
|
||||||
|
"""
|
||||||
|
# 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 argparse
|
||||||
|
import collections
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
import locale
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
Mapping,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
TextIO,
|
||||||
|
Tuple,
|
||||||
|
)
|
||||||
|
from ..beancount_types import (
|
||||||
|
MetaValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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']
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def start_spreadsheet(self) -> None:
|
||||||
|
self.use_sheet("Fund Report")
|
||||||
|
for width in [2.5, 1.5, 1.2, 1.2, 1.2, 1.5]:
|
||||||
|
col_style = self.column_style(width)
|
||||||
|
self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
|
||||||
|
center_bold = self.merge_styles(self.style_centertext, self.style_bold)
|
||||||
|
self.add_row(
|
||||||
|
self.string_cell(
|
||||||
|
"Fund", stylename=self.merge_styles(self.style_endtext, self.style_bold),
|
||||||
|
),
|
||||||
|
self.multiline_cell(["Balance as of", self.start_date.isoformat()],
|
||||||
|
stylename=center_bold),
|
||||||
|
self.string_cell("Income", stylename=center_bold),
|
||||||
|
self.string_cell("Expenses", stylename=center_bold),
|
||||||
|
self.multiline_cell(["Realized", "Gain/Loss"], stylename=center_bold),
|
||||||
|
self.multiline_cell(["Balance as of", self.stop_date.isoformat()],
|
||||||
|
stylename=center_bold),
|
||||||
|
)
|
||||||
|
self.lock_first_row()
|
||||||
|
self.add_row()
|
||||||
|
self.add_row(self.string_cell(
|
||||||
|
f"Fund Report From {self.start_date.isoformat()} To {self.stop_date.isoformat()}",
|
||||||
|
stylename=center_bold,
|
||||||
|
numbercolumnsspanned=6,
|
||||||
|
))
|
||||||
|
self.add_row()
|
||||||
|
|
||||||
|
def _row_balances(self, accounts_map: AccountsMap) -> Iterable[core.Balance]:
|
||||||
|
acct_order = ['Income', 'Expenses', 'Equity']
|
||||||
|
key_order = [core.OPENING_BALANCE_NAME, *acct_order, core.ENDING_BALANCE_NAME]
|
||||||
|
balances: Dict[str, core.Balance] = {key: core.MutableBalance() for key in key_order}
|
||||||
|
for acct_s, balance in core.account_balances(accounts_map, acct_order):
|
||||||
|
if acct_s in balances:
|
||||||
|
balances[acct_s] = balance
|
||||||
|
else:
|
||||||
|
acct_root, _, _ = acct_s.partition(':')
|
||||||
|
balances[acct_root] += balance
|
||||||
|
for key in key_order:
|
||||||
|
if key == 'Expenses':
|
||||||
|
yield balances[key]
|
||||||
|
else:
|
||||||
|
yield -balances[key]
|
||||||
|
|
||||||
|
def write_row(self, row: FundPosts) -> None:
|
||||||
|
fund, accounts_map = row
|
||||||
|
if fund == UNRESTRICTED_FUND:
|
||||||
|
assert not self.unrestricted
|
||||||
|
self.unrestricted = accounts_map
|
||||||
|
return
|
||||||
|
self.add_row(
|
||||||
|
self.string_cell(fund, stylename=self.style_endtext),
|
||||||
|
*(self.balance_cell(bal) for bal in self._row_balances(accounts_map)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def write(self, rows: Iterable[FundPosts]) -> None:
|
||||||
|
super().write(rows)
|
||||||
|
if self.unrestricted:
|
||||||
|
self.add_row()
|
||||||
|
self.write_row(("Unrestricted", self.unrestricted))
|
||||||
|
|
||||||
|
|
||||||
|
class TextReport:
|
||||||
|
def __init__(self,
|
||||||
|
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):
|
||||||
|
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')
|
||||||
|
|
||||||
|
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"),
|
||||||
|
file=self.out_file)
|
||||||
|
fund_start = f' balance as of {self.start_date.isoformat()}'
|
||||||
|
for acct_s, bal_seq in output:
|
||||||
|
if acct_s.endswith(fund_start):
|
||||||
|
print(line_fmt.format('―' * acct_width, '―' * bal_width),
|
||||||
|
file=self.out_file)
|
||||||
|
bal_iter = iter(bal_seq)
|
||||||
|
print(line_fmt.format(acct_s, next(bal_iter)), file=self.out_file)
|
||||||
|
for bal_s in bal_iter:
|
||||||
|
print(line_fmt.format('', bal_s), file=self.out_file)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportType(enum.Enum):
|
||||||
|
TEXT = TextReport
|
||||||
|
ODS = ODSReport
|
||||||
|
TXT = TEXT
|
||||||
|
SPREADSHEET = ODS
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_arg(cls, s: str) -> 'ReportType':
|
||||||
|
try:
|
||||||
|
return cls[s.upper()]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"no report type matches {s!r}") from None
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnFlag(enum.IntFlag):
|
||||||
|
LOAD_ERRORS = 1
|
||||||
|
NOTHING_TO_REPORT = 8
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
metavar='DATE',
|
||||||
|
type=cliutil.date_arg,
|
||||||
|
help="""Date to start reporting entries, inclusive, in YYYY-MM-DD format.
|
||||||
|
The default is one year ago.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--end', '--stop', '-e',
|
||||||
|
dest='stop_date',
|
||||||
|
metavar='DATE',
|
||||||
|
type=cliutil.date_arg,
|
||||||
|
help="""Date to stop reporting entries, exclusive, in YYYY-MM-DD format.
|
||||||
|
The default is a year after the start date.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--report-type', '-t',
|
||||||
|
metavar='TYPE',
|
||||||
|
type=ReportType.from_arg,
|
||||||
|
help="""Type of report to generate. `text` gives a plain two-column text
|
||||||
|
report listing accounts and balances over the period, and is the default when
|
||||||
|
you search for a specific project/fund. `ods` produces a higher-level
|
||||||
|
spreadsheet, meant to provide an overview of all funds, and is the default when
|
||||||
|
you don't specify a project/fund.
|
||||||
|
""")
|
||||||
|
parser.add_argument(
|
||||||
|
'--output-file', '-O',
|
||||||
|
metavar='PATH',
|
||||||
|
type=Path,
|
||||||
|
help="""Write the report to this file, or stdout when PATH is `-`.
|
||||||
|
The default is stdout for text reports, and a generated filename for ODS
|
||||||
|
reports.
|
||||||
|
""")
|
||||||
|
cliutil.add_loglevel_argument(parser)
|
||||||
|
parser.add_argument(
|
||||||
|
'search_terms',
|
||||||
|
metavar='FILTER',
|
||||||
|
type=cliutil.SearchTerm.arg_parser('project', 'rt-id'),
|
||||||
|
nargs=argparse.ZERO_OR_MORE,
|
||||||
|
help="""Report on postings that match this criteria. The format is
|
||||||
|
NAME=TERM. TERM is a link or word that must exist in a posting's NAME
|
||||||
|
metadata to match. A single ticket number is a shortcut for
|
||||||
|
`rt-id=rt:NUMBER`. Any other word is a shortcut for `project=TERM`.
|
||||||
|
""")
|
||||||
|
args = parser.parse_args(arglist)
|
||||||
|
if args.report_type is None:
|
||||||
|
if any(term.meta_key == 'project' for term in args.search_terms):
|
||||||
|
args.report_type = ReportType.TEXT
|
||||||
|
else:
|
||||||
|
args.report_type = ReportType.ODS
|
||||||
|
return args
|
||||||
|
|
||||||
|
def diff_year(date: datetime.date, diff: int) -> datetime.date:
|
||||||
|
new_year = date.year + diff
|
||||||
|
try:
|
||||||
|
return date.replace(year=new_year)
|
||||||
|
except ValueError:
|
||||||
|
# The original date is Feb 29, which doesn't exist in the new year.
|
||||||
|
if diff < 0:
|
||||||
|
return datetime.date(new_year, 2, 28)
|
||||||
|
else:
|
||||||
|
return datetime.date(new_year, 3, 1)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if args.stop_date is None:
|
||||||
|
if args.start_date is None:
|
||||||
|
args.stop_date = datetime.date.today()
|
||||||
|
else:
|
||||||
|
args.stop_date = diff_year(args.start_date, 1)
|
||||||
|
if args.start_date is None:
|
||||||
|
args.start_date = diff_year(args.stop_date, -1)
|
||||||
|
|
||||||
|
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(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()))
|
||||||
|
)
|
||||||
|
if not fund_map:
|
||||||
|
logger.warning("no matching postings found to report")
|
||||||
|
returncode |= ReturnFlag.NOTHING_TO_REPORT
|
||||||
|
elif args.report_type is ReportType.TEXT:
|
||||||
|
out_file = cliutil.text_output(args.output_file, stdout)
|
||||||
|
report = TextReport(args.start_date, args.stop_date, out_file)
|
||||||
|
report.write(fund_map.items())
|
||||||
|
else:
|
||||||
|
ods_report = ODSReport(args.start_date, args.stop_date)
|
||||||
|
ods_report.write(fund_map.items())
|
||||||
|
if args.output_file is None:
|
||||||
|
out_dir_path = config.repository_path() or Path()
|
||||||
|
args.output_file = out_dir_path / 'FundReport_{}_{}.ods'.format(
|
||||||
|
args.start_date.isoformat(), args.stop_date.isoformat(),
|
||||||
|
)
|
||||||
|
logger.info("Writing report to %s", args.output_file)
|
||||||
|
ods_file = cliutil.bytes_output(args.output_file, stdout)
|
||||||
|
ods_report.save_file(ods_file)
|
||||||
|
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.2.6',
|
version='1.3.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+',
|
||||||
|
@ -36,6 +36,7 @@ setup(
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'accrual-report = conservancy_beancount.reports.accrual:entry_point',
|
'accrual-report = conservancy_beancount.reports.accrual: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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
71
tests/books/fund.beancount
Normal file
71
tests/books/fund.beancount
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
option "inferred_tolerance_default" "USD:0.01"
|
||||||
|
|
||||||
|
2018-01-01 open Equity:Funds:Restricted
|
||||||
|
2018-01-01 open Equity:Funds:Unrestricted
|
||||||
|
2018-01-01 open Equity:Realized:CurrencyConversion
|
||||||
|
2018-01-01 open Assets:Checking
|
||||||
|
2018-01-01 open Expenses:Other
|
||||||
|
2018-01-01 open Income:Other
|
||||||
|
|
||||||
|
2018-02-28 * "Opening balances"
|
||||||
|
Equity:Funds:Unrestricted -4,000 USD
|
||||||
|
project: "Conservancy"
|
||||||
|
Equity:Funds:Restricted -3,000 USD
|
||||||
|
project: "Alpha"
|
||||||
|
Equity:Funds:Restricted -2,000 USD
|
||||||
|
project: "Bravo"
|
||||||
|
Equity:Funds:Restricted -1,000 USD
|
||||||
|
project: "Charlie"
|
||||||
|
Assets:Checking 10,000 USD
|
||||||
|
|
||||||
|
2018-03-03 * "Conservancy income 2018"
|
||||||
|
project: "Conservancy"
|
||||||
|
Income:Other -40 USD
|
||||||
|
Assets:Checking 40 USD
|
||||||
|
|
||||||
|
2018-03-06 * "Conservancy expense 2018"
|
||||||
|
project: "Conservancy"
|
||||||
|
Expenses:Other 4 USD
|
||||||
|
Assets:Checking -4 USD
|
||||||
|
|
||||||
|
2018-06-03 * "Alpha income 2018A"
|
||||||
|
project: "Alpha"
|
||||||
|
Income:Other -30 USD
|
||||||
|
Assets:Checking 30 USD
|
||||||
|
|
||||||
|
2018-06-06 * "Alpha income 2018B"
|
||||||
|
project: "Alpha"
|
||||||
|
Income:Other -30 USD
|
||||||
|
Assets:Checking 30 USD
|
||||||
|
|
||||||
|
2018-09-03 * "Bravo expense"
|
||||||
|
project: "Bravo"
|
||||||
|
Expenses:Other 20 USD
|
||||||
|
Assets:Checking -20 USD
|
||||||
|
|
||||||
|
2019-03-03 * "Conservancy income 2019"
|
||||||
|
project: "Conservancy"
|
||||||
|
Income:Other -44 EUR {1.0 USD}
|
||||||
|
Assets:Checking 40 USD
|
||||||
|
Equity:Realized:CurrencyConversion
|
||||||
|
|
||||||
|
2019-03-06 * "Conservancy expense 2019"
|
||||||
|
project: "Conservancy"
|
||||||
|
Expenses:Other 4.40 EUR {1.0 USD}
|
||||||
|
Assets:Checking -4.00 USD
|
||||||
|
Equity:Realized:CurrencyConversion
|
||||||
|
|
||||||
|
2019-06-03 * "Alpha expense 2019A"
|
||||||
|
project: "Alpha"
|
||||||
|
Expenses:Other 3 USD
|
||||||
|
Assets:Checking -3 USD
|
||||||
|
|
||||||
|
2019-06-06 * "Alpha expense 2019B"
|
||||||
|
project: "Alpha"
|
||||||
|
Expenses:Other 3 USD
|
||||||
|
Assets:Checking -3 USD
|
||||||
|
|
||||||
|
2019-09-03 * "Bravo income"
|
||||||
|
project: "Bravo"
|
||||||
|
Income:Other -200 USD
|
||||||
|
Assets:Checking 200 USD
|
227
tests/test_reports_fund.py
Normal file
227
tests/test_reports_fund.py
Normal file
|
@ -0,0 +1,227 @@
|
||||||
|
"""test_reports_fund.py - Unit tests for fund report"""
|
||||||
|
# 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 itertools
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from . import testutil
|
||||||
|
|
||||||
|
import babel.numbers
|
||||||
|
import odf.opendocument
|
||||||
|
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)
|
||||||
|
|
||||||
|
OPENING_BALANCES = {
|
||||||
|
'Alpha': 3000,
|
||||||
|
'Bravo': 2000,
|
||||||
|
'Charlie': 1000,
|
||||||
|
'Conservancy': 4000,
|
||||||
|
}
|
||||||
|
|
||||||
|
BALANCES_BY_YEAR = {
|
||||||
|
('Conservancy', 2018): [
|
||||||
|
('Income:Other', 40),
|
||||||
|
('Expenses:Other', -4),
|
||||||
|
],
|
||||||
|
('Conservancy', 2019): [
|
||||||
|
('Income:Other', 44),
|
||||||
|
('Expenses:Other', Decimal('-4.40')),
|
||||||
|
('Equity:Realized:CurrencyConversion', Decimal('-3.60')),
|
||||||
|
],
|
||||||
|
('Alpha', 2018): [
|
||||||
|
('Income:Other', 60),
|
||||||
|
],
|
||||||
|
('Alpha', 2019): [
|
||||||
|
('Expenses:Other', -6),
|
||||||
|
],
|
||||||
|
('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_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:
|
||||||
|
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
|
||||||
|
end_acct, end_amt = next(actual)
|
||||||
|
assert end_acct == "{} balance as of {}".format(
|
||||||
|
project, stop_date.isoformat(),
|
||||||
|
)
|
||||||
|
assert end_amt == format_amount(balance_amount)
|
||||||
|
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),
|
||||||
|
}) 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:
|
||||||
|
acct_key = 'opening'
|
||||||
|
else:
|
||||||
|
acct_key, _, _ = account.partition(':')
|
||||||
|
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']
|
||||||
|
else:
|
||||||
|
assert not next(cells).value
|
||||||
|
assert next(cells).value == sum(balances.values())
|
||||||
|
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)
|
||||||
|
return retcode, output, errors
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('project,start_date,stop_date', [
|
||||||
|
('Conservancy', START_DATE, STOP_DATE),
|
||||||
|
('project=Conservancy', MID_DATE, STOP_DATE),
|
||||||
|
('Conservancy', START_DATE, MID_DATE),
|
||||||
|
('Alpha', START_DATE, STOP_DATE),
|
||||||
|
('project=Alpha', MID_DATE, STOP_DATE),
|
||||||
|
('Alpha', START_DATE, MID_DATE),
|
||||||
|
('Bravo', START_DATE, STOP_DATE),
|
||||||
|
('project=Bravo', MID_DATE, STOP_DATE),
|
||||||
|
('Bravo', START_DATE, MID_DATE),
|
||||||
|
('project=Charlie', START_DATE, STOP_DATE),
|
||||||
|
])
|
||||||
|
def test_text_report(project, start_date, stop_date):
|
||||||
|
retcode, output, errors = run_main(io.StringIO, [
|
||||||
|
'-b', start_date.isoformat(), '-e', stop_date.isoformat(), project,
|
||||||
|
])
|
||||||
|
assert not errors.getvalue()
|
||||||
|
assert retcode == 0
|
||||||
|
check_text_report(output, project, start_date, stop_date)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('start_date,stop_date', [
|
||||||
|
(START_DATE, STOP_DATE),
|
||||||
|
(MID_DATE, STOP_DATE),
|
||||||
|
(START_DATE, MID_DATE),
|
||||||
|
])
|
||||||
|
def test_ods_report(start_date, stop_date):
|
||||||
|
retcode, output, errors = run_main(io.BytesIO, [
|
||||||
|
'--begin', start_date.isoformat(), '--end', stop_date.isoformat(),
|
||||||
|
])
|
||||||
|
assert not errors.getvalue()
|
||||||
|
assert retcode == 0
|
||||||
|
ods = odf.opendocument.load(output)
|
||||||
|
check_ods_report(ods, start_date, stop_date)
|
||||||
|
|
||||||
|
def test_main_no_postings(caplog):
|
||||||
|
retcode, output, errors = run_main(io.StringIO, ['NonexistentProject'])
|
||||||
|
assert retcode == 24
|
||||||
|
assert any(log.levelname == 'WARNING' for log in caplog.records)
|
Loading…
Reference in a new issue