Files @ 6159870681a6
Branch filter:

Location: NPO-Accounting/conservancy_beancount/conservancy_beancount/reports/balance_sheet.py

Brett Smith
balance_sheet: Refactor out Report.write_totals_row method.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
"""balance_sheet.py - Balance sheet 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 argparse
import collections
import datetime
import enum
import logging
import operator
import os
import sys

from decimal import Decimal
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 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.tools.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()
    PERIOD = enum.auto()
    BEFORE_PERIOD = OPENING | PRIOR
    ANY = OPENING | PRIOR | 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],
                 start_date: datetime.date,
                 stop_date: datetime.date,
                 fund_key: str='project',
                 unrestricted_fund_value: str='Conservancy',
    ) -> None:
        self.prior_range = ranges.DateRange(
            cliutil.diff_year(start_date, -1),
            cliutil.diff_year(stop_date, -1),
        )
        assert self.prior_range.stop <= start_date
        self.period_range = ranges.DateRange(start_date, stop_date)
        self.balances: Mapping[BalanceKey, core.MutableBalance] \
            = collections.defaultdict(core.MutableBalance)
        for post in postings:
            post_date = post.meta.date
            if post_date in self.period_range:
                period = Period.PERIOD
            elif post_date in self.prior_range:
                period = Period.PRIOR
            elif post_date < self.prior_range.start:
                period = Period.OPENING
            else:
                continue
            if post.account == 'Expenses:CurrencyConversion':
                account = data.Account('Income:CurrencyConversion')
            else:
                account = post.account
            if post.meta.get(fund_key) == unrestricted_fund_value:
                fund = Fund.UNRESTRICTED
            else:
                fund = Fund.RESTRICTED
            try:
                classification_s = account.meta['classification']
                if isinstance(classification_s, str):
                    classification = data.Account(classification_s)
                else:
                    raise TypeError()
            except (KeyError, TypeError):
                classification = account
            if account.root_part() == 'Expenses':
                post_type = post.meta.get('expense-type')
            else:
                post_type = None
            key = BalanceKey(account, classification, period, fund, post_type)
            self.balances[key] += post.at_cost()

    def total(self,
              account: Union[None, str, Collection[str]]=None,
              classification: Optional[str]=None,
              period: int=Period.ANY,
              fund: int=Fund.ANY,
              post_type: Optional[str]=None,
    ) -> core.Balance:
        if isinstance(account, str):
            account = (account,)
        retval = core.MutableBalance()
        for key, balance in self.balances.items():
            if not (account is None or key.account.is_under(*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:
                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)


class Report(core.BaseODS[Sequence[None], None]):
    C_CASH = 'Cash'
    C_SATISFIED = 'Satisfaction of program restrictions'
    NO_BALANCE = core.Balance()
    SPACE = ' ' * 4

    def __init__(self,
                 balances: Balances,
                 *,
                 date_fmt: str='%B %d, %Y',
    ) -> None:
        super().__init__()
        self.balances = balances
        self.date_fmt = date_fmt
        one_day = datetime.timedelta(days=1)
        date = balances.period_range.stop - one_day
        self.period_name = date.strftime(date_fmt)
        date = balances.prior_range.stop - one_day
        self.opening_name = date.strftime(date_fmt)
        self.last_totals_row = odf.table.TableRow()

    def section_key(self, row: Sequence[None]) -> None:
        raise NotImplementedError("balance_sheet.Report.section_key")

    def init_styles(self) -> None:
        super().init_styles()
        self.style_header = self.merge_styles(self.style_bold, self.style_centertext)
        self.style_huline = self.merge_styles(
            self.style_header,
            self.border_style(core.Border.BOTTOM, '1pt'),
        )
        self.style_subtotline = self.border_style(core.Border.TOP, '1pt')
        self.style_totline = self.border_style(core.Border.TOP | core.Border.BOTTOM, '1pt')
        self.style_bottomline = self.merge_styles(
            self.style_subtotline,
            self.border_style(core.Border.BOTTOM, '2pt', 'double'),
        )

    def write_all(self) -> None:
        self.write_financial_position()
        self.write_activities()
        self.write_functional_expenses()
        self.write_cash_flows()

    def walk_classifications(self, cseq: Iterable[data.Account]) \
        -> Iterator[Tuple[str, Optional[data.Account]]]:
        last_prefix: Sequence[str] = []
        for classification in cseq:
            parts = classification.split(':')
            tail = parts.pop()
            space = self.SPACE * len(parts)
            if parts != last_prefix:
                yield f'{space[len(self.SPACE):]}{parts[-1]}', None
                last_prefix = parts
            yield f'{space}{tail}', classification

    def walk_classifications_by_account(
            self,
            account: str,
            sort_period: Optional[int]=None,
    ) -> Iterator[Tuple[str, Optional[data.Account]]]:
        return self.walk_classifications(self.balances.classifications(
            account, sort_period,
        ))

    def start_sheet(self,
                    sheet_name: str,
                    *headers: Iterable[str],
                    totals_prefix: Sequence[str]=(),
                    first_width: Union[float, str]=3,
                    width: Union[float, str]=1.5,
    ) -> None:
        header_cells: Sequence[odf.table.TableCell] = [
            odf.table.TableCell(),
            *(self.multiline_cell(header_lines, stylename=self.style_huline)
              for header_lines in headers),
            *(self.multiline_cell([*totals_prefix, date_s], stylename=self.style_huline)
              for date_s in [self.period_name, self.opening_name]),
        ]
        self.col_count = len(header_cells)
        self.use_sheet(sheet_name)
        for index in range(self.col_count):
            col_style = self.column_style(width if index else first_width)
            self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
        start_date = self.balances.period_range.start.strftime(self.date_fmt)
        self.add_row(
            self.multiline_cell([
                f"DRAFT Statement of {sheet_name}",
                f"{start_date}—{self.period_name}",
            ], numbercolumnsspanned=self.col_count, stylename=self.style_header)
        )
        self.add_row()
        self.add_row(*header_cells)

    def write_classifications_by_account(
            self,
            account: str,
            balance_kwargs: Sequence[KWArgs],
            exclude_classifications: Collection[str]=frozenset(),
            text_prefix: str='',
            norm_func: Optional[Callable[[core.Balance], core.Balance]]=None,
    ) -> Sequence[core.Balance]:
        if norm_func is None:
            norm_func = core.normalize_amount_func(f'{account}:RootsOK')
        assert len(balance_kwargs) + 1 == self.col_count, \
            "called write_classifications with wrong number of balance_kwargs"
        retval = [core.MutableBalance() for _ in balance_kwargs]
        for text, classification in self.walk_classifications_by_account(account):
            text_cell = self.string_cell(text_prefix + text)
            if classification is None:
                if not text[0].isspace():
                    self.add_row()
                self.add_row(text_cell)
            elif classification in exclude_classifications:
                pass
            else:
                row = self.add_row(text_cell)
                for kwargs, total_bal in zip(balance_kwargs, retval):
                    balance = norm_func(self.balances.total(
                        classification=classification, **kwargs,
                    ))
                    row.addElement(self.balance_cell(balance))
                    total_bal += balance
        return retval

    def write_totals_row(
            self,
            text: str,
            *balances: Sequence[core.Balance],
            stylename: Union[None, str, odf.style.Style]=None,
            leading_rows: Optional[int]=None,
    ) -> odf.table.TableRow:
        if leading_rows is None:
            if (self.sheet.lastChild is self.last_totals_row
                or stylename is self.style_bottomline):
                leading_rows = 1
            else:
                leading_rows = 0
        expect_len = self.col_count - 1
        assert all(len(seq) == expect_len for seq in balances), \
            "called write_totals_row with the wrong length of balance columns"
        for _ in range(leading_rows):
            self.add_row()
        self.last_totals_row = self.add_row(
            self.string_cell(text),
            *(self.balance_cell(
                sum(sum_bals, core.MutableBalance()),
                stylename=stylename,
            ) 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.BEFORE_PERIOD},
        ]

        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_totline,
        )
        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)
                row.addElement(self.balance_cell(balance))
                total_bal += balance
        self.write_totals_row(
            "Total Net Assets", equity_totals, stylename=self.style_subtotline,
        )
        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=["Total Year 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.C_SATISFIED,),
        )
        self.write_totals_row("", income_totals, stylename=self.style_subtotline)
        self.add_row()
        self.add_row(
            self.string_cell("Net Assets released from restrictions:"),
        )
        released = self.balances.total(
            account='Expenses', period=Period.PERIOD, fund=Fund.RESTRICTED,
        ) - self.balances.total(
            classification=self.C_SATISFIED, period=Period.PERIOD, fund=Fund.RESTRICTED,
        )
        other_totals = [core.MutableBalance() for _ in bal_kwargs]
        other_totals[0] += released
        other_totals[1] -= released
        self.write_totals_row(self.C_SATISFIED, other_totals)
        self.write_totals_row(
            "Total Support and Revenue",
            income_totals, other_totals,
            stylename=self.style_totline,
        )

        period_expenses = core.MutableBalance()
        prior_expenses = core.MutableBalance()
        self.add_row()
        self.add_row(self.string_cell("Expenses", stylename=self.style_bold))
        self.add_row()
        for text, type_value in [
                ("Program services", 'program'),
                ("Management and administrative services", 'management'),
                ("Fundraising", 'fundraising'),
        ]:
            period_bal = self.balances.total(
                account='Expenses', period=Period.PERIOD, post_type=type_value,
            )
            prior_bal = self.balances.total(
                account='Expenses', period=Period.PRIOR, post_type=type_value,
            )
            self.write_totals_row(text, [
                period_bal,
                self.NO_BALANCE,
                period_bal,
                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_totline, 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.BEFORE_PERIOD
            else:
                kwargs['period'] = Period.OPENING
        equity_totals = [
            -self.balances.total(account=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=["Total Year 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)
            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 activites",
            period_totals,
            stylename=self.style_totline,
        )
        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.BEFORE_PERIOD, 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 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(
        '--fund-metadata-key', '-m',
        metavar='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(
        '--output-file', '-O',
        metavar='PATH',
        type=Path,
        help="""Write the report to this file, or stdout when PATH is `-`.
