689 lines
23 KiB
Python
689 lines
23 KiB
Python
"""Enhanced Beancount data structures for Conservancy
|
|
|
|
The classes in this module are interface-compatible with Beancount's core data
|
|
structures, and provide additional business logic that we want to use
|
|
throughout Conservancy tools.
|
|
"""
|
|
# Copyright © 2020 Brett Smith
|
|
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
|
|
#
|
|
# Full copyright and licensing details can be found at toplevel file
|
|
# LICENSE.txt in the repository.
|
|
|
|
import collections
|
|
import datetime
|
|
import decimal
|
|
import functools
|
|
import re
|
|
|
|
from beancount.core import account as bc_account
|
|
from beancount.core import amount as bc_amount
|
|
from beancount.core import convert as bc_convert
|
|
from beancount.core import data as bc_data
|
|
from beancount.core import position as bc_position
|
|
from beancount.parser import options as bc_options
|
|
|
|
from typing import (
|
|
cast,
|
|
overload,
|
|
Callable,
|
|
Hashable,
|
|
Iterable,
|
|
Iterator,
|
|
MutableMapping,
|
|
Optional,
|
|
Pattern,
|
|
Sequence,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
|
|
from .beancount_types import (
|
|
Close,
|
|
Currency,
|
|
Directive,
|
|
Meta,
|
|
MetaKey,
|
|
MetaValue,
|
|
Open,
|
|
OptionsMap,
|
|
Posting as BasePosting,
|
|
Transaction,
|
|
)
|
|
|
|
DecimalCompat = Union[decimal.Decimal, int]
|
|
|
|
EQUITY_ACCOUNTS = frozenset([
|
|
'Equity',
|
|
'Expenses',
|
|
'Income',
|
|
])
|
|
FUND_ACCOUNTS = EQUITY_ACCOUNTS | frozenset([
|
|
'Assets:Prepaid',
|
|
'Assets:Receivable',
|
|
'Liabilities:Payable',
|
|
'Liabilities:UnearnedIncome',
|
|
])
|
|
LINK_METADATA = frozenset([
|
|
'approval',
|
|
'bank-statement',
|
|
'check',
|
|
'contract',
|
|
'invoice',
|
|
'purchase-order',
|
|
'receipt',
|
|
'rt-id',
|
|
'statement',
|
|
'tax-reporting',
|
|
'tax-statement',
|
|
])
|
|
|
|
class AccountMeta(MutableMapping[MetaKey, MetaValue]):
|
|
"""Access account metadata
|
|
|
|
This class provides a consistent interface to all the metadata provided by
|
|
Beancount's ``open`` and ``close`` directives: open and close dates,
|
|
used currencies, booking method, and the metadata associated with each.
|
|
|
|
For convenience, you can use this class as a Mapping to access the ``open``
|
|
directive's metadata directly.
|
|
"""
|
|
__slots__ = ('_opening', '_closing')
|
|
|
|
def __init__(self, opening: Open, closing: Optional[Close]=None) -> None:
|
|
self._opening = opening
|
|
self._closing: Optional[Close] = None
|
|
if closing is not None:
|
|
self.add_closing(closing)
|
|
|
|
def add_closing(self, closing: Close) -> None:
|
|
if self._closing is not None and self._closing is not closing:
|
|
raise ValueError(f"{self.account} already closed by {self._closing!r}")
|
|
elif closing.account != self.account:
|
|
raise ValueError(f"cannot close {self.account} with {closing.account}")
|
|
elif closing.date < self.open_date:
|
|
raise ValueError(f"close date {closing.date} predates open date {self.open_date}")
|
|
else:
|
|
self._closing = closing
|
|
|
|
def __iter__(self) -> Iterator[MetaKey]:
|
|
return iter(self._opening.meta)
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._opening.meta)
|
|
|
|
def __getitem__(self, key: MetaKey) -> MetaValue:
|
|
return self._opening.meta[key]
|
|
|
|
def __setitem__(self, key: MetaKey, value: MetaValue) -> None:
|
|
self._opening.meta[key] = value
|
|
|
|
def __delitem__(self, key: MetaKey) -> None:
|
|
del self._opening.meta[key]
|
|
|
|
@property
|
|
def account(self) -> 'Account':
|
|
return Account(self._opening.account)
|
|
|
|
@property
|
|
def booking(self) -> Optional[bc_data.Booking]:
|
|
return self._opening.booking
|
|
|
|
@property
|
|
def close_date(self) -> Optional[datetime.date]:
|
|
return None if self._closing is None else self._closing.date
|
|
|
|
@property
|
|
def close_meta(self) -> Optional[Meta]:
|
|
return None if self._closing is None else self._closing.meta
|
|
|
|
@property
|
|
def currencies(self) -> Optional[Sequence[Currency]]:
|
|
return self._opening.currencies
|
|
|
|
@property
|
|
def open_date(self) -> datetime.date:
|
|
return self._opening.date
|
|
|
|
@property
|
|
def open_meta(self) -> Meta:
|
|
return self._opening.meta
|
|
|
|
|
|
class Account(str):
|
|
"""Account name string
|
|
|
|
This is a string that names an account, like Assets:Bank:Checking
|
|
or Income:Donations. This class provides additional methods for common
|
|
account name parsing and queries.
|
|
"""
|
|
__slots__ = ()
|
|
|
|
ACCOUNT_RE: Pattern
|
|
SEP = bc_account.sep
|
|
_meta_map: MutableMapping[str, AccountMeta] = {}
|
|
_options_map: OptionsMap
|
|
|
|
@classmethod
|
|
def load_options_map(cls, options_map: OptionsMap) -> None:
|
|
cls._options_map = options_map
|
|
roots: Sequence[str] = bc_options.get_account_types(options_map)
|
|
cls.ACCOUNT_RE = re.compile(
|
|
r'^(?:{})(?:{}[A-Z0-9][-A-Za-z0-9]*)+$'.format(
|
|
'|'.join(roots), cls.SEP,
|
|
))
|
|
|
|
@classmethod
|
|
def load_opening(cls, opening: Open) -> None:
|
|
cls._meta_map[opening.account] = AccountMeta(opening)
|
|
|
|
@classmethod
|
|
def load_closing(cls, closing: Close) -> None:
|
|
try:
|
|
cls._meta_map[closing.account].add_closing(closing)
|
|
except KeyError:
|
|
raise ValueError(
|
|
f"tried to load {closing.account} close directive before open",
|
|
) from None
|
|
|
|
@classmethod
|
|
def load_openings_and_closings(cls, entries: Iterable[Directive]) -> None:
|
|
for entry in entries:
|
|
# type ignores because Beancount's directives aren't type-checkable.
|
|
if isinstance(entry, bc_data.Open):
|
|
cls.load_opening(entry) # type:ignore[arg-type]
|
|
elif isinstance(entry, bc_data.Close):
|
|
cls.load_closing(entry) # type:ignore[arg-type]
|
|
|
|
@classmethod
|
|
def load_from_books(cls, entries: Iterable[Directive], options_map: OptionsMap) -> None:
|
|
cls.load_options_map(options_map)
|
|
cls.load_openings_and_closings(entries)
|
|
|
|
@classmethod
|
|
def is_account(cls, s: str) -> bool:
|
|
return cls.ACCOUNT_RE.fullmatch(s) is not None
|
|
|
|
@classmethod
|
|
def iter_accounts_by_classification(cls, s: str) -> Iterator['Account']:
|
|
class_re = re.compile(f'^{re.escape(s)}(?::|$)')
|
|
for name, meta in cls._meta_map.items():
|
|
try:
|
|
match = class_re.match(meta['classification'])
|
|
except KeyError:
|
|
match = None
|
|
if match:
|
|
yield cls(name)
|
|
|
|
@classmethod
|
|
def iter_accounts_by_hierarchy(cls, s: str) -> Iterator['Account']:
|
|
for name in cls._meta_map:
|
|
account = cls(name)
|
|
if account.is_under(s):
|
|
yield account
|
|
|
|
@classmethod
|
|
def iter_accounts(cls, s: Optional[str]=None) -> Iterator['Account']:
|
|
"""Iterate account objects by name or classification
|
|
|
|
With no argument, returns an iterator of all known account names.
|
|
If you pass in a root account name, or a valid account string, returns
|
|
an iterator of all accounts under that account in the hierarchy.
|
|
Otherwise, returns an iterator of all accounts with the given
|
|
``classification`` metadata.
|
|
"""
|
|
if s is None:
|
|
return (cls(acct) for acct in cls._meta_map)
|
|
# We append a stub subaccount to match root accounts.
|
|
elif cls.is_account(f'{s}:RootsOK'):
|
|
return cls.iter_accounts_by_hierarchy(s)
|
|
else:
|
|
return cls.iter_accounts_by_classification(s)
|
|
|
|
@property
|
|
def meta(self) -> AccountMeta:
|
|
return self._meta_map[self]
|
|
|
|
def is_cash_equivalent(self) -> bool:
|
|
return (
|
|
self.is_under('Assets:') is not None
|
|
and self.is_under('Assets:Prepaid', 'Assets:Receivable') is None
|
|
)
|
|
|
|
def is_checking(self) -> bool:
|
|
return self.is_cash_equivalent() and ':Check' in self
|
|
|
|
def is_credit_card(self) -> bool:
|
|
return self.is_under('Liabilities:CreditCard') is not None
|
|
|
|
def is_open_on_date(self, date: datetime.date) -> Optional[bool]:
|
|
"""Return true if this account is open on the given date.
|
|
|
|
This method considers the dates on the account's open and close
|
|
directives. If there is no close directive, it just checks the date is
|
|
on or after the opening date. If neither exists, returns None.
|
|
"""
|
|
try:
|
|
meta = self.meta
|
|
except KeyError:
|
|
return None
|
|
close_date = meta.close_date
|
|
if close_date is None:
|
|
close_date = date + datetime.timedelta(days=1)
|
|
return meta.open_date <= date < close_date
|
|
|
|
def is_opening_equity(self) -> bool:
|
|
return self.is_under('Equity:Funds', 'Equity:OpeningBalance') is not None
|
|
|
|
def is_under(self, *acct_seq: str) -> Optional[str]:
|
|
"""Return a match if this account is "under" a part of the hierarchy
|
|
|
|
Pass in any number of account name strings as arguments. If this
|
|
account is under one of those strings in the account hierarchy, the
|
|
first matching string will be returned. Otherwise, None is returned.
|
|
|
|
You can use the return value of this method as a boolean if you don't
|
|
care which account string is matched.
|
|
|
|
An account is considered to be under itself:
|
|
|
|
Account('Expenses:Tax').is_under('Expenses:Tax') # returns 'Expenses:Tax'
|
|
|
|
To do a "strictly under" search, end your search strings with colons:
|
|
|
|
Account('Expenses:Tax').is_under('Expenses:Tax:') # returns None
|
|
Account('Expenses:Tax').is_under('Expenses:') # returns 'Expenses:'
|
|
|
|
This method does check that all the account boundaries match:
|
|
|
|
Account('Expenses:Tax').is_under('Exp') # returns None
|
|
"""
|
|
for prefix in acct_seq:
|
|
if self.startswith(prefix) and (
|
|
prefix.endswith(self.SEP)
|
|
or self == prefix
|
|
or self[len(prefix)] == self.SEP
|
|
):
|
|
return prefix
|
|
return None
|
|
|
|
def keeps_balance(self) -> bool:
|
|
return self.is_under(
|
|
self._options_map['name_assets'],
|
|
self._options_map['name_liabilities'],
|
|
) is not None
|
|
|
|
def _find_part_slice(self, index: int) -> slice:
|
|
if index < 0:
|
|
raise ValueError(f"bad part index {index!r}")
|
|
start = 0
|
|
for _ in range(index):
|
|
try:
|
|
start = self.index(self.SEP, start) + 1
|
|
except ValueError:
|
|
raise IndexError("part index {index!r} out of range") from None
|
|
try:
|
|
stop = self.index(self.SEP, start + 1)
|
|
except ValueError:
|
|
stop = len(self)
|
|
return slice(start, stop)
|
|
|
|
def count_parts(self) -> int:
|
|
return self.count(self.SEP) + 1
|
|
|
|
@overload
|
|
def slice_parts(self, start: None=None, stop: None=None) -> Sequence[str]: ...
|
|
|
|
@overload
|
|
def slice_parts(self, start: slice, stop: None=None) -> Sequence[str]: ...
|
|
|
|
@overload
|
|
def slice_parts(self, start: int, stop: int) -> Sequence[str]: ...
|
|
|
|
@overload
|
|
def slice_parts(self, start: int, stop: None=None) -> str: ...
|
|
|
|
def slice_parts(self,
|
|
start: Optional[Union[int, slice]]=None,
|
|
stop: Optional[int]=None,
|
|
) -> Sequence[str]:
|
|
"""Slice the account parts like they were a list
|
|
|
|
Given a single index, return that part of the account name as a string.
|
|
Otherwise, return a list of part names sliced according to the arguments.
|
|
"""
|
|
if start is None:
|
|
part_slice = slice(None)
|
|
elif isinstance(start, slice):
|
|
part_slice = start
|
|
elif stop is None:
|
|
return self[self._find_part_slice(start)]
|
|
else:
|
|
part_slice = slice(start, stop)
|
|
return self.split(self.SEP)[part_slice]
|
|
|
|
def root_part(self, count: int=1) -> str:
|
|
"""Return the first part(s) of the account name as a string"""
|
|
try:
|
|
stop = self._find_part_slice(count - 1).stop
|
|
except IndexError:
|
|
return self
|
|
else:
|
|
return self[:stop]
|
|
Account.load_options_map(bc_options.OPTIONS_DEFAULTS)
|
|
|
|
|
|
class Amount(bc_amount.Amount):
|
|
"""Beancount amount after processing
|
|
|
|
Beancount's native Amount class declares number to be Optional[Decimal],
|
|
because the number is None when Beancount first parses a posting that does
|
|
not have an amount, because the user wants it to be automatically balanced.
|
|
|
|
As part of the loading process, Beancount replaces those None numbers
|
|
with the calculated amount, so it will always be a Decimal. This class
|
|
overrides the type declaration accordingly, so the type checker knows
|
|
that our code doesn't have to consider the possibility that number is
|
|
None.
|
|
"""
|
|
number: decimal.Decimal
|
|
|
|
# beancount.core._Amount is the plain namedtuple.
|
|
# beancore.core.Amount adds instance methods to it.
|
|
# b.c.Amount.__New__ calls `b.c._Amount.__new__`, which confuses type
|
|
# checking. See <https://github.com/python/mypy/issues/1279>.
|
|
# It works fine if you use super(), which is better practice anyway.
|
|
# So we override __new__ just to call _Amount.__new__ this way.
|
|
def __new__(cls, number: decimal.Decimal, currency: str) -> 'Amount':
|
|
return super(bc_amount.Amount, Amount).__new__(cls, number, currency)
|
|
|
|
|
|
class Metadata(MutableMapping[MetaKey, MetaValue]):
|
|
"""Transaction or posting metadata
|
|
|
|
This class wraps a Beancount metadata dictionary with additional methods
|
|
for common parsing and query tasks.
|
|
"""
|
|
__slots__ = ('meta',)
|
|
_HUMAN_NAMES: MutableMapping[MetaKey, str] = {
|
|
# Initialize this dict with special cases.
|
|
# We use it as a cache for other metadata names as they're queried.
|
|
'check-id': 'Check Number',
|
|
'paypal-id': 'PayPal ID',
|
|
'rt-id': 'Ticket',
|
|
}
|
|
_HUMAN_TRANSLATIONS = str.maketrans('-_', ' ')
|
|
|
|
def __init__(self, source: MutableMapping[MetaKey, MetaValue]) -> None:
|
|
self.meta = source
|
|
|
|
def __iter__(self) -> Iterator[MetaKey]:
|
|
return iter(self.meta)
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.meta)
|
|
|
|
def __getitem__(self, key: MetaKey) -> MetaValue:
|
|
return self.meta[key]
|
|
|
|
def __setitem__(self, key: MetaKey, value: MetaValue) -> None:
|
|
self.meta[key] = value
|
|
|
|
def __delitem__(self, key: MetaKey) -> None:
|
|
del self.meta[key]
|
|
|
|
def get_links(self, key: MetaKey) -> Sequence[str]:
|
|
try:
|
|
value = self.meta[key]
|
|
except KeyError:
|
|
return ()
|
|
if isinstance(value, str):
|
|
return value.split()
|
|
else:
|
|
raise TypeError("{} metadata is a {}, not str".format(
|
|
key, type(value).__name__,
|
|
))
|
|
|
|
def report_links(self, key: MetaKey) -> Sequence[str]:
|
|
"""Return a sequence of link strings under the named metadata key
|
|
|
|
get_links raises a TypeError if the metadata is not a string.
|
|
This method simply returns the empty sequence.
|
|
Validation code (like in the plugin) usually uses get_links()
|
|
while reporting code uses report_links().
|
|
"""
|
|
try:
|
|
return self.get_links(key)
|
|
except TypeError:
|
|
return ()
|
|
|
|
@overload
|
|
def first_link(self, key: MetaKey, default: None=None) -> Optional[str]: ...
|
|
|
|
@overload
|
|
def first_link(self, key: MetaKey, default: str) -> str: ...
|
|
|
|
def first_link(self, key: MetaKey, default: Optional[str]=None) -> Optional[str]:
|
|
try:
|
|
return self.get_links(key)[0]
|
|
except (IndexError, TypeError):
|
|
return default
|
|
|
|
@classmethod
|
|
def human_name(cls, key: MetaKey) -> str:
|
|
"""Return the "human" version of a metadata name
|
|
|
|
This is usually the metadata key with punctuation replaced with spaces,
|
|
and then titlecased, with a few special cases. The return value is
|
|
suitable for using in reports.
|
|
"""
|
|
try:
|
|
retval = cls._HUMAN_NAMES[key]
|
|
except KeyError:
|
|
retval = key.translate(cls._HUMAN_TRANSLATIONS).title()
|
|
retval = re.sub(r'\bId$', 'ID', retval)
|
|
cls._HUMAN_NAMES[key] = retval
|
|
return retval
|
|
|
|
|
|
class PostingMeta(Metadata):
|
|
"""Combined access to posting metadata with its parent transaction metadata
|
|
|
|
This lets you access posting metadata through a single dict-like object.
|
|
If you try to look up metadata that doesn't exist on the posting, it will
|
|
look for the value in the parent transaction metadata instead.
|
|
|
|
You can set and delete metadata as well. Changes only affect the metadata
|
|
of the posting, never the transaction. Changes are propagated to the
|
|
underlying Beancount data structures.
|
|
|
|
Functionally, you can think of this as identical to:
|
|
|
|
collections.ChainMap(post.meta, txn.meta)
|
|
|
|
Under the hood, this class does a little extra work to avoid creating
|
|
posting metadata if it doesn't have to.
|
|
"""
|
|
__slots__ = ('txn', 'index', 'post')
|
|
|
|
def __init__(self,
|
|
txn: Transaction,
|
|
index: int,
|
|
post: Optional[BasePosting]=None,
|
|
) -> None:
|
|
if post is None:
|
|
post = txn.postings[index]
|
|
self.txn = txn
|
|
self.index = index
|
|
self.post = post
|
|
self.meta: collections.ChainMap = collections.ChainMap(txn.meta)
|
|
if post.meta is not None:
|
|
self.meta = self.meta.new_child(post.meta)
|
|
|
|
def __getitem__(self, key: MetaKey) -> MetaValue:
|
|
try:
|
|
return super().__getitem__(key)
|
|
except KeyError:
|
|
if key == 'entity' and self.txn.payee is not None:
|
|
return self.txn.payee
|
|
else:
|
|
raise
|
|
|
|
def __setitem__(self, key: MetaKey, value: MetaValue) -> None:
|
|
if len(self.meta.maps) == 1:
|
|
self.post = self.post._replace(meta={key: value})
|
|
assert self.post.meta is not None
|
|
self.txn.postings[self.index] = self.post
|
|
self.meta = self.meta.new_child(self.post.meta)
|
|
else:
|
|
super().__setitem__(key, value)
|
|
|
|
def __delitem__(self, key: MetaKey) -> None:
|
|
if len(self.meta.maps) == 1:
|
|
raise KeyError(key)
|
|
else:
|
|
super().__delitem__(key)
|
|
|
|
# This is arguably cheating a litttle bit, but I'd argue the date of
|
|
# the parent transaction still qualifies as posting metadata, and
|
|
# it's something we want to access so often it's good to have it
|
|
# within easy reach.
|
|
@property
|
|
def date(self) -> datetime.date:
|
|
return self.txn.date
|
|
|
|
def detached(self) -> 'PostingMeta':
|
|
"""Create a copy of this PostingMeta detached from the original post
|
|
|
|
Changes you make to the detached copy will not propagate to the
|
|
underlying data structures. This is mostly useful for reporting code
|
|
that may want to "split" and manipulate the metadata multiple times.
|
|
"""
|
|
retval = type(self)(self.txn, self.index, self.post)
|
|
retval.meta = self.meta.new_child()
|
|
return retval
|
|
|
|
|
|
class Posting(BasePosting):
|
|
"""Enhanced Posting objects
|
|
|
|
This class is a subclass of Beancount's native Posting class where
|
|
specific fields are replaced with enhanced versions:
|
|
|
|
* The `account` field is an Account object
|
|
* The `units` field is our Amount object (which simply declares that the
|
|
number is always a Decimal—see that docstring for details)
|
|
* The `meta` field is a PostingMeta object
|
|
"""
|
|
__slots__ = ()
|
|
|
|
account: Account
|
|
units: Amount
|
|
cost: Optional[bc_position.Cost]
|
|
# mypy correctly complains that our MutableMapping is not compatible
|
|
# with Beancount's meta type declaration of Optional[Dict]. IMO
|
|
# Beancount's type declaration is a smidge too specific: I think its type
|
|
# declaration should also use MutableMapping, because it would be very
|
|
# unusual for code to specifically require a Dict over that.
|
|
# If it did, this declaration would pass without issue.
|
|
meta: PostingMeta # type:ignore[assignment]
|
|
|
|
@classmethod
|
|
def from_beancount(cls,
|
|
txn: Transaction,
|
|
index: int,
|
|
post: Optional[BasePosting]=None,
|
|
) -> 'Posting':
|
|
if post is None:
|
|
post = txn.postings[index]
|
|
return cls(
|
|
Account(post.account),
|
|
*post[1:5],
|
|
# see rationale above about Posting.meta
|
|
PostingMeta(txn, index, post), # type:ignore[arg-type]
|
|
)
|
|
|
|
@classmethod
|
|
def from_txn(cls, txn: Transaction) -> Iterator['Posting']:
|
|
"""Yield an enhanced Posting object for every posting in the transaction"""
|
|
for index, post in enumerate(txn.postings):
|
|
yield cls.from_beancount(txn, index, post)
|
|
|
|
@classmethod
|
|
def from_entries(cls, entries: Iterable[Directive]) -> Iterator['Posting']:
|
|
"""Yield an enhanced Posting object for every posting in these entries"""
|
|
for entry in entries:
|
|
# Because Beancount's own Transaction class isn't type-checkable,
|
|
# we can't statically check this. Might as well rely on duck
|
|
# typing while we're at it: just try to yield postings from
|
|
# everything, and ignore entries that lack a postings attribute.
|
|
try:
|
|
yield from cls.from_txn(entry) # type:ignore[arg-type]
|
|
except AttributeError:
|
|
pass
|
|
|
|
def at_cost(self) -> Amount:
|
|
if self.cost is None:
|
|
return self.units
|
|
else:
|
|
return Amount(self.units.number * self.cost.number, self.cost.currency)
|
|
|
|
|
|
_KT = TypeVar('_KT', bound=Hashable)
|
|
_VT = TypeVar('_VT')
|
|
class _SizedDict(collections.OrderedDict, MutableMapping[_KT, _VT]):
|
|
def __init__(self, maxsize: int=128) -> None:
|
|
self.maxsize = maxsize
|
|
super().__init__()
|
|
|
|
def __setitem__(self, key: _KT, value: _VT) -> None:
|
|
super().__setitem__(key, value)
|
|
for _ in range(self.maxsize, len(self)):
|
|
self.popitem(last=False)
|
|
|
|
|
|
def balance_of(txn: Transaction,
|
|
*preds: Callable[[Account], Optional[bool]],
|
|
) -> Amount:
|
|
"""Return the balance of specified postings in a transaction.
|
|
|
|
Given a transaction and a series of account predicates, balance_of
|
|
returns the balance of the amounts of all postings with accounts that
|
|
match any of the predicates.
|
|
|
|
balance_of uses the "weight" of each posting, so the return value will
|
|
use the currency of the postings' cost when available.
|
|
"""
|
|
match_posts = [post for post in Posting.from_txn(txn)
|
|
if any(pred(post.account) for pred in preds)]
|
|
number = decimal.Decimal(0)
|
|
if not match_posts:
|
|
currency = ''
|
|
else:
|
|
weights: Sequence[Amount] = [
|
|
bc_convert.get_weight(post) for post in match_posts
|
|
]
|
|
number = sum((wt.number for wt in weights), number)
|
|
currency = weights[0].currency
|
|
return Amount(number, currency)
|
|
|
|
_opening_balance_cache: MutableMapping[str, bool] = _SizedDict()
|
|
def is_opening_balance_txn(txn: Transaction) -> bool:
|
|
key = '\0'.join(
|
|
f'{post.account}={post.units}' for post in txn.postings
|
|
)
|
|
try:
|
|
return _opening_balance_cache[key]
|
|
except KeyError:
|
|
pass
|
|
opening_equity = balance_of(txn, Account.is_opening_equity)
|
|
if not opening_equity.currency:
|
|
retval = False
|
|
else:
|
|
rest = balance_of(txn, lambda acct: not acct.is_under(*EQUITY_ACCOUNTS))
|
|
retval = (
|
|
opening_equity.currency == rest.currency
|
|
and abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
|
|
)
|
|
_opening_balance_cache[key] = retval
|
|
return retval
|