Changeset - c712105bed3c
[Not reviewed]
0 18 0
Brett Smith - 4 years ago 2020-04-03 14:34:10
brettcsmith@brettcsmith.org
Revise chart of accounts used throughout.

The main impetus of this change is to rename accounts that were outside
Beancount's accepted five root accounts, to move them into that
structure. This includes:

Accrued:*Payable: → Liabilities:Payable:*
Accrued:*Receivable: → Assets:Receivable:*
UneanedIncome:* → Liabilities:UnearnedIncome:*

Note the last change did inspire in a change to our validation rules. We no
longer require income-type on unearned income, because it's no longer
considered income at all. Once it's earned and converted to an Income
account, that has an income-type of course.

This did inspire another rename that was not required, but
provided more consistency with the other account names above:

Assets:Prepaid* → Assets:Prepaid:*

Where applicable, I have generally extended tests to make sure one of each
of the five account types is tested. (This mostly meant adding an Equity
account to the tests.) I also added tests for key parts of the hierarchy,
like Assets:Receivable and Liabilities:Payable, where applicable.

As part of this change, Account.is_real_asset() got renamed to
Account.is_cash_equivalent(), to better self-document its purpose.
18 files changed with 155 insertions and 114 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/data.py
Show inline comments
 
"""Enhanced Beancount data structures for Conservancy
 

	
 
The classes in this module are interface-compatible with Beancount's core data
 
structures, and provide additional business logic that we want to use
 
throughout Conservancy tools.
 
"""
 
# Copyright © 2020  Brett Smith
 
#
 
# This program is free software: you can redistribute it and/or modify
 
# it under the terms of the GNU Affero General Public License as published by
 
# the Free Software Foundation, either version 3 of the License, or
 
# (at your option) any later version.
 
#
 
# This program is distributed in the hope that it will be useful,
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
# GNU Affero General Public License for more details.
 
#
 
# You should have received a copy of the GNU Affero General Public License
 
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
 

	
 
import collections
 
import decimal
 
import operator
 

	
 
from beancount.core import account as bc_account
 

	
 
from typing import (
 
    cast,
 
    Callable,
 
    Iterable,
 
    Iterator,
 
    MutableMapping,
 
    Optional,
 
    Sequence,
 
    Union,
 
)
 

	
 
from .beancount_types import (
 
    MetaKey,
 
    MetaValue,
 
    Posting as BasePosting,
 
    Transaction,
 
)
 

	
 
DecimalCompat = Union[decimal.Decimal, int]
 

	
 
LINK_METADATA = frozenset([
 
    'approval',
 
    'check',
 
    'contract',
 
    'invoice',
 
    'purchase-order',
 
    'receipt',
 
    'statement',
 
])
 

	
 
class Account(str):
 
    """Account name string
 

	
 
    This is a string that names an account, like Accrued:AccountsPayable
 
    This is a string that names an account, like Assets:Bank:Checking
 
    or Income:Donations. This class provides additional methods for common
 
    account name parsing and queries.
 
    """
 
    __slots__ = ()
 

	
 
    SEP = bc_account.sep
 

	
 
    def is_checking(self) -> bool:
 
        return self.is_real_asset() and ':Check' in self
 
    def is_cash_equivalent(self) -> bool:
 
        return (
 
            self.is_under('Assets:') is not None
 
            and self.is_under('Assets:Prepaid', 'Assets:Receivable') is None
 
        )
 

	
 
    def is_income(self) -> bool:
 
        return self.is_under('Income:', 'UnearnedIncome:') is not None
 
    def is_checking(self) -> bool:
 
        return self.is_cash_equivalent() and ':Check' in self
 

	
 
    def is_real_asset(self) -> bool:
 
        return bool(
 
            self.is_under('Assets:')
 
            and not self.is_under('Assets:PrepaidExpenses', 'Assets:PrepaidVacation')
 
        )
 
    def is_credit_card(self) -> bool:
 
        return self.is_under('Liabilities:CreditCard') is not None
 

	
 
    def is_under(self, *acct_seq: str) -> Optional[str]:
 
        """Return a match if this account is "under" a part of the hierarchy
 

	
 
        Pass in any number of account name strings as arguments. If this
 
        account is under one of those strings in the account hierarchy, the
 
        first matching string will be returned. Otherwise, None is returned.
 

	
 
        You can use the return value of this method as a boolean if you don't
 
        care which account string is matched.
 

	
 
        An account is considered to be under itself:
 

	
 
          Account('Expenses:Tax').is_under('Expenses:Tax') # returns 'Expenses:Tax'
 

	
 
        To do a "strictly under" search, end your search strings with colons:
 

	
 
          Account('Expenses:Tax').is_under('Expenses:Tax:') # returns None
 
          Account('Expenses:Tax').is_under('Expenses:') # returns 'Expenses:'
 

	
 
        This method does check that all the account boundaries match:
 

	
 
          Account('Expenses:Tax').is_under('Exp') # returns None
 
        """
 
        for prefix in acct_seq:
 
            if self.startswith(prefix) and (
 
                prefix.endswith(self.SEP)
 
                or self == prefix
 
                or self[len(prefix)] == self.SEP
 
            ):
 
                return prefix
 
        return None
 

	
 

	
 
class Metadata(MutableMapping[MetaKey, MetaValue]):
 
    """Transaction or posting metadata
 

	
 
    This class wraps a Beancount metadata dictionary with additional methods
 
    for common parsing and query tasks.
 
    """
 
    __slots__ = ('meta',)
 

	
 
    def __init__(self, source: MutableMapping[MetaKey, MetaValue]) -> None:
 
        self.meta = source
 

	
 
    def __iter__(self) -> Iterator[MetaKey]:
 
        return iter(self.meta)
 

	
 
    def __len__(self) -> int:
 
        return len(self.meta)
 

	
 
    def __getitem__(self, key: MetaKey) -> MetaValue:
 
        return self.meta[key]
 

	
 
    def __setitem__(self, key: MetaKey, value: MetaValue) -> None:
 
        self.meta[key] = value
 

	
 
    def __delitem__(self, key: MetaKey) -> None:
 
        del self.meta[key]
 

	
 
    def get_links(self, key: MetaKey) -> Sequence[str]:
 
        try:
 
            value = self.meta[key]
 
        except KeyError:
 
            return ()
 
        if isinstance(value, str):
 
            return value.split()
 
        else:
 
            raise TypeError("{} metadata is a {}, not str".format(
 
                key, type(value).__name__,
 
            ))
 

	
 

	
 
