Changeset - fdb62dd1c641
[Not reviewed]
0 7 0
Brett Smith - 4 years ago 2020-04-07 02:02:14
brettcsmith@brettcsmith.org
plugin.core: _RequireLinksPostingMetadataHook can check several metadata.

Extend the base class from checking 1 metadata value to checking N.

This is preparation for RT#10643, letting payables be documented with
invoice or contract.

This does unify error reporting, because now we always report all
type/invalid value errors *plus* a missing error if appropriate.
I think this consistency and thoroughness is appropriate, although
it did require some adjustments to the tests.
7 files changed with 73 insertions and 103 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/plugin/core.py
Show inline comments
 
"""Base classes for plugin checks"""
 
# 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 abc
 
import datetime
 
import re
 

	
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 

	
 
from typing import (
 
    Any,
 
    Dict,
 
    FrozenSet,
 
    Generic,
 
    Iterable,
 
    Iterator,
 
    Mapping,
 
    Optional,
 
    Sequence,
 
    Type,
 
    TypeVar,
 
)
 
from ..beancount_types import (
 
    Account,
 
    Directive,
 
    MetaKey,
 
    MetaValue,
 
    MetaValueEnum,
 
    Transaction,
 
)
 

	
 
### CONSTANTS
 

	
 
# I expect these will become configurable in the future, which is why I'm
 
# keeping them outside of a class, but for now constants will do.
 
DEFAULT_START_DATE: datetime.date = datetime.date(2020, 3, 1)
 
# The default stop date leaves a little room after so it's easy to test
 
# dates past the far end of the range.
 
DEFAULT_STOP_DATE: datetime.date = datetime.date(datetime.MAXYEAR, 1, 1)
 

	
 
### TYPE DEFINITIONS
 

	
 
HookName = str
 

	
 
Entry = TypeVar('Entry', bound=Directive)
 
class Hook(Generic[Entry], metaclass=abc.ABCMeta):
 
    DIRECTIVE: Type[Directive]
 
    HOOK_GROUPS: FrozenSet[HookName] = frozenset()
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        pass
 
        # Subclasses that need configuration should override __init__ to check
 
        # and store it.
 

	
 
    @abc.abstractmethod
 
    def run(self, entry: Entry) -> errormod.Iter: ...
 

	
 

	
 
class TransactionHook(Hook[Transaction]):
 
    DIRECTIVE = Transaction
 

	
 

	
 
### HELPER CLASSES
 

	
 
class LessComparable(metaclass=abc.ABCMeta):
 
    @abc.abstractmethod
 
    def __le__(self, other: Any) -> bool: ...
 

	
 
    @abc.abstractmethod
 
    def __lt__(self, other: Any) -> bool: ...
 

	
 

	
 
CT = TypeVar('CT', bound=LessComparable)
 
class _GenericRange(Generic[CT]):
 
    """Convenience class to check whether a value is within a range.
 

	
 
    `foo in generic_range` is equivalent to `start <= foo < stop`.
 
    Since we have multiple user-configurable ranges, having the check
 
    encapsulated in an object helps implement the check consistently, and
 
    makes it easier for subclasses to override.
 
    """
 

	
 
    def __init__(self, start: CT, stop: CT) -> None:
 
        self.start = start
 
        self.stop = stop
 

	
 
    def __repr__(self) -> str:
 
        return "{clsname}({self.start!r}, {self.stop!r})".format(
 
            clsname=type(self).__name__,
 
            self=self,
 
        )
 

	
 
    def __contains__(self, item: CT) -> bool:
 
        return self.start <= item < self.stop
 

	
 

	
 
