def test_pad_check_balances(self, entries, errors, __): """ 2013-05-01 open Assets:Checking 2013-05-01 open Assets:Cash 2013-05-01 open Equity:Opening-Balances 2013-05-01 pad Assets:Checking Equity:Opening-Balances 2013-05-03 txn "Add 20$" Assets:Checking 10 USD Assets:Cash 2013-05-10 balance Assets:Checking 105 USD 2013-05-15 txn "Add 20$" Assets:Checking 20 USD Assets:Cash 2013-05-16 txn "Add 20$" Assets:Checking 20 USD Assets:Cash 2013-06-01 balance Assets:Checking 145 USD """ post_map = realization.postings_by_account(entries) txn_postings = post_map['Assets:Checking'] balances = [] pad_balance = inventory.Inventory() for txn_posting in txn_postings: if isinstance(txn_posting, data.TxnPosting): position_, _ = pad_balance.add_position( txn_posting.posting.position) self.assertFalse(position_.is_negative_at_cost()) balances.append((type(txn_posting), pad_balance.get_units('USD'))) self.assertEqual(balances, [(data.Open, A('0.00 USD')), (data.Pad, A('0.00 USD')), (data.TxnPosting, A('95.00 USD')), (data.TxnPosting, A('105.00 USD')), (data.Balance, A('105.00 USD')), (data.TxnPosting, A('125.00 USD')), (data.TxnPosting, A('145.00 USD')), (data.Balance, A('145.00 USD'))])
def test_postings_by_account(self, entries, errors, _): """ option "plugin_processing_mode" "raw" 2012-01-01 open Expenses:Restaurant 2012-01-01 open Expenses:Movie 2012-01-01 open Assets:Cash 2012-01-01 open Liabilities:CreditCard 2012-01-01 open Equity:Opening-Balances 2012-01-15 pad Liabilities:CreditCard Equity:Opening-Balances 2012-03-01 * "Food" Expenses:Restaurant 100 CAD Assets:Cash -100 CAD 2012-03-10 * "Food again" Expenses:Restaurant 80 CAD Liabilities:CreditCard -80 CAD ;; Two postings on the same account. 2012-03-15 * "Two Movies" Expenses:Movie 10 CAD Expenses:Movie 10 CAD Liabilities:CreditCard -20 CAD 2012-03-20 note Liabilities:CreditCard "Called Amex, asked about 100 CAD dinner" 2012-03-28 document Liabilities:CreditCard "march-statement.pdf" 2013-04-01 balance Liabilities:CreditCard 204 CAD 2014-01-01 close Liabilities:CreditCard """ self.assertEqual(0, len(errors)) txn_postings_map = realization.postings_by_account(entries) self.assertTrue(isinstance(txn_postings_map, dict)) self.assertEqual([data.Open, data.TxnPosting], list(map(type, txn_postings_map['Assets:Cash']))) self.assertEqual([data.Open, data.TxnPosting, data.TxnPosting], list( map(type, txn_postings_map['Expenses:Restaurant']))) self.assertEqual([data.Open, data.TxnPosting, data.TxnPosting], list(map(type, txn_postings_map['Expenses:Movie']))) self.assertEqual( [ data.Open, data.Pad, data.TxnPosting, data.TxnPosting, # data.TxnPosting, data.Note, data.Document, data.Balance, data.Close ], list(map(type, txn_postings_map['Liabilities:CreditCard']))) self.assertEqual([data.Open, data.Pad], list( map(type, txn_postings_map['Equity:Opening-Balances'])))
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 inserte, 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.position) 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_units(check_amount.currency) diff_amount = amount.amount_sub(balance_amount, check_amount) # Use the specified tolerance or automatically infer it. tolerance = balance.get_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.lot.currency == check_amount.currency ] for position_ in positions: if position_.lot.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. lot = position.Lot(check_amount.currency, None, None) diff_position = position.Position( lot, check_amount.number - balance_amount.number) # 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, None, None, []) new_entry.postings.append( data.Posting(active_pad.account, diff_position, None, None, None)) new_entry.postings.append( data.Posting(active_pad.source_account, -diff_position, None, None, None)) # Save it for later insertion after the active pad. new_entries[id(active_pad)].append(new_entry) # Fixup the running balance. position_, _ = pad_balance.add_position(diff_position) if position_.is_negative_at_cost(): raise ValueError( "Position held at cost goes negative: {}". format(position_)) # 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