示例#1
0
 def test_sub(self):
     self.assertEqual(Amount(D('82.98'), 'CAD'),
                      amount.sub(Amount(D('100'), 'CAD'),
                                        Amount(D('17.02'), 'CAD')))
     with self.assertRaises(ValueError):
         amount.sub(Amount(D('100'), 'USD'),
                           Amount(D('17.02'), 'CAD'))
示例#2
0
def compute_stack(stack):
    for i in range(1, len(stack), 2):
        if stack[i] == '+':
            stack[0] = add(stack[0], stack[i + 1])
        elif stack[i] == '-':
            stack[0] = sub(stack[0], stack[i + 1])
    return stack[0]
示例#3
0
def check(entries, options_map):
    """Process the balance assertion directives.

    For each Balance directive, check that their expected balance corresponds to
    the actual balance computed at that time and replace failing ones by new
    ones with a flag that indicates failure.

    Args:
      entries: A list of directives.
      options_map: A dict of options, parsed from the input file.
    Returns:
      A pair of a list of directives and a list of balance check errors.
    """
    new_entries = []
    check_errors = []

    # This is similar to realization, but performed in a different order, and
    # where we only accumulate inventories for accounts that have balance
    # assertions in them (this saves on time). Here we process the entries one
    # by one along with the balance checks. We use a temporary realization in
    # order to hold the incremental tree of balances, so that we can easily get
    # the amounts of an account's subaccounts for making checks on parent
    # accounts.
    real_root = realization.RealAccount('')

    # Figure out the set of accounts for which we need to compute a running
    # inventory balance.
    asserted_accounts = {
        entry.account
        for entry in entries if isinstance(entry, Balance)
    }

    # Add all children accounts of an asserted account to be calculated as well,
    # and pre-create these accounts, and only those (we're just being tight to
    # make sure).
    asserted_match_list = [
        account.parent_matcher(account_) for account_ in asserted_accounts
    ]
    for account_ in getters.get_accounts(entries):
        if (account_ in asserted_accounts
                or any(match(account_) for match in asserted_match_list)):
            realization.get_or_create(real_root, account_)

    # Get the Open directives for each account.
    open_close_map = getters.get_account_open_close(entries)

    for entry in entries:
        if isinstance(entry, Transaction):
            # For each of the postings' accounts, update the balance inventory.
            for posting in entry.postings:
                real_account = realization.get(real_root, posting.account)

                # The account will have been created only if we're meant to track it.
                if real_account is not None:
                    # Note: Always allow negative lots for the purpose of balancing.
                    # This error should show up somewhere else than here.
                    real_account.balance.add_position(posting)

        elif isinstance(entry, Balance):
            # Check that the currency of the balance check is one of the allowed
            # currencies for that account.
            expected_amount = entry.amount
            try:
                open, _ = open_close_map[entry.account]
            except KeyError:
                check_errors.append(
                    BalanceError(
                        entry.meta,
                        "Account '{}' does not exist: ".format(entry.account),
                        entry))
                continue

            if (expected_amount is not None and open and open.currencies
                    and expected_amount.currency not in open.currencies):
                check_errors.append(
                    BalanceError(
                        entry.meta,
                        "Invalid currency '{}' for Balance directive: ".format(
                            expected_amount.currency), entry))

            # Check the balance against the check entry.
            real_account = realization.get(real_root, entry.account)
            assert real_account is not None, "Missing {}".format(entry.account)

            # Sum up the current balances for this account and its
            # sub-accounts. We want to support checks for parent accounts
            # for the total sum of their subaccounts.
            subtree_balance = inventory.Inventory()
            for real_child in realization.iter_children(real_account, False):
                subtree_balance += real_child.balance

            # Get only the amount in the desired currency.
            balance_amount = subtree_balance.get_currency_units(
                expected_amount.currency)

            # Check if the amount is within bounds of the expected amount.
            diff_amount = amount.sub(balance_amount, expected_amount)

            # Use the specified tolerance or automatically infer it.
            tolerance = get_balance_tolerance(entry, options_map)

            if abs(diff_amount.number) > tolerance:
                check_errors.append(
                    BalanceError(
                        entry.meta,
                        ("Balance failed for '{}': "
                         "expected {} != accumulated {} ({} {})").format(
                             entry.account, expected_amount, balance_amount,
                             abs(diff_amount.number),
                             ('too much' if diff_amount.number > 0 else
                              'too little')), entry))

                # Substitute the entry by a failing entry, with the diff_amount
                # field set on it. I'm not entirely sure that this is the best
                # of ideas, maybe leaving the original check intact and insert a
                # new error entry might be more functional or easier to
                # understand.
                entry = entry._replace(meta=entry.meta.copy(),
                                       diff_amount=diff_amount)

        new_entries.append(entry)

    return new_entries, check_errors
