Beispiel #1
0
 def test_get_balance_tolerance__regular(self, entries, errors,
                                         options_map):
     """
       2015-05-01 open Assets:Bank:Checking
       2015-05-02 balance Assets:Bank:Checking   0 USD
       2015-05-02 balance Assets:Bank:Checking   0.0 USD
       2015-05-02 balance Assets:Bank:Checking   0.00 USD
       2015-05-02 balance Assets:Bank:Checking   0.000 USD
       2015-05-02 balance Assets:Bank:Checking   1 USD
       2015-05-02 balance Assets:Bank:Checking   1.0 USD
       2015-05-02 balance Assets:Bank:Checking   1.00 USD
       2015-05-02 balance Assets:Bank:Checking   1.000 USD
       2015-05-02 balance Assets:Bank:Checking   1.01 USD
     """
     tolerances = [
         balance.get_balance_tolerance(entry, options_map)
         for entry in entries[1:]
     ]
     self.assertEqual([
         D('0'),
         D('0.1'),
         D('0.01'),
         D('0.001'),
         D('0'),
         D('0.1'),
         D('0.01'),
         D('0.001'),
         D('0.01')
     ], tolerances)
Beispiel #2
0
 def test_get_balance_tolerance__explicit(self, entries, errors, options_map):
     """
       2015-05-01 open Assets:Bank:Checking
       2015-05-02 balance Assets:Bank:Checking   0    ~ 0.002 USD
       2015-05-02 balance Assets:Bank:Checking   0.0  ~ 0.002 USD
       2015-05-02 balance Assets:Bank:Checking   0.00 ~ 0.002 USD
       2015-05-02 balance Assets:Bank:Checking   1    ~ 0.002 USD
       2015-05-02 balance Assets:Bank:Checking   1.0  ~ 0.002 USD
       2015-05-02 balance Assets:Bank:Checking   1.00 ~ 0.002 USD
     """
     tolerances = [balance.get_balance_tolerance(entry, options_map)
                   for entry in entries[1:]]
     self.assertEqual([D('0.002')] * 6, tolerances)
Beispiel #3
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