52fa66bba1
The rationale is the same as it was for Posting.amount.
374 lines
13 KiB
Python
374 lines
13 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
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import collections
|
|
import datetime
|
|
import decimal
|
|
import functools
|
|
|
|
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 position as bc_position
|
|
|
|
from typing import (
|
|
cast,
|
|
Callable,
|
|
Hashable,
|
|
Iterable,
|
|
Iterator,
|
|
MutableMapping,
|
|
Optional,
|
|
Sequence,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
|
|
from .beancount_types import (
|
|
Directive,
|
|
MetaKey,
|
|
MetaValue,
|
|
Posting as BasePosting,
|
|
Transaction,
|
|
)
|
|
|
|
DecimalCompat = Union[decimal.Decimal, int]
|
|
|
|
LINK_METADATA = frozenset([
|
|
'approval',
|
|
'check',
|
|
'contract',
|
|
'invoice',
|
|
'purchase-order',
|
|
'receipt',
|
|
'rt-id',
|
|
'statement',
|
|
])
|
|
|
|
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__ = ()
|
|
|
|
SEP = bc_account.sep
|
|
|
|
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_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
|
|
|
|
|
|
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',)
|
|
|
|
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__,
|
|
))
|
|
|
|
|
|
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
|
|
if post.meta is None:
|
|
self.meta = self.txn.meta
|
|
else:
|
|
self.meta = collections.ChainMap(post.meta, txn.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 self.post.meta is None:
|
|
self.post = self.post._replace(meta={key: value})
|
|
self.txn.postings[self.index] = self.post
|
|
# mypy complains that self.post.meta could be None, but we know
|
|
# from two lines up that it's not.
|
|
self.meta = collections.ChainMap(self.post.meta, self.txn.meta) # type:ignore[arg-type]
|
|
else:
|
|
super().__setitem__(key, value)
|
|
|
|
def __delitem__(self, key: MetaKey) -> None:
|
|
if self.post.meta is None:
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
_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_opening_equity())
|
|
if not rest.currency:
|
|
retval = False
|
|
else:
|
|
retval = abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
|
|
_opening_balance_cache[key] = retval
|
|
return retval
|