class MetadataEnum:
 
    """Map acceptable metadata values to their normalized forms.
 

	
 
    When a piece of metadata uses a set of allowed values, use this class to
 
    define them. You can also specify aliases that hooks will normalize to
 
    the primary values.
 
    """
 

	
 
    def __init__(self,
 
                 key: MetaKey,
 
                 standard_values: Iterable[MetaValueEnum],
 
                 aliases_map: Optional[Mapping[MetaValueEnum, MetaValueEnum]]=None,
 
    ) -> None:
 
        """Specify allowed values and aliases for this metadata.
 

	
 
        Arguments:
 

	
 
        * key: The name of the metadata key that uses this enum.
 
        * standard_values: A sequence of strings that enumerate the standard
 
          values for this metadata.
 
        * aliases_map: A mapping of strings to strings. The keys are
 
          additional allowed metadata values. The values are standard values
 
          that each key will evaluate to. The code asserts that all values are
 
          in standard_values.
 
        """
 
        self.key = key
 
        self._stdvalues = frozenset(standard_values)
 
        self._aliases: Dict[MetaValueEnum, MetaValueEnum] = dict(aliases_map or ())
 
        assert self._stdvalues.issuperset(self._aliases.values())
 
        self._aliases.update((v, v) for v in standard_values)
 

	
 
    def __repr__(self) -> str:
 
        return "{}<{}>".format(type(self).__name__, self.key)
 

	
 
    def __contains__(self, key: MetaValueEnum) -> bool:
 
        """Returns true if `key` is a standard value or alias."""
 
        return key in self._aliases
 

	
 
    def __getitem__(self, key: MetaValueEnum) -> MetaValueEnum:
 
        """Return the standard value for `key`.
 

	
 
        Raises KeyError if `key` is not a known value or alias.
 
        """
 
        return self._aliases[key]
 

	
 
    def __iter__(self) -> Iterator[MetaValueEnum]:
 
        """Iterate over standard values."""
 
        return iter(self._stdvalues)
 

	
 
    def get(self,
 
            key: MetaValueEnum,
 
            default_key: Optional[MetaValueEnum]=None,
 
    ) -> Optional[MetaValueEnum]:
 
        """Return self[key], or a default fallback if that doesn't exist.
 

	
 
        default_key is another key to look up, *not* a default value to return.
 
        This helps ensure you always get a standard value.
 
        """
 
        try:
 
            return self[key]
 
        except KeyError:
 
            if default_key is None:
 
                return None
 
            else:
 
                return self[default_key]
 

	
 

	
 
### HOOK SUBCLASSES
 

	
 
class _PostingHook(TransactionHook, metaclass=abc.ABCMeta):
 
    TXN_DATE_RANGE: _GenericRange = _GenericRange(DEFAULT_START_DATE, DEFAULT_STOP_DATE)
 

	
 
    def __init_subclass__(cls) -> None:
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['posting'])
 

	
 
    def _run_on_txn(self, txn: Transaction) -> bool:
 
        return txn.date in self.TXN_DATE_RANGE
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return True
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        if self._run_on_txn(txn):
 
            for post in data.iter_postings(txn):
 
                if self._run_on_post(txn, post):
 
                    yield from self.post_run(txn, post)
 

	
 
    @abc.abstractmethod
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: ...
 

	
 

	
 
class _NormalizePostingMetadataHook(_PostingHook):
 
    """Base class to normalize posting metadata from an enum."""
 
    # This class provides basic functionality to filter postings, normalize
 
    # metadata values, and set default values.
 
    METADATA_KEY: MetaKey
 
    VALUES_ENUM: MetadataEnum
 

	
 
    def __init_subclass__(cls) -> None:
 
        super().__init_subclass__()
 
        cls.METADATA_KEY = cls.VALUES_ENUM.key
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['metadata', cls.METADATA_KEY])
 

	
 
    # If the posting does not specify METADATA_KEY, the hook calls
 
    # _default_value to get a default. This method should either return
 
    # a value string from METADATA_ENUM, or else raise InvalidMetadataError.
 
    # This base implementation does the latter.
 
    def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
 
        raise errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post)
 

	
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        source_value = post.meta.get(self.METADATA_KEY)
 
        set_value = source_value
 
        error: Optional[errormod.Error] = None
 
        if source_value is None:
 
            try:
 
                set_value = self._default_value(txn, post)
 
            except errormod.Error as error_:
 
                error = error_
 
        else:
 
            try:
 
                set_value = self.VALUES_ENUM[source_value]
 
            except KeyError:
 
                error = errormod.InvalidMetadataError(
 
                    txn, self.METADATA_KEY, source_value, post,
 
                )
 
        if error is None:
 
            post.meta[self.METADATA_KEY] = set_value
 
        else:
 
            yield error
 

	
 

	
 
