"""
Module containing classes that read, manipulate, and write accounts.
.. module:: account
:synopsis:
.. moduleauthor: Paul Bromwell Jr.
"""
import abc
import re
from collections import namedtuple
from datetime import datetime
from decimal import Decimal, ROUND_UP
from typing import Optional, Union
from gnewcash.commodity import Commodity
from gnewcash.enums import AccountType
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):
"""Represents an account in GnuCash."""
def __init__(
self,
guid: Optional[str] = None,
slots: Optional[list[Slot]] = None,
name: str = '',
account_type: Optional[str] = None,
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
GuidObject.__init__(self, guid)
SlottableObject.__init__(self, slots)
self.name: str = name
self.type: Optional[str] = account_type
self.description: Optional[str] = description
self.__parent: Optional['Account'] = None
self.children: list['Account'] = children or []
self.code: Optional[str] = code
self.commodity: Optional[Commodity] = commodity
self.commodity_scu: Optional[str] = commodity_scu
self.non_std_scu: Optional[int] = non_std_scu
def __str__(self) -> str:
return f'{self.name} - {self.type}'
def __repr__(self) -> str:
return str(self)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Account):
raise NotImplementedError
return self.guid == getattr(other, 'guid', None)
def __hash__(self) -> int:
return hash(self.guid)
[docs]
def as_dict(
self,
account_hierarchy: Optional[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 = {}
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: re.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().get_slot_value('color')
@color.setter
def color(self, value: str) -> None:
super().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().get_slot_value('notes')
@notes.setter
def notes(self, value: str) -> None:
super().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().get_slot_value('hidden') == 'true'
@hidden.setter
def hidden(self, value: bool) -> None:
super().set_slot_value_bool('hidden', value, 'string')
@property
def placeholder(self) -> bool:
"""
Placeholder flag for the account.
:return: True if the account is a placeholder, otherwise False
:rtype: bool
"""
return super().get_slot_value('placeholder') == 'true'
@placeholder.setter
def placeholder(self, value: bool) -> None:
super().set_slot_value_bool('placeholder', value, 'string')
[docs]
def get_account_guids(self, account_guids: Optional[list[str]] = None) -> list[str]:
"""
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
[docs]
class BankAccount(Account):
"""Shortcut class to create an account with the type set to AccountType.BANK."""
def __init__(
self,
name: str = '',
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
super().__init__(
name=name,
description=description,
children=children,
code=code,
commodity=commodity,
commodity_scu=commodity_scu,
non_std_scu=non_std_scu
)
self.type = AccountType.BANK
[docs]
class IncomeAccount(Account):
"""Shortcut class to create an account with the type set to AccountType.INCOME."""
def __init__(
self,
name: str = '',
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
super().__init__(
name=name,
description=description,
children=children,
code=code,
commodity=commodity,
commodity_scu=commodity_scu,
non_std_scu=non_std_scu
)
self.type = AccountType.INCOME
[docs]
class AssetAccount(Account):
"""Shortcut class to create an account with the type set to AccountType.ASSET."""
def __init__(
self,
name: str = '',
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
super().__init__(
name=name,
description=description,
children=children,
code=code,
commodity=commodity,
commodity_scu=commodity_scu,
non_std_scu=non_std_scu
)
self.type = AccountType.ASSET
[docs]
class CreditAccount(Account):
"""Shortcut class to create an account with the type set to AccountType.CREDIT."""
def __init__(
self,
name: str = '',
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
super().__init__(
name=name,
description=description,
children=children,
code=code,
commodity=commodity,
commodity_scu=commodity_scu,
non_std_scu=non_std_scu
)
self.type = AccountType.CREDIT
[docs]
class ExpenseAccount(Account):
"""Shortcut class to create an account with the type set to AccountType.EXPENSE."""
def __init__(
self,
name: str = '',
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
super().__init__(
name=name,
description=description,
children=children,
code=code,
commodity=commodity,
commodity_scu=commodity_scu,
non_std_scu=non_std_scu
)
self.type = AccountType.EXPENSE
[docs]
class EquityAccount(Account):
"""Shortcut class to create an account with the type set to AccountType.EQUITY."""
def __init__(
self,
name: str = '',
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
super().__init__(
name=name,
description=description,
children=children,
code=code,
commodity=commodity,
commodity_scu=commodity_scu,
non_std_scu=non_std_scu
)
self.type = AccountType.EQUITY
[docs]
class LiabilityAccount(Account):
"""Shortcut class to create an account with the type set to AccountType.LIABILITY."""
def __init__(
self,
name: str = '',
description: Optional[str] = None,
children: Optional[list['Account']] = None,
code: Optional[str] = None,
commodity: Optional[Commodity] = None,
commodity_scu: Optional[str] = None,
non_std_scu: Optional[int] = None,
) -> None:
super().__init__(
name=name,
description=description,
children=children,
code=code,
commodity=commodity,
commodity_scu=commodity_scu,
non_std_scu=non_std_scu
)
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 f'{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 = []
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
if not self.interest_start_date or iterator_date > self.interest_start_date:
interest = Decimal(interest_rate / 12 * iterator_balance).quantize(Decimal('.01'), rounding=ROUND_UP)
else:
interest = Decimal(0)
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)