示例#4
0
def balexpr(entries, options_map):
    errors = []
    accounts = []

    real_root = realization.RealAccount('')

    balexpr_entries = [entry for entry in entries if is_balexpr_entry(entry)]

    asserted_accounts = {
        account_
        for entry in balexpr_entries
        for account_ in get_accounts_from_entry(entry)
    }

    asserted_match_list = [
        account.parent_matcher(account_) for account_ in asserted_accounts
    ]
    for account_ in getters.get_accounts(entries):
        if (account_ in asserted_accounts
                or any(match(account_) for match in asserted_match_list)):
            realization.get_or_create(real_root, account_)

    open_close_map = getters.get_account_open_close(entries)

    current_checking_balexpr_entry = 0

    for entry in entries:
        if current_checking_balexpr_entry >= len(balexpr_entries):
            break

        while current_checking_balexpr_entry < len(
                balexpr_entries) and balexpr_entries[
                    current_checking_balexpr_entry].date == entry.date:
            checking_entry = balexpr_entries[current_checking_balexpr_entry]
            current_checking_balexpr_entry += 1

            accounts = get_accounts_from_entry(checking_entry)
            if not accounts:
                errors.append(
                    BalExprError(checking_entry.meta,
                                 'No account found in the expression',
                                 checking_entry))
                continue

            currency = get_expected_amount_from_entry(checking_entry).currency
            error_found_in_currencies = False
            for account_ in accounts:
                try:
                    open, _ = open_close_map[account_]
                except KeyError:
                    errors.append(
                        BalExprError(
                            checking_entry.meta,
                            'Invalid reference to unknown account \'{}\''.
                            format(account_), checking_entry))
                    error_found_in_currencies = True
                    break

                if currency not in open.currencies:
                    errors.append(
                        BalExprError(checking_entry.meta,
                                     'Currencies are inconsistent',
                                     checking_entry))
                    error_found_in_currencies = True
                    break

            if error_found_in_currencies:
                continue

            expression = get_expression_from_entry(checking_entry)
            expected_amount = get_expected_amount_from_entry(checking_entry)

            real_amount, error_msg = calcuate(expression, currency, real_root)
            if error_msg:
                errors.append(
                    BalExprError(checking_entry.meta, error_msg,
                                 checking_entry))
                continue

            diff_amount = sub(real_amount, expected_amount)
            if abs(diff_amount.number) > 0.005:
                errors.append(
                    BalExprError(
                        checking_entry.meta,
                        "BalExpr failed: expected {} != accumulated {} ({} {})"
                        .format(expected_amount, real_amount,
                                abs(diff_amount.number),
                                ('too much' if diff_amount.number > 0 else
                                 'too little')), checking_entry))

        if isinstance(entry, Transaction):
            for posting in entry.postings:
                real_account = realization.get(real_root, posting.account)
                if real_account is not None:
                    real_account.balance.add_position(posting)

    return entries, errors