""")
    cliutil.add_loglevel_argument(parser)
    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()

    if args.stop_date is None:
        if args.start_date is None:
            args.stop_date = datetime.date.today()
        else:
            args.stop_date = cliutil.diff_year(args.start_date, 1)
    if args.start_date is None:
        args.start_date = cliutil.diff_year(args.stop_date, -1)

    returncode = 0
    books_loader = config.books_loader()
    if books_loader is None:
        entries, load_errors, options_map = books.Loader.load_none(config.config_file_path())
        returncode = cliutil.ExitCode.NoConfiguration
    else:
        start_fy = config.fiscal_year_begin().for_date(args.start_date) - 1
        entries, load_errors, options_map = books_loader.load_fy_range(start_fy, args.stop_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)

    data.Account.load_from_books(entries, options_map)
    balances = Balances(
        data.Posting.from_entries(entries),
        args.start_date,
        args.stop_date,
        args.fund_metadata_key,
        args.unrestricted_fund,
    )
    report = Report(balances)
    report.set_common_properties(config.books_repo())
    report.write_all()
    if args.output_file is None:
        out_dir_path = config.repository_path() or Path()
        args.output_file = out_dir_path / 'BalanceSheet_{}_{}.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)
    report.save_file(ods_file)
    return returncode

entry_point = cliutil.make_entry_point(__name__, PROGNAME)

if __name__ == '__main__':
    exit(entry_point())