class PostingMeta(Metadata):
 
    """Combined access to posting metadata with its parent transaction metadata
 

	
 
    This lets you access posting metadata through a single dict-like object.
 
    If you try to look up metadata that doesn't exist on the posting, it will
 
    look for the value in the parent transaction metadata instead.
 

	
 
    You can set and delete metadata as well. Changes only affect the metadata
 
    of the posting, never the transaction. Changes are propagated to the
 
    underlying Beancount data structures.
 

	
 
    Functionally, you can think of this as identical to:
 

	
 
      collections.ChainMap(post.meta, txn.meta)
 

	
 
    Under the hood, this class does a little extra work to avoid creating
 
    posting metadata if it doesn't have to.
 
    """
 
    __slots__ = ('txn', 'index', 'post')
 

	
 
    def __init__(self,
 
                 txn: Transaction,
 
                 index: int,
 
                 post: Optional[BasePosting]=None,
 
    ) -> None:
 
        if post is None:
 
            post = txn.postings[index]
 
        self.txn = txn
 
        self.index = index
 
        self.post = post
 
        if post.meta is None:
 
            self.meta = self.txn.meta
 
        else:
 
            self.meta = collections.ChainMap(post.meta, txn.meta)
 

	
 
    def __setitem__(self, key: MetaKey, value: MetaValue) -> None:
 
        if self.post.meta is None:
 
            self.post = self.post._replace(meta={key: value})
 
            self.txn.postings[self.index] = self.post
 
            # mypy complains that self.post.meta could be None, but we know
 
            # from two lines up that it's not.
 
            self.meta = collections.ChainMap(self.post.meta, self.txn.meta)  # type:ignore[arg-type]
 
        else:
 
            super().__setitem__(key, value)
 

	
 
    def __delitem__(self, key: MetaKey) -> None:
 
        if self.post.meta is None:
 
            raise KeyError(key)
 
        else:
 
            super().__delitem__(key)
 

	
 

	
 
class Posting(BasePosting):
 
    """Enhanced Posting objects
 

	
 
    This class is a subclass of Beancount's native Posting class where
 
    specific fields are replaced with enhanced versions:
 

	
 
    * The `account` field is an Account object
 
    * The `meta` field is a PostingMeta object
 
    """
 
    __slots__ = ()
 

	
 
    account: Account
 
    # mypy correctly complains that our MutableMapping is not compatible
 
    # with Beancount's meta type declaration of Optional[Dict]. IMO
 
    # Beancount's type declaration is a smidge too specific: I think its type
 
    # declaration should also use MutableMapping, because it would be very
 
    # unusual for code to specifically require a Dict over that.
 
    # If it did, this declaration would pass without issue.
 
    meta: Metadata  # type:ignore[assignment]
 

	
 
    def _compare_amount(self,
 
                        op: Callable[[decimal.Decimal], decimal.Decimal],
 
                        threshold: DecimalCompat,
 
                        default: Optional[bool],
 
    ) -> Optional[bool]:
 
        if self.units.number is None:
 
            return default
 
        else:
 
            return op(self.units.number) > threshold
 

	
 
    def is_credit(self,
 
                  threshold: DecimalCompat=0,
 
                  default: Optional[bool]=None,
 
    ) -> Optional[bool]:
 
        return self._compare_amount(operator.pos, threshold, default)
 

	
 
    def is_debit(self,
 
                  threshold: DecimalCompat=0,
 
                  default: Optional[bool]=None,
 
    ) -> Optional[bool]:
 
        return self._compare_amount(operator.neg, threshold, default)
 

	
 
    def is_payment(self,
 
                  threshold: DecimalCompat=0,
 
                  default: Optional[bool]=None,
 
    ) -> Optional[bool]:
 
        return self.account.is_real_asset() and self.is_debit(threshold, default)
 
        return self.account.is_cash_equivalent() and self.is_debit(threshold, default)
 

	
 

	
 
def iter_postings(txn: Transaction) -> Iterator[Posting]:
 
    """Yield an enhanced Posting object for every posting in the transaction"""
 
    for index, source in enumerate(txn.postings):
 
        yield Posting(
 
            Account(source.account),
 
            *source[1:5],
 
            # see rationale above about Posting.meta
 
            PostingMeta(txn, index, source), # type:ignore[arg-type]
 
        )
conservancy_beancount/plugin/meta_entity.py
Show inline comments
 
"""meta_entity - Validate entity 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/>.
 

	
 
# Type stubs aren't available for regex.
 
# Fortunately, we're using it in a way that's API-compatible with the re
 
# module. We mitigate the lack of type stubs by providing type declarations
 
# for returned objects. This way, the only thing that isn't type checked are
 
# the calls to regex functions.
 
import regex  # type:ignore[import]
 

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

	
 
from typing import (
 
    Pattern,
 
)
 

	
 
class MetaEntity(core.TransactionHook):
 
    METADATA_KEY = 'entity'
 
    HOOK_GROUPS = frozenset(['posting', 'metadata', METADATA_KEY])
 

	
 
    # alnum is the set of characters we always accept in entity metadata:
 
    # letters and digits, minus the Latin 1 supplement (i.e., Roman letters
 
    # with diacritics: áÁàÀâÂåÅäÄãà çÇ ðÐ ñÑ øØ ß etc.)
 
    # See the tests for specific cases.
 
    alnum = r'\p{Letter}\p{Digit}--\p{Block=Latin_1_Supplement}'
 
    # A regexp that would be reasonably stricter would be:
 
    #   f'^[{alnum}][.{alnum}]*(?:-[.{alnum}])*$'
 
    # However, current producers fail that regexp in a few different ways.
 
    # See the tests for specific cases.
 
    ENTITY_RE: Pattern[str] = regex.compile(f'^[{alnum}][-.{alnum}]*$', regex.VERSION1)
 
    del alnum
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        txn_entity = txn.meta.get(self.METADATA_KEY)
 
        if txn_entity is None:
 
            txn_entity_ok = None
 
        elif isinstance(txn_entity, str):
 
            txn_entity_ok = bool(self.ENTITY_RE.match(txn_entity))
 
        else:
 
            txn_entity_ok = False
 
        if txn_entity_ok is False:
 
            yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, txn_entity)
 
        for post in data.iter_postings(txn):
 
            if post.account.is_under('Assets', 'Equity', 'Liabilities'):
 
            if not post.account.is_under(
 
                    'Assets:Receivable',
 
                    'Expenses',
 
                    'Income',
 
                    'Liabilities:Payable',
 
            ):
 
                continue
 
            entity = post.meta.get(self.METADATA_KEY)
 
            if entity is None:
 
                yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, entity, post)
 
            elif entity is txn_entity:
 
                pass
 
            elif not self.ENTITY_RE.match(entity):
 
                yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, entity, post)
conservancy_beancount/plugin/meta_income_type.py
Show inline comments
 
"""meta_income_type - Validate income-type 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 MetaIncomeType(core._NormalizePostingMetadataHook):
 
    VALUES_ENUM = core.MetadataEnum('income-type', {
 
        'Donations',
 
        'Payable-Derecognition',
 
        'RBI',
 
        'UBTI',
 
    })
 
    DEFAULT_VALUES = {
 
        'Income:Conferences:Registrations': 'RBI',
 
        'Income:Conferences:Sponsorship': 'RBI',
 
        'Income:Donations': 'Donations',
 
        'Income:Honoraria': 'RBI',
 
        'Income:Interest': 'RBI',
 
        'Income:Interest:Dividend': 'RBI',
 
        'Income:Royalties': 'RBI',
 
        'Income:Sales': 'RBI',
 
        'Income:SoftwareDevelopment': 'RBI',
 
        'Income:TrademarkLicensing': 'RBI',
 
        'UnearnedIncome:Conferences:Registrations': 'RBI',
 
        'UnearnedIncome:MatchPledges': 'Donations',
 
    }
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return post.account.is_income()
 
        return post.account.is_under('Income') is not None
 

	
 
    def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
 
        try:
 
            return self.DEFAULT_VALUES[post.account]
 
        except KeyError:
 
            raise errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post) from None
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'
 

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

	
 