class _RequireLinksPostingMetadataHook(_PostingHook):
 
    """Base class to require that posting metadata include links"""
 
    # This base class confirms that a posting's metadata has one or more links
 
    # under METADATA_KEY.
 
    # Most subclasses only need to define METADATA_KEY and _run_on_post.
 
    METADATA_KEY: str
 
    # under one of the metadata keys listed in CHECKED_METADATA.
 
    # Most subclasses only need to define CHECKED_METADATA and _run_on_post.
 
    CHECKED_METADATA: Sequence[MetaKey]
 

	
 
    def __init_subclass__(cls) -> None:
 
        super().__init_subclass__()
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['metadata', cls.METADATA_KEY])
 

	
 
    def _check_links(self, txn: Transaction, post: data.Posting, key: MetaKey) -> None:
 
        try:
 
            problem = not post.meta.get_links(key)
 
            value = None
 
        except TypeError:
 
            problem = True
 
            value = post.meta[key]
 
        if problem:
 
            raise errormod.InvalidMetadataError(txn, key, value, post)
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(cls.CHECKED_METADATA).union('metadata')
 

	
 
    def _check_metadata(self,
 
                        txn: Transaction,
 
                        post: data.Posting,
 
                        keys: Sequence[MetaKey],
 
    ) -> Iterable[errormod.InvalidMetadataError]:
 
        have_docs = False
 
        for key in keys:
 
            try:
 
                links = post.meta.get_links(key)
 
            except TypeError as error:
 
                yield errormod.InvalidMetadataError(txn, key, post.meta[key], post)
 
            else:
 
                have_docs = have_docs or any(links)
 
        if not have_docs:
 
            yield errormod.InvalidMetadataError(txn, '/'.join(keys), None, post)
 

	
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        try:
 
            self._check_links(txn, post, self.METADATA_KEY)
 
        except errormod.Error as error:
 
            yield error
 
        return self._check_metadata(txn, post, self.CHECKED_METADATA)
conservancy_beancount/plugin/meta_approval.py
Show inline comments
 
"""meta_approval - Validate approval metadata"""
 
# 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 decimal
 

	
 
from . import core
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    Transaction,
 
)
 

	
 
class MetaApproval(core._RequireLinksPostingMetadataHook):
 
    METADATA_KEY = 'approval'
 
    CREDIT_CARD_ACCT = 'Liabilities:CreditCard'
 
    CHECKED_METADATA = ['approval']
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        self.payment_threshold = config.payment_threshold()
 

	
 
    def _run_on_txn(self, txn: Transaction) -> bool:
 
        if not super()._run_on_txn(txn):
 
            return False
 
        debits_sum = decimal.Decimal(0)
 
        for post in data.iter_postings(txn):
 
            # approval is required:
 
            # 1. When a payable is accrued
 
            if (post.account.is_under('Liabilities:Payable:Accounts')
 
                and post.is_debit(self.payment_threshold)):
 
                return True
 
            # 2. When funds leave a cash equivalent asset, UNLESS that
 
            # transaction is a transfer to another asset, or paying off a
 
            # credit card.
 
            # In this case, debits_sum keeps a running tally of how much is
 
            # moving in each direction, and we'll return True if it ends up over
 
            # the payment threshold.
 
            elif post.account.is_cash_equivalent() or post.account.is_credit_card():
 
                debits_sum -= post.units.number or 0
 
        return debits_sum > self.payment_threshold
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        if post.account.is_under('Liabilities:Payable:Accounts'):
 
            threshold_check = post.is_debit(self.payment_threshold)
 
        else:
 
            threshold_check = post.is_payment(0)
 
        return threshold_check is not False
conservancy_beancount/plugin/meta_invoice.py
Show inline comments
 
"""meta_invoice - Validate invoice metadata"""
 
# 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/>.
 

	
 
from . import core
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    MetaValueEnum,
 
    Transaction,
 
)
 

	
 
class MetaInvoice(core._RequireLinksPostingMetadataHook):
 
    METADATA_KEY = 'invoice'
 
    CHECKED_METADATA = ['invoice']
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return post.account.is_under(
 
            'Assets:Receivable',
 
            'Liabilities:Payable',
 
        ) is not None
conservancy_beancount/plugin/meta_receipt.py
Show inline comments
 
"""meta_receipt - Validate receipt metadata"""
 
# 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/>.
 

	
 
from decimal import Decimal
 

	
 