示例#5
0
def pad(entries, options_map):
    """Insert transaction entries for to fulfill a subsequent balance check.

    Synthesize and insert Transaction entries right after Pad entries in order
    to fulfill checks in the padded accounts. Returns a new list of entries.
    Note that this doesn't pad across parent-child relationships, it is a very
    simple kind of pad. (I have found this to be sufficient in practice, and
    simpler to implement and understand.)

    Furthermore, this pads for a single currency only, that is, balance checks
    are specified only for one currency at a time, and pads will only be
    inserted for those currencies.

    Args:
      entries: A list of directives.
      options_map: A parser options dict.
    Returns:
      A new list of directives, with Pad entries inserted, and a list of new
      errors produced.
    """
    pad_errors = []

    # Find all the pad entries and group them by account.
    pads = list(misc_utils.filter_type(entries, data.Pad))
    pad_dict = misc_utils.groupby(lambda x: x.account, pads)

    # Partially realize the postings, so we can iterate them by account.
    by_account = realization.postings_by_account(entries)

    # A dict of pad -> list of entries to be inserted.
    new_entries = {id(pad): [] for pad in pads}

    # Process each account that has a padding group.
    for account_, pad_list in sorted(pad_dict.items()):

        # Last encountered / currency active pad entry.
        active_pad = None

        # Gather all the postings for the account and its children.
        postings = []
        is_child = account.parent_matcher(account_)
        for item_account, item_postings in by_account.items():
            if is_child(item_account):
                postings.extend(item_postings)
        postings.sort(key=data.posting_sortkey)

        # A set of currencies already padded so far in this account.
        padded_lots = set()

        pad_balance = inventory.Inventory()
        for entry in postings:

            assert not isinstance(entry, data.Posting)
            if isinstance(entry, data.TxnPosting):
                # This is a transaction; update the running balance for this
                # account.
                pad_balance.add_position(entry.posting)

            elif isinstance(entry, data.Pad):
                if entry.account == account_:
                    # Mark this newly encountered pad as active and allow all lots
                    # to be padded heretofore.
                    active_pad = entry
                    padded_lots = set()

            elif isinstance(entry, data.Balance):
                check_amount = entry.amount

                # Compare the current balance amount to the expected one from
                # the check entry. IMPORTANT: You need to understand that this
                # does not check a single position, but rather checks that the
                # total amount for a particular currency (which itself is
                # distinct from the cost).
                balance_amount = pad_balance.get_currency_units(
                    check_amount.currency)
                diff_amount = amount.sub(balance_amount, check_amount)

                # Use the specified tolerance or automatically infer it.
                tolerance = balance.get_balance_tolerance(entry, options_map)

                if abs(diff_amount.number) > tolerance:
                    # The check fails; we need to pad.

                    # Pad only if pad entry is active and we haven't already
                    # padded that lot since it was last encountered.
                    if active_pad and (check_amount.currency
                                       not in padded_lots):

                        # Note: we decide that it's an error to try to pad
                        # positions at cost; we check here that all the existing
                        # positions with that currency have no cost.
                        positions = [
                            pos for pos in pad_balance.get_positions()
                            if pos.units.currency == check_amount.currency
                        ]
                        for position_ in positions:
                            if position_.cost is not None:
                                pad_errors.append(
                                    PadError(entry.meta, (
                                        "Attempt to pad an entry with cost for "
                                        "balance: {}".format(pad_balance)),
                                             active_pad))

                        # Thus our padding lot is without cost by default.
                        diff_position = position.Position.from_amounts(
                            amount.Amount(
                                check_amount.number - balance_amount.number,
                                check_amount.currency))

                        # Synthesize a new transaction entry for the difference.
                        narration = ('(Padding inserted for Balance of {} for '
                                     'difference {})').format(
                                         check_amount, diff_position)
                        new_entry = data.Transaction(active_pad.meta.copy(),
                                                     active_pad.date,
                                                     flags.FLAG_PADDING, None,
                                                     narration, data.EMPTY_SET,
                                                     data.EMPTY_SET, [])

                        new_entry.postings.append(
                            data.Posting(active_pad.account,
                                         diff_position.units,
                                         diff_position.cost, None, None, None))
                        neg_diff_position = -diff_position
                        new_entry.postings.append(
                            data.Posting(active_pad.source_account,
                                         neg_diff_position.units,
                                         neg_diff_position.cost, None, None,
                                         None))

                        # Save it for later insertion after the active pad.
                        new_entries[id(active_pad)].append(new_entry)

                        # Fixup the running balance.
                        pos, _ = pad_balance.add_position(diff_position)
                        if pos is not None and pos.is_negative_at_cost():
                            raise ValueError(
                                "Position held at cost goes negative: {}".
                                format(pos))

                # Mark this lot as padded. Further checks should not pad this lot.
                padded_lots.add(check_amount.currency)

    # Insert the newly created entries right after the pad entries that created them.
    padded_entries = []
    for entry in entries:
        padded_entries.append(entry)
        if isinstance(entry, data.Pad):
            entry_list = new_entries[id(entry)]
            if entry_list:
                padded_entries.extend(entry_list)
            else:
                # Generate errors on unused pad entries.
                pad_errors.append(
                    PadError(entry.meta, "Unused Pad entry", entry))

    return padded_entries, pad_errors