import yaml
 
import yaml.error
 

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

	
 
from typing import (
 
    Any,
 
    Dict,
 
    NoReturn,
 
    Optional,
 
    Set,
 
)
 

	
 
class MetaProject(core._NormalizePostingMetadataHook):
 
    DEFAULT_PROJECT = 'Conservancy'
 
    PROJECT_DATA_PATH = Path('Projects', 'project-data.yml')
 
    VALUES_ENUM = core.MetadataEnum('project', {DEFAULT_PROJECT})
 

	
 
    def __init__(self, config: configmod.Config, source_path: Path=PROJECT_DATA_PATH) -> None:
 
        repo_path = config.repository_path()
 
        if repo_path is None:
 
            self._config_error("no repository configured")
 
        project_data_path = repo_path / source_path
 
        source = {'filename': str(project_data_path)}
 
        try:
 
            with project_data_path.open() as yaml_file:
 
                project_data: Dict[str, Dict[str, Any]] = yaml.safe_load(yaml_file)
 
            names: Set[MetaValueEnum] = {self.DEFAULT_PROJECT}
 
            aliases: Dict[MetaValueEnum, MetaValueEnum] = {}
 
            for key, params in project_data.items():
 
                name = params.get('accountName', key)
 
                names.add(name)
 
                human_name = params.get('humanName', name)
 
                if name != human_name:
 
                    aliases[human_name] = name
 
                if name != key:
 
                    aliases[key] = name
 
        except AttributeError:
 
            self._config_error("loaded YAML data not in project-data format", project_data_path)
 
        except OSError as error:
 
            self._config_error(error.strerror, project_data_path)
 
        except yaml.error.YAMLError as error:
 
            self._config_error(error.args[0] or "YAML load error", project_data_path)
 
        else:
 
            self.VALUES_ENUM = core.MetadataEnum(self.METADATA_KEY, names, aliases)
 

	
 
    def _config_error(self, msg: str, filename: Optional[Path]=None) -> NoReturn:
 
        source = {}
 
        if filename is not None:
 
            source['filename'] = str(filename)
 
        raise errormod.ConfigurationError(
 
            "cannot load project data: " + msg,
 
            source=source,
 
        )
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return post.account.is_under('Assets', 'Equity', 'Liabilities') is None
 
        if post.account.is_under('Liabilities'):
 
            return not post.account.is_credit_card()
 
        else:
 
            return post.account.is_under(
 
                'Assets:Receivable',
 
                'Expenses',
 
                'Income',
 
            ) is not None
 

	
 
    def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
 
        if post.account.is_under(
 
                'Accrued:VacationPayable',
 
                'Expenses:Payroll',
 
                'Liabilities:Payable:Vacation',
 
        ):
 
            return self.DEFAULT_PROJECT
 
        else:
 
            raise errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post)
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 . import core
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    Transaction,
 
)
 

	
 
class MetaReceipt(core._RequireLinksPostingMetadataHook):
 
    METADATA_KEY = '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 bool(
 
            (post.account.is_real_asset() or post.account.is_under('Liabilities'))
 
        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 post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        try:
 
            self._check_links(txn, post, self.METADATA_KEY)
 
        except errormod.InvalidMetadataError as error:
 
            receipt_error = error
 
        else:
 
            return
 

	
 
        if not post.units.number:
 
            post_amount = 0
 
        elif post.units.number > 0:
 
            post_amount = 1
 
        else:
 
            post_amount = -1
 

	
 
        if post.account.is_checking():
 
            fallback_key = 'check'
 
        elif post.account.is_under('Liabilities:CreditCard') and post_amount == -1:
 
        elif post.account.is_credit_card() and post_amount == -1:
 
            fallback_key = 'invoice'
 
        elif post.account.is_under('Assets:PayPal') and post_amount == 1:
 
            fallback_key = 'paypal-id'
 
        else:
 
            yield receipt_error
 
            return
 

	
 
        try:
 
            self._check_links(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
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))
 
    # 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('Accrued:AccountsReceivable'):
 
        if not post.account.is_under('Assets:Receivable'):
 
            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_data_account.py
Show inline comments
 
"""Test Account class"""
 
# 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 conservancy_beancount import data
 

	
 
@pytest.mark.parametrize('acct_name,under_arg,expected', [
 
    ('Expenses:Tax:Sales', 'Expenses:Tax:Sales:', False),
 
    ('Expenses:Tax:Sales', 'Expenses:Tax:Sales', True),
 
    ('Expenses:Tax:Sales', 'Expenses:Tax:', True),
 
    ('Expenses:Tax:Sales', 'Expenses:Tax', True),
 
    ('Expenses:Tax:Sales', 'Expenses:', True),
 
    ('Expenses:Tax:Sales', 'Expenses', True),
 
    ('Expenses:Tax:Sales', 'Expense', False),
 
    ('Expenses:Tax:Sales', 'Accrued:', False),
 
    ('Expenses:Tax:Sales', 'Accrued', False),
 
    ('Expenses:Tax:Sales', 'Equity:', False),
 
    ('Expenses:Tax:Sales', 'Equity', False),
 
])
 
def test_is_under_one_arg(acct_name, under_arg, expected):
 
    expected = under_arg if expected else None
 
    assert data.Account(acct_name).is_under(under_arg) == expected
 

	
 
@pytest.mark.parametrize('acct_name,expected', [
 
    ('Income:Other', 'Income'),
 
    ('UnearnedIncome:Other', 'UnearnedIncome'),
 
    ('Accrued:AccountsPayable', None),
 
    ('Expenses:General', None),
 
    ('Assets:Cash', None),
 
    ('Assets:Checking', None),
 
    ('Assets:Prepaid:Expenses', 'Assets:Prepaid'),
 
    ('Assets:Receivable:Accounts', 'Assets:Receivable'),
 
])
 