from . import core
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    MetaKey,
 
    Transaction,
 
)
 

	
 
from typing import (
 
    Callable,
 
)
 

	
 
_CheckMethod = Callable[[Transaction, data.Posting, MetaKey], None]
 

	
 
class MetaReceipt(core._RequireLinksPostingMetadataHook):
 
    METADATA_KEY = 'receipt'
 
    CHECKED_METADATA = ['receipt']
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        self.payment_threshold = abs(config.payment_threshold())
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return (
 
            (post.account.is_cash_equivalent() or post.account.is_credit_card())
 
            and post.units.number is not None
 
            and abs(post.units.number) >= self.payment_threshold
 
        )
 

	
 
    def _check_check_id(self, txn: Transaction, post: data.Posting, key: MetaKey) -> None:
 
        value = post.meta.get(key)
 
        if (not isinstance(value, Decimal)
 
            or value < 1
 
            or value % 1):
 
            raise errormod.InvalidMetadataError(txn, key, value, post, Decimal)
 

	
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
    def _run_checking_debit(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        receipt_errors = list(self._check_metadata(txn, post, self.CHECKED_METADATA))
 
        if not receipt_errors:
 
            return
 
        for error in receipt_errors:
 
            if error.value is not None:
 
                yield error
 
        try:
 
            self._check_links(txn, post, self.METADATA_KEY)
 
        except errormod.InvalidMetadataError as error:
 
            receipt_error = error
 
            check_id = post.meta['check-id']
 
        except KeyError:
 
            check_id_ok = False
 
        else:
 
            return
 
            check_id_ok = (isinstance(check_id, Decimal)
 
                           and check_id >= 1
 
                           and not check_id % 1)
 
            if not check_id_ok:
 
                yield errormod.InvalidMetadataError(txn, 'check-id', check_id, post, Decimal)
 
        if not check_id_ok:
 
            yield errormod.InvalidMetadataError(txn, 'receipt/check-id', post=post)
 

	
 
        check_method: _CheckMethod = self._check_links
 
        if post.account.is_checking():
 
            if post.is_debit():
 
                check_method = self._check_check_id
 
                fallback_key = 'check-id'
 
            else:
 
                fallback_key = 'check'
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        keys = list(self.CHECKED_METADATA)
 
        is_checking = post.account.is_checking()
 
        if is_checking and post.is_debit():
 
            return self._run_checking_debit(txn, post)
 
        elif is_checking:
 
            keys.append('check')
 
        elif post.account.is_credit_card() and not post.is_credit():
 
            fallback_key = 'invoice'
 
            keys.append('invoice')
 
        elif post.account.is_under('Assets:PayPal') and not post.is_debit():
 
            fallback_key = 'paypal-id'
 
        else:
 
            yield receipt_error
 
            return
 

	
 
        try:
 
            check_method(txn, post, fallback_key)
 
        except errormod.InvalidMetadataError as fallback_error:
 
            if receipt_error.value is None and fallback_error.value is None:
 
                yield errormod.InvalidMetadataError(
 
                    txn, f"{self.METADATA_KEY} or {fallback_key}", None, post,
 
                )
 
            else:
 
                yield receipt_error
 
                yield fallback_error
 
            keys.append('paypal-id')
 
        return self._check_metadata(txn, post, keys)
conservancy_beancount/plugin/meta_receivable_documentation.py
Show inline comments
 
"""meta_receivable_documentation - Validate receivables have supporting docs"""
 
# 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 re
 

	
 
from . import core
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    MetaKey,
 
    Transaction,
 
)
 

	
 
from typing import (
 
    Dict,
 
    Optional,
 
)
 

	
 