示例#6
0
def depreciate(entries, options_map, config):
    """Add depreciation entries for fixed assets.  See module docstring for more
    details and example"""

    config_obj = eval(config, {}, {})
    if not isinstance(config_obj, dict):
        raise RuntimeError("Invalid plugin configuration: should be a single dict.")

    depr_method = config_obj.pop('method', 'WDV')
    year_closing_month = config_obj.pop('year_closing_month', 12)
    half_depr = config_obj.pop('half_depr', True)
    depr_account = config_obj.pop('account', "Expenses:Depreciation")
    expense_subaccount = config_obj.pop('expense_subaccount', False)
    asset_subaccount = config_obj.pop('asset_subaccount', False)

    if depr_method not in ['WDV','CRA']:
        raise RuntimeError("Specified depreciation method in plugin not implemented")

    if not 0 < year_closing_month <= 12:
        raise RuntimeError("Invalid year-closing-month specified")

    errors = []
    depr_candidates = []
    for entry in entries:
        date = entry.date
        try:
            for p in entry.postings:
                if 'depreciation' in p.meta:
                    depr_candidates.append((date, p, entry))
        except (AttributeError, TypeError):
            pass
    for date, posting, entry in depr_candidates:
        narration, rate = posting.meta['depreciation'].split('@')
        narration = narration.strip()
        rate = Decimal(rate)
        rate_used = rate

        orig_val = posting.units
        current_val = orig_val
        new_dates = get_closing_dates(date, year_closing_month)

        for d in new_dates:
            if depr_method == 'WDV':
                if half_depr and d - date < datetime.timedelta(180):
                    # Asset used for less than 180 days, use half the rate allowed.
                    rate_used = rate/2
                    narration_suffix = " - Half Depreciation (<180days)"
                else:
                    rate_used = rate
                    narration_suffix = ""

            elif depr_method == 'CRA':
                if half_depr and d < datetime.date(date.year+1, date.month, date.day):
                   # Asset purchased this year, use half of rate allowed
                    rate_used = rate/2
                    narration_suffix = " - Half Depreciation (Same year)"
                else:
                    rate_used = rate
                    narration_suffix = ""

            multiplier = Decimal(config_obj.get(str(d.year),1))
            rate_used = rate_used*multiplier
            current_depr = mul(current_val, rate_used)

            account = posting.account
            if asset_subaccount:
                account += ":Depreciation"

            depr_account_used = depr_account
            if expense_subaccount:
                depr_account_used = depr_account + ":" + narration.split(" ")[0]

            p1 = data.Posting(account=account,
                              price=None,
                              cost=None,
                              meta=None,
                              flag=None,
                              units=mul(current_depr, Decimal(-1)))
            p2 = data.Posting(account=depr_account_used,
                              price=None,
                              cost=None,
                              meta=None,
                              flag=None,
                              units=current_depr)

            e = entry._replace(narration=narration + narration_suffix,
                               date=d,
                               flag='*',
                               payee=None,
                               tags={'AUTO-DEPRECIATION'},
                               postings=[p1, p2])
            entries.append(e)

            current_val = sub(current_val, current_depr)

    return entries, errors
def depreciate(entries, options_map, config):
    """Add depreciation entries for fixed assets.  See module docstring for more
    details and example"""

    config_obj = eval(config, {}, {})
    if not isinstance(config_obj, dict):
        raise RuntimeError("Invalid plugin configuration: should be a single dict.")

    depr_method = config_obj.pop("method", "WDV")
    year_closing_month = config_obj.pop("year_closing_month", 12)
    half_depr = config_obj.pop("half_depr", True)
    depr_account = config_obj.pop("account", "Expenses:Depreciation")
    expense_subaccount = config_obj.pop("expense_subaccount", False)
    asset_subaccount = config_obj.pop("asset_subaccount", False)

    if depr_method not in ["WDV", "CRA"]:
        raise RuntimeError("Specified depreciation method in plugin not implemented")

    if not 0 < year_closing_month <= 12:
        raise RuntimeError("Invalid year-closing-month specified")

    errors = []
    depr_candidates = []
    for entry in entries:
        date = entry.date
        try:
            for p in entry.postings:
                if "depreciation" in p.meta:
                    depr_candidates.append((date, p, entry))
        except (AttributeError, TypeError):
            pass
    for date, posting, entry in depr_candidates:
        narration, rate = posting.meta["depreciation"].split("@")
        narration = narration.strip()
        rate = Decimal(rate)
        rate_used = rate

        orig_val = posting.units
        current_val = orig_val
        new_dates = get_closing_dates(date, year_closing_month)

        for d in new_dates:
            if depr_method == "WDV":
                if half_depr and d - date < datetime.timedelta(180):
                    # Asset used for less than 180 days, use half the rate allowed.
                    rate_used = rate / 2
                    narration_suffix = " - Half Depreciation (<180days)"
                else:
                    rate_used = rate
                    narration_suffix = ""

            elif depr_method == "CRA":
                if half_depr and d < datetime.date(date.year + 1, date.month, date.day):
                    # Asset purchased this year, use half of rate allowed
                    rate_used = rate / 2
                    narration_suffix = " - Half Depreciation (Same year)"
                else:
                    rate_used = rate
                    narration_suffix = ""

            multiplier = Decimal(config_obj.get(str(d.year), 1))
            rate_used = rate_used * multiplier
            current_depr = mul(current_val, rate_used)

            account = posting.account
            if asset_subaccount:
                account += ":Depreciation"

            depr_account_used = depr_account
            if expense_subaccount:
                depr_account_used = depr_account + ":" + narration.split(" ")[0]

            p1 = data.Posting(
                account=account, price=None, cost=None, meta=None, flag=None, units=mul(current_depr, Decimal(-1))
            )
            p2 = data.Posting(
                account=depr_account_used, price=None, cost=None, meta=None, flag=None, units=current_depr
            )

            e = entry._replace(
                narration=narration + narration_suffix,
                date=d,
                flag="*",
                payee=None,
                tags={"AUTO-DEPRECIATION"},
                postings=[p1, p2],
            )
            entries.append(e)

            current_val = sub(current_val, current_depr)

    return entries, errors