def test_is_under_multi_arg(acct_name, expected):
 
    assert data.Account(acct_name).is_under('Income', 'UnearnedIncome') == expected
 
    assert expected == data.Account(acct_name).is_under(
 
        'Assets:Prepaid', 'Assets:Receivable',
 
    )
 
    if expected:
 
        expected += ':'
 
    assert data.Account(acct_name).is_under('Income:', 'UnearnedIncome:') == expected
 
    assert expected == data.Account(acct_name).is_under(
 
        'Assets:Prepaid:', 'Assets:Receivable:',
 
    )
 

	
 
@pytest.mark.parametrize('acct_name,expected', [
 
    ('Accrued:AccountsReceivable', False),
 
    ('Assets:Cash', False),
 
    ('Expenses:General', False),
 
    ('Income:Donations', True),
 
    ('Income:Sales', True),
 
    ('Income:Other', True),
 
    ('Liabilities:CreditCard', False),
 
    ('UnearnedIncome:MatchPledges', True),
 
])
 
def test_is_income(acct_name, expected):
 
    assert data.Account(acct_name).is_income() == expected
 

	
 
@pytest.mark.parametrize('acct_name,expected', [
 
    ('Accrued:AccountsPayable', False),
 
    ('Accrued:AccountsReceivable', False),
 
    ('Assets:Bank:Checking', True),
 
    ('Assets:Cash', True),
 
    ('Assets:Cash:EUR', True),
 
    ('Assets:PrepaidExpenses', False),
 
    ('Assets:PrepaidVacation', False),
 
    ('Assets:Bank:Checking', True),
 
    ('Expenses:General', False),
 
    ('Income:Donations', False),
 
    ('Assets:Prepaid:Expenses', False),
 
    ('Assets:Prepaid:Vacation', False),
 
    ('Assets:Receivable:Accounts', False),
 
    ('Assets:Receivable:Fraud', False),
 
    ('Expenses:Other', False),
 
    ('Equity:OpeningBalance', False),
 
    ('Income:Other', False),
 
    ('Liabilities:CreditCard', False),
 
])
 
def test_is_real_asset(acct_name, expected):
 
    assert data.Account(acct_name).is_real_asset() == expected
 
def test_is_cash_equivalent(acct_name, expected):
 
    assert data.Account(acct_name).is_cash_equivalent() == expected
 

	
 
@pytest.mark.parametrize('acct_name,expected', [
 
    ('Accrued:AccountsPayable', False),
 
    ('Accrued:AccountsReceivable', False),
 
    ('Assets:Bank:Check9999', True),
 
    ('Assets:Bank:CheckCard', True),
 
    ('Assets:Bank:Checking', True),
 
    ('Assets:Bank:Savings', False),
 
    ('Assets:Cash', False),
 
    ('Assets:Check9999', True),
 
    ('Assets:CheckCard', True),
 
    ('Assets:Checking', True),
 
    ('Assets:PrepaidExpenses', False),
 
    ('Assets:Savings', False),
 
    ('Expenses:CheckingFees', False),
 
    ('Income:Interest:Checking', False),
 
    ('Assets:Prepaid:Expenses', False),
 
    ('Assets:Receivable:Accounts', False),
 
    ('Expenses:Other', False),
 
    ('Equity:OpeningBalance', False),
 
    ('Income:Other', False),
 
    ('Liabilities:CreditCard', False),
 
])
 
def test_is_checking(acct_name, expected):
 
    assert data.Account(acct_name).is_checking() == expected
 

	
 
@pytest.mark.parametrize('acct_name,expected', [
 
    ('Assets:Cash', False),
 
    ('Assets:Prepaid:Expenses', False),
 
    ('Assets:Receivable:Accounts', False),
 
    ('Expenses:Other', False),
 
    ('Equity:OpeningBalance', False),
 
    ('Income:Other', False),
 
    ('Liabilities:CreditCard', True),
 
    ('Liabilities:CreditCard:Visa', True),
 
    ('Liabilities:Payable:Accounts', False),
 
    ('Liabilities:UnearnedIncome:Donations', False),
 
])
 
def test_is_credit_card(acct_name, expected):
 
    assert data.Account(acct_name).is_credit_card() == expected
tests/test_data_posting.py
Show inline comments
 
"""Test Posting class"""
 
# 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 decimal import Decimal
 

	
 
import beancount.core.amount as bc_amount
 

	
 
from conservancy_beancount import data
 

	
 
PAYMENT_ACCOUNTS = {
 
    'Assets:Cash',
 
    'Assets:Checking',
 
    'Assets:Bank:Checking',
 
}
 

	
 
NON_PAYMENT_ACCOUNTS = {
 
    'Accrued:AccountsReceivable',
 
    'Assets:PrepaidExpenses',
 
    'Assets:PrepaidVacation',
 
    'Assets:Prepaid:Expenses',
 
    'Assets:Prepaid:Vacation',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'Liabilities:CreditCard',
 
    'UnearnedIncome:MatchPledges',
 
}
 

	
 
AMOUNTS = [
 
    None,
 
    '-25.50',
 
    0,
 
    '25.75',
 
]
 

	
 
def Posting(account, number,
 
            currency='USD', cost=None, price=None, flag=None,
 
            **meta):
 
    if not meta:
 
        meta = None
 
    if number is not None:
 
        number = Decimal(number)
 
    return data.Posting(
 
        data.Account(account),
 
        bc_amount.Amount(number, currency),
 
        cost,
 
        price,
 
        flag,
 
        meta,
 
    )
 

	
 
def check_all_thresholds(expected, method, threshold, *args):
 
    assert method(threshold, *args) is expected
 
    assert method(Decimal(threshold), *args) is expected
 

	
 
@pytest.mark.parametrize('amount', AMOUNTS)
 
def test_is_credit(amount):
 
    expected = None if amount is None else float(amount) > 0
 
    assert Posting('Assets:Cash', amount).is_credit() is expected
 

	
 
def test_is_credit_threshold():
 
    post = Posting('Assets:Cash', 25)
 
    check_all_thresholds(True, post.is_credit, 0)
 
    check_all_thresholds(True, post.is_credit, 20)
 
    check_all_thresholds(False, post.is_credit, 40)
 

	
 
def test_is_credit_default():
 
    post = Posting('Assets:Cash', None)
 
    assert post.is_credit(default=True) is True
 
    assert post.is_credit(default=False) is False
 

	
 
@pytest.mark.parametrize('amount', AMOUNTS)
 
def test_is_debit(amount):
 
    expected = None if amount is None else float(amount) < 0
 
    assert Posting('Assets:Cash', amount).is_debit() is expected
 

	
 
def test_is_debit_threshold():
 
    post = Posting('Assets:Cash', -25)
 
    check_all_thresholds(True, post.is_debit, 0)
 
    check_all_thresholds(True, post.is_debit, 20)
 
    check_all_thresholds(False, post.is_debit, 40)
 

	
 
