Exemple #1
0
def do_missing_open(filename, args):
    """Print out Open directives that are missing for the given input file.

    This can be useful during demos in order to quickly generate all the
    required Open directives without having to type them manually.

    Args:
      filename: A string, which consists in the filename.
      args: A tuple of the rest of arguments. We're expecting the first argument
        to be an integer as a string.
    """
    from beancount.parser import printer
    from beancount.core import data
    from beancount.core import getters
    from beancount import loader

    entries, errors, options_map = loader.load_file(filename)

    # Get accounts usage and open directives.
    first_use_map, _ = getters.get_accounts_use_map(entries)
    open_close_map = getters.get_account_open_close(entries)

    new_entries = []
    for account, first_use_date in first_use_map.items():
        if account not in open_close_map:
            new_entries.append(
                data.Open(data.new_metadata(filename, 0), first_use_date,
                          account, None, None))

    dcontext = options_map['dcontext']
    printer.print_entries(data.sorted(new_entries), dcontext)
def tax_adjust(realacc):
    account_open_close = getters.get_account_open_close(entries)
    for acc in realization.iter_children(realacc):
        if acc.account in account_open_close:
            tax_adj = account_open_close[acc.account][0].meta.get(
                'asset_allocation_tax_adjustment', 100)
            acc.balance = scale_inventory(acc.balance, tax_adj)
    return realacc
Exemple #3
0
def create_report(entries, options_map):
    real_root = realization.realize(entries)

    # Find the institutions from the data.
    groups, ignored_accounts = find_institutions(entries, options_map)

    # List all the asset accounts which aren't included in the report.
    oc_map = getters.get_account_open_close(entries)
    open_map = {acc: open_entry for acc, (open_entry, _) in oc_map.items()}
    for acc in sorted(ignored_accounts):
        logging.info("Ignored account: %s", acc)

    # Gather missing fields and create a report object.
    institutions = []
    for name, accounts in sorted(groups.items()):
        # Get the institution fields, which is the union of the fields for all
        # the accounts with the institution fields.
        institution_accounts = [
            acc for acc in accounts if 'institution' in open_map[acc].meta
        ]

        institution_fields = {}
        for acc in institution_accounts:
            for key, value in open_map[acc].meta.items():
                institution_fields.setdefault(key, value)
        institution_fields.pop('filename', None)
        institution_fields.pop('lineno', None)

        # Create infos for each account in this institution.
        account_reports = []
        for acc in accounts:
            account_fields = {}
            for subacc in account.parents(acc):
                open_entry = open_map[subacc]
                if 'institution' in open_entry.meta:
                    break
                account_fields.update(open_entry.meta)
            account_fields.pop('filename', None)
            account_fields.pop('lineno', None)
            for field in institution_fields:
                account_fields.pop(field, None)

            real_node = realization.get(real_root, acc)
            account_reports.append(
                AccountReport(
                    acc, open_entry.date, real_node.balance,
                    sum(1 for posting in real_node.txn_postings
                        if isinstance(posting, data.TxnPosting)),
                    account_fields))

        # Create the institution report.
        institution = InstitutionReport(name, institution_fields,
                                        account_reports)
        institutions.append(institution)

    return Report(options_map['title'], institutions)
Exemple #4
0
def merge_meta(entries, options_map, config):
    """Load a secondary file and merge its metadata in our given set of entries.

    Args:
      entries: A list of directives. We're interested only in the Transaction instances.
      unused_options_map: A parser options dict.
      config: The plugin configuration string.
    Returns:
      A list of entries, with more metadata attached to them.
    """
    external_filename = config
    new_entries = list(entries)

    ext_entries, ext_errors, ext_options_map = loader.load_file(
        external_filename)

    # Map Open and Close directives.
    oc_map = getters.get_account_open_close(entries)
    ext_oc_map = getters.get_account_open_close(ext_entries)
    for account in set(oc_map.keys()) & set(ext_oc_map.keys()):
        open_entry, close_entry = oc_map[account]
        ext_open_entry, ext_close_entry = ext_oc_map[account]
        if open_entry and ext_open_entry:
            open_entry.meta.update(ext_open_entry.meta)
        if close_entry and ext_close_entry:
            close_entry.meta.update(ext_close_entry.meta)

    # Map Commodity directives.
    comm_map = getters.get_commodity_map(entries, False)
    ext_comm_map = getters.get_commodity_map(ext_entries, False)
    for currency in set(comm_map) & set(ext_comm_map):
        comm_entry = comm_map[currency]
        ext_comm_entry = ext_comm_map[currency]
        if comm_entry and ext_comm_entry:
            comm_entry.meta.update(ext_comm_entry.meta)

    # Note: We cannot include the external file in the list of inputs so that a
    # change of it triggers a cache rebuild because side-effects on options_map
    # aren't cascaded through. This is something that should be defined better
    # in the plugin interface and perhaps improved upon.

    return new_entries, ext_errors
