# Copyright(C) 2010-2016 Romain Bignon
#
# This file is part of woob.
#
# woob is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# woob 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with woob. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from binascii import crc32
import re
from typing import Iterable, List
from woob.capabilities.account import CapCredentialsCheck
from woob.capabilities.base import (
BaseObject, Capability, Field, StringField, DecimalField, IntField,
BoolField, UserError, Currency, NotAvailable, EnumField, Enum, empty,
find_object, NotLoaded, NotLoadedType, NotAvailableType
)
from woob.capabilities.date import DateField
from woob.capabilities.collection import CapCollection
from woob.exceptions import BrowserIncorrectPassword
__all__ = [
'CapBank', 'BaseAccount', 'Account', 'Loan', 'Transaction', 'AccountNotFound',
'AccountType', 'AccountOwnership', 'Balance', 'AccountSchemeName', 'TransactionCounterparty',
'PartyIdentity', 'AccountParty', 'AccountIdentification', 'PartyRole', 'CapAccountCheck',
'NoAccountsException', 'BalanceType', 'BankTransactionCode',
]
[docs]class NoAccountsException(Exception):
"""
Raised by :meth:`CapBank.iter_accounts()` if we are sure there is no accounts.
Sometimes we can't parse find accounts on websites but that's a scraping
error. To attest we know that's a real case, this exception is raised.
"""
class ObjectNotFound(UserError):
pass
[docs]class AccountNotFound(ObjectNotFound):
"""
Raised when an account is not found.
"""
def __init__(self, msg: str = 'Account not found'):
super().__init__(msg)
[docs]class BaseAccount(BaseObject, Currency):
"""
Generic class aiming to be parent of :class:`Recipient` and
:class:`Account`.
"""
label = StringField('Pretty label')
currency = StringField('Currency', default=None)
bank_name = StringField('Bank Name', mandatory=False)
# TODO add iban field here
def __init__(
self,
id: str = '0',
url: str | NotLoadedType | NotAvailableType = NotLoaded
):
super().__init__(id, url)
@property
def currency_text(self) -> str:
return Currency.currency2txt(self.currency)
@property
def ban(self) -> str | NotAvailableType:
""" Bank Account Number part of IBAN"""
if not self.iban:
return NotAvailable
return self.iban[4:]
[docs]class AccountType(Enum):
UNKNOWN = 0
CHECKING = 1
"Transaction, everyday transactions"
SAVINGS = 2
"Savings/Deposit, can be used for every banking"
DEPOSIT = 3
"Term of Fixed Deposit, has time/amount constraints"
LOAN = 4
"Loan account"
MARKET = 5
"Stock market or other variable investments"
JOINT = 6
"Joint account"
CARD = 7
"Card account"
LIFE_INSURANCE = 8
"Life insurances"
PEE = 9
"Employee savings PEE"
PERCO = 10
"Employee savings PERCO"
ARTICLE_83 = 11
"Article 83"
RSP = 12
"Employee savings RSP"
PEA = 13
"Share savings"
CAPITALISATION = 14
"Life Insurance capitalisation"
PERP = 15
"Retirement savings"
MADELIN = 16
"Complementary retirement savings"
MORTGAGE = 17
"Mortgage"
CONSUMER_CREDIT = 18
"Consumer credit"
REVOLVING_CREDIT = 19
"Revolving credit"
PER = 20
"Pension plan PER"
REAL_ESTATE = 21
"Real estate investment such as SCPI, OPCI, SCI"
CROWDLENDING = 22
"Crowdlending accounts"
LDDS = 23
"LDD/LDDS Livret de développement durable et solidaire"
PEL = 24
"Plan épargne logement"
CSL = 25
"Compte sur Livret"
CEL = 26
"Compte épargne logement"
CAT = 27
"Compte à terme"
LIVRET_A = 28
"Livret A"
LIVRET_B = 29
"Livret B"
class AccountOwnerType:
"""
Specifies the usage of the account
"""
PRIVATE = 'PRIV'
"""private personal account"""
ORGANIZATION = 'ORGA'
"""professional account"""
ASSOCIATION = 'ASSO'
"""association account"""
[docs]class AccountOwnership:
"""
Relationship between the credentials owner (PSU) and the account
"""
OWNER = 'owner'
"""The PSU is the account owner"""
CO_OWNER = 'co-owner'
"""The PSU is the account co-owner"""
ATTORNEY = 'attorney'
"""The PSU is the account attorney"""
[docs]class AccountSchemeName(Enum):
IBAN = 'iban'
"""IBAN as defined in ISO 13616"""
BBAN = 'bban'
"""Basic Bank Account Number, represents a country-specific bank account number"""
SORT_CODE_ACCOUNT_NUMBER = 'sort_code_account_number'
"""Account Identification Number sometimes employed instead of IBAN (e.g.: in UK)"""
CPAN = 'cpan'
"""Card PAN (masked or plain)"""
TPAN = 'tpan'
"""Tokenized card PAN issued by a Token Service Provider to obfuscate the real PAN"""
MPAN = 'mpan'
"""Card PAN where some digits were replaced for security reason"""
BANK_PARTY_IDENTIFICATION = 'bank_party_identification'
"""
BankPartyIdentification - Unique and unambiguous assignment made by a specific bank or
similar financial institution to identify a relationship between the bank and its client.
Its definition can be found at page 13 of https://www.stet.eu/assets/files/PSD2/1-6-3/api-dsp2-stet-v1.6.3.1-part-2-functional-model.pdf
"""
class AccountManagementType(Enum):
UNKNOWN = 'unknown'
CAPITALIZATION = 'capitalization'
FIXED_FUNDS = 'fixed_funds'
PROFILED = 'profiled'
DISCRETIONARY = 'discretionary'
DELEGATED = 'delegated'
UNIT_LINKED = 'unit_linked'
[docs]class TransactionCounterparty(BaseObject):
label = StringField('Name of the other stakeholder (Creditor or debtor)', default=None)
account_scheme_name = EnumField('Type of account Scheme', AccountSchemeName, default=None)
account_identification = StringField('ID of the account', default=None)
debtor = BoolField('Type of the counterparty (debtor/creditor/null)', default=None)
def __repr__(self):
return f'<label={self.label} debtor={self.debtor} account_scheme_name={self.account_scheme_name} account_identification={self.account_identification}>'
[docs]class PartyRole(Enum):
UNKNOWN = 'unknown'
HOLDER = 'holder'
CO_HOLDER = 'co_holder'
ATTORNEY = 'attorney'
CUSTODIAN_FOR_MINOR = 'custodian_for_minor'
LEGAL_GUARDIAN = 'legal_guardian'
NOMINEE = 'nominee'
BENEFICIARY = 'beneficiary'
SUCCESSOR_ON_DEATH = 'successor_on_death'
TRUSTEE = 'trustee'
[docs]class AccountIdentification(BaseObject):
"""
Defines the identification of a account:
- scheme_name: Name of the account scheme type
- identification: ID of the account
"""
scheme_name = EnumField('Name of the account scheme type', AccountSchemeName, default=None)
identification = StringField('ID of the account', default=None)
def __repr__(self):
return f'<AccountIdentification scheme_name={self.scheme_name} identification={self.identification}>'
[docs]class PartyIdentity(BaseObject):
"""
Defines the identity of a party:
- full_name: Full name of the party
- role: Role of the party
- is_user: Defines the link between the party and the connected PSU
"""
ROLE_UNKNOWN = PartyRole.UNKNOWN
ROLE_HOLDER = PartyRole.HOLDER
ROLE_CO_HOLDER = PartyRole.CO_HOLDER
ROLE_ATTORNEY = PartyRole.ATTORNEY
ROLE_CUSTODIAN_FOR_MINOR = PartyRole.CUSTODIAN_FOR_MINOR
ROLE_LEGAL_GUARDIAN = PartyRole.LEGAL_GUARDIAN
ROLE_NOMINEE = PartyRole.NOMINEE
ROLE_BENEFICIARY = PartyRole.BENEFICIARY
ROLE_SUCCESSOR_ON_DEATH = PartyRole.SUCCESSOR_ON_DEATH
ROLE_TRUSTEE = PartyRole.TRUSTEE
full_name = StringField('Full name of the party.', default=None)
is_user = BoolField('Is the party the connected PSU?', default=None)
role = EnumField('Role of the party.', PartyRole, default=ROLE_UNKNOWN)
def __repr__(self):
return f'<PartyIdentity full_name={self.full_name} role={self.role} is_user={self.is_user}>'
[docs]class AccountParty(BaseObject):
"""
Defines all the information related to an account party:
- party_identities: list of PartyIdentity elements
- account_identifications : list of AccountIdentification elements
"""
party_identities = Field('Identities of the account party', list, default=[])
account_identifications = Field('Identification information of the account', list, default=[])
def __repr__(self):
return f'<AccountParty party_identities={self.party_identities} account_identifications={self.account_identifications}>'
[docs]class Account(BaseAccount):
"""
Bank account.
"""
TYPE_UNKNOWN = AccountType.UNKNOWN
TYPE_CHECKING = AccountType.CHECKING
TYPE_SAVINGS = AccountType.SAVINGS
TYPE_DEPOSIT = AccountType.DEPOSIT
TYPE_LOAN = AccountType.LOAN
TYPE_MARKET = AccountType.MARKET
TYPE_JOINT = AccountType.JOINT
TYPE_CARD = AccountType.CARD
TYPE_LIFE_INSURANCE = AccountType.LIFE_INSURANCE
TYPE_PEE = AccountType.PEE
TYPE_PERCO = AccountType.PERCO
TYPE_ARTICLE_83 = AccountType.ARTICLE_83
TYPE_RSP = AccountType.RSP
TYPE_PEA = AccountType.PEA
TYPE_CAPITALISATION = AccountType.CAPITALISATION
TYPE_PERP = AccountType.PERP
TYPE_MADELIN = AccountType.MADELIN
TYPE_MORTGAGE = AccountType.MORTGAGE
TYPE_CONSUMER_CREDIT = AccountType.CONSUMER_CREDIT
TYPE_REVOLVING_CREDIT = AccountType.REVOLVING_CREDIT
TYPE_PER = AccountType.PER
TYPE_REAL_ESTATE = AccountType.REAL_ESTATE
TYPE_CROWDLENDING = AccountType.CROWDLENDING
TYPE_LDDS = AccountType.LDDS
TYPE_PEL = AccountType.PEL
TYPE_CSL = AccountType.CSL
TYPE_CEL = AccountType.CEL
TYPE_CAT = AccountType.CAT
TYPE_LIVRET_A = AccountType.LIVRET_A
TYPE_LIVRET_B = AccountType.LIVRET_B
type = EnumField('Type of account', AccountType, default=TYPE_UNKNOWN)
owner_type = StringField('Usage of account') # cf AccountOwnerType class
balance = DecimalField('Balance on this bank account')
coming = DecimalField('Sum of coming movements')
iban = StringField('International Bank Account Number', mandatory=False)
ownership = StringField('Relationship between the credentials owner (PSU) and the account') # cf AccountOwnership class
# card attributes
paydate = DateField('For credit cards. When next payment is due.')
paymin = DecimalField('For credit cards. Minimal payment due.')
cardlimit = DecimalField('For credit cards. Credit limit.')
number = StringField('Shown by the bank to identify your account ie XXXXX7489')
# Wealth accounts (market, life insurance...)
valuation_diff = DecimalField('+/- values total')
valuation_diff_ratio = DecimalField('+/- values ratio')
management_type = EnumField('Management type of account', AccountManagementType, default=None)
# Employee savings (PERP, PERCO, Article 83...)
company_name = StringField('Name of the company of the stock - only for employee savings')
# parent account
# - A checking account parent of a card account
# - A checking account parent of a recurring loan account
# - An investment account parent of a liquidity account
# - ...
parent = Field('Parent account', BaseAccount)
opening_date = DateField('Date when the account contract was created on the bank')
all_balances = Field('List of balances', list, default=[])
party = Field('Party associated to the account', AccountParty, default=None)
def __repr__(self):
return "<%s id=%r label=%r>" % (type(self).__name__, self.id, self.label)
# compatibility alias
@property
def valuation_diff_percent(self):
return self.valuation_diff_ratio
@valuation_diff_percent.setter
def valuation_diff_percent(self, value):
self.valuation_diff_ratio = value
[docs]class BalanceType(Enum):
CLOSING = 1
"""Current balance of the account"""
PENDING = 2
"""Forecast balance of the account"""
[docs]class Balance(BaseObject):
"""
Object made to receive balance on one Account
"""
amount = DecimalField('Amount on this balance')
type = EnumField('Type of balance', BalanceType)
currency = StringField('Currency')
reference_date = DateField('date of the balance')
last_update = DateField('Last time balance was updated')
credit_included = BoolField('If factoring is included in balance', default=False)
label = StringField('Bank name of the balance')
calculated = BoolField('If computation has been made on the balance', default=False)
def __repr__(self):
# Ex: '< Balance: label="Solde en Valeur" amount=972.94 type=1 credit_included=False reference_date=2023-06-09 >'
return ' '.join((
'<',
f'{type(self).__name__}:',
f'label="{self.label}"',
f'amount={self.amount}',
f'type={self.type}',
f'credit_included={self.credit_included}',
f'reference_date={self.reference_date}',
'>'
))
[docs]class Loan(Account):
"""
Account type dedicated to loans and credits.
"""
name = StringField('Person name')
account_label = StringField('Label of the debited account')
insurance_label = StringField('Label of the insurance')
total_amount = DecimalField('Total amount loaned')
available_amount = DecimalField('Amount available') # only makes sense for revolving credit
used_amount = DecimalField('Amount already used') # only makes sense for revolving credit
insurance_amount = DecimalField("Amount of the loan's insurance")
insurance_rate = DecimalField("Rate of the loan's insurance")
subscription_date = DateField('Date of subscription of the loan')
maturity_date = DateField('Estimated end date of the loan')
start_repayment_date = DateField('Date of start repayment of the loan')
deferred = BoolField('If loan is deferred')
duration = IntField('Duration of the loan given in months')
rate = DecimalField('Monthly rate of the loan')
nb_payments_left = IntField('Number of payments still due')
nb_payments_done = IntField('Number of payments already done')
nb_payments_total = IntField('Number total of payments')
last_payment_amount = DecimalField('Amount of the last payment done')
last_payment_date = DateField('Date of the last payment done')
next_payment_amount = DecimalField('Amount of next payment')
next_payment_date = DateField('Date of the next payment')
class TransactionType(Enum):
UNKNOWN = 0
TRANSFER = 1
ORDER = 2
CHECK = 3
DEPOSIT = 4
PAYBACK = 5
WITHDRAWAL = 6
CARD = 7
LOAN_PAYMENT = 8
BANK = 9
CASH_DEPOSIT = 10
CARD_SUMMARY = 11
DEFERRED_CARD = 12
INSTANT = 13
MARKET_ORDER = 14
MARKET_FEE = 15
ARBITRAGE = 16
PROFIT = 17
[docs]class BankTransactionCode(BaseObject):
"""
Object dedicating to bank transaction codes
It follows the ISO20022 standards.
See https://www.iso20022.org/catalogue-messages/additional-content-messages/external-code-sets
"""
domain = StringField('Domain of the transaction')
family = StringField('Family of the transaction')
sub_family = StringField('Sub-family of the transaction')
[docs]class Transaction(BaseObject):
"""
Bank transaction.
"""
TYPE_UNKNOWN = TransactionType.UNKNOWN
TYPE_TRANSFER = TransactionType.TRANSFER
TYPE_ORDER = TransactionType.ORDER
TYPE_CHECK = TransactionType.CHECK
TYPE_DEPOSIT = TransactionType.DEPOSIT
TYPE_PAYBACK = TransactionType.PAYBACK
TYPE_WITHDRAWAL = TransactionType.WITHDRAWAL
TYPE_CARD = TransactionType.CARD
TYPE_LOAN_PAYMENT = TransactionType.LOAN_PAYMENT
TYPE_BANK = TransactionType.BANK
TYPE_CASH_DEPOSIT = TransactionType.CASH_DEPOSIT
TYPE_CARD_SUMMARY = TransactionType.CARD_SUMMARY
TYPE_DEFERRED_CARD = TransactionType.DEFERRED_CARD
TYPE_INSTANT = TransactionType.INSTANT
TYPE_MARKET_ORDER = TransactionType.MARKET_ORDER
TYPE_MARKET_FEE = TransactionType.MARKET_FEE
TYPE_ARBITRAGE = TransactionType.ARBITRAGE
TYPE_PROFIT = TransactionType.PROFIT
date = DateField('Debit date on the bank statement')
rdate = DateField('Real date, when the payment has been made; usually extracted from the label or from credit card info')
vdate = DateField('Value date, or accounting date; usually for professional accounts')
bdate = DateField('Bank date, when the transaction appear on website (usually extracted from column date)')
type = EnumField('Type of transaction, use TYPE_* constants', TransactionType, default=TYPE_UNKNOWN)
raw = StringField('Raw label of the transaction')
category = StringField('Category of the transaction')
label = StringField('Pretty label')
amount = DecimalField('Net amount of the transaction, used to compute account balance')
coming = BoolField('True if the transaction is not yet booked')
card = StringField('Card number (if any)')
commission = DecimalField('Commission part on the transaction (in account currency)')
gross_amount = DecimalField('Amount of the transaction without the commission')
# International
original_amount = DecimalField('Original net amount (in another currency)')
original_currency = StringField('Currency of the original amount')
country = StringField('Country of transaction')
original_commission = DecimalField('Original commission (in another currency)')
original_commission_currency = StringField('Currency of the original commission')
original_gross_amount = DecimalField('Original gross amount (in another currency)')
attachments = Field('List of files attached to the transaction', list)
# Financial arbitrations
investments = Field('List of investments related to the transaction', list, default=[])
counterparty = Field('Counterparty of transaction', TransactionCounterparty)
bank_transaction_code = Field('Bank transaction code of transaction', BankTransactionCode)
def __repr__(self):
return "<Transaction date=%r label=%r amount=%r>" % (self.date, self.label, self.amount)
[docs] def unique_id(self, seen: set | None = None, account_id: str | None = None) -> str:
"""
Get an unique ID for the transaction based on date, amount and raw.
:param seen: if given, the method uses this set as a cache to
prevent several transactions with the same values to have the same
unique ID.
:type seen: :class:`set`
:param account_id: if given, add the account ID in data used to create
the unique ID. Can be useful if you want your ID to be unique across
several accounts.
:type account_id: :class:`str`
:returns: an unique ID encoded in 8 length hexadecimal string (for example ``'a64e1bc9'``)
:rtype: :class:`str`
"""
crc = crc32(str(self.date).encode('utf-8'))
crc = crc32(str(self.amount).encode('utf-8'), crc)
if not empty(self.raw):
label = self.raw
else:
label = self.label
crc = crc32(re.sub('[ ]+', ' ', label).encode("utf-8"), crc)
if account_id is not None:
crc = crc32(str(account_id).encode('utf-8'), crc)
if seen is not None:
while crc in seen:
crc = crc32(b"*", crc)
seen.add(crc)
return "%08x" % (crc & 0xffffffff)
[docs]class CapBank(CapCollection, CapCredentialsCheck):
"""
Capability of bank websites to see accounts and transactions.
"""
[docs] def check_credentials(self) -> bool:
"""
Check that the given credentials are correct by trying to login.
The default implementation of this method check if the class using this capability
has a browser, execute its do_login if it has one and then see if no error pertaining to the creds is raised.
If any other unexpected error occurs, we don't know whether the creds are correct or not.
"""
# TODO move this in a specific capability
if getattr(self, 'BROWSER', None) is None:
raise NotImplementedError()
try:
self.browser.do_login()
except BrowserIncorrectPassword:
return False
return True
[docs] def iter_resources(self, objs: List[BaseObject], split_path: List[str]) -> Iterable[BaseObject]:
"""
Iter resources.
Default implementation of this method is to return on top-level
all accounts (by calling :func:`iter_accounts`).
:param objs: type of objects to get
:type objs: tuple[:class:`BaseObject`]
:param split_path: path to discover
:type split_path: :class:`list`
:rtype: iter[:class:`BaseObject`]
"""
if Account in objs:
self._restrict_level(split_path)
yield from self.iter_accounts()
[docs] def iter_accounts(self) -> Iterable[Account]:
"""
Iter accounts.
:rtype: iter[:class:`Account`]
"""
raise NotImplementedError()
[docs] def get_account(self, id: str) -> Account | None:
"""
Get an account from its ID.
:param id: ID of the account
:type id: :class:`str`
:rtype: :class:`Account`
:raises: :class:`AccountNotFound`
"""
return find_object(self.iter_accounts(), id=id, error=AccountNotFound)
[docs] def iter_history(self, account: Account) -> Iterable[Transaction]:
"""
Iter history of transactions of a specific account.
:param account: account to get history
:type account: :class:`Account`
:rtype: iter[:class:`Transaction`]
:raises: :class:`AccountNotFound`
"""
return self.iter_transactions(account, with_coming=False)
[docs] def iter_coming(self, account: Account) -> Iterable[Transaction]:
"""
Iter coming transactions of a specific account.
:param account: account to get coming transactions
:type account: :class:`Account`
:rtype: iter[:class:`Transaction`]
:raises: :class:`AccountNotFound`
"""
return self.iter_transactions(account, with_history=False)
[docs] def iter_transactions(
self, account: Account, *, with_history: bool = True, with_coming: bool = True
) -> Iterable[Transaction]:
"""
Iter all transactions (history and coming) of a specific account.
:param account: account to get transactions
:param with_history: if False, booked transactions will not be returned
:param with_coming: if False, coming transactions will not be returned
:type account: :class:`Account`
:rtype: iter[:class:`Transaction`]
:raises: :class:`AccountNotFound`
"""
raise NotImplementedError()
[docs]class CapAccountCheck(Capability):
"""
Capability to get accounts parties information.
The expected structure is the following:
- AccountParty object
* party_identities (list of PartyIdentity elements):
* full name
* role
* is_user
* account_identifications (list of type AccountIdentification elements):
* scheme name
* identification
"""