def test_is_debit_default():
 
    post = Posting('Assets:Cash', None)
 
    assert post.is_debit(default=True) is True
 
    assert post.is_debit(default=False) is False
 

	
 
@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS)
 
def test_is_payment(acct):
 
    assert Posting(acct, -500).is_payment()
 

	
 
@pytest.mark.parametrize('acct,amount,threshold', testutil.combine_values(
 
    NON_PAYMENT_ACCOUNTS,
 
    range(5, 20, 5),
 
    range(0, 30, 10),
 
))
 
def test_is_not_payment_account(acct, amount, threshold):
 
    post = Posting(acct, -amount)
 
    assert not post.is_payment()
 
    check_all_thresholds(False, post.is_payment, threshold)
 

	
 
@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS)
 
def test_is_payment_with_threshold(acct):
 
    threshold = len(acct) * 10
 
    post = Posting(acct, -500)
 
    check_all_thresholds(True, post.is_payment, threshold)
 

	
 
@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS)
 
def test_is_not_payment_by_threshold(acct):
 
    threshold = len(acct) * 10
 
    post = Posting(acct, -9)
 
    check_all_thresholds(False, post.is_payment, threshold)
 

	
 
@pytest.mark.parametrize('acct', PAYMENT_ACCOUNTS)
 
def test_is_not_payment_but_credit(acct):
 
    post = Posting(acct, 9)
 
    assert not post.is_payment()
 
    check_all_thresholds(False, post.is_payment, 0)
 
    check_all_thresholds(False, post.is_payment, 5)
 
    check_all_thresholds(False, post.is_payment, 10)
tests/test_meta_approval.py
Show inline comments
 
"""Test validation of 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 pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount.plugin import meta_approval
 

	
 
REQUIRED_ACCOUNTS = {
 
    'Assets:Bank:Checking',
 
    'Assets:Cash',
 
    'Assets:Checking',
 
    'Assets:Savings',
 
}
 

	
 
NON_REQUIRED_ACCOUNTS = {
 
    'Accrued:AccountsPayable',
 
    'Assets:PrepaidExpenses',
 
    'Assets:PrepaidVacation',
 
    'Assets:Prepaid:Expenses',
 
    'Assets:Receivable:Accounts',
 
    'Equity:QpeningBalance',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'UnearnedIncome:Donations',
 
    'Liabilities:Payable:Accounts',
 
}
 

	
 
CREDITCARD_ACCOUNT = 'Liabilities:CreditCard'
 

	
 
TEST_KEY = 'approval'
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig()
 
    return meta_approval.MetaApproval(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__,
 
    )
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected_msg in actual
 

	
 
@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__,
 
    )
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected_msg in actual
tests/test_meta_entity.py
Show inline comments
...
 
@@ -18,116 +18,120 @@ import pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount.plugin import meta_entity
 

	
 
VALID_VALUES = {
 
    # Classic entity: LastName-FirstName
 
    'Smith-Alex',
 
    # Various people and companies have one-word names
 
    # Digits are allowed, as part of a name or standalone
 
    'Company19',
 
    'Company-19',
 
    # No case requirements
 
    'boyd-danah',
 
    # No limit on the number of parts of the name
 
    'B-van-der-A',
 
    # Names that have no ASCII are allowed, with or without dash separators
 
    '田中流星',
 
    '田中-流星',
 
    'スミスダコタ',
 
    'スミス-ダコタ',
 
    'Яшин-Данила',
 
    # The PayPal importer produces . in entity metadata
 
    'Du-Bois-W.-E.-B.',
 
    # import2ledger produces entities that end with -
 
    # That's probably a bug, but allow it for now.
 
    'foo-',
 
}
 

	
 
INVALID_VALUES = {
 
    # Starting with a - is not allowed
 
    '-foo',
 
    '-',
 
    # Names that can be reduced to ASCII should be
 
    # Producers should change this to Uberentity or Ueberentity
 
    # I am not wild about this rule and would like to relax it—it's mostly
 
    # based on an expectation that entities are typed in by an American. That's
 
    # true less and less and it seems like we should reduce the amount of
 
    # mangling producers are expected to do. But it's the rule for today.
 
    'Überentity',
 
    # Whitespace is never allowed
 
    ' ',
 
    'Alex Smith',
 
    '田中\u00A0流星',  # Non-breaking space
 
    # The only punctuation allowed is - and .
 
    'スミス_ダコタ',
 
    'Яшин—Данила',  # em dash
 
    # An empty string is not valid
 
    '',
 
}
 

	
 
TEST_KEY = 'entity'
 

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

	
 
@pytest.mark.parametrize('src_value', VALID_VALUES)
 
def test_valid_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    assert not any(hook.run(txn))
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert len(errors) == 1
 
    assert errors[0].message == "Expenses:General has invalid entity: {}".format(src_value)
 

	
 
@pytest.mark.parametrize('src_value', VALID_VALUES)
 
def test_valid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    assert not any(hook.run(txn))
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert 1 <= len(errors) <= 2
 
    assert all(error.message == "transaction has invalid entity: {}".format(src_value)
 
               for error in hook.run(txn))
 

	
 
@pytest.mark.parametrize('account,required', [
 
    ('Accrued:AccountsReceivable', True),
 
    ('Assets:Bank:Checking', False),
 
    ('Assets:Cash', False),
 
    ('Assets:Receivable:Accounts', True),
 
    ('Assets:Receivable:Loans', True),
 
    ('Equity:OpeningBalances', False),
 
    ('Expenses:General', True),
 
    ('Income:Donations', True),
 
    ('Liabilities:CreditCard', False),
 
    ('UnearnedIncome:Donations', True),
 
    ('Liabilities:Payable:Accounts', True),
 
    ('Liabilities:Payable:Vacation', True),
 
    ('Liabilities:UnearnedIncome:Donations', False),
 
])
 
def test_which_accounts_required_on(hook, account, required):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', 25),
 
        ('Assets:Checking', -25),
 
        (account, 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    if not required:
 
        assert not errors
 
    else:
 
        assert errors
 
        assert any(error.message == "{} missing entity".format(account)
 
                   for error in errors)
tests/test_meta_expense_allocation.py
Show inline comments
 
"""Test handling of expense-allocation 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_expense_allocation
 

	
 
VALID_VALUES = {
 
    'program': 'program',
 
    'administration': 'administration',
 
    'fundraising': 'fundraising',
 
    'admin': 'administration',
 
}
 

	
 
INVALID_VALUES = {
 
    'invalid',
 
    'porgram',
 
    'adimn',
 
    'fundrasing',
 
    '',
 
}
 

	
 