Exemple #5
0
    def make_table(self, period):
        """An account tree based on matching regex patterns."""
        root = [
            self.ledger.all_root_account.get('Income'),
            self.ledger.all_root_account.get('Expenses'),
        ]

        today = datetime.date.today()

        if period is not None:
            year, month = (int(n) for n in period.split('-', 1))
        else:
            year = today.year
            month = today.month
            period = f'{year:04}-{month:02}'

        self.open_close_map = getters.get_account_open_close(
            self.ledger.all_entries)

        # self.period_start = self.ledger._date_first
        # self.period_end = self.ledger._date_last
        self.period_start = datetime.date(year, month, 1)
        self.period_end = datetime.date(year + month // 12, month % 12 + 1, 1)

        endtoday = self.period_end
        if today.year == self.period_start.year and today.month == self.period_start.month:
            endtoday = today + datetime.timedelta(days=1)

        self.brows = ddict(Inventory)
        self.midbrows = ddict(Inventory)
        self.srows = ddict(Inventory)
        self.midsrows = ddict(Inventory)
        self.vrows = ddict(Inventory)
        self.midvrows = ddict(Inventory)
        for entry, posting in filter_postings(self.ledger.entries):
            if entry.date >= self.period_end:
                continue
            self.vrows[posting.account].add_position(posting)
            if entry.date < endtoday:
                self.midvrows[posting.account].add_position(posting)
            if entry.date < self.period_start:
                continue
            if 'rebudget' in entry.tags:
                rows = self.brows
                midrows = self.midbrows
            else:
                rows = self.srows
                midrows = self.midsrows
            rows[posting.account].add_position(posting)
            if entry.date < endtoday:
                midrows[posting.account].add_position(posting)

        return root, period
def validate_average_cost(entries, options_map, config_str=None):
    """Check that reducing legs on unbooked postings are near the average cost basis.

    Args:
      entries: A list of directives.
      unused_options_map: An options map.
      config_str: The configuration as a string version of a float.
    Returns:
      A list of new errors, if any were found.
    """
    # Initialize tolerance bounds.
    if config_str and config_str.strip():
        # pylint: disable=eval-used
        config_obj = eval(config_str, {}, {})
        if not isinstance(config_obj, float):
            raise RuntimeError("Invalid configuration for check_average_cost: "
                               "must be a float")
        tolerance = config_obj
    else:
        tolerance = DEFAULT_TOLERANCE
    min_tolerance = D(1 - tolerance)
    max_tolerance = D(1 + tolerance)

    errors = []
    ocmap = getters.get_account_open_close(entries)
    balances = collections.defaultdict(inventory.Inventory)
    for entry in entries:
        if isinstance(entry, Transaction):
            for posting in entry.postings:
                dopen = ocmap.get(posting.account, None)
                # Only process accounts with a NONE booking value.
                if dopen and dopen[0] and dopen[0].booking == Booking.NONE:
                    balance = balances[(posting.account,
                                        posting.units.currency,
                                        posting.cost.currency
                                        if posting.cost else None)]
                    if posting.units.number < ZERO:
                        average = balance.average().get_only_position()
                        if average is not None:
                            number = average.cost.number
                            min_valid = number * min_tolerance
                            max_valid = number * max_tolerance
                            if not (min_valid <= posting.cost.number <=
                                    max_valid):
                                errors.append(
                                    MatchBasisError(entry.meta, (
                                        "Cost basis on reducing posting is too far from "
                                        "the average cost ({} vs. {})".format(
                                            posting.cost.number,
                                            average.cost.number)), entry))
                    balance.add_position(posting)
    return entries, errors
Exemple #7
0
def create_row_context(entries, options_map):
    """Create the context container which we will use to evaluate rows."""
    context = RowContext()
    context.balance = inventory.Inventory()

    # Initialize some global properties for use by some of the accessors.
    context.options_map = options_map
    context.account_types = options.get_account_types(options_map)
    context.open_close_map = getters.get_account_open_close(entries)
    context.commodity_map = getters.get_commodity_directives(entries)
    context.price_map = prices.build_price_map(entries)

    return context
Exemple #8
0
    def test_get_account_open_close__duplicates(self, entries, _, __):
        """
        2014-01-01 open  Assets:Checking
        2014-01-02 open  Assets:Checking

        2014-01-28 close Assets:Checking
        2014-01-29 close Assets:Checking
        """
        open_close_map = getters.get_account_open_close(entries)
        self.assertEqual(1, len(open_close_map))
        open_entry, close_entry = open_close_map['Assets:Checking']
        self.assertEqual(datetime.date(2014, 1, 1), open_entry.date)
        self.assertEqual(datetime.date(2014, 1, 28), close_entry.date)
def create_open_directives(new_accounts, entries):
    meta = data.new_metadata('<zerosum>', 0)
    # Ensure that the accounts we're going to use to book the postings exist, by
    # creating open entries for those that we generated that weren't already
    # existing accounts.
    earliest_date = entries[0].date
    open_entries = getters.get_account_open_close(entries)
    new_open_entries = []
    for account_ in sorted(new_accounts):
        if account_ not in open_entries:
            meta = data.new_metadata(meta['filename'], 0)
            open_entry = data.Open(meta, earliest_date, account_, None, None)
            new_open_entries.append(open_entry)
    return (new_open_entries)
Exemple #10
0
    def test_get_account_open_close(self):
        entries = loader.load_string(TEST_INPUT)[0]
        ocmap = getters.get_account_open_close(entries)
        self.assertEqual(5, len(ocmap))

        def mapfound(account_name):
            open, close = ocmap[account_name]
            return (open is not None, close is not None)

        self.assertEqual(mapfound('Assets:US:Cash'), (True, True))
        self.assertEqual(mapfound('Assets:US:Credit-Card'), (True, True))
        self.assertEqual(mapfound('Expenses:Grocery'), (True, False))
        self.assertEqual(mapfound('Expenses:Coffee'), (True, False))
        self.assertEqual(mapfound('Expenses:Restaurant'), (True, False))
Exemple #11
0
def find_accounts(entries: data.Entries, options_map: data.Options,
                  start_date: Optional[Date]) -> List[Account]:
    """Return a list of account names from the balance sheet which either aren't
    closed or are closed now but were still open at the given start date.
    """
    commodities = getters.get_commodity_directives(entries)
    open_close_map = getters.get_account_open_close(entries)
    atypes = options.get_account_types(options_map)
    return sorted(
        account for account, (_open, _close) in open_close_map.items()
        if (accountlib.leaf(account) in commodities
            and acctypes.is_balance_sheet_account(account, atypes)
            and not acctypes.is_equity_account(account, atypes) and (
                _close is None or (start_date and _close.date > start_date))))
Exemple #12
0
    def render_beancount(self, entries, errors, options_map, file):
        if not entries:
            return

        open_close = getters.get_account_open_close(entries)

        # Render to stdout.
        maxlen = (max(len(account) for account in open_close) if open_close else 0)
        sortkey_fun = functools.partial(account_types.get_account_sort_key,
                                        options.get_account_types(options_map))
        for account, (open, close) in sorted(open_close.items(),
                                             key=lambda entry: sortkey_fun(entry[0])):
            open_date = open.date if open else ''
            close_date = close.date if close else ''
            file.write('{:{len}}  {}  {}\n'.format(account, open_date, close_date,
                                                   len=maxlen))
Exemple #13
0
def get_account_groups(entries: data.Entries, account_data: List[AccountData],
                       render_open: bool,
                       render_closed: bool) -> Dict[str, List[AccountData]]:
    """Logically group accounts for reporting."""
    groups = collections.defaultdict(list)
    open_close_map = getters.get_account_open_close(entries)
    for accdata in account_data:
        opn, cls = open_close_map[accdata.account]
        assert opn
        is_open = cls is None and not accdata.cash_flows[-1].balance.is_empty()
        if not (render_open if is_open else render_closed):
            continue
        prefix = "open" if is_open else "closed"
        group = "{}.{}".format(prefix, accdata.currency)
        groups[group].append(accdata)
    return dict(groups)
Exemple #14
0
def get_matching_postings(entries, options_map, query):
    query_text = 'SELECT * ' + query
    parser = query_parser.Parser()
    parsed_query = parser.parse(query_text)
    c_from = None
    if parsed_query.from_clause:
        c_from = query_compile.compile_from(
            parsed_query.from_clause, query_env.FilterEntriesEnvironment())
    c_where = None
    if parsed_query.where_clause:
        c_where = query_compile.compile_expression(
            parsed_query.where_clause, query_env.FilterPostingsEnvironment())

    # Figure out if we need to compute balance.
    balance = None
    if c_where and query_execute.uses_balance_column(c_where):
        balance = inventory.Inventory()

    context = query_execute.RowContext()
    context.balance = balance

    # Initialize some global properties for use by some of the accessors.
    context.options_map = options_map
    context.account_types = options.get_account_types(options_map)
    context.open_close_map = getters.get_account_open_close(entries)
    #context.commodity_map = getters.get_commodity_map(entries)
    context.price_map = prices.build_price_map(entries)

    if c_from is not None:
        filtered_entries = query_execute.filter_entries(
            c_from, entries, options_map)
    else:
        filtered_entries = entries
    for entry in filtered_entries:
        if isinstance(entry, Transaction):
            context.entry = entry
            matching_postings = []
            for posting in entry.postings:
                context.posting = posting
                if c_where is None or c_where(context):
                    matching_postings.append(posting)
            if matching_postings:
                yield (entry, matching_postings)
Exemple #15
0
    def test_group_accounts(self, entries, _, __):
        """
          ;; Absence of meta.
          2010-01-01 open Assets:US:BofA:Checking

          ;; Metadata on the parent.
          2010-01-01 open Assets:US:WellsFargo
            institution: "Wells Fargo Bank."
          2010-01-01 open Assets:US:WellsFargo:Checking

          ;; Ambiguous metadata.
          2010-01-01 open Assets:US:Chase
            institution: "Chase Manhattan Bank."
          2010-01-01 open Assets:US:Chase:Checking
            institution: "Chase Manhattan Bank Checking Division"

          ;; Two accounts joined by same institution.
          2010-01-01 open Assets:US:TDBank:Checking
            institution: "Toronto Dominion Bank."
          2010-01-01 open Liabilities:US:TDBank:CreditCard
            institution: "Toronto Dominion Bank."
        """
        open_close_map = getters.get_account_open_close(entries)
        accounts_map = {
            acc: open_entry
            for acc, (open_entry, _) in open_close_map.items()
        }
        groups, ignored = will.group_accounts_by_metadata(
            accounts_map, "institution")
        self.assertEqual(
            {
                'Chase Manhattan Bank Checking Division':
                ['Assets:US:Chase:Checking'],
                'Chase Manhattan Bank.': ['Assets:US:Chase'],
                'Toronto Dominion Bank.': [
                    'Assets:US:TDBank:Checking',
                    'Liabilities:US:TDBank:CreditCard'
                ],
                'Wells Fargo Bank.':
                ['Assets:US:WellsFargo', 'Assets:US:WellsFargo:Checking']
            }, groups)
        self.assertEqual({'Assets:US:BofA:Checking'}, ignored)
Exemple #16
0
def find_institutions(entries, options_map):
    """Gather all the institutions and valid accounts from the list of entries.

    Args:
      entries: A list of entries.
      options_map: A dict of options, as per the parser.
    Returns:
      See group_accounts_by_metadata().
    """
    acc_types = options.get_account_types(options_map)

    # Filter out accounts that are closed or that are income accounts.
    open_close_map = getters.get_account_open_close(entries)
    accounts_map = {acc: open_entry
                    for acc, (open_entry, close_entry) in open_close_map.items()
                    if (account_types.is_balance_sheet_account(acc, acc_types) and
                        close_entry is None)}

    # Group the accounts using groups defined implicitly by metadata.
    return group_accounts_by_metadata(accounts_map, 'institution')
Exemple #17
0
def get_accounts_table(entries: data.Entries, attributes: List[str]) -> Table:
    """Produce a Table of per-account attributes."""
    oc_map = getters.get_account_open_close(entries)
    accounts_map = {account: dopen for account, (dopen, _) in oc_map.items()}
    header = ['account'] + attributes
    defaults = {'tax': 'taxable',
                'liquid': False}
    def getter(entry, key):
        """Lookup the value working up the accounts tree."""
        value = entry.meta.get(key, None)
        if value is not None:
            return value
        account_name = account.parent(entry.account)
        if not account_name:
            return defaults.get(key, None)
        parent_entry = accounts_map.get(account_name, None)
        if not parent_entry:
            return defaults.get(key, None)
        return getter(parent_entry, key)
    return get_metamap_table(accounts_map, header, getter), accounts_map
Exemple #18
0
def infer_investments_configuration(entries: data.Entries,
                                    account_list: List[Account],
                                    out_config: InvestmentConfig):
    """Infer a reasonable configuration for input."""

    all_accounts = set(getters.get_account_open_close(entries))

    for account in account_list:
        aconfig = out_config.investment.add()
        aconfig.currency = accountlib.leaf(account)
        aconfig.asset_account = account

        regexp = re.compile(
            re.sub(r"^[A-Z][^:]+:", "[A-Z][A-Za-z0-9]+:", account) +
            ":Dividends?")
        for maccount in filter(regexp.match, all_accounts):
            aconfig.dividend_accounts.append(maccount)

        match_accounts = set()
        match_accounts.add(aconfig.asset_account)
        match_accounts.update(aconfig.dividend_accounts)
        match_accounts.update(aconfig.match_accounts)

        # Figure out the total set of accounts seed in those transactions.
        cash_accounts = set()
        for entry in data.filter_txns(entries):
            if any(posting.account in match_accounts
                   for posting in entry.postings):
                for posting in entry.postings:
                    if (posting.account == aconfig.asset_account
                            or posting.account in aconfig.dividend_accounts
                            or posting.account in aconfig.match_accounts):
                        continue
                    if (re.search(r":(Cash|Checking|Receivable|GSURefund)$",
                                  posting.account) or re.search(
                                      r"Receivable|Payable", posting.account)
                            or re.match(r"Income:.*:(Match401k)$",
                                        posting.account)):
                        cash_accounts.add(posting.account)
        aconfig.cash_accounts.extend(cash_accounts)
Exemple #19
0
def missing_open(filename):
    """Print Open directives missing in FILENAME.

    This can be useful during demos in order to quickly generate all the
    required Open directives without having to type them manually.

    """
    entries, errors, options_map = loader.load_file(filename)

    # Get accounts usage and open directives.
    first_use_map, _ = getters.get_accounts_use_map(entries)
    open_close_map = getters.get_account_open_close(entries)

    new_entries = []
    for account, first_use_date in first_use_map.items():
        if account not in open_close_map:
            new_entries.append(
                data.Open(data.new_metadata(filename, 0), first_use_date, account,
                          None, None))

    dcontext = options_map['dcontext']
    printer.print_entries(data.sorted(new_entries), dcontext)
Exemple #20
0
def read_assets(filename, currency, reduce_accounts, quantization):
    """Read a Beancount file and produce a list of assets.

    Args:
      filename: A string, the path to the Beancount file to read.
      currency: A string, the currency to convert all the contents to.
      reduce_accounts: A set of account names to be aggregated.
      quantization: A Decimal instance, to quantize all the resulting amounts.
    Returns:
      A list of (account-name, number-balance), numbers being assumed to be in
      the requested currency.
    """

    # Read the Beancount input file.
    entries, _, options_map = loader.load_file(filename,
                                               log_errors=logging.error)
    acctypes = options.get_account_types(options_map)
    price_map = prices.build_price_map(entries)
    ocmap = getters.get_account_open_close(entries)

    # Compute aggregations.
    real_root = realization.realize(entries, compute_balance=True)

    # Reduce accounts which have been found in details (mutate the tree in-place).
    for account in reduce_accounts:
        real_acc = realization.get(real_root, account)
        real_acc.balance = realization.compute_balance(real_acc)
        real_acc.clear()

    # Prune all the closed accounts and their parents.
    real_root = prune_closed_accounts(real_root, ocmap)

    # Produce a list of accounts and their balances reduced to a single currency.
    acceptable_types = (acctypes.assets, acctypes.liabilities)
    accounts = []
    for real_acc in realization.iter_children(real_root):
        atype = account_types.get_account_type(real_acc.account)
        if atype not in acceptable_types:
            continue

        try:
            _, close = ocmap[real_acc.account]
            if close is not None:
                continue
        except KeyError:
            #logging.info("Account not there: {}".format(real_acc.account))
            if real_acc.account not in reduce_accounts and real_acc.balance.is_empty(
            ):
                continue

        value_inv = real_acc.balance.reduce(
            lambda x: convert.get_value(x, price_map))
        currency_inv = value_inv.reduce(convert.convert_position, currency,
                                        price_map)
        amount = currency_inv.get_currency_units(currency)
        accounts.append(
            (real_acc.account, amount.number.quantize(quantization)))

    # Reduce this list of (account-name, balance-number) sorted by reverse amount order.
    accounts.sort(key=lambda x: x[1], reverse=True)
    return accounts
Exemple #21
0
def balance_by_account(entries, date=None, compress_unbooked=False):
    """Sum up the balance per account for all entries strictly before 'date'.

    Args:
      entries: A list of directives.
      date: An optional datetime.date instance. If provided, stop accumulating
        on and after this date. This is useful for summarization before a
        specific date.
      compress_unbooked: For accounts that have a booking method of NONE,
        compress their positions into a single average position. This can be
        used when you export the full list of positions, because those accounts
        will have a myriad of small positions from fees at negative cost and
        what-not.
    Returns:
      A pair of a dict of account string to instance Inventory (the balance of
      this account before the given date), and the index in the list of entries
      where the date was encountered. If all entries are located before the
      cutoff date, an index one beyond the last entry is returned.

    """
    balances = collections.defaultdict(inventory.Inventory)
    for index, entry in enumerate(entries):
        if date and entry.date >= date:
            break

        if isinstance(entry, Transaction):
            for posting in entry.postings:
                account_balance = balances[posting.account]

                # Note: We must allow negative lots at cost, because this may be
                # used to reduce a filtered list of entries which may not
                # include the entries necessary to keep units at cost always
                # above zero. The only summation that is guaranteed to be above
                # zero is if all the entries are being summed together, no
                # entries are filtered, at least for a particular account's
                # postings.
                account_balance.add_position(posting)
    else:
        index = len(entries)

    # If the account has "NONE" booking method, merge all its postings
    # together in order to obtain an accurate cost basis and balance of
    # units.
    #
    # (This is a complex issue.) If you accrued positions without having them
    # booked properly against existing cost bases, you have not properly accounted
    # for the profit/loss to other postings. This means that the resulting
    # profit/loss is merged in the cost basis of the positive and negative
    # postings.
    if compress_unbooked:
        oc_map = getters.get_account_open_close(entries)
        accounts_map = {
            account: dopen
            for account, (dopen, _) in oc_map.items()
        }

        for account, balance in balances.items():
            dopen = accounts_map.get(account, None)
            if dopen is not None and dopen.booking is data.Booking.NONE:
                average_balance = balance.average()
                balances[account] = inventory.Inventory(
                    pos for pos in average_balance)

    return balances, index
Exemple #22
0
def split_expenses(entries, options_map, config):
    """Split postings according to expenses (see module docstring for details).

    Args:
      entries: A list of directives. We're interested only in the Transaction instances.
      unused_options_map: A parser options dict.
      config: The plugin configuration string.
    Returns:
      A list of entries, with potentially more accounts and potentially more
      postings with smaller amounts.
    """

    # Validate and sanitize configuration.
    if isinstance(config, str):
        members = config.split()
    elif isinstance(config, (tuple, list)):
        members = config
    else:
        raise RuntimeError(
            "Invalid plugin configuration: configuration for split_expenses "
            "should be a string or a sequence.")

    acctypes = options.get_account_types(options_map)

    def is_expense_account(account):
        return account_types.get_account_type(account) == acctypes.expenses

    # A predicate to quickly identify if an account contains the name of a
    # member.
    is_individual_account = re.compile('|'.join(map(re.escape,
                                                    members))).search

    # Existing and previously unseen accounts.
    new_accounts = set()

    # Filter the entries and transform transactions.
    new_entries = []
    for entry in entries:
        if isinstance(entry, data.Transaction):
            new_postings = []
            for posting in entry.postings:
                if (is_expense_account(posting.account)
                        and not is_individual_account(posting.account)):

                    # Split this posting into multiple postings.
                    split_units = amount.Amount(
                        posting.units.number / len(members),
                        posting.units.currency)

                    for member in members:
                        # Mark the account as new if never seen before.
                        subaccount = account.join(posting.account, member)
                        new_accounts.add(subaccount)

                        # Ensure the modified postings are marked as
                        # automatically calculated, so that the resulting
                        # calculated amounts aren't used to affect inferred
                        # tolerances.
                        meta = posting.meta.copy() if posting.meta else {}
                        meta[interpolate.AUTOMATIC_META] = True

                        # Add a new posting for each member, to a new account
                        # with the name of this member.
                        new_postings.append(
                            posting._replace(meta=meta,
                                             account=subaccount,
                                             units=split_units,
                                             cost=posting.cost))
                else:
                    new_postings.append(posting)

            # Modify the entry in-place, replace its postings.
            entry = entry._replace(postings=new_postings)

        new_entries.append(entry)

    # Create Open directives for new subaccounts if necessary.
    oc_map = getters.get_account_open_close(entries)
    open_date = entries[0].date
    meta = data.new_metadata('<split_expenses>', 0)
    open_entries = []
    for new_account in new_accounts:
        if new_account not in oc_map:
            entry = data.Open(meta, open_date, new_account, None, None)
            open_entries.append(entry)

    return open_entries + new_entries, []
Exemple #23
0
 def get_account_open_close(self):
     return getters.get_account_open_close(self.entries)
Exemple #24
0
 def get_account_open(self):
     oc = getters.get_account_open_close(self.entries)
     opens = [e for e in oc if isinstance(e, Open)]
     return opens
def add_unrealized_gains(entries, options_map, subaccount=None):
    """Insert entries for unrealized capital gains.

    This function inserts entries that represent unrealized gains, at the end of
    the available history. It returns a new list of entries, with the new gains
    inserted. It replaces the account type with an entry in an income account.
    Optionally, it can book the gain in a subaccount of the original and income
    accounts.

    Args:
      entries: A list of data directives.
      options_map: A dict of options, that confirms to beancount.parser.options.
      subaccount: A string, and optional the name of a subaccount to create
        under an account to book the unrealized gain. If this is left to its
        default value, the gain is booked directly in the same account.
    Returns:
      A list of entries, which includes the new unrealized capital gains entries
      at the end, and a list of errors. The new list of entries is still sorted.
    """
    errors = []
    meta = data.new_metadata('<unrealized_gains>', 0)

    account_types = options.get_account_types(options_map)

    # Assert the subaccount name is in valid format.
    if subaccount:
        validation_account = account.join(account_types.assets, subaccount)
        if not account.is_valid(validation_account):
            errors.append(
                UnrealizedError(meta,
                                "Invalid subaccount name: '{}'".format(subaccount),
                                None))
            return entries, errors

    if not entries:
        return (entries, errors)

    # Group positions by (account, cost, cost_currency).
    price_map = prices.build_price_map(entries)

    new_entries = []

    # Start at the first month after our first transaction
    date = date_utils.next_month(entries[0].date)
    last_month = date_utils.next_month(entries[-1].date)
    last_holdings_with_currencies = None
    while date <= last_month:
        date_entries, holdings_with_currencies, date_errors = add_unrealized_gains_at_date(
            entries, new_entries, account_types.income, price_map, date, meta,
            subaccount)
        new_entries.extend(date_entries)
        errors.extend(date_errors)

        if last_holdings_with_currencies:
            for account_, cost_currency, currency in last_holdings_with_currencies - holdings_with_currencies:
                # Create a negation transaction specifically to mark that all gains have been realized
                if subaccount:
                    account_ = account.join(account_, subaccount)

                latest_unrealized_entry = find_previous_unrealized_transaction(new_entries, account_, cost_currency, currency)
                if not latest_unrealized_entry:
                    continue
                entry = data.Transaction(data.new_metadata(meta["filename"], lineno=999,
                                         kvlist={'prev_currency': currency}), date,
                                         flags.FLAG_UNREALIZED, None, 'Clear unrealized gains/losses of {}'.format(currency), set(), set(), [])

                # Negate the previous transaction because of unrealized gains are now 0
                for posting in latest_unrealized_entry.postings[:2]:
                    entry.postings.append(
                        data.Posting(
                            posting.account,
                            -posting.units,
                            None,
                            None,
                            None,
                            None))
                new_entries.append(entry)


        last_holdings_with_currencies = holdings_with_currencies
        date = date_utils.next_month(date)

    # Ensure that the accounts we're going to use to book the postings exist, by
    # creating open entries for those that we generated that weren't already
    # existing accounts.
    new_accounts = {posting.account
                    for entry in new_entries
                    for posting in entry.postings}
    open_entries = getters.get_account_open_close(entries)
    new_open_entries = []
    for index, account_ in enumerate(sorted(new_accounts)):
        if account_ not in open_entries:
            meta = data.new_metadata(meta["filename"], index)
            open_entry = data.Open(meta, new_entries[0].date, account_, None, None)
            new_open_entries.append(open_entry)

    return (entries + new_open_entries + new_entries, errors)
Exemple #26
0
def main():
    logging.basicConfig(level=logging.INFO, format='%(levelname)-8s: %(message)s')
    parser = argparse.ArgumentParser(description=__doc__.strip())
    parser.add_argument('filename', help='Beancount input file')
    #parser.add_argument('docid', help="Spreadsheets doc id to update")
    parser.add_argument('-n', '--dry-run', action='store_true')
    args = parser.parse_args()

    # Load the file contents.
    entries, errors, options_map = loader.load_file(args.filename)

    # Enumerate the list of assets.
    def keyfun(posting):
        if posting.cost is None:
            return (1, posting.units.currency, posting.account)
        else:
            return (0, posting.account, posting.cost.currency)

    postings = sorted(get_balance_sheet_balances(clean_entries_for_balances(entries),
                                                 options_map),
                      key=keyfun)

    # Simplify the accounts to their root accounts.
    root_accounts = get_root_accounts(postings)
    postings = [posting._replace(account=root_accounts[posting.account])
                for posting in postings]

    # Aggregate postings by account/currency.
    agg_postings = sorted(aggregate_postings(postings), key=keyfun)
    agg_postings = list(agg_postings)

    # Add prices to the postings.
    agg_postings = add_prices_to_postings(entries, agg_postings)

    # Get the map of commodities to export meta tags.
    commodities_map = getters.get_commodity_map(entries)
    exports = getters.get_values_meta(commodities_map, 'export')
    asset_type = getters.get_values_meta(commodities_map, 'assets')

    # Get the map of accounts to export meta tags.
    accounts_map = {
        account: open
        for account, (open, _) in getters.get_account_open_close(entries).items()}
    tax_map = populate_with_parents(getters.get_values_meta(accounts_map, 'tax'), 'TAXABLE')

    # Filter out postings to be ignored.
    agg_postings = [posting
                    for posting in agg_postings
                    if exports.get(posting.units.currency, None) != 'IGNORE']

    # Realize the model.
    price_map = prices.build_price_map(entries)
    model = Model(price_map, list(agg_postings), exports, asset_type, tax_map)

    # Write out the assets to stdout in CSV format.
    if args.dry_run:
        return
    table = model_to_table(model)
    table[0][0] += ' ({:%Y-%m-%d %H:%M})'.format(datetime.datetime.now())
    wr = csv.writer(sys.stdout)
    wr.writerows(table)
Exemple #27
0
def internalize(entries,
                transfer_account,
                accounts_value,
                accounts_intflows,
                accounts_internalize=None):
    """Internalize internal flows that would be lost because booked against external
    flow accounts. This splits up entries that have accounts both in internal
    flows and external flows. A new set of entries are returned, along with a
    list of entries that were split and replaced by a pair of entries.

    Args:
      entries: A list of directives to process for internalization.
      transfer_account: A string, the name of an account to use for internalizing entries
        which need to be split between internal and external flows. A good default value
        would be an equity account, 'Equity:Internalized' or something like that.
      accounts_value: A set of account name strings, the names of the asset accounts
        included in valuing the portfolio.
      accounts_intflows: A set of account name strings, the names of internal flow
        accounts (normally income and expenses) that aren't external flows.
      accounts_internalize: A set of account name strings to trigger explicit
        internalization of transactions with no value account. If a transaction
        is found that has only internal accounts and external accounts, the
        postings whose accounts are in this set of accounts will be internalize.
        This is a method that can be used to pull dividends in the portfolio
        when valueing portfolios without their cash component. See docstring and
        documentation for details. If specific, this set of accounts must be a
        subset of the internal flows accounts.
    Returns:
      A pair of the new list of internalized entries, including all the other entries, and
      a short list of just the original entires that were removed and replaced by pairs of
      entries.
    """
    # Verify that external flow entries only affect balance sheet accounts and
    # not income or expenses accounts (internal flows). We do this because we
    # want to ensure that all income and expenses are incurred against assets
    # that live within the assets group. An example of something we'd like to
    # avoid is an external flow paying for fees incurred within the account that
    # should diminish the returns of the related accounts. To fix this, we split
    # the entry into two entries, one without external flows against an transfer
    # account that we consider an assets account, and just the external flows
    # against this same tranfer account.
    assert (isinstance(
        transfer_account,
        str)), ("Invalid transfer account: {}".format(transfer_account))

    if accounts_internalize and not (accounts_internalize <=
                                     accounts_intflows):
        raise ValueError(
            "Internalization accounts is not a subset of internal flows accounts."
        )

    new_entries = []
    replaced_entries = []
    index = 1
    for entry in entries:
        if not isinstance(entry, data.Transaction):
            new_entries.append(entry)
            continue

        # Break up postings into the three categories.
        postings_assets = []
        postings_intflows = []
        postings_extflows = []
        postings_internalize = []
        for posting in entry.postings:
            if posting.account in accounts_value:
                postings_list = postings_assets
            elif posting.account in accounts_intflows:
                postings_list = postings_intflows
            else:
                postings_list = postings_extflows
            postings_list.append(posting)

            if accounts_internalize and posting.account in accounts_internalize:
                postings_internalize.append(posting)

        # Check if the entry is to be internalized and split it up in two
        # entries and replace the entrie if that's the case.
        if (postings_intflows and postings_extflows
                and (postings_assets or postings_internalize)):

            replaced_entries.append(entry)

            # We will attach a link to each of the split entries.
            link = LINK_FORMAT.format(index)
            index += 1

            # Calculate the weight of the balance to transfer.
            balance_transfer = inventory.Inventory()
            for posting in postings_extflows:
                balance_transfer.add_amount(
                    posting.position.get_weight(posting.price))

            prototype_entry = entry._replace(flag=flags.FLAG_RETURNS,
                                             links=(entry.links or set())
                                             | set([link]))

            # Create internal flows posting.
            postings_transfer_int = [
                data.Posting(transfer_account, position_, None, None, None)
                for position_ in balance_transfer.get_positions()
            ]
            new_entries.append(
                prototype_entry._replace(postings=(postings_assets +
                                                   postings_intflows +
                                                   postings_transfer_int)))

            # Create external flows posting.
            postings_transfer_ext = [
                data.Posting(transfer_account, -position_, None, None, None)
                for position_ in balance_transfer.get_positions()
            ]
            new_entries.append(
                prototype_entry._replace(postings=(postings_transfer_ext +
                                                   postings_extflows)))
        else:
            new_entries.append(entry)

    # The transfer account does not have an Open entry, insert one. (This is
    # just us being pedantic about Beancount requirements, this will not change
    # the returns, but if someone looks at internalized entries it produces a
    # correct set of entries you can load cleanly).
    open_close_map = getters.get_account_open_close(new_entries)
    if transfer_account not in open_close_map:
        open_transfer_entry = data.Open(
            data.new_metadata("beancount.projects.returns", 0),
            new_entries[0].date, transfer_account, None, None)
        new_entries.insert(0, open_transfer_entry)

    return new_entries, replaced_entries
Exemple #28
0
def execute_query(query, entries, options_map):
    """Given a compiled select statement, execute the query.

    Args:
      query: An instance of a query_compile.Query
      entries: A list of directives.
      options_map: A parser's option_map.
    Returns:
      A pair of:
        result_types: A list of (name, data-type) item pairs.
        result_rows: A list of ResultRow tuples of length and types described by
          'result_types'.
    """
    # Filter the entries using the FROM clause.
    filt_entries = (filter_entries(query.c_from, entries, options_map)
                    if query.c_from is not None else
                    entries)

    # Figure out the result types that describe what we return.
    result_types = [(target.name, target.c_expr.dtype)
                    for target in query.c_targets
                    if target.name is not None]

    # Create a class for each final result.
    # pylint: disable=invalid-name
    ResultRow = collections.namedtuple('ResultRow',
                                       [target.name
                                        for target in query.c_targets
                                        if target.name is not None])

    # Pre-compute lists of the expressions to evaluate.
    group_indexes = (set(query.group_indexes)
                     if query.group_indexes is not None
                     else query.group_indexes)

    # Indexes of the columns for result rows and order rows.
    result_indexes = [index
                      for index, c_target in enumerate(query.c_targets)
                      if c_target.name]
    order_indexes = query.order_indexes

    # Figure out if we need to compute balance.
    balance = None
    if any(uses_balance_column(c_expr)
           for c_expr in itertools.chain(
               [c_target.c_expr for c_target in query.c_targets],
               [query.c_where] if query.c_where else [])):
        balance = inventory.Inventory()

    # Create the context container which we will use to evaluate rows.
    context = RowContext()
    context.balance = balance

    # Initialize some global properties for use by some of the accessors.
    context.options_map = options_map
    context.account_types = options.get_account_types(options_map)
    context.open_close_map = getters.get_account_open_close(entries)
    context.commodity_map = getters.get_commodity_map(entries)
    context.price_map = prices.build_price_map(entries)

    # Dispatch between the non-aggregated queries and aggregated queries.
    c_where = query.c_where
    schwartz_rows = []
    if query.group_indexes is None:
        # This is a non-aggregated query.

        # Precompute a list of expressions to be evaluated, and of indexes
        # within it for the result rows and the order keys.
        c_target_exprs = [c_target.c_expr
                          for c_target in query.c_targets]

        # Iterate over all the postings once and produce schwartzian rows.
        for entry in filt_entries:
            if isinstance(entry, data.Transaction):
                context.entry = entry
                for posting in entry.postings:
                    context.posting = posting
                    if c_where is None or c_where(context):
                        # Compute the balance.
                        if balance is not None:
                            balance.add_position(posting)

                        # Evaluate all the values.
                        values = [c_expr(context) for c_expr in c_target_exprs]

                        # Compute result and sort-key objects.
                        result = ResultRow._make(values[index]
                                                 for index in result_indexes)
                        sortkey = (tuple(values[index] for index in order_indexes)
                                   if order_indexes is not None
                                   else None)
                        schwartz_rows.append((sortkey, result))
    else:
        # This is an aggregated query.

        # Precompute lists of non-aggregate and aggregate expressions to
        # evaluate. For aggregate targets, we hunt down the aggregate
        # sub-expressions to evaluate, to avoid recursion during iteration.
        c_nonaggregate_exprs = []
        c_aggregate_exprs = []
        for index, c_target in enumerate(query.c_targets):
            c_expr = c_target.c_expr
            if index in group_indexes:
                c_nonaggregate_exprs.append(c_expr)
            else:
                _, aggregate_exprs = query_compile.get_columns_and_aggregates(c_expr)
                c_aggregate_exprs.extend(aggregate_exprs)
        # Note: it is possible that there are no aggregates to compute here. You could
        # have all columns be non-aggregates and group-by the entire list of columns.

        # Pre-allocate handles in aggregation nodes.
        allocator = Allocator()
        for c_expr in c_aggregate_exprs:
            c_expr.allocate(allocator)

        # Iterate over all the postings to evaluate the aggregates.
        agg_store = {}
        for entry in filt_entries:
            if isinstance(entry, data.Transaction):
                context.entry = entry
                for posting in entry.postings:
                    context.posting = posting
                    if c_where is None or c_where(context):
                        # Compute the balance.
                        if balance is not None:
                            balance.add_position(posting)

                        # Compute the non-aggregate expressions.
                        row_key = tuple(c_expr(context)
                                        for c_expr in c_nonaggregate_exprs)

                        # Get an appropriate store for the unique key of this row.
                        try:
                            store = agg_store[row_key]
                        except KeyError:
                            # This is a row; create a new store.
                            store = allocator.create_store()
                            for c_expr in c_aggregate_exprs:
                                c_expr.initialize(store)
                            agg_store[row_key] = store

                        # Update the aggregate expressions.
                        for c_expr in c_aggregate_exprs:
                            c_expr.update(store, context)

        # Iterate over all the aggregations to produce the schwartzian rows.
        for key, store in agg_store.items():
            key_iter = iter(key)
            values = []

            # Finalize the store.
            for c_expr in c_aggregate_exprs:
                c_expr.finalize(store)
            context.store = store

            for index, c_target in enumerate(query.c_targets):
                if index in group_indexes:
                    value = next(key_iter)
                else:
                    value = c_target.c_expr(context)
                values.append(value)

            # Compute result and sort-key objects.
            result = ResultRow._make(values[index]
                                     for index in result_indexes)
            sortkey = (tuple(values[index] for index in order_indexes)
                       if order_indexes is not None
                       else None)
            schwartz_rows.append((sortkey, result))

    # Order results if requested.
    if order_indexes is not None:
        schwartz_rows.sort(key=lambda x: x[0],
                           reverse=(query.ordering == 'DESC'))

    # Extract final results, in sorted order at this point.
    result_rows = [x[1] for x in schwartz_rows]

    # Apply distinct.
    if query.distinct:
        result_rows = list(misc_utils.uniquify(result_rows))

    # Apply limit.
    if query.limit is not None:
        result_rows = result_rows[:query.limit]

    # Flatten inventories if requested.
    if query.flatten:
        result_types, result_rows = flatten_results(result_types, result_rows)

    return (result_types, result_rows)
Exemple #29
0
def process_account_entries(entries: data.Entries, config: InvestmentConfig,
                            investment: Investment,
                            check_explicit_flows: bool) -> AccountData:
    """Process a single account."""
    account = investment.asset_account
    logging.info("Processing account: %s", account)

    # Extract the relevant transactions.
    transactions = extract_transactions_for_account(entries, investment)
    if not transactions:
        logging.warning("No transactions for %s; skipping.", account)
        return None

    # Categorize the set of accounts encountered in the filtered transactions.
    seen_accounts = {
        posting.account
        for entry in transactions for posting in entry.postings
    }
    catmap = categorize_accounts(config, investment, seen_accounts)

    # Process each of the transactions, adding derived values as metadata.
    cash_flows = []
    balance = Inventory()
    decorated_transactions = []
    for entry in transactions:

        # Compute the signature of the transaction.
        entry = categorize_entry(catmap, entry)
        signature = compute_transaction_signature(entry)
        entry.meta["signature"] = signature

        # TODO(blais): Cache balance in every transaction to speed up
        # computation? Do this later.
        if False:
            # Update the total position in the asset we're interested in.
            for posting in entry.postings:
                if posting.meta["category"] is Cat.ASSET:
                    balance.add_position(posting)

        # Compute the cash flows associated with the transaction.
        flows_general = produce_cash_flows_general(entry, account)
        if check_explicit_flows:
            # Attempt the explicit method.
            flows_explicit = produce_cash_flows_explicit(entry, account)
            if flows_explicit != flows_general:
                print(
                    "Differences found between general and explicit methods:")
                print("Explicit handlers:")
                for flow in flows_explicit:
                    print("  ", flow)
                print("General handler:")
                for flow in flows_general:
                    print("  ", flow)
                raise ValueError(
                    "Differences found between general and explicit methods:")

        cash_flows.extend(flows_general)
        decorated_transactions.append(entry)

    cost_currencies = set(cf.amount.currency for cf in cash_flows)
    #assert len(cost_currencies) == 1, str(cost_currencies)
    cost_currency = cost_currencies.pop() if cost_currencies else None

    currency = investment.currency
    commodity_map = getters.get_commodity_directives(entries)
    comm = commodity_map[currency] if currency else None

    open_close_map = getters.get_account_open_close(entries)
    opn, cls = open_close_map[account]

    # Compute the final balance.
    balance = compute_balance_at(decorated_transactions)

    return AccountData(account, currency, cost_currency, comm, opn, cls,
                       cash_flows, decorated_transactions, balance, catmap)
Exemple #30
0
def add_unrealized_gains(entries, options_map, subaccount=None):
    """Insert entries for unrealized capital gains.

    This function inserts entries that represent unrealized gains, at the end of
    the available history. It returns a new list of entries, with the new gains
    inserted. It replaces the account type with an entry in an income account.
    Optionally, it can book the gain in a subaccount of the original and income
    accounts.

    Args:
      entries: A list of data directives.
      options_map: A dict of options, that confirms to beancount.parser.options.
      subaccount: A string, and optional the name of a subaccount to create
        under an account to book the unrealized gain. If this is left to its
        default value, the gain is booked directly in the same account.
    Returns:
      A list of entries, which includes the new unrealized capital gains entries
      at the end, and a list of errors. The new list of entries is still sorted.
    """
    errors = []
    meta = data.new_metadata('<unrealized_gains>', 0)

    account_types = options.get_account_types(options_map)

    # Assert the subaccount name is in valid format.
    if subaccount:
        validation_account = account.join(account_types.assets, subaccount)
        if not account.is_valid(validation_account):
            errors.append(
                UnrealizedError(meta,
                                "Invalid subaccount name: '{}'".format(subaccount),
                                None))
            return entries, errors

    if not entries:
        return (entries, errors)

    # Group positions by (account, cost, cost_currency).
    price_map = prices.build_price_map(entries)
    holdings_list = holdings.get_final_holdings(entries, price_map=price_map)

    # Group positions by (account, cost, cost_currency).
    holdings_list = holdings.aggregate_holdings_by(
        holdings_list, lambda h: (h.account, h.currency, h.cost_currency))

    # Get the latest prices from the entries.
    price_map = prices.build_price_map(entries)

    # Create transactions to account for each position.
    new_entries = []
    latest_date = entries[-1].date
    for index, holding in enumerate(holdings_list):
        if (holding.currency == holding.cost_currency or
            holding.cost_currency is None):
            continue

        # Note: since we're only considering positions held at cost, the
        # transaction that created the position *must* have created at least one
        # price point for that commodity, so we never expect for a price not to
        # be available, which is reasonable.
        if holding.price_number is None:
            # An entry without a price might indicate that this is a holding
            # resulting from leaked cost basis. {0ed05c502e63, b/16}
            if holding.number:
                errors.append(
                    UnrealizedError(meta,
                                    "A valid price for {h.currency}/{h.cost_currency} "
                                    "could not be found".format(h=holding), None))
            continue

        # Compute the PnL; if there is no profit or loss, we create a
        # corresponding entry anyway.
        pnl = holding.market_value - holding.book_value
        if holding.number == ZERO:
            # If the number of units sum to zero, the holdings should have been
            # zero.
            errors.append(
                UnrealizedError(
                    meta,
                    "Number of units of {} in {} in holdings sum to zero "
                    "for account {} and should not".format(
                        holding.currency, holding.cost_currency, holding.account),
                    None))
            continue

        # Compute the name of the accounts and add the requested subaccount name
        # if requested.
        asset_account = holding.account
        income_account = account.join(account_types.income,
                                      account.sans_root(holding.account))
        if subaccount:
            asset_account = account.join(asset_account, subaccount)
            income_account = account.join(income_account, subaccount)

        # Create a new transaction to account for this difference in gain.
        gain_loss_str = "gain" if pnl > ZERO else "loss"
        narration = ("Unrealized {} for {h.number} units of {h.currency} "
                     "(price: {h.price_number:.4f} {h.cost_currency} as of {h.price_date}, "
                     "average cost: {h.cost_number:.4f} {h.cost_currency})").format(
                         gain_loss_str, h=holding)
        entry = data.Transaction(data.new_metadata(meta["filename"], lineno=1000 + index),
                                 latest_date, flags.FLAG_UNREALIZED,
                                 None, narration, EMPTY_SET, EMPTY_SET, [])

        # Book this as income, converting the account name to be the same, but as income.
        # Note: this is a rather convenient but arbitrary choice--maybe it would be best to
        # let the user decide to what account to book it, but I don't a nice way to let the
        # user specify this.
        #
        # Note: we never set a price because we don't want these to end up in Conversions.
        entry.postings.extend([
            data.Posting(
                asset_account,
                amount.Amount(pnl, holding.cost_currency),
                None,
                None,
                None,
                None),
            data.Posting(
                income_account,
                amount.Amount(-pnl, holding.cost_currency),
                None,
                None,
                None,
                None)
        ])

        new_entries.append(entry)

    # Ensure that the accounts we're going to use to book the postings exist, by
    # creating open entries for those that we generated that weren't already
    # existing accounts.
    new_accounts = {posting.account
                    for entry in new_entries
                    for posting in entry.postings}
    open_entries = getters.get_account_open_close(entries)
    new_open_entries = []
    for account_ in sorted(new_accounts):
        if account_ not in open_entries:
            meta = data.new_metadata(meta["filename"], index)
            open_entry = data.Open(meta, latest_date, account_, None, None)
            new_open_entries.append(open_entry)

    return (entries + new_open_entries + new_entries, errors)
Exemple #31
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