Changeset - a5b3dc146351
[Not reviewed]
0 4 0
Brett Smith - 4 years ago 2020-07-01 13:50:20
brettcsmith@brettcsmith.org
accrual: AccrualPostings.make_consistent() groups accruals by date.

This accommodates cases of contracts without separate invoices,
where a series of payments are scheduled over time.

The dance we used to do of group-by-invoice, then make consistent was
already suspect. It was originally motivated by the consistency checks that
are now gone. Use this opportunity to clean up and just make make_consistent
a classmethod.
4 files changed with 213 insertions and 62 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -36,247 +36,280 @@ You can pass any number of search terms. For example::
 
    # Report accruals associated with RT#1230 and Jane Doe
 
    accrual-report 1230 entity=Doe-Jane
 

	
 
accrual-report will automatically decide what kind of report to generate
 
from the search terms you provide and the results they return. If you
 
searched on an RT ticket or invoice that returned a single outstanding
 
payable, it writes an outgoing approval report. If you searched on RT ticket
 
or invoice that returned other results, it writes a balance
 
report. Otherwise, it writes an aging report. You can specify what report
 
type you want with the ``--report-type`` option::
 

	
 
    # Write an outgoing approval report for all outstanding payables for
 
    # Jane Doe, even if there's more than one
 
    accrual-report --report-type outgoing entity=Doe-Jane
 
    # Write an aging report for a single RT invoice (this can be helpful when
 
    # one invoice covers multiple parties)
 
    accrual-report --report-type aging 12/345
 
