Source code for account

"""
Module containing classes that read, manipulate, and write accounts.

.. module:: account
   :synopsis:
.. moduleauthor: Paul Bromwell Jr.
"""
import abc
import re
from datetime import datetime
from decimal import Decimal, ROUND_UP
from xml.etree import ElementTree
from collections import namedtuple
from typing import List, Tuple, Dict, Optional, Union, Pattern

from gnewcash.commodity import Commodity
from gnewcash.enums import AccountType
from gnewcash.file_formats import GnuCashXMLObject, GnuCashSQLiteObject
from gnewcash.guid_object import GuidObject
from gnewcash.slot import Slot, SlottableObject


LoanStatus = namedtuple('LoanStatus', ['iterator_balance', 'iterator_date', 'interest', 'amount_to_capital'])
LoanExtraPayment = namedtuple('LoanExtraPayment', ['payment_date', 'payment_amount'])


[docs]class Account(GuidObject, SlottableObject, GnuCashXMLObject, GnuCashSQLiteObject): """Represents an account in GnuCash.""" def __init__(self) -> None: super(Account, self).__init__() self.name: str = '' self.type: Optional[str] = None self.commodity_scu: Optional[str] = None self.__parent: Optional['Account'] = None self.children: List['Account'] = [] self.commodity: Optional[Commodity] = None self.code: Optional[str] = None self.description: Optional[str] = None def __str__(self) -> str: return '{} - {}'.format(self.name, self.type) def __repr__(self) -> str: return str(self) def __eq__(self, other: object) -> bool: if not isinstance(object, Account): return NotImplemented return self.guid == getattr(other, 'guid', None) def __hash__(self) -> int: return hash(self.guid) @property def as_xml(self) -> List[ElementTree.Element]: """ Returns the current account configuration (and all of its child accounts) as GnuCash-compatible XML. :return: Current account and children as XML :rtype: list[xml.etree.ElementTree.Element] :raises: ValueError if no commodity found. """ node_and_children: List = list() account_node: ElementTree.Element = ElementTree.Element('gnc:account', {'version': '2.0.0'}) ElementTree.SubElement(account_node, 'act:name').text = self.name ElementTree.SubElement(account_node, 'act:id', {'type': 'guid'}).text = self.guid ElementTree.SubElement(account_node, 'act:type').text = self.type if self.commodity: account_node.append(self.commodity.as_short_xml('act:commodity')) else: parent_commodity: Optional[Commodity] = self.get_parent_commodity() if parent_commodity: account_node.append(parent_commodity.as_short_xml('act:commodity')) if self.commodity_scu: ElementTree.SubElement(account_node, 'act:commodity-scu').text = str(self.commodity_scu) if self.code: ElementTree.SubElement(account_node, 'act:code').text = str(self.code) if self.description: ElementTree.SubElement(account_node, 'act:description').text = str(self.description) if self.slots: slots_node = ElementTree.SubElement(account_node, 'act:slots') for slot in self.slots: slots_node.append(slot.as_xml) if self.parent is not None: ElementTree.SubElement(account_node, 'act:parent', {'type': 'guid'}).text = self.parent.guid node_and_children.append(account_node) if self.children: for child in self.children: node_and_children += child.as_xml return node_and_children
[docs] @classmethod def from_xml(cls, account_node: ElementTree.Element, namespaces: Dict[str, str], account_objects: List['Account']) -> 'Account': """ Creates an Account object from the GnuCash XML. :param account_node: XML node for the account :type account_node: ElementTree.Element :param namespaces: XML namespaces for GnuCash elements :type namespaces: dict[str, str] :param account_objects: Account objects already created from XML (used for assigning parent account) :type account_objects: list[Account] :return: Account object from XML :rtype: Account """ account_object: 'Account' = cls() account_guid_node = account_node.find('act:id', namespaces) if account_guid_node is None or not account_guid_node.text: raise ValueError('Account guid node is missing or empty') account_object.guid = account_guid_node.text account_name_node = account_node.find('act:name', namespaces) if account_name_node is not None and account_name_node.text: account_object.name = account_name_node.text account_type_node = account_node.find('act:type', namespaces) if account_type_node is not None and account_type_node.text: account_object.type = account_type_node.text commodity: Optional[ElementTree.Element] = account_node.find('act:commodity', namespaces) if commodity is not None and commodity.find('cmdty:id', namespaces) is not None: account_object.commodity = Commodity.from_xml(commodity, namespaces) else: account_object.commodity = None commodity_scu: Optional[ElementTree.Element] = account_node.find('act:commodity-scu', namespaces) if commodity_scu is not None: account_object.commodity_scu = commodity_scu.text slots: Optional[ElementTree.Element] = account_node.find('act:slots', namespaces) if slots is not None: for slot in slots.findall('slot', namespaces): account_object.slots.append(Slot.from_xml(slot, namespaces)) code: Optional[ElementTree.Element] = account_node.find('act:code', namespaces) if code is not None: account_object.code = code.text description: Optional[ElementTree.Element] = account_node.find('act:description', namespaces) if description is not None: account_object.description = description.text parent: Optional[ElementTree.Element] = account_node.find('act:parent', namespaces) if parent is not None: account_object.parent = [x for x in account_objects if x.guid == parent.text][0] return account_object
[docs] def as_dict(self, account_hierarchy: Dict[str, 'Account'] = None, path_to_self: str = '/') -> Dict[str, 'Account']: """ Retrieves the current account hierarchy as a dictionary. :param account_hierarchy: Existing account hierarchy. If None is provided, assumes a new dictionary. :type account_hierarchy: dict :param path_to_self: Dictionary key for the current account. :type path_to_self: str :return: Dictionary containing current account and all subaccounts. :rtype: dict """ if account_hierarchy is None: account_hierarchy = dict() account_hierarchy[path_to_self] = self for child in self.children: if path_to_self != '/': account_hierarchy = child.as_dict(account_hierarchy, path_to_self + '/' + child.dict_entry_name) else: account_hierarchy = child.as_dict(account_hierarchy, path_to_self + child.dict_entry_name) return account_hierarchy
@property def dict_entry_name(self) -> str: """ Retrieves the dictionary entry based on account name. Only alpha-numeric and underscore characters allowed. Spaces and slashes (/) are converted to underscores. :return: String with the dictionary entry name. :rtype: str """ non_alphanumeric_underscore: Pattern = re.compile('[^a-zA-Z0-9_]') dict_entry_name: str = self.name dict_entry_name = dict_entry_name.replace(' ', '_') dict_entry_name = dict_entry_name.replace('/', '_') dict_entry_name = dict_entry_name.lower() dict_entry_name = re.sub(non_alphanumeric_underscore, '', dict_entry_name) return dict_entry_name
[docs] def get_parent_commodity(self) -> Optional[Commodity]: """ Retrieves the commodity for the account. If none is provided, it will look at it's parent (and ancestors recursively) to find it. :return: Commodity object, or None if no commodity was found in the ancestry chain. :rtype: NoneType|Commodity """ if self.commodity: return self.commodity if self.parent: return self.parent.get_parent_commodity() return None
[docs] def get_subaccount_by_id(self, subaccount_id: str) -> Optional['Account']: """ Finds a subaccount by its guid field. :param subaccount_id: Subaccount guid to find :type subaccount_id: str :return: Account object for that guid or None if no account was found :rtype: NoneType|Account """ if self.guid == subaccount_id: return self for subaccount in self.children: subaccount_result: Optional[Account] = subaccount.get_subaccount_by_id(subaccount_id) if subaccount_result is not None: return subaccount_result return None
@property def parent(self) -> Optional['Account']: """ Parent account of the current account. :return: Account's parent :rtype: NoneType|Account """ return self.__parent @parent.setter def parent(self, value: 'Account') -> None: if value is not None: if self not in value.children: value.children.append(self) self.__parent = value @property def color(self) -> str: """ Account color. :return: Account color as a string :rtype: str """ return super(Account, self).get_slot_value('color') @color.setter def color(self, value: str) -> None: super(Account, self).set_slot_value('color', value, 'string') @property def notes(self) -> str: """ User defined notes for the account. :return: User-defined notes :rtype: str """ return super(Account, self).get_slot_value('notes') @notes.setter def notes(self, value: str) -> None: super(Account, self).set_slot_value('notes', value, 'string') @property def hidden(self) -> bool: """ Hidden flag for the account. :return: True if account is marked hidden, otherwise False. :rtype: bool """ return super(Account, self).get_slot_value('hidden') == 'true' @hidden.setter def hidden(self, value: bool) -> None: super(Account, self).set_slot_value_bool('hidden', value, 'string') @property def placeholder(self) -> None: """ Placeholder flag for the account. :return: True if the account is a placeholder, otherwise False :rtype: bool """ return super(Account, self).get_slot_value('placeholder') @placeholder.setter def placeholder(self, value: bool) -> None: super(Account, self).set_slot_value_bool('placeholder', value, 'string')
[docs] @classmethod def from_sqlite(cls, sqlite_cursor, account_id): """ Creates an Account object from the GnuCash SQLite database. :param sqlite_cursor: Open cursor to the GnuCash SQLite database. :type sqlite_cursor: sqlite3.Cursor :param account_id: ID of the account to load from the SQLite database :type account_id: str :return: Account object from SQLite :rtype: Account """ account_data = cls.get_sqlite_table_data(sqlite_cursor, 'accounts', 'guid = ?', (account_id,)) if not account_data: raise RuntimeError('Could not find account {} in the SQLite database'.format(account_id)) account_data, = account_data new_account = cls() new_account.guid = account_data['guid'] new_account.name = account_data['name'] new_account.type = account_data['account_type'] new_account.code = account_data['code'] new_account.description = account_data['description'] if account_data['hidden'] is not None and account_data['hidden'] == 1: new_account.hidden = True if account_data['placeholder'] is not None and account_data['placeholder'] == 1: new_account.placeholder = True new_account.slots = Slot.from_sqlite(sqlite_cursor, account_data['guid']) new_account.commodity = Commodity.from_sqlite(sqlite_cursor, commodity_guid=account_data['commodity_guid']) new_account.commodity_scu = account_data['commodity_scu'] # TODO: non_std_scu for subaccount in cls.get_sqlite_table_data(sqlite_cursor, 'accounts', 'parent_guid = ?', (account_id,)): new_account.children.append(cls.from_sqlite(sqlite_cursor, subaccount['guid'])) return new_account
[docs] def to_sqlite(self, sqlite_handle): raise NotImplementedError
[docs] def get_account_guids(self, account_guids=None): """ Gets a flat list of account GUIDs under the current account. :param account_guids: Running list of account GUIDs (should be None on first call) :type account_guids: list[str] :return: List of account GUIDs under the current account :rtype: list[str] """ if account_guids is None: account_guids = [] account_guids.append(self.guid) for sub_account in self.children: account_guids = sub_account.get_account_guids(account_guids) return account_guids
GnuCashSQLiteObject.register(Account)
[docs]class BankAccount(Account): """Shortcut class to create an account with the type set to AccountType.BANK.""" def __init__(self) -> None: super(BankAccount, self).__init__() self.type = AccountType.BANK
[docs]class IncomeAccount(Account): """Shortcut class to create an account with the type set to AccountType.INCOME.""" def __init__(self) -> None: super(IncomeAccount, self).__init__() self.type = AccountType.INCOME
[docs]class AssetAccount(Account): """Shortcut class to create an account with the type set to AccountType.ASSET.""" def __init__(self) -> None: super(AssetAccount, self).__init__() self.type = AccountType.ASSET
[docs]class CreditAccount(Account): """Shortcut class to create an account with the type set to AccountType.CREDIT.""" def __init__(self) -> None: super(CreditAccount, self).__init__() self.type = AccountType.CREDIT
[docs]class ExpenseAccount(Account): """Shortcut class to create an account with the type set to AccountType.EXPENSE.""" def __init__(self) -> None: super(ExpenseAccount, self).__init__() self.type = AccountType.EXPENSE
[docs]class EquityAccount(Account): """Shortcut class to create an account with the type set to AccountType.EQUITY.""" def __init__(self) -> None: super(EquityAccount, self).__init__() self.type = AccountType.EQUITY
[docs]class LiabilityAccount(Account): """Shortcut class to create an account with the type set to AccountType.LIABILITY.""" def __init__(self) -> None: super(LiabilityAccount, self).__init__() self.type = AccountType.LIABILITY
[docs]class InterestAccountBase(abc.ABC): """Abstract class defining the API for Interest accounts.""" @property @abc.abstractmethod def starting_date(self) -> datetime: """Abstract method for retrieving the starting date for the account.""" raise NotImplementedError @property @abc.abstractmethod def interest_percentage(self) -> Decimal: """Abstract method for retrieving the interest percentage for the account.""" raise NotImplementedError @property @abc.abstractmethod def payment_amount(self) -> Decimal: """Abstract method for retrieving the payment amount for the account.""" raise NotImplementedError @property @abc.abstractmethod def starting_balance(self) -> Decimal: """Abstract method for retrieving the starting balance for the account.""" raise NotImplementedError
[docs] @abc.abstractmethod def get_info_at_date(self, date: datetime) -> LoanStatus: """ Abstract method for retrieving the loan info at a specified date for the account. :param date: datetime object indicating the date you want the loan status of :type date: datetime.datetime """ raise NotImplementedError
[docs] @abc.abstractmethod def get_all_payments(self, skip_additional_payments: bool = False) -> List[Tuple[datetime, Decimal, Decimal]]: """ Abstract method for retrieving all payments for the loan plan. :param skip_additional_payments: Skips additional payments if True. :type skip_additional_payments: bool """ raise NotImplementedError
[docs]class InterestAccount(InterestAccountBase): """Class used to calculate interest balances."""
[docs] def __init__(self, starting_balance: Decimal, starting_date: datetime, interest_percentage: Decimal, payment_amount: Decimal, additional_payments: Optional[List[LoanExtraPayment]] = None, skip_payment_dates: Optional[List[datetime]] = None, interest_start_date: Optional[datetime] = None): """ Class initializer. :param starting_balance: Starting balance for the interest account. :type starting_balance: decimal.Decimal|NoneType :param starting_date: datetime object indicating the date of the starting balance. :type starting_date: datetime.datetime|NoneType :param interest_percentage: Percentage to interest on the loan. :type interest_percentage: decimal.Decimal|NoneType :param payment_amount: Payment amount on the loan. :type payment_amount: decimal.Decimal|NoneType :param additional_payments: List of LoanExtraPayment objects indicating extra payments to the loan. :type additional_payments: list[LoanExtraPayment]|NoneType :param skip_payment_dates: List of datetime objects that the loan payment should be skipped :type skip_payment_dates: list[datetime.datetime]|NoneType :param interest_start_date: datetime object that interest starts on :type interest_start_date: datetime.datetime|NoneType """ if additional_payments is None: additional_payments = [] if skip_payment_dates is None: skip_payment_dates = [] self.__starting_balance: Decimal = starting_balance self.__starting_date: datetime = starting_date self.__interest_percentage: Decimal = interest_percentage self.additional_payments: List[LoanExtraPayment] = additional_payments self.skip_payment_dates: List[datetime] = skip_payment_dates self.__payment_amount: Decimal = payment_amount self.interest_start_date: Optional[datetime] = interest_start_date
def __str__(self) -> str: return '{} - {} - {}'.format(self.payment_amount, self.starting_balance, self.interest_percentage) def __repr__(self) -> str: return str(self) @property def starting_date(self) -> datetime: """ Retrieves the starting date for the account. :return: Current InterestAccount's starting date. :rtype: datetime.datetime """ return self.__starting_date @starting_date.setter def starting_date(self, new_starting_date: datetime) -> None: self.__starting_date = new_starting_date @property def interest_percentage(self) -> Decimal: """ Retrieves the interest percentage for the account. :return: Current InterestAccount object's percentage. :rtype: decimal.Decimal """ return self.__interest_percentage @interest_percentage.setter def interest_percentage(self, new_interest_percentage: Decimal) -> None: self.__interest_percentage = new_interest_percentage @property def payment_amount(self) -> Decimal: """ Retrieves the payment amount for the account. :return: Current InterestAccount object's payment amount. :rtype: decimal.Decimal """ return self.__payment_amount @payment_amount.setter def payment_amount(self, new_payment_amount: Decimal) -> None: self.__payment_amount = new_payment_amount @property def starting_balance(self) -> Decimal: """ Retrieves the starting balance for the account. :return: Current InterestAccount object's starting balance. :rtype: decimal.Decimal """ return self.__starting_balance @starting_balance.setter def starting_balance(self, new_starting_balance: Decimal) -> None: self.__starting_balance = new_starting_balance
[docs] def get_info_at_date(self, date: datetime) -> LoanStatus: """ Retrieves the loan info at a specified date for the current account. :param date: datetime object indicating the date you want the loan status of :type date: datetime.datetime :return: LoanStatus object :rtype: LoanStatus """ iterator_date: datetime = self.starting_date iterator_balance: Decimal = self.starting_balance interest_rate: Decimal = self.interest_percentage if interest_rate > 1: interest_rate /= 100 interest: Decimal = Decimal(0) amount_to_capital: Decimal = Decimal(0) while iterator_date < date: previous_date: datetime = iterator_date if iterator_date.month == 12: iterator_date = datetime(iterator_date.year + 1, 1, iterator_date.day, tzinfo=iterator_date.tzinfo) else: iterator_date = datetime(iterator_date.year, iterator_date.month + 1, iterator_date.day, tzinfo=iterator_date.tzinfo) applicable_extra_payments: List[LoanExtraPayment] = [ x for x in self.additional_payments if previous_date < x.payment_date < iterator_date ] if applicable_extra_payments: for extra_payment in applicable_extra_payments: iterator_balance -= extra_payment.payment_amount if iterator_date > date: break if iterator_date in self.skip_payment_dates: continue if self.interest_start_date is None or iterator_date >= self.interest_start_date: interest = Decimal(interest_rate / 12 * iterator_balance).quantize(Decimal('.01'), rounding=ROUND_UP) amount_to_capital = self.payment_amount - interest else: interest = Decimal(0) amount_to_capital = self.payment_amount new_balance = iterator_balance - amount_to_capital if new_balance < 0: new_balance = Decimal(0) iterator_balance = new_balance if iterator_balance == 0: break # Zero out if we're still before the requested date (debt has been fully paid already) if iterator_date < date: iterator_balance = Decimal(0) iterator_date = date interest = Decimal(0) amount_to_capital = Decimal(0) return LoanStatus(iterator_balance, iterator_date, interest, amount_to_capital)
[docs] def get_all_payments(self, skip_additional_payments: bool = False) -> List[Tuple[datetime, Decimal, Decimal]]: """ Retrieves a list of tuples that show all payments for the loan plan. :param skip_additional_payments: Skips additional payments if True. :type skip_additional_payments: bool :return: List of tuples with the date (index 0), balance (index 1) and amount to capital (index 2) :rtype: list[tuple] """ iterator_date = self.starting_date iterator_balance = self.starting_balance interest_rate = self.interest_percentage payments = list() if interest_rate > 1: interest_rate /= 100 while iterator_balance > 0: previous_date = iterator_date if iterator_date.month == 12: iterator_date = datetime(iterator_date.year + 1, 1, iterator_date.day, tzinfo=iterator_date.tzinfo) else: iterator_date = datetime(iterator_date.year, iterator_date.month + 1, iterator_date.day, tzinfo=iterator_date.tzinfo) applicable_extra_payments = [x for x in self.additional_payments if previous_date < x.payment_date < iterator_date] if applicable_extra_payments and not skip_additional_payments: for extra_payment in applicable_extra_payments: payments.append((extra_payment.payment_date, iterator_balance, extra_payment.payment_amount)) iterator_balance -= extra_payment.payment_amount if iterator_date in self.skip_payment_dates: continue interest = Decimal(interest_rate / 12 * iterator_balance).quantize(Decimal('.01'), rounding=ROUND_UP) amount_to_capital = self.payment_amount - interest payments.append((iterator_date, iterator_balance, amount_to_capital)) new_balance = iterator_balance - amount_to_capital iterator_balance = new_balance return payments
InterestAccountBase.register(InterestAccount)
[docs]class InterestAccountWithSubaccounts(InterestAccountBase): """Class used to calculate interest balances based off of balances of subaccounts."""
[docs] def __init__(self, subaccounts: List[InterestAccount], additional_payments: Optional[List[Dict[str, Union[Decimal, datetime]]]] = None, skip_payment_dates: Optional[List[datetime]] = None): """ Class initializer. :param subaccounts: List of InterestAccount objects that are subaccounts of this InterestAccount :type subaccounts: list[InterestAccount] :param additional_payments: List of dictionaries containing an "amount" key for additional amount paid, and "payment_date" for the date the additional amount was paid. :type additional_payments: list[dict]|NoneType :param skip_payment_dates: List of datetime objects that the loan payment should be skipped :type skip_payment_dates: list[datetime.datetime]|NoneType """ if additional_payments is None: additional_payments = [] if skip_payment_dates is None: skip_payment_dates = [] self.additional_payments: Optional[List[Dict[str, Union[Decimal, datetime]]]] = additional_payments self.skip_payment_dates: Optional[List[datetime]] = skip_payment_dates self.subaccounts: List[InterestAccount] = subaccounts
@property def starting_date(self) -> datetime: """ Retrieves the minimum starting date of the subaccounts. :return: Minimum starting date. :rtype: datetime.datetime """ return min([x.starting_date for x in self.subaccounts]) @property def interest_percentage(self) -> Decimal: """ Retrieves the sum of the subaccounts' interest percentage. :return: Sum of interest percentages. :rtype: decimal.Decimal """ return Decimal(sum([x.interest_percentage for x in self.subaccounts])) @property def payment_amount(self) -> Decimal: """ Retrieves the sum of the subaccounts' payment amount. :return: Sum of the payment amounts. :rtype: decimal.Decimal """ return Decimal(sum([x.payment_amount for x in self.subaccounts])) @property def starting_balance(self) -> Decimal: """ Retrieves the sum of the subaccounts' starting balance. :return: Sum of the starting balances. :rtype: decimal.Decimal """ return Decimal(sum([x.starting_balance for x in self.subaccounts]))
[docs] def get_info_at_date(self, date: datetime) -> LoanStatus: """ Retrieves the loan info at a specified date for all subaccounts. :param date: datetime object indicating the date you want the loan status of :type date: datetime.datetime :return: LoanStatus object :rtype: LoanStatus """ iterator_balance: Decimal = Decimal(0) iterator_date: Optional[datetime] = None interest: Decimal = Decimal(0) amount_to_capital: Decimal = Decimal(0) for account in self.subaccounts: account_status = account.get_info_at_date(date) iterator_balance += account_status.iterator_balance iterator_date = account_status.iterator_date interest += account_status.interest amount_to_capital += account_status.amount_to_capital return LoanStatus(iterator_balance, iterator_date, interest, amount_to_capital)
[docs] def get_all_payments(self, skip_additional_payments: bool = False) -> List[Tuple[datetime, Decimal, Decimal]]: """ Retrieves a list of tuples that show all payments for the loan plan. :param skip_additional_payments: Skips additional payments if True. :type skip_additional_payments: bool :return: List of tuples with the date (index 0), balance (index 1) and amount to capital (index 2) :rtype: list[tuple] """ all_payments: List[Tuple[datetime, Decimal, Decimal]] = [] for account in self.subaccounts: subaccount_payments = account.get_all_payments(skip_additional_payments) if not all_payments: all_payments = subaccount_payments else: for index, (payment1, payment2) in enumerate(zip(all_payments, subaccount_payments)): all_payments[index] = payment1[0], payment1[1] + payment2[1], payment1[2] + payment2[2] return all_payments
InterestAccountBase.register(InterestAccountWithSubaccounts)