class MetaReceivableDocumentation(core._RequireLinksPostingMetadataHook):
 
    HOOK_GROUPS = frozenset(['network', 'rt'])
 
    SUPPORTING_METADATA = frozenset([
 
        'approval',
 
        'contract',
 
        'purchase-order',
 
    ])
 
    METADATA_KEY = '/'.join(sorted(SUPPORTING_METADATA))
 
    CHECKED_METADATA = ['approval', 'contract', 'purchase-order']
 
    # Conservancy invoice filenames have followed two patterns.
 
    # The pre-RT pattern: `YYYY-MM-DD_Entity_invoice-YYYYMMDDNN??_as-sent.pdf`
 
    # The RT pattern: `ProjectInvoice-30NNNN??.pdf`
 
    # This regexp matches both, with a little slack to try to reduce the false
 
    # negative rate due to minor renames, etc.
 
    ISSUED_INVOICE_RE = re.compile(
 
        r'[Ii]nvoice[-_ ]*(?:2[0-9]{9,}|30[0-9]+)[A-Za-z]*[-_ .]',
 
    )
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        rt_wrapper = config.rt_wrapper()
 
        # In principle, we could still check for non-RT invoices and enforce
 
        # checks on them without an RT wrapper. In practice, that would
 
        # provide so little utility today it's not worth bothering with.
 
        if rt_wrapper is None:
 
            raise errormod.ConfigurationError("can't log in to RT")
 
        self.rt = rt_wrapper
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        if not post.account.is_under('Assets:Receivable'):
 
            return False
 
        elif post.is_debit():
 
            return False
 

	
 
        # Get the first invoice, or return False if it doesn't exist.
 
        try:
 
            invoice_link = post.meta.get_links('invoice')[0]
 
        except (IndexError, TypeError):
 
            return False
 

	
 
        # Get the filename, following an RT link if necessary.
 
        rt_args = self.rt.parse(invoice_link)
 
        if rt_args is not None:
 
            ticket_id, attachment_id = rt_args
 
            invoice_link = self.rt.url(ticket_id, attachment_id) or invoice_link
 
        return self.ISSUED_INVOICE_RE.search(invoice_link) is not None
 

	
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        errors: Dict[MetaKey, Optional[errormod.InvalidMetadataError]] = {
 
            key: None for key in self.SUPPORTING_METADATA
 
        }
 
        have_support = False
 
        for key in errors:
 
            try:
 
                self._check_links(txn, post, key)
 
            except errormod.InvalidMetadataError as key_error:
 
                errors[key] = key_error
 
            else:
 
                have_support = True
 
        for key, error in errors.items():
 
            if error is not None and error.value is not None:
 
                yield error
 
        if not have_support:
 
            yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post)
tests/test_meta_invoice.py
Show inline comments
 
"""Test validation of invoice metadata"""
 
# 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 pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount.plugin import meta_invoice
 

	
 
REQUIRED_ACCOUNTS = {
 
    'Assets:Receivable:Accounts',
 
    'Assets:Receivable:Loans',
 
    'Liabilities:Payable:Accounts',
 
    'Liabilities:Payable:Vacation',
 
}
 

	
 
NON_REQUIRED_ACCOUNTS = {
 
    'Assets:Cash',
 
    'Equity:OpeningBalance',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'Liabilities:CreditCard',
 
}
 

	
 
TEST_KEY = 'invoice'
 

	
 
MISSING_MSG = f'{{}} missing {TEST_KEY}'.format
 
WRONG_TYPE_MSG = f'{{}} has wrong type of {TEST_KEY}: expected str but is a {{}}'.format
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig()
 
    return meta_invoice.MetaInvoice(config)
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_values_on_postings(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25, {TEST_KEY: value}),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
 