"""
 
# 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 re
 
import sys
 

	
 
from pathlib import Path
 

	
 
from typing import (
 
    cast,
 
    Any,
 
    BinaryIO,
 
    Callable,
 
    Deque,
 
    Dict,
 
    Hashable,
 
    Iterable,
 
    Iterator,
 
    List,
 
    Mapping,
 
    NamedTuple,
 
    Optional,
 
    Sequence,
 
    Set,
 
    TextIO,
 
    Tuple,
 
    TypeVar,
 
    Union,
 
)
 
from ..beancount_types import (
 
    Entries,
 
    Error,
 
    Errors,
 
    MetaKey,
 
    MetaValue,
 
    Transaction,
 
)
 

	
 
import odf.style  # type:ignore[import]
 
import odf.table  # type:ignore[import]
 
import rt
 

	
 
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 filters
 
from .. import rtutil
 

	
 
PROGNAME = 'accrual-report'
 

	
 
PostGroups = Mapping[Optional[str], 'AccrualPostings']
 
PostGroups = Mapping[Optional[Hashable], 'AccrualPostings']
 
T = TypeVar('T')
 

	
 
logger = logging.getLogger('conservancy_beancount.reports.accrual')
 

	
 
class Sentinel:
 
    pass
 

	
 

	
 
class Account(NamedTuple):
 
    name: str
 
    aging_thresholds: Sequence[int]
 

	
 

	
 
class AccrualAccount(enum.Enum):
 
    # Note the aging report uses the same order accounts are defined here.
 
    # See AgingODS.start_spreadsheet().
 
    RECEIVABLE = Account('Assets:Receivable', [365, 120, 90, 60])
 
    PAYABLE = Account('Liabilities:Payable', [365, 90, 60, 30])
 

	
 
    @classmethod
 
    def account_names(cls) -> Iterator[str]:
 
        return (acct.value.name for acct in cls)
 

	
 
    @classmethod
 
    def by_account(cls, name: data.Account) -> 'AccrualAccount':
 
        for account in cls:
 
            if name.is_under(account.value.name):
 
                return account
 
        raise ValueError(f"unrecognized account {name!r}")
 

	
 
    @classmethod
 
    def classify(cls, related: core.RelatedPostings) -> 'AccrualAccount':
 
        for account in cls:
 
            account_name = account.value.name
 
            if all(post.account.is_under(account_name) for post in related):
 
                return account
 
        raise ValueError("unrecognized account set in related postings")
 

	
 
    @property
 
    def normalize_amount(self) -> Callable[[T], T]:
 
        return core.normalize_amount_func(self.value.name)
 

	
 

	
 
class AccrualPostings(core.RelatedPostings):
 
    __slots__ = (
 
        'accrual_type',
 
        'date',
 
        'end_balance',
 
        'account',
 
        'entity',
 
        'invoice',
 
    )
 
    INCONSISTENT = Sentinel()
 

	
 
    def __init__(self,
 
                 source: Iterable[data.Posting]=(),
 
                 *,
 
                 _can_own: bool=False,
 
    ) -> None:
 
        super().__init__(source, _can_own=_can_own)
 
        self.account = self._single_item(post.account for post in self)
 
        if isinstance(self.account, Sentinel):
 
            self.accrual_type: Optional[AccrualAccount] = None
 
            norm_func: Callable[[T], T] = lambda x: x
 
            entity_pred: Callable[[data.Posting], bool] = bool
 
            accrual_pred: Callable[[data.Posting], bool] = bool
 
        else:
 
            self.accrual_type = AccrualAccount.by_account(self.account)
 
            norm_func = self.accrual_type.normalize_amount
 
            entity_pred = lambda post: norm_func(post.units).number > 0
 
        self.entity = self._single_item(self.entities(entity_pred))
 
            accrual_pred = lambda post: norm_func(post.units).number > 0
 
            if not any(accrual_pred(post) for post in self):
 
                accrual_pred = bool
 
        self.date = self._single_item(self.dates(accrual_pred))
 
        self.entity = self._single_item(self.entities(accrual_pred))
 
        self.invoice = self._single_item(self.first_meta_links('invoice', None))
 
        self.end_balance = norm_func(self.balance_at_cost())
 

	
 
    def _single_item(self, seq: Iterable[T]) -> Union[T, Sentinel]:
 
        items = iter(seq)
 
        try:
 
            item1 = next(items)
 
        except StopIteration:
 
            all_same = False
 
        else:
 
            all_same = all(item == item1 for item in items)
 
        return item1 if all_same else self.INCONSISTENT
 

	
 
    def dates(self, pred: Callable[[data.Posting], bool]=bool) -> Iterator[datetime.date]:
 
        return filters.iter_unique(post.meta.date for post in self if pred(post))
 

	
 
    def entities(self, pred: Callable[[data.Posting], bool]=bool) -> Iterator[MetaValue]:
 
        return filters.iter_unique(
 
            post.meta['entity']
 
            for post in self
 
            if pred(post) and 'entity' in post.meta
 
        )
 

	
 
    def make_consistent(self) -> Iterator[Tuple[str, 'AccrualPostings']]:
 
        account_ok = isinstance(self.account, str)
 
        entity_ok = isinstance(self.entity, str)
 
        # `'/' in self.invoice` is just our heuristic to ensure that the
 
        # invoice metadata is "unique enough," and not just a placeholder
 
        # value like "FIXME". It can be refined if needed.
 
        invoice_ok = isinstance(self.invoice, str) and '/' in self.invoice
 
        if account_ok and entity_ok and invoice_ok:
 
            # type ignore for <https://github.com/python/mypy/issues/6670>
 
            yield (self.invoice, self)  # type:ignore[misc]
 
            return
 
        groups = collections.defaultdict(list)
 
        for post in self:
 
            post_invoice = self.invoice if invoice_ok else (
 
                post.meta.get('invoice') or 'BlankInvoice'
 
            )
 
            post_entity = self.entity if entity_ok else (
 
                post.meta.get('entity') or 'BlankEntity'
 
            )
 
            groups[f'{post.account} {post_invoice} {post_entity}'].append(post)
 
        type_self = type(self)
 
        for group_key, posts in groups.items():
 
            yield group_key, type_self(posts, _can_own=True)
 
    @classmethod
 
    def make_consistent(cls,
 
                        postings: Iterable[data.Posting],
 
    ) -> Iterator[Tuple[Hashable, 'AccrualPostings']]:
 
        accruals: Dict[Tuple[str, ...], List[data.Posting]] = collections.defaultdict(list)
 
        payments: Dict[Tuple[str, ...], Deque[data.Posting]] = collections.defaultdict(Deque)
 
        key: Tuple[str, ...]
 
        for post in postings:
 
            norm_func = core.normalize_amount_func(post.account)
 
            invoice = str(post.meta.get('invoice', 'BlankInvoice'))
 
            if norm_func(post.units.number) >= 0:
 
                entity = str(post.meta.get('entity', 'BlankEntity'))
 
                key = (post.meta.date.isoformat(), entity, invoice, post.account)
 
                accruals[key].append(post)
 
            else:
 
                key = (invoice, post.account)
 
                payments[key].append(post)
 

	
 
        for key, acc_posts in accruals.items():
 
            pay_posts = payments[key[2:]]
 
            if not pay_posts:
 
                continue
 
            norm_func = core.normalize_amount_func(key[-1])
 
            balance = norm_func(core.MutableBalance(post.at_cost() for post in acc_posts))
 
            while pay_posts and not balance.le_zero():
 
                pay_post = pay_posts.popleft()
 
                acc_posts.append(pay_post)
 
                balance += norm_func(pay_post.at_cost())
 
            if balance.le_zero() and not balance.is_zero():
 
                # pay_post causes the accrual to be overpaid. Split it into two
 
                # synthesized postings: one that causes the accrual to be
 
                # exactly zero, and one with the remainder back in payments.
 
                post_cost = pay_post.at_cost()
 
                # Calling norm_func() reverses the call in the while loop to add
 
                # the amount to the balance.
 
                overpayment = norm_func(balance[post_cost.currency])
 
                amt_to_zero = post_cost._replace(number=post_cost.number - overpayment.number)
 
                acc_posts[-1] = pay_post._replace(units=amt_to_zero, cost=None, price=None)
 
                pay_posts.appendleft(pay_post._replace(units=overpayment, cost=None, price=None))
 
            acc_posts.sort(key=lambda post: post.meta.date)
 

	
 
        for key, acc_posts in accruals.items():
 
            yield key, cls(acc_posts, _can_own=True)
 
        for key, pay_posts in payments.items():
 
            if pay_posts:
 
                yield key, cls(pay_posts, _can_own=True)
 

	
 
    def is_paid(self, default: Optional[bool]=None) -> Optional[bool]:
 
        if self.accrual_type is None:
 
            return default
 
        else:
 
            return self.end_balance.le_zero()
 

	
 
    def is_zero(self, default: Optional[bool]=None) -> Optional[bool]:
 
        if self.accrual_type is None:
 
            return default
 
        else:
 
            return self.end_balance.is_zero()
 

	
 
    def since_last_nonzero(self) -> 'AccrualPostings':
 
        for index, (post, balance) in enumerate(self.iter_with_balance()):
 
            if balance.is_zero():
 
                start_index = index
 
        try:
 
            empty = start_index == index
 
        except NameError:
 
            empty = True
 
        return self if empty else self[start_index + 1:]
 

	
 
    @property
 
    def rt_id(self) -> Union[str, None, Sentinel]:
 
        return self._single_item(self.first_meta_links('rt-id', None))
 

	
 

	
 
class BaseReport:
 
    def __init__(self, out_file: TextIO) -> None:
 
        self.out_file = out_file
 
        self.logger = logger.getChild(type(self).__name__)
 

	
 
    def _report(self, posts: AccrualPostings, index: int) -> Iterable[str]:
 
        raise NotImplementedError("BaseReport._report")
 

	
 
    def run(self, groups: PostGroups) -> None:
 
        for index, invoice in enumerate(groups):
 
            for line in self._report(groups[invoice], index):
 
                print(line, file=self.out_file)
 

	
 

	
 
class AgingODS(core.BaseODS[AccrualPostings, Optional[data.Account]]):
 
    DOC_COLUMNS = [
 
        'rt-id',
 
        'invoice',
 
        'approval',
 
        'contract',
...
 
@@ -694,109 +727,103 @@ filename for other reports.
 
        help="""Report on accruals 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 link, including an RT attachment link in
 
`TIK/ATT` format, is a shortcut for `invoice=LINK`.
 