TEST_KEY = 'expense-allocation'
 

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

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_postings(hook, src_value, set_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
 

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_transactions(hook, src_value, set_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
@pytest.mark.parametrize('account', [
 
    'Accrued:AccountsReceivable',
 
    'Assets:Cash',
 
    'Income:Donations',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Income:Other',
 
    'Liabilities:CreditCard',
 
    'UnearnedIncome:Donations',
 
    'Liabilities:Payable:Vacation',
 
])
 
def test_non_expense_accounts_skipped(hook, account):
 
    meta = {TEST_KEY: 'program'}
 
    txn = testutil.Transaction(postings=[
 
        (account, -25),
 
        ('Expenses:General', 25, meta.copy()),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, meta)
 

	
 
@pytest.mark.parametrize('account,set_value', [
 
    ('Expenses:Services:Accounting', 'administration'),
 
    ('Expenses:Services:Administration', 'administration'),
 
    ('Expenses:Services:Advocacy', 'program'),
 
    ('Expenses:Services:Development', 'program'),
 
    ('Expenses:Services:Fundraising', 'fundraising'),
 
])
 
def test_default_values(hook, account, set_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Liabilites:CreditCard', -25),
 
        (account, 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('date,set_value', [
 
    (testutil.EXTREME_FUTURE_DATE, None),
 
    (testutil.FUTURE_DATE, 'program'),
 
    (testutil.FY_START_DATE, 'program'),
 
    (testutil.FY_MID_DATE, 'program'),
 
    (testutil.PAST_DATE, None),
 
])
 
def test_default_value_set_in_date_range(hook, date, set_value):
 
    txn = testutil.Transaction(date=date, postings=[
 
        ('Liabilites:CreditCard', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    expect_meta = None if set_value is None else {TEST_KEY: set_value}
 
    testutil.check_post_meta(txn, None, expect_meta)
tests/test_meta_income_type.py
Show inline comments
 
"""Test handling of income-type 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_income_type
 

	
 
VALID_VALUES = {
 
    'Donations': 'Donations',
 
    'Payable-Derecognition': 'Payable-Derecognition',
 
    'RBI': 'RBI',
 
    'UBTI': 'UBTI',
 
}
 

	
 
INVALID_VALUES = {
 
    'Dontion',
 
    'Payble-Derecognitoin',
 
    'RIB',
 
    'UTBI',
 
    '',
 
}
 

	
 
TEST_KEY = 'income-type'
 

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

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_postings(hook, src_value, set_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', 25),
 
        ('Income:Other', -25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', 25),
 
        ('Income:Other', -25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
 

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_transactions(hook, src_value, set_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', 25),
 
        ('Income:Other', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', 25),
 
        ('Income:Other', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
@pytest.mark.parametrize('account', [
 
    'Accrued:AccountsReceivable',
 
    'Assets:Cash',
 
    'Expenses:General',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Expenses:Other',
 
    'Liabilities:CreditCard',
 
    'Liabilities:Payable:Vacation',
 
])
 
def test_non_income_accounts_skipped(hook, account):
 
    meta = {TEST_KEY: 'RBI'}
 
    txn = testutil.Transaction(postings=[
 
        (account, 25),
 
        ('Income:Other', -25, meta.copy()),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, meta)
 

	
 
@pytest.mark.parametrize('account,set_value', [
 
    ('Income:Conferences:Registrations', 'RBI'),
 
    ('Income:Conferences:Sponsorship', 'RBI'),
 
    ('Income:Donations', 'Donations'),
 
    ('Income:Honoraria', 'RBI'),
 
    ('Income:Interest', 'RBI'),
 
    ('Income:Interest:Dividend', 'RBI'),
 
    ('Income:Royalties', 'RBI'),
 
    ('Income:Sales', 'RBI'),
 
    ('Income:SoftwareDevelopment', 'RBI'),
 
    ('Income:TrademarkLicensing', 'RBI'),
 
    ('UnearnedIncome:Conferences:Registrations', 'RBI'),
 
    ('UnearnedIncome:MatchPledges', 'Donations'),
 
])
 
def test_default_values(hook, account, set_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', 25),
 
        (account, -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('account', [
 
    'Income:Other',
 
])
 
def test_no_default_value(hook, account):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', 25),
 
        (account, -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
@pytest.mark.parametrize('date,set_value', [
 
    (testutil.EXTREME_FUTURE_DATE, None),
 
    (testutil.FUTURE_DATE, 'Donations'),
 
    (testutil.FY_START_DATE, 'Donations'),
 
    (testutil.FY_MID_DATE, 'Donations'),
 
    (testutil.PAST_DATE, None),
 
])
 
def test_default_value_set_in_date_range(hook, date, set_value):
 
    txn = testutil.Transaction(date=date, postings=[
 
        ('Assets:Cash', 25),
 
        ('Income:Donations', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    expect_meta = None if set_value is None else {TEST_KEY: set_value}
 
    testutil.check_post_meta(txn, None, expect_meta)
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 = {
 
    'Accrued:AccountsPayable',
 
    'Accrued:AccountsReceivable',
 
    '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',
 
    'UnearnedIncome:Donations',
 
}
 

	
 
TEST_KEY = 'invoice'
 

	
 
@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__,
 
    )
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {expected_msg}
 

	
 
@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__,
 
    )
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {expected_msg}
tests/test_meta_project.py
Show inline comments
 
"""Test handling of project 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 pathlib import Path
 

	
 
import pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount import errors as errormod
 
from conservancy_beancount.plugin import meta_project
 

	
 
VALID_VALUES = {
 
    'Conservancy': 'Conservancy',
 
    'Alpha': 'Alpha',
 
    'Bravo': 'Bravo',
 
    'Charles': 'Charlie',
 
    'Chuck': 'Charlie',
 
}
 

	
 
INVALID_VALUES = {
 
    'Alhpa',
 
    'Yankee',
 
    '',
 
}
 

	
 
TEST_KEY = 'project'
 
DEFAULT_VALUE = 'Conservancy'
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig(repo_path='repository')
 
    return meta_project.MetaProject(config)
 

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_postings(hook, src_value, set_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
 

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_transactions(hook, src_value, set_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
@pytest.mark.parametrize('account,required', [
 
    ('Accrued:AccountsReceivable', True),
 
    ('Assets:Cash', False),
 
    ('Equity:Opening-Balances', False),
 
    ('Assets:Receivable:Accounts', True),
 
    ('Assets:Receivable:Loans', True),
 
    ('Equity:OpeningBalance', False),
 
    ('Expenses:General', True),
 
    ('Income:Donations', True),
 
    ('Liabilities:CreditCard', False),
 
    ('UnearnedIncome:Donations', True),
 
    ('Liabilities:Payable:Accounts', True),
 
    # We do want a "project" for Lia:Pay:Vacation but it has a default value
 
    ('Liabilities:Payable:Vacation', False),
 
    ('Liabilities:UnearnedIncome:Donations', True),
 
])
 
def test_which_accounts_required_on(hook, account, required):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', 25),
 
        (account, 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert required == any(errors)
 

	
 
@pytest.mark.parametrize('account', [
 
    'Accrued:VacationPayable',
 
    'Expenses:Payroll:Salary',
 
    'Expenses:Payroll:Tax',
 
    'Liabilities:Payable:Vacation',
 
])
 
def test_default_values(hook, account):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -25),
 
        (account, 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: DEFAULT_VALUE})
 

	
 
@pytest.mark.parametrize('date,required', [
 
    (testutil.EXTREME_FUTURE_DATE, False),
 
    (testutil.FUTURE_DATE, True),
 
    (testutil.FY_START_DATE, True),
 
    (testutil.FY_MID_DATE, True),
 
    (testutil.PAST_DATE, None),
 
])
 
def test_default_value_set_in_date_range(hook, date, required):
 
    txn = testutil.Transaction(date=date, postings=[
 
        ('Expenses:Payroll:Benefits', 25),
 
        ('Accrued:VacationPayable', -25),
 
        ('Liabilities:Payable:Vacation', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    expect_meta = {TEST_KEY: DEFAULT_VALUE} if required else None
 
    testutil.check_post_meta(txn, expect_meta, expect_meta)
 

	
 
@pytest.mark.parametrize('repo_path', [
 
    None,
 
    '..',
 
])
 
def test_missing_project_data(repo_path):
 
    config = testutil.TestConfig(repo_path=repo_path)
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_project.MetaProject(config)
 

	
 
@pytest.mark.parametrize('repo_path_s,data_path_s', [
 
    ('repository', 'Projects/project-list.yml'),
 
    ('..', 'LICENSE.txt'),
 
])
 
def test_invalid_project_data(repo_path_s, data_path_s):
 
    config = testutil.TestConfig(repo_path=repo_path_s)
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_project.MetaProject(config, Path(data_path_s))
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}"
 
        return f"{self.name} missing {TEST_KEY}{rest}"
 

	
 
    def wrong_type_message(self, wrong_value, key=TEST_KEY):
 
        return  "{} has wrong type of {}: expected str but is a {}".format(
 
            self.name,
 
            key,
 
            type(wrong_value).__name__,
 
        )
 

	
 

	
 
ACCOUNTS = [AccountForTesting._make(t) for t in [
 
    ('Assets:Bank:CheckCard', PostType.BOTH, 'check'),
 
    ('Assets:Cash', PostType.BOTH, None),
 
    ('Assets:Checking', PostType.BOTH, 'check'),
 
    ('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_FALLBACKS = [acct for acct in ACCOUNTS if acct.fallback_meta]
 
ACCOUNTS_WITHOUT_FALLBACKS = [acct for acct in ACCOUNTS if not acct.fallback_meta]
 
KNOWN_FALLBACKS = {acct.fallback_meta for acct in ACCOUNTS_WITH_FALLBACKS}
 

	
 
# 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([
 
    'Accrued:AccountsPayable',
 
    'Accrued:AccountsReceivable',
 
    'Assets:PrepaidExpenses',
 
    'Assets:PrepaidVacation',
 
    'Assets:Prepaid:Expenses',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'UnearnedIncome:Donations',
 
    'Liabilities:Payable:Accounts',
 
    'Liabilities:UnearnedIncome:Donations',
 
])
 

	
 
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_FALLBACKS,
 
    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_FALLBACKS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
tests/test_meta_receivable_documentation.py
Show inline comments
 
"""Test metadata for receivables includes supporting documentation"""
 
# 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 random
 

	
 
import pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount import errors as errormod
 
from conservancy_beancount.plugin import meta_receivable_documentation
 

	
 
TEST_ACCT = 'Accrued:AccountsReceivable'
 
TEST_ACCT = 'Assets:Receivable:Accounts'
 
OTHER_ACCT = 'Income:Donations'
 

	
 
SUPPORTING_METADATA = [
 
    'approval',
 
    'contract',
 
    'purchase-order',
 
]
 

	
 
NON_SUPPORTING_METADATA = [
 
    'check',
 
    'receipt',
 
    'statement',
 
]
 

	
 
ISSUED_INVOICE_LINKS = [
 
    'rt:1/6',
 
    'rt://ticket/2/attachments/10',
 
    'Financial/Invoices/Company_invoice-2020030405_as-sent.pdf',
 
    'Projects/Alpha/Invoices/ProjectInvoice-304567.pdf',
 
]
 

	
 
RECEIVED_INVOICE_LINKS = [
 
    'rt:1/4',
 
    'rt://ticket/2/attachments/14',
 
    'Financial/Invoices/Invoice-23456789.csv',
 
    'Projects/Bravo/Invoices/Statement304567.pdf',
 
]
 

	
 
MISSING_MSG = f"{TEST_ACCT} missing approval/contract/purchase-order"
 

	
 
# for supporting links, use the lists from testutil
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig(rt_client=testutil.RTClient())
 
    return meta_receivable_documentation.MetaReceivableDocumentation(config)
 

	
 
def seed_meta(invoice=ISSUED_INVOICE_LINKS[0],
 
              support_values=testutil.NON_LINK_METADATA_STRINGS,
 
              **kwargs,
 
):
 
    meta = dict(testutil.combine_values(
 
        SUPPORTING_METADATA,
 
        support_values,
 
    ))
 
    meta.update(kwargs)
 
    meta['invoice'] = invoice
 
    return meta
 

	
 
def wrong_type_message(key, wrong_value):
 
    return  "{} has wrong type of {}: expected str but is a {}".format(
 
        TEST_ACCT,
 
        key,
 
        type(wrong_value).__name__,
 
    )
 

	
 
def check(hook, expected, test_acct=TEST_ACCT, other_acct=OTHER_ACCT, *,
 
          txn_meta={}, post_meta={}, min_amt=10):
 
    amount = '{:.02f}'.format(min_amt + random.random() * 100)
 
    txn = testutil.Transaction(**txn_meta, postings=[
 
        (test_acct, amount, post_meta),
 
        (other_acct, f'-{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.mark.parametrize('invoice,support_key,support_value', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    SUPPORTING_METADATA,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_docs_in_post(hook, invoice, support_key, support_value):
 
    meta = seed_meta(invoice)
 
    meta[support_key] = support_value
 
    check(hook, None, post_meta=meta)
 

	
 
@pytest.mark.parametrize('invoice,support_key,support_value', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    SUPPORTING_METADATA,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_docs_in_txn(hook, invoice, support_key, support_value):
 
    meta = seed_meta(invoice)
 
    meta[support_key] = support_value
 
    check(hook, None, txn_meta=meta)
 

	
 
@pytest.mark.parametrize('invoice,meta_type', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    ['post_meta', 'txn_meta'],
 
))
 
def test_no_valid_docs(hook, invoice, meta_type):
 
    meta = seed_meta(invoice)
 
    meta.update((key, value) for key, value in testutil.combine_values(
 
        NON_SUPPORTING_METADATA,
 
        testutil.LINK_METADATA_STRINGS,
 
    ))
 
    check(hook, {MISSING_MSG}, **{meta_type: meta})
 

	
 
@pytest.mark.parametrize('invoice,meta_type', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    ['post_meta', 'txn_meta'],
 
))
 
def test_docs_all_bad_type(hook, invoice, meta_type):
 
    meta = seed_meta(invoice, testutil.NON_STRING_METADATA_VALUES)
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
        if key != 'invoice'
 
    }
 
    expected.add(MISSING_MSG)
 
    check(hook, expected, **{meta_type: meta})
 

	
 
@pytest.mark.parametrize('invoice,support_key,support_value', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    SUPPORTING_METADATA,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_type_errors_reported_with_valid_post_docs(hook, invoice, support_key, support_value):
 
    meta = seed_meta(invoice, testutil.NON_STRING_METADATA_VALUES)
 
    meta[support_key] = support_value
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
        if key != 'invoice' and  key != support_key
 
    }
 
    check(hook, expected, post_meta=meta)
 

	
 
@pytest.mark.parametrize('invoice,support_key,support_value', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    SUPPORTING_METADATA,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_type_errors_reported_with_valid_txn_docs(hook, invoice, support_key, support_value):
 
    meta = seed_meta(invoice, testutil.NON_STRING_METADATA_VALUES)
 
    meta[support_key] = support_value
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
        if key != 'invoice' and  key != support_key
 
    }
 
    check(hook, expected, txn_meta=meta)
 

	
 
@pytest.mark.parametrize('invoice,meta_type', testutil.combine_values(
 
    RECEIVED_INVOICE_LINKS,
 
    ['post_meta', 'txn_meta'],
 
))
 
def test_received_invoices_not_checked(hook, invoice, meta_type):
 
    check(hook, None, **{meta_type: {'invoice': invoice}})
 

	
 
def test_does_not_apply_to_payables(hook):
 
@pytest.mark.parametrize('account', [
 
    'Assets:Bank:Checking',
 
    'Assets:Cash',
 
    'Equity:OpeningBalance',
 
    'Expenses:BankingFees',
 
    'Liabilities:CreditCard',
 
    'Liabilities:Payable:Accounts',
 
])
 
def test_does_not_apply_to_other_accounts(hook, account):
 
    meta = seed_meta()
 
    check(hook, None, 'Accrued:AccountsPayable', 'Expenses:Other', post_meta=meta)
 
    check(hook, None, account, 'Expenses:Other', post_meta=meta)
 

	
 
def test_configuration_error_without_rt():
 
    config = testutil.TestConfig()
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_receivable_documentation.MetaReceivableDocumentation(config)
tests/test_meta_tax_implication.py
Show inline comments
 
"""Test handling of tax-implication 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_tax_implication
 

	
 
VALID_VALUES = {
 
    '1099': '1099',
 
    'Accountant-Advises-No-1099': 'Accountant-Advises-No-1099',
 
    'Bank-Transfer': 'Bank-Transfer',
 
    'Foreign-Corporation': 'Foreign-Corporation',
 
    'Foreign-Individual-Contractor': 'Foreign-Individual-Contractor',
 
    'Fraud': 'Fraud',
 
    'HSA-Contribution': 'HSA-Contribution',
 
    'Loan': 'Loan',
 
    'Payroll': 'Payroll',
 
    'Refund': 'Refund',
 
    'Reimbursement': 'Reimbursement',
 
    'Retirement-Pretax': 'Retirement-Pretax',
 
    'Tax-Payment': 'Tax-Payment',
 
    'USA-501c3': 'USA-501c3',
 
    'USA-Corporation': 'USA-Corporation',
 
    'USA-LLC-No-1099': 'USA-LLC-No-1099',
 
    'W2': 'W2',
 
}
 

	
 
INVALID_VALUES = {
 
    '199',
 
    'W3',
 
    'Payrol',
 
    '',
 
}
 

	
 
TEST_KEY = 'tax-implication'
 

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

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_postings(hook, src_value, set_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Accrued:AccountsPayable', 25),
 
        ('Liabilities:Payable:Accounts', 25),
 
        ('Assets:Cash', -25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Accrued:AccountsPayable', 25),
 
        ('Liabilities:Payable:Accounts', 25),
 
        ('Assets:Cash', -25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
 

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_transactions(hook, src_value, set_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Accrued:AccountsPayable', 25),
 
        ('Liabilities:Payable:Accounts', 25),
 
        ('Assets:Cash', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Accrued:AccountsPayable', 25),
 
        ('Liabilities:Payable:Accounts', 25),
 
        ('Assets:Cash', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
@pytest.mark.parametrize('account', [
 
    'Accrued:AccountsPayable',
 
    'Expenses:General',
 
@pytest.mark.parametrize('count,account', enumerate([
 
    'Assets:Payable:Accounts',
 
    'Assets:Prepaid:Expenses',
 
    'Equity:OpeningBalance',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'Liabilities:CreditCard',
 
])
 
def test_non_asset_accounts_skipped(hook, account):
 
    'Liabilities:Payable:Accounts',
 
    'Liabilities:UnearnedIncome:Donations',
 
], 1))
 
def test_non_payment_accounts_skipped(hook, account, count):
 
    amount = count * 100
 
    meta = {TEST_KEY: 'USA-Corporation'}
 
    txn = testutil.Transaction(postings=[
 
        (account, 25),
 
        ('Assets:Cash', -25, meta.copy()),
 
        (account, amount),
 
        ('Assets:Checking', -amount, meta.copy()),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, meta)
 

	
 
def test_prepaid_expenses_skipped(hook, ):
 
    txn = testutil.Transaction(postings=[
 
        ('Expenses:General', 25),
 
        ('Assets:PrepaidExpenses', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
def test_asset_credits_skipped(hook, ):
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -25),
 
        ('Assets:Cash', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
@pytest.mark.parametrize('date,need_value', [
 
    (testutil.EXTREME_FUTURE_DATE, False),
 
    (testutil.FUTURE_DATE, True),
 
    (testutil.FY_START_DATE, True),
 
    (testutil.FY_MID_DATE, True),
 
    (testutil.PAST_DATE, False),
 
])
 
def test_validation_only_in_date_range(hook, date, need_value):
 
    txn = testutil.Transaction(date=date, postings=[
 
        ('Liabilites:CreditCard', 25),
 
        ('Assets:Cash', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert bool(errors) == bool(need_value)
 
    testutil.check_post_meta(txn, None, None)
0 comments (0 inline, 0 general)