def test_invalid_values_on_postings(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25, {TEST_KEY: value}),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_values_on_postings(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25, {TEST_KEY: value}),
 
    ])
 
    expected_msg = "{} has wrong type of {}: expected str but is a {}".format(
 
        acct1,
 
        TEST_KEY,
 
        type(value).__name__,
 
    )
 
    expected = {
 
        MISSING_MSG(acct1),
 
        WRONG_TYPE_MSG(acct1, type(value).__name__),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {expected_msg}
 
    assert actual == expected
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
 
def test_invalid_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    expected_msg = "{} has wrong type of {}: expected str but is a {}".format(
 
        acct1,
 
        TEST_KEY,
 
        type(value).__name__,
 
    )
 
    expected = {
 
        MISSING_MSG(acct1),
 
        WRONG_TYPE_MSG(acct1, type(value).__name__),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {expected_msg}
 
    assert actual == expected
 

	
 
@pytest.mark.parametrize('acct1,acct2', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
))
 
def test_missing_invoice(hook, acct1, acct2):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
tests/test_meta_receipt.py
Show inline comments
 
"""Test validation of receipt metadata"""
 
# 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 decimal
 
import enum
 
import itertools
 
import random
 
import typing
 

	
 
import pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount.plugin import meta_receipt
 

	
 
TEST_KEY = 'receipt'
 

	
 
class PostType(enum.IntFlag):
 
    NONE = 0
 
    CREDIT = 1
 
    DEBIT = 2
 
    BOTH = CREDIT | DEBIT
 

	
 

	
 
class AccountForTesting(typing.NamedTuple):
 
    name: str
 
    required_types: PostType
 
    fallback_meta: typing.Optional[str]
 

	
 
    def missing_message(self, include_fallback=True):
 
        if self.fallback_meta is None or not include_fallback:
 
            rest = ""
 
        else:
 
            rest = f" or {self.fallback_meta}"
 
            rest = f"/{self.fallback_meta}"
 
        return f"{self.name} missing {TEST_KEY}{rest}"
 

	
 
    def wrong_type_message(self, wrong_value, key=TEST_KEY):
 
        expect_type = 'Decimal' if key == 'check-id' else 'str'
 
        return  "{} has wrong type of {}: expected {} but is a {}".format(
 
            self.name,
 
            key,
 
            expect_type,
 
            type(wrong_value).__name__,
 
        )
 

	
 

	
 
ACCOUNTS = [AccountForTesting._make(t) for t in [
 
    ('Assets:Bank:CheckCard', PostType.CREDIT, 'check'),
 
    ('Assets:Bank:CheckCard', PostType.DEBIT, 'check-id'),
 
    ('Assets:Cash', PostType.BOTH, None),
 
    ('Assets:Checking', PostType.CREDIT, 'check'),
 
    ('Assets:Checking', PostType.DEBIT, 'check-id'),
 
    ('Assets:PayPal', PostType.CREDIT, 'paypal-id'),
 
    ('Assets:PayPal', PostType.DEBIT, None),
 
    ('Assets:Savings', PostType.BOTH, None),
 
    ('Liabilities:CreditCard', PostType.CREDIT, None),
 
    ('Liabilities:CreditCard', PostType.DEBIT, 'invoice'),
 
]]
 

	
 
ACCOUNTS_WITH_LINK_FALLBACK = [acct for acct in ACCOUNTS
 
                               if acct.fallback_meta and acct.fallback_meta != 'check-id']
 
ACCOUNTS_WITH_CHECK_ID_FALLBACK = [acct for acct in ACCOUNTS
 
                                   if acct.fallback_meta == 'check-id']
 
ACCOUNTS_WITHOUT_FALLBACKS = [acct for acct in ACCOUNTS if not acct.fallback_meta]
 
KNOWN_FALLBACKS = {acct.fallback_meta for acct in ACCOUNTS if acct.fallback_meta}
 

	
 
# These are mostly fill-in values.
 
# We don't need to run every test on every value for these, just enough to
 
# convince ourselves the hook never reports errors against these accounts.
 
# Making this a iterator rather than a sequence means testutil.combine_values
 
# doesn't require the decorated test to go over every value, which in turn
 
# trims unnecessary test time.
 
NOT_REQUIRED_ACCOUNTS = itertools.cycle([
 
    'Assets:Prepaid:Expenses',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'Liabilities:Payable:Accounts',
 
    'Liabilities:UnearnedIncome:Donations',
 
])
 

	
 
CHECK_IDS = (decimal.Decimal(n) for n in itertools.count(1))
 
def BAD_CHECK_IDS():
 
    # Valid check-id values are positive integers
 
    yield decimal.Decimal(0)
 
    yield -next(CHECK_IDS)
 
    yield next(CHECK_IDS) * decimal.Decimal('1.1')
 
BAD_CHECK_IDS = BAD_CHECK_IDS()
 

	
 
def check(hook, test_acct, other_acct, expected, *,
 
          txn_meta={}, post_meta={}, check_type=PostType.BOTH, min_amt=0):
 
    check_type &= test_acct.required_types
 
    assert check_type, "tried to test a non-applicable account"
 
    if check_type == PostType.BOTH:
 
        check(hook, test_acct, other_acct, expected,
 
              txn_meta=txn_meta, post_meta=post_meta, check_type=PostType.CREDIT)
 
        check_type = PostType.DEBIT
 
    amount = decimal.Decimal('{:.02f}'.format(min_amt + random.random() * 100))
 
    if check_type == PostType.DEBIT:
 
        amount = -amount
 
    txn = testutil.Transaction(**txn_meta, postings=[
 
        (test_acct.name, amount, post_meta),
 
        (other_acct, -amount),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    if expected is None:
 
        assert not actual
 
    elif isinstance(expected, str):
 
        assert expected in actual
 
    else:
 
        assert actual == expected
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig()
 
    return meta_receipt.MetaReceipt(config)
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_receipt_on_post(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, None, post_meta={TEST_KEY: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
 
def test_invalid_receipt_on_post(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, {test_acct.missing_message()},
 
          post_meta={TEST_KEY: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_receipt_on_post(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, test_acct.wrong_type_message(value),
 
          post_meta={TEST_KEY: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_receipt_on_txn(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, None, txn_meta={TEST_KEY: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
 
def test_invalid_receipt_on_txn(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, {test_acct.missing_message()},
 
          txn_meta={TEST_KEY: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_receipt_on_txn(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, test_acct.wrong_type_message(value),
 
          txn_meta={TEST_KEY: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_fallback_on_post(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, None,
 
          post_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
 
def test_invalid_fallback_on_post(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, {test_acct.missing_message()},
 
          post_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_fallback_on_post(hook, test_acct, other_acct, value):
 
    expected = {
 
        test_acct.missing_message(False),
 
        test_acct.missing_message(),
 
        test_acct.wrong_type_message(value, test_acct.fallback_meta),
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          post_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_fallback_on_txn(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, None,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
 
def test_invalid_fallback_on_txn(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, {test_acct.missing_message()},
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_fallback_on_txn(hook, test_acct, other_acct, value):
 
    expected = {
 
        test_acct.missing_message(False),
 
        test_acct.missing_message(),
 
        test_acct.wrong_type_message(value, test_acct.fallback_meta),
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    CHECK_IDS,
 
))
 
def test_valid_check_id_on_post(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, None,
 
          post_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    BAD_CHECK_IDS,
 
))
 
def test_invalid_check_id_on_post(hook, test_acct, other_acct, value):
 
    expected = {
 
        test_acct.missing_message(False),
 
        test_acct.missing_message(),
 
        f"{test_acct.name} has invalid {test_acct.fallback_meta}: {value}",
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          post_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_check_id_on_post(hook, test_acct, other_acct, value):
 
    if isinstance(value, decimal.Decimal):
 
        value = ''
 
    expected = {
 
        test_acct.missing_message(False),
 
        test_acct.missing_message(),
 
        test_acct.wrong_type_message(value, test_acct.fallback_meta),
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          post_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    CHECK_IDS,
 
))
 
def test_valid_check_id_on_txn(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, None,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    BAD_CHECK_IDS,
 
))
 
def test_invalid_check_id_on_txn(hook, test_acct, other_acct, value):
 
    expected = {
 
        test_acct.missing_message(False),
 
        test_acct.missing_message(),
 
        f"{test_acct.name} has invalid {test_acct.fallback_meta}: {value}",
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_check_id_on_txn(hook, test_acct, other_acct, value):
 
    if isinstance(value, decimal.Decimal):
 
        value = ''
 
    expected = {
 
        test_acct.missing_message(False),
 
        test_acct.missing_message(),
 
        test_acct.wrong_type_message(value, test_acct.fallback_meta),
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,key,value', testutil.combine_values(
 
    ACCOUNTS_WITHOUT_FALLBACKS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    KNOWN_FALLBACKS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_fallback_not_accepted_on_other_accounts(hook, test_acct, other_acct, key, value):
 
    check(hook, test_acct, other_acct, {test_acct.missing_message()},
 
          post_meta={key: value})
 

	
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_fallback_on_zero_amount_postings(hook, test_acct, other_acct, value):
 
    # Unfortunately it does happen that we get donations that go 100% to
 
    # banking fees, and our importer writes a zero-amount posting to the
 
    # Assets account.
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', '-.1'),
 
        ('Expenses:BankingFees', '.1'),
 
        (test_acct.name, 0, {test_acct.fallback_meta: value}),
 
    ])
 
    assert not list(hook.run(txn))
0 comments (0 inline, 0 general)