""")
 
    args = parser.parse_args(arglist)
 
    if args.report_type is None and not any(
 
            term.meta_key == 'invoice' or term.meta_key == 'rt-id'
 
            for term in args.search_terms
 
    ):
 
        args.report_type = ReportType.AGING
 
    return args
 

	
 
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()
 

	
 
    returncode = 0
 
    books_loader = config.books_loader()
 
    if books_loader is None:
 
        entries, load_errors, _ = books.Loader.load_none(config.config_file_path())
 
    else:
 
        load_since = None if args.report_type == ReportType.AGING else args.since
 
        entries, load_errors, _ = books_loader.load_all(load_since)
 
    filters.remove_opening_balance_txn(entries)
 
    for error in load_errors:
 
        bc_printer.print_error(error, file=stderr)
 
        returncode |= ReturnFlag.LOAD_ERRORS
 

	
 
    postings = list(filter_search(
 
        data.Posting.from_entries(entries), args.search_terms,
 
    ))
 
    if not postings:
 
        logger.warning("no matching entries found to report")
 
        returncode |= ReturnFlag.NOTHING_TO_REPORT
 
    # groups is a mapping of metadata value strings to AccrualPostings.
 
    # The keys are basically arbitrary, the report classes don't rely on them,
 
    # but they do help symbolize what's being grouped.
 
    # For the outgoing approval report, groups maps rt-id link strings to
 
    # associated accruals.
 
    # For all other reports, groups starts by grouping postings together by
 
    # invoice link string, then uses AccrualReport.make_consistent() to split
 
    # out groups that need it.
 
    # For all other reports, groups comes from AccrualReport.make_consistent().
 
    groups: PostGroups
 
    if args.report_type is None or args.report_type is ReportType.OUTGOING:
 
        groups = dict(AccrualPostings.group_by_first_meta_link(postings, 'rt-id'))
 
        if (args.report_type is None
 
            and len(groups) == 1
 
            and all(group.accrual_type is AccrualAccount.PAYABLE
 
                    and not group.is_paid()
 
                    and key  # Make sure we have a usable rt-id
 
                    for key, group in groups.items())
 
        ):
 
            args.report_type = ReportType.OUTGOING
 
    if args.report_type is not ReportType.OUTGOING:
 
        groups = {
 
            key: group
 
            for _, source in AccrualPostings.group_by_first_meta_link(postings, 'invoice')
 
            for key, group in source.make_consistent()
 
        }
 
        groups = dict(AccrualPostings.make_consistent(postings))
 
    if args.report_type is not ReportType.AGING:
 
        groups = {
 
            key: posts for key, posts in groups.items() if not posts.is_paid()
 
        } or groups
 
    del postings
 

	
 
    report: Optional[BaseReport] = None
 
    output_path: Optional[Path] = None
 
    if args.report_type is ReportType.AGING:
 
        rt_wrapper = config.rt_wrapper()
 
        if rt_wrapper is None:
 
            logger.error("unable to generate aging report: RT client is required")
 
        else:
 
            now = datetime.datetime.now()
 
            if args.output_file is None:
 
                out_dir_path = config.repository_path() or Path()
 
                args.output_file = out_dir_path / now.strftime('AgingReport_%Y-%m-%d_%H:%M.ods')
 
                logger.info("Writing report to %s", args.output_file)
 
            out_bin = cliutil.bytes_output(args.output_file, stdout)
 
            report = AgingReport(rt_wrapper, out_bin)
 
    elif args.report_type is ReportType.OUTGOING:
 
        rt_wrapper = config.rt_wrapper()
 
        if rt_wrapper is None:
 
            logger.error("unable to generate outgoing report: RT client is required")
 
        else:
 
            out_file = cliutil.text_output(args.output_file, stdout)
 
            report = OutgoingReport(rt_wrapper, out_file)
 
    else:
 
        out_file = cliutil.text_output(args.output_file, stdout)
 
        report = BalanceReport(out_file)
 

	
 
    if report is None:
 
        returncode |= ReturnFlag.REPORT_ERRORS
 
    else:
 
        report.run(groups)
 
    return 0 if returncode == 0 else 16 + returncode
 

	
 
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
 

	
 
if __name__ == '__main__':
 
    exit(entry_point())
conservancy_beancount/reports/core.py
Show inline comments
...
 
@@ -227,98 +227,98 @@ class Balance(Mapping[str, data.Amount]):
 
        balance = self.clean_copy(tolerance) or self.copy(tolerance)
 
        if not balance:
 
            return empty
 
        elif zero is not None and balance.is_zero():
 
            return zero
 
        else:
 
            amounts = list(balance.values())
 
            amounts.sort(key=lambda amt: (-abs(amt.number), amt.currency))
 
            return sep.join(
 
                babel.numbers.format_currency(
 
                    amt.number, amt.currency, fmt, format_type='accounting',
 
                ) for amt in amounts
 
            )
 

	
 

	
 
class MutableBalance(Balance):
 
    __slots__ = ()
 

	
 
    def __iadd__(self: BalanceType, other: Union[data.Amount, Balance]) -> BalanceType:
 
        self._add_other(self._currency_map, other)
 
        return self
 

	
 
    def __isub__(self: BalanceType, other: Union[data.Amount, Balance]) -> BalanceType:
 
        self._add_other(self._currency_map, -other)
 
        return self
 

	
 

	
 
class RelatedPostings(Sequence[data.Posting]):
 
    """Collect and query related postings
 

	
 
    This class provides common functionality for collecting related postings
 
    and running queries on them: iterating over them, tallying their balance,
 
    etc.
 

	
 
    This class doesn't know anything about how the postings are related. That's
 
    entirely up to the caller.
 

	
 
    A common pattern is to use this class with collections.defaultdict
 
    to organize postings based on some key. See the group_by_meta classmethod
 
    for an example.
 
    """
 
    __slots__ = ('_postings',)
 

	
 
    def __init__(self,
 
                 source: Iterable[data.Posting]=(),
 
                 *,
 
                 _can_own: bool=False,
 
    ) -> None:
 
        self._postings: List[data.Posting]
 
        if _can_own and isinstance(source, list):
 
        self._postings: Sequence[data.Posting]
 
        if _can_own and isinstance(source, Sequence):
 
            self._postings = source
 
        else:
 
            self._postings = list(source)
 

	
 
    @classmethod
 
    def _group_by(cls: Type[RelatedType],
 
                  postings: Iterable[data.Posting],
 
                  key: Callable[[data.Posting], T],
 
    ) -> Iterator[Tuple[T, RelatedType]]:
 
        mapping: Dict[T, List[data.Posting]] = collections.defaultdict(list)
 
        for post in postings:
 
            mapping[key(post)].append(post)
 
        for value, posts in mapping.items():
 
            yield value, cls(posts, _can_own=True)
 

	
 
    @classmethod
 
    def group_by_account(cls: Type[RelatedType],
 
                         postings: Iterable[data.Posting],
 
    ) -> Iterator[Tuple[data.Account, RelatedType]]:
 
        return cls._group_by(postings, operator.attrgetter('account'))
 

	
 
    @classmethod
 
    def group_by_meta(cls: Type[RelatedType],
 
                      postings: Iterable[data.Posting],
 
                      key: MetaKey,
 
                      default: Optional[MetaValue]=None,
 
    ) -> Iterator[Tuple[Optional[MetaValue], RelatedType]]:
 
        """Relate postings by metadata value
 

	
 
        This method takes an iterable of postings and returns a mapping.
 
        The keys of the mapping are the values of post.meta.get(key, default).
 
        The values are RelatedPostings instances that contain all the postings
 
        that had that same metadata value.
 
        """
 
        def key_func(post: data.Posting) -> Optional[MetaValue]:
 
            return post.meta.get(key, default)
 
        return cls._group_by(postings, key_func)
 

	
 
    @classmethod
 
    def group_by_first_meta_link(
 
            cls: Type[RelatedType],
 
            postings: Iterable[data.Posting],
 
            key: MetaKey,
 
    ) -> Iterator[Tuple[Optional[str], RelatedType]]:
 
        """Relate postings by the first link in metadata
 

	
 
        This method takes an iterable of postings and returns a mapping.
 
        The keys of the mapping are the values of
tests/books/accruals.beancount
Show inline comments
...
 
@@ -94,88 +94,88 @@
 
  invoice: "rt:505/5050"
 
  approval: "rt:505/5040"
 
  project: "Conservancy"
 
  Income:Donations  -2,500 EUR {1.100 USD}
 
  Assets:Receivable:Accounts  2,500 EUR {1.100 USD}
 

	
 
2010-05-10 * "Lawyer" "April legal services"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/5100"
 
  contract: "rt:510/4000"
 
  project: "Conservancy"
 
  Expenses:Services:Legal  200.00 USD
 
  Liabilities:Payable:Accounts  -200.00 USD
 
  payment-method: "USD ACH"
 

	
 
2010-05-15 * "MatchingProgram" "May matched donations"
 
  invoice: "rt://ticket/515/attachments/5150"
 
  approval: "rt://ticket/515/attachments/5140"
 
  project: "Conservancy"
 
  Income:Donations  -1500.00 USD
 
  Assets:Receivable:Accounts  1500.00 USD
 

	
 
2010-05-20 * "DonorA" "Donation made"
 
  rt-id: "rt:505"
 
  invoice: "rt:505/5050"
 
  project: "Conservancy"
 
  Assets:Receivable:Accounts  -2,750.00 USD
 
  Assets:Checking  2,750.00 USD
 
  receipt: "DonorAWire.pdf"
 

	
 
2010-05-25 * "Lawyer" "May payment"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/5100"
 
  project: "Conservancy"
 
  Liabilities:Payable:Accounts  200.00 USD
 
  contract: "rt:510/4000"
 
  Assets:Checking  -200.00 USD
 
  receipt: "rt:510/5105"
 

	
 
2010-06-10 * "Lawyer" "May legal services"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/6100"
 
  contract: "rt:510/4000"
 
  project: "Conservancy"
 
  Expenses:Services:Legal  220.00 USD
 
  Liabilities:Payable:Accounts  -220.00 USD
 
  payment-method: "USD ACH"
 

	
 
2010-06-12 * "Lawyer" "Additional legal fees for May"
 
2010-06-10 * "Lawyer" "Additional legal fees for May"
 
  rt-id: "rt:510"
 
  invoice: "rt:510/6100"
 
  contract: "rt:510/4000"
 
  project: "Conservancy"
 
  Expenses:FilingFees  60.00 USD
 
  Liabilities:Payable:Accounts  -60.00 USD
 
  payment-method: "USD ACH"
 

	
 
2010-06-15 * "GrantCo" "2010Q2 grant"
 
  rt-id: "rt:470"
 
  invoice: "rt:470/4700"
 
  project: "Development Grant"
 
  Assets:Receivable:Accounts  5500 USD
 
  Income:Donations           -5500 USD
 

	
 
2010-06-18 * "EuroGov" "European legal fees"
 
  ; Multiple rt-ids are used to test proper handling for both
 
  ; searching and generating the outgoing report.
 
  rt-id: "rt:520 rt:525"
 
  invoice: "rt:520/5200"
 
  contract: "rt:520/5220"
 
  project: "Conservancy"
 
  Liabilities:Payable:Accounts  -1,000 EUR {1.100 USD}
 
  payment-method: "eur fxwire"
 
  Expenses:FilingFees  1,000 EUR {1.100 USD}
 

	
 
2010-06-20 * "StateGov" "Business registration"
 
  ; Intentionally has no rt-id
 
  invoice: "Invoices/2010StateRegistration.pdf"
 
  project: "Conservancy"
 
  Liabilities:Payable:Accounts  -50 USD
 
  Expenses:FilingFees  50 USD
 

	
 
2010-09-15 * "GrantCo" "2010Q3 grant"
 
  rt-id: "rt:470"
 
  invoice: "rt:470/4700"
 
  project: "Development Grant"
 
  Assets:Receivable:Accounts  6000 USD
 
  Income:Donations           -6000 USD
tests/test_reports_accrual.py
Show inline comments
...
 
@@ -71,97 +71,99 @@ class AgingRow(NamedTuple):
 
    project: Sequence[str]
 

	
 
    @classmethod
 
    def make_simple(cls, date, entity, at_cost, invoice,
 
                    rt_id=None, orig_amount=None, project='Conservancy'):
 
        if isinstance(date, str):
 
            date = datetime.datetime.strptime(date, '%Y-%m-%d').date()
 
        if not isinstance(at_cost, tuple):
 
            at_cost = testutil.Amount(at_cost)
 
        if rt_id is None and invoice.startswith('rt:'):
 
            rt_id, _, _ = invoice.partition('/')
 
        return cls(date, [entity], orig_amount, at_cost, [rt_id], [invoice], [project])
 

	
 
    def check_row_match(self, sheet_row):
 
        cells = testutil.ODSCell.from_row(sheet_row)
 
        assert len(cells) >= len(self)
 
        cells = iter(cells)
 
        assert next(cells).value == self.date
 
        assert next(cells).text == '\0'.join(self.entity)
 
        assert next(cells).text == '\0'.join(
 
            babel.numbers.format_currency(number, currency, format_type='accounting')
 
            for number, currency in self.amount or ()
 
        )
 
        usd_cell = next(cells)
 
        assert usd_cell.value_type == 'currency'
 
        assert usd_cell.value == self.at_cost.number
 
        assert next(cells).text == '\0'.join(self.project)
 
        for index, cell in enumerate(cells):
 
            links = cell.getElementsByType(odf.text.A)
 
            assert len(links) == len(cell.childNodes)
 
        assert index >= 1
 

	
 

	
 
AGING_AP = [
 
    AgingRow.make_simple('2010-03-06', 'EarlyBird', -125, 'rt:44/440'),
 
    AgingRow.make_simple('2010-03-30', 'EarlyBird', 75, 'rt:490/4900'),
 
    AgingRow.make_simple('2010-04-25', 'Vendor', 200, 'FIXME'),
 
    AgingRow.make_simple('2010-04-30', 'Vendor', 220, 'rt:310/3120'),
 
    AgingRow.make_simple('2010-06-10', 'Lawyer', 280, 'rt:510/6100'),
 
    AgingRow.make_simple('2010-06-18', 'EuroGov', 1100, 'rt:520/5200',
 
                         orig_amount=[testutil.Amount(1000, 'EUR')]),
 
    AgingRow.make_simple('2010-06-20', 'StateGov', 50, 'Invoices/2010StateRegistration.pdf'),
 
]
 

	
 
AGING_AR = [
 
    AgingRow.make_simple('2010-03-05', 'EarlyBird', -500, 'rt:40/400'),
 
    AgingRow.make_simple('2010-05-15', 'MatchingProgram', 1500,
 
                         'rt://ticket/515/attachments/5150'),
 
    AgingRow.make_simple('2010-06-15', 'GrantCo', 11500, 'rt:470/4700',
 
    AgingRow.make_simple('2010-06-15', 'GrantCo', 5500, 'rt:470/4700',
 
                         project='Development Grant'),
 
    AgingRow.make_simple('2010-09-15', 'GrantCo', 6000, 'rt:470/4700',
 
                         project='Development Grant'),
 
]
 

	
 
class RTClient(testutil.RTClient):
 
    TICKET_DATA = {
 
        '40': [
 
            ('400', 'invoice feb.csv', 'text/csv', '40.4k'),
 
        ],
 
        '44': [
 
            ('440', 'invoice feb.csv', 'text/csv', '40.4k'),
 
        ],
 
        '310': [
 
            ('3100', 'VendorContract.pdf', 'application/pdf', '1.7m'),
 
            ('3120', 'VendorInvoiceB.pdf', 'application/pdf', '1.8m'),
 
        ],
 
        '490': [],
 
        '505': [],
 
        '510': [
 
            ('4000', 'contract.pdf', 'application/pdf', '1.4m'),
 
            ('5100', 'invoice april.pdf', 'application/pdf', '1.5m'),
 
            ('5105', 'payment.png', 'image/png', '51.5k'),
 
            ('6100', 'invoice may.pdf', 'application/pdf', '1.6m'),
 
        ],
 
        '515': [],
 
        '520': [],
 
    }
 

	
 

	
 
@pytest.fixture
 
def accrual_postings():
 
    return data.Posting.from_entries(copy.deepcopy(ACCRUAL_TXNS))
 

	
 
def accruals_by_meta(postings, value, key='invoice', wrap_type=iter):
 
    return wrap_type(
 
        post for post in postings
 
        if post.meta.get(key) == value
 
        and post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
    )
 

	
 
def find_row_by_text(row_source, want_text):
 
    for row in row_source:
 
        try:
 
            found_row = row.childNodes[0].text == want_text
 
        except IndexError:
 
            found_row = False
 
        if found_row:
 
            return row
 
    return None
...
 
@@ -298,319 +300,441 @@ def test_accrual_postings_entities_no_duplicates():
 
        (ACCOUNTS[0], -15, {'entity': 'Accruee'}),
 
        (ACCOUNTS[0], -10, {'entity': 'Other'}),
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    actual = related.entities()
 
    assert next(actual, None) == 'Accruee'
 
    assert next(actual, None) == 'Other'
 
    assert next(actual, None) is None
 

	
 
def test_accrual_postings_inconsistent_account():
 
    meta = {'invoice': 'invoice.pdf'}
 
    txn = testutil.Transaction(postings=[
 
        (acct_name, index, meta)
 
        for index, acct_name in enumerate(ACCOUNTS)
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    assert related.account is related.INCONSISTENT
 

	
 
def test_accrual_postings_rt_id():
 
    txn = testutil.Transaction(postings=[
 
        (ACCOUNTS[0], 10, {'rt-id': 'rt:90'}),
 
        (ACCOUNTS[0], 10, {'rt-id': 'rt:90 rt:92'}),
 
        (ACCOUNTS[0], 10, {'rt-id': 'rt:90 rt:94 rt:92'}),
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    assert related.rt_id == 'rt:90'
 

	
 
def test_accrual_postings_rt_id_inconsistent():
 
    txn = testutil.Transaction(postings=[
 
        (ACCOUNTS[0], 10, {'rt-id': 'rt:96'}),
 
        (ACCOUNTS[0], 10, {'rt-id': 'rt:98 rt:96'}),
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    assert related.rt_id is related.INCONSISTENT
 

	
 
def test_accrual_postings_rt_id_none():
 
    txn = testutil.Transaction(postings=[
 
        (ACCOUNTS[0], 10),
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    assert related.rt_id is None
 

	
 
@pytest.mark.parametrize('acct_name,invoice,day', testutil.combine_values(
 
    ACCOUNTS,
 
    ['FIXME', '', None, *testutil.NON_STRING_METADATA_VALUES],
 
    itertools.count(1),
 
))
 
def test_make_consistent_bad_invoice(acct_name, invoice, day):
 
    if acct_name.startswith('Assets:'):
 
        mult = 10
 
    else:
 
        mult = -10
 
    txn = testutil.Transaction(date=datetime.date(2019, 1, day), postings=[
 
        (acct_name, index * 10, {'invoice': invoice, 'entity': f'BadInvoice{day}'})
 
        (acct_name, index * mult, {'invoice': invoice, 'entity': f'BadInvoice{day}'})
 
        for index in range(1, 4)
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    consistent = dict(related.make_consistent())
 
    consistent = dict(accrual.AccrualPostings.make_consistent(data.Posting.from_txn(txn)))
 
    assert len(consistent) == 1
 
    key = next(iter(consistent))
 
    assert acct_name in key
 
    if invoice:
 
        assert str(invoice) in key
 
    actual = consistent[key]
 
    assert actual
 
    assert len(actual) == 3
 
    for act_post, exp_post in zip(actual, txn.postings):
 
        assert act_post.units == exp_post.units
 
        assert act_post.meta.get('invoice') == invoice
 

	
 
def test_make_consistent_across_accounts():
 
    invoice = 'Invoices/CrossAccount.pdf'
 
    txn = testutil.Transaction(date=datetime.date(2019, 2, 1), postings=[
 
        (acct_name, 100, {'invoice': invoice, 'entity': 'CrossAccount'})
 
        for acct_name in ACCOUNTS
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    consistent = dict(related.make_consistent())
 
    consistent = dict(accrual.AccrualPostings.make_consistent(data.Posting.from_txn(txn)))
 
    assert len(consistent) == len(ACCOUNTS)
 
    for key, posts in consistent.items():
 
        assert len(posts) == 1
 
        assert posts.account in key
 

	
 
def test_make_consistent_both_invoice_and_account():
 
    txn = testutil.Transaction(date=datetime.date(2019, 2, 2), postings=[
 
        (acct_name, 150) for acct_name in ACCOUNTS
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    consistent = dict(related.make_consistent())
 
    consistent = dict(accrual.AccrualPostings.make_consistent(data.Posting.from_txn(txn)))
 
    assert len(consistent) == len(ACCOUNTS)
 
    for key, posts in consistent.items():
 
        assert len(posts) == 1
 
        assert posts.account in key
 

	
 
@pytest.mark.parametrize('acct_name', ACCOUNTS)
 
def test_make_consistent_across_entity(acct_name):
 
    amt_sign = operator.pos if acct_name.startswith('Assets') else operator.neg
 
    txn = testutil.Transaction(postings=[
 
        (acct_name, amt_sign(n), {'invoice': 'Inv/1.pdf', 'entity': f'Entity{n}'})
 
        for n in range(1, 4)
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    consistent = dict(related.make_consistent())
 
    consistent = dict(accrual.AccrualPostings.make_consistent(data.Posting.from_txn(txn)))
 
    assert len(consistent) == 3
 
    for key, posts in consistent.items():
 
        assert len(posts) == 1
 
        entities = posts.entities()
 
        assert next(entities, None) == posts.entity
 
        assert next(entities, None) is None
 
        assert posts.entity in key
 

	
 
@pytest.mark.parametrize('acct_name', ACCOUNTS)
 
def test_make_consistent_entity_differs_accrual_payment(acct_name):
 
    invoice = 'Invoices/DifferPay.pdf'
 
    txn = testutil.Transaction(postings=[
 
        # Depending on the account, the order of the accrual and payment might
 
        # be swapped here, but that shouldn't matter.
 
        (acct_name, 125, {'invoice': invoice, 'entity': 'Positive'}),
 
        (acct_name, -125, {'invoice': invoice, 'entity': 'Negative'}),
 
    ])
 
    related = accrual.AccrualPostings(data.Posting.from_txn(txn))
 
    consistent = related.make_consistent()
 
    related = list(data.Posting.from_txn(txn))
 
    consistent = accrual.AccrualPostings.make_consistent(related)
 
    _, actual = next(consistent)
 
    assert actual is related
 
    assert len(actual) == len(related)
 
    assert all(post in actual for post in related)
 
    assert next(consistent, None) is None
 

	
 
def test_make_consistent_by_date_accruals_differ():
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/2', 'entity': 'MultiDate'}
 
    entries = [testutil.Transaction(date=date, postings=[
 
        ('Assets:Receivable:Accounts', date.day * 100, meta),
 
    ]) for date in itertools.islice(testutil.date_seq(), 3)]
 
    actual = [group for _, group in
 
              accrual.AccrualPostings.make_consistent(data.Posting.from_entries(entries))]
 
    assert len(actual) == 3
 
    assert {post.units.number for group in actual for post in group} == {100, 200, 300}
 

	
 
def test_make_consistent_by_date_with_exact_payment():
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/3', 'entity': 'OnePayment'}
 
    entries = [testutil.Transaction(date=date, postings=[(
 
        'Assets:Receivable:Accounts',
 
        35 * (1 if date.day % 2 else -1),
 
        meta,
 
    )]) for date in itertools.islice(testutil.date_seq(), 3)]
 
    actual = [group for _, group in
 
              accrual.AccrualPostings.make_consistent(data.Posting.from_entries(entries))]
 
    assert len(actual) == 2
 
    assert actual[0].is_zero()
 
    assert len(actual[1]) == 1
 
    assert actual[1][0].meta.date.day == 3
 

	
 
def test_make_consistent_by_date_with_underpayment():
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/4', 'entity': 'UnderPayment'}
 
    entries = [testutil.Transaction(date=date, postings=[(
 
        'Assets:Receivable:Accounts',
 
        40 * (1 if date.day % 2 else -.5),
 
        meta,
 
    )]) for date in itertools.islice(testutil.date_seq(), 3)]
 
    actual = [group for _, group in
 
              accrual.AccrualPostings.make_consistent(data.Posting.from_entries(entries))]
 
    assert len(actual) == 2
 
    assert len(actual[0]) == 2
 
    assert actual[0][0].units.number == 40
 
    assert actual[0][1].units.number == -20
 
    assert len(actual[1]) == 1
 
    assert actual[1][0].meta.date.day == 3
 

	
 
def test_make_consistent_by_date_with_overpayment():
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/5', 'entity': 'OverPayment'}
 
    entries = [testutil.Transaction(date=date, postings=[(
 
        'Assets:Receivable:Accounts',
 
        50 * (1 if date.day % 2 else -1.5),
 
        meta,
 
    )]) for date in itertools.islice(testutil.date_seq(), 3)]
 
    actual = [group for _, group in
 
              accrual.AccrualPostings.make_consistent(data.Posting.from_entries(entries))]
 
    assert len(actual) == 2
 
    assert actual[0].is_zero()
 
    assert len(actual[1]) == 2
 
    assert actual[1][0].meta.date.day == 2
 
    assert actual[1][0].units.number == -25
 
    assert actual[1][1].meta.date.day == 3
 

	
 
def test_make_consistent_by_date_with_late_payment():
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/6', 'entity': 'LatePayment'}
 
    entries = [testutil.Transaction(date=date, postings=[(
 
        'Assets:Receivable:Accounts',
 
        60 * (-1 if date.day > 2 else 1),
 
        meta,
 
    )]) for date in itertools.islice(testutil.date_seq(), 3)]
 
    actual = [group for _, group in
 
              accrual.AccrualPostings.make_consistent(data.Posting.from_entries(entries))]
 
    assert len(actual) == 2
 
    assert len(actual[0]) == 2
 
    assert actual[0][0].meta.date.day == 1
 
    assert actual[0][1].meta.date.day == 3
 
    assert len(actual[1]) == 1
 
    assert actual[1][0].meta.date.day == 2
 

	
 
def test_make_consistent_by_date_with_split_payments():
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/7', 'entity': 'SplitPayments'}
 
    entries = [testutil.Transaction(date=date, postings=[(
 
        'Assets:Receivable:Accounts', amount, meta,
 
    )]) for date, amount in zip(testutil.date_seq(), [70, 80, -50, -100])]
 
    actual = [group for _, group in
 
              accrual.AccrualPostings.make_consistent(data.Posting.from_entries(entries))]
 
    assert len(actual) == 2
 
    assert [post.units.number for post in actual[0]] == [70, -50, -20]
 
    assert [post.units.number for post in actual[1]] == [80, -80]
 

	
 
@pytest.mark.parametrize('account,day', itertools.product(
 
    ACCOUNTS,
 
    [1, 10, 20, 30],
 
))
 
def test_make_consistent_with_three_one_split(account, day):
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/8', 'entity': '3Split'}
 
    entries = [testutil.Transaction(date=datetime.date(2019, 5, dd), postings=[(
 
        account, dd, meta,
 
    )]) for dd in [5, 15, 25]]
 
    meta['entity'] = '1Split'
 
    entries.insert(day // 10, testutil.Transaction(
 
        date=datetime.date(2019, 5, day),
 
        postings=[(account, -45, meta)],
 
    ))
 
    postings = data.Posting.from_entries(entries)
 
    actual = dict(accrual.AccrualPostings.make_consistent(iter(postings)))
 
    if account.startswith('Assets:'):
 
        group_count = 3
 
        post_count = 2
 
    else:
 
        group_count = 1
 
        post_count = 4
 
    assert len(actual) == group_count
 
    for related in actual.values():
 
        assert len(related) == post_count
 
        assert sum(post.units.number for post in related) == 0
 
        assert all(post.meta['invoice'] == meta['invoice'] for post in related)
 
        assert {post.meta['entity'] for post in related} == {'1Split', '3Split'}
 

	
 
@pytest.mark.parametrize('account', ACCOUNTS)
 
def test_make_consistent_with_three_two_split(account):
 
    meta = {'rt-id': '1', 'invoice': 'rt:1/9'}
 
    entries = [testutil.Transaction(date=datetime.date(2019, 5, day), postings=[(
 
        account, day * (1 if day % 10 else -1.5), meta,
 
    )]) for day in range(5, 30, 5)]
 
    postings = data.Posting.from_entries(entries)
 
    actual = dict(accrual.AccrualPostings.make_consistent(iter(postings)))
 
    if account.startswith('Assets:'):
 
        group_count = 3
 
    else:
 
        group_count = 2
 
    assert len(actual) == group_count
 
    for related in actual.values():
 
        assert len(related) >= 2
 
        assert sum(post.units.number for post in related) == 0
 
        assert all(post.meta['invoice'] == meta['invoice'] for post in related)
 

	
 
def check_output(output, expect_patterns):
 
    output.seek(0)
 
    testutil.check_lines_match(iter(output), expect_patterns)
 

	
 
def run_outgoing(rt_id, postings, rt_client=None):
 
    if rt_client is None:
 
        rt_client = RTClient()
 
    rt_wrapper = rtutil.RT(rt_client)
 
    if not isinstance(postings, accrual.AccrualPostings):
 
        postings = accruals_by_meta(postings, rt_id, 'rt-id', wrap_type=accrual.AccrualPostings)
 
    output = io.StringIO()
 
    report = accrual.OutgoingReport(rt_wrapper, output)
 
    report.run({rt_id: postings})
 
    return output
 

	
 
@pytest.mark.parametrize('invoice,expected', [
 
    ('rt:505/5050', "Zero balance outstanding since 2010-05-05"),
 
    ('rt:510/5100', "Zero balance outstanding since 2010-05-10"),
 
    ('rt:510/6100', "-280.00 USD outstanding since 2010-06-10"),
 
    ('rt://ticket/515/attachments/5150', "1,500.00 USD outstanding since 2010-05-15",),
 
])
 
def test_balance_report(accrual_postings, invoice, expected, caplog):
 
    related = accruals_by_meta(accrual_postings, invoice, wrap_type=accrual.AccrualPostings)
 
    output = io.StringIO()
 
    report = accrual.BalanceReport(output)
 
    report.run({invoice: related})
 
    assert not caplog.records
 
    check_output(output, [invoice, expected])
 

	
 
def test_outgoing_report(accrual_postings, caplog):
 
    rt_client = RTClient()
 
    output = run_outgoing('rt:510', accrual_postings, rt_client)
 
    rt_url = RTClient.DEFAULT_URL[:-9]
 
    rt_id_url = rf'\b{re.escape(f"{rt_url}Ticket/Display.html?id=510")}\b'
 
    contract_url = rf'\b{re.escape(f"{rt_url}Ticket/Attachment/4000/4000/contract.pdf")}\b'
 
    assert not caplog.records
 
    check_output(output, [
 
        r'^PAYMENT FOR APPROVAL:$',
 
        r'^REQUESTOR: Mx\. 510 <mx510@example\.org>$',
 
        r'^PAYMENT TO: Hon\. Mx\. 510$',
 
        r'^TOTAL TO PAY: \$280\.00$',
 
        fr'^AGREEMENT: {contract_url}',
 
        r'^BEANCOUNT ENTRIES:$',
 
        # For each transaction, check for the date line, a metadata, and the
 
        # Expenses posting.
 
        r'^\s*2010-06-10\s',
 
        fr'^\s+rt-id: "{rt_id_url}"$',
 
        r'^\s+Expenses:Services:Legal\s+220\.00 USD$',
 
        r'^\s*2010-06-12\s',
 
        r'^\s*2010-06-10\s',
 
        fr'^\s+contract: "{contract_url}"$',
 
        r'^\s+Expenses:FilingFees\s+60\.00 USD$',
 
    ])
 
    assert rt_client.edits == {'510': {
 
        'CF_payment-amount': 'USD 280.00',
 
        'CF_payment-method': 'USD ACH',
 
    }}
 
    assert 'payment-method:' not in output.getvalue()
 

	
 
def test_outgoing_report_custom_field_fallbacks(accrual_postings, caplog):
 
    rt_client = RTClient(want_cfs=False)
 
    output = run_outgoing('rt:510', accrual_postings, rt_client)
 
    assert not caplog.records
 
    check_output(output, [
 
        r'^PAYMENT FOR APPROVAL:$',
 
        r'^REQUESTOR: <mx510@example\.org>$',
 
        r'^PAYMENT TO:\s*$',
 
    ])
 

	
 
def test_outgoing_report_fx_amounts(accrual_postings, caplog):
 
    rt_client = RTClient()
 
    output = run_outgoing('rt:520 rt:525', accrual_postings, rt_client)
 
    assert not caplog.records
 
    check_output(output, [
 
        r'^PAYMENT FOR APPROVAL:$',
 
        r'^REQUESTOR: Mx\. 520 <mx520@example\.org>$',
 
        r'^TOTAL TO PAY: 1,000\.00 EUR \(\$1,100.00\)$',
 
    ])
 
    assert rt_client.edits == {'520': {
 
        'CF_payment-amount': 'EUR 1,000.00 ($1,100.00)',
 
        'CF_payment-method': 'EUR International Wire',
 
    }}
 
    assert 'payment-method:' not in output.getvalue()
 

	
 
def test_outgoing_report_multi_invoice(accrual_postings, caplog):
 
    rt_client = RTClient()
 
    output = run_outgoing('rt:310', accrual_postings, rt_client)
 
    log, = caplog.records
 
    assert log.levelname == 'WARNING'
 
    assert log.message.startswith('cannot set payment-method for rt:310: ')
 
    check_output(output, [
 
        r'^PAYMENT FOR APPROVAL:$',
 
        r'^REQUESTOR: Mx\. 310 <mx310@example\.org>$',
 
        r'^TOTAL TO PAY: \$420.00$',
 
    ])
 
    assert rt_client.edits == {'310': {
 
        'CF_payment-amount': 'USD 420.00',
 
    }}
 
    assert 'payment-method:' not in output.getvalue()
 

	
 
def test_outgoing_report_without_rt_id(accrual_postings, caplog):
 
    invoice = 'rt://ticket/515/attachments/5150'
 
    related = accruals_by_meta(
 
        accrual_postings, invoice, wrap_type=accrual.AccrualPostings,
 
    )
 
    output = run_outgoing(None, related)
 
    assert caplog.records
 
    log = caplog.records[0]
 
    assert log.message.startswith(
 
        f"can't generate outgoings report for {invoice} because no RT ticket available:",
 
    )
 
    assert not output.getvalue()
 

	
 
def run_aging_report(postings, today=None):
 
    if today is None:
 
        today = datetime.date.today()
 
    postings = (
 
        post for post in postings
 
        if post.account.is_under('Assets:Receivable', 'Liabilities:Payable')
 
    )
 
    groups = {
 
        key: group
 
        for _, related in accrual.AccrualPostings.group_by_meta(postings, 'invoice')
 
        for key, group in related.make_consistent()
 
    }
 
    groups = dict(accrual.AccrualPostings.make_consistent(postings))
 
    output = io.BytesIO()
 
    rt_wrapper = rtutil.RT(RTClient())
 
    report = accrual.AgingReport(rt_wrapper, output, today)
 
    report.run(groups)
 
    return output
 

	
 
def test_aging_report(accrual_postings):
 
    output = run_aging_report(accrual_postings)
 
    check_aging_ods(output)
 

	
 
@pytest.mark.parametrize('date,recv_end,pay_end', [
 
    # Both these dates are chosen for their off-by-one potential:
 
    # the first is exactly 30 days after the 2010-06-10 payable;
 
    # the second is exactly 60 days after the 2010-05-15 receivable.
 
    (datetime.date(2010, 7, 10), 1, 5),
 
    (datetime.date(2010, 7, 14), 2, 5),
 
])
 
def test_aging_report_date_cutoffs(accrual_postings, date, recv_end, pay_end):
 
    expect_recv = AGING_AR[:recv_end]
 
    expect_pay = AGING_AP[:pay_end]
 
    if 10 <= date.day < 12:
 
        # Take the 60 USD posting out of the invoice 510/6100 payable.
 
        expect_pay[-1] = expect_pay[-1]._replace(
 
            at_cost=testutil.Amount(expect_pay[-1].at_cost.number - 60),
 
        )
 
    output = run_aging_report(accrual_postings, date)
 
    check_aging_ods(output, date, expect_recv, expect_pay)
 

	
 
def test_aging_report_entity_consistency(accrual_postings):
 
    output = run_aging_report((
 
        post for post in accrual_postings
 
        if post.meta.get('rt-id') == 'rt:480'
 
        and post.units.number < 0
 
    ))
 
    check_aging_ods(output, None, [], [
 
        AgingRow.make_simple('2010-04-15', 'MultiPartyA', 125, 'rt:480/4800'),
 
        AgingRow.make_simple('2010-04-15', 'MultiPartyB', 125, 'rt:480/4800'),
 
    ])
 

	
 
def test_aging_report_does_not_include_too_recent_postings(accrual_postings):
 
    # This date is after the Q3 posting, but too soon after for that to be
 
    # included in the aging report.
 
    date = datetime.date(2010, 10, 1)
 
    output = run_aging_report((
 
        post for post in accrual_postings
 
        if post.meta.get('rt-id') == 'rt:470'
 
    ), date)
 
    # Date+amount are both from the Q2 posting only.
 
    check_aging_ods(output, date, [
 
        AgingRow.make_simple('2010-06-15', 'GrantCo', 5500, 'rt:470/4700',
 
                             project='Development Grant'),
 
    ], [])
 

	
 
def run_main(arglist, config=None, out_type=io.StringIO):
 
    if config is None:
 
        config = testutil.TestConfig(
 
            books_path=testutil.test_path('books/accruals.beancount'),
 
            rt_client=RTClient(),
 
        )
 
    if out_type is io.BytesIO:
 
        arglist.insert(0, '--output-file=-')
 
    output = out_type()
 
    errors = io.StringIO()
 
    retcode = accrual.main(arglist, output, errors, config)
 
    output.seek(0)
 
    errors.seek(0)
 
    return retcode, output, errors
 

	
 
def check_main_fails(arglist, config, error_flags):
 
    retcode, output, errors = run_main(arglist, config)
 
    assert retcode > 16
 
    assert (retcode - 16) & error_flags
 
    assert not output.getvalue()
0 comments (0 inline, 0 general)