# -*- coding: utf-8 -*-
# Copyright(C) 2010-2016 Romain Bignon
#
# This file is part of weboob.
#
# weboob 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.
#
# weboob 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 weboob. If not, see .
from datetime import date, datetime
from binascii import crc32
import re
from unidecode import unidecode
from weboob.capabilities.base import empty, find_object
from weboob.exceptions import BrowserQuestion
from weboob.tools.capabilities.bank.iban import is_iban_valid
from weboob.tools.compat import unicode
from .base import BaseObject, Field, StringField, DecimalField, IntField, \
UserError, Currency, NotAvailable, EnumField, Enum
from .date import DateField
from .collection import CapCollection
__all__ = [
'CapBank', 'BaseAccount', 'Account', 'Loan', 'Transaction', 'AccountNotFound',
'AccountType',
'CapBankWealth', 'Investment', 'CapBankPockets', 'Pocket',
'CapBankTransfer', 'Transfer', 'Recipient',
'TransferError', 'TransferBankError', 'TransferInvalidAmount', 'TransferInsufficientFunds',
'TransferInvalidCurrency', 'TransferInvalidLabel',
'TransferInvalidEmitter', 'TransferInvalidRecipient',
'TransferStep',
'CapBankTransferAddRecipient',
'RecipientNotFound', 'AddRecipientError', 'AddRecipientBankError', 'AddRecipientTimeout',
'AddRecipientStep', 'RecipientInvalidIban', 'RecipientInvalidLabel',
'Rate', 'CapCurrencyRate',
]
class AccountNotFound(UserError):
"""
Raised when an account is not found.
"""
def __init__(self, msg='Account not found'):
super(AccountNotFound, self).__init__(msg)
class RecipientNotFound(UserError):
"""
Raised when a recipient is not found.
"""
def __init__(self, msg='Recipient not found'):
super(RecipientNotFound, self).__init__(msg)
class TransferError(UserError):
"""
A transfer has failed.
"""
code = 'transferError'
def __init__(self, description=None, message=None):
"""
:param message: error message from the bank, if any
"""
super(TransferError, self).__init__(message or description)
self.message = message
self.description = description
class TransferBankError(TransferError):
"""The transfer was rejected by the bank with a message."""
code = 'bankMessage'
class TransferInvalidLabel(TransferError):
"""The transfer label is invalid."""
code = 'invalidLabel'
class TransferInvalidEmitter(TransferError):
"""The emitter account cannot be used for transfers."""
code = 'invalidEmitter'
class TransferInvalidRecipient(TransferError):
"""The emitter cannot transfer to this recipient."""
code = 'invalidRecipient'
class TransferInvalidAmount(TransferError):
"""This amount is not allowed."""
code = 'invalidAmount'
class TransferInvalidCurrency(TransferInvalidAmount):
"""The transfer currency is invalid."""
code = 'invalidCurrency'
class TransferInsufficientFunds(TransferInvalidAmount):
"""Not enough funds on emitter account."""
code = 'insufficientFunds'
class TransferInvalidDate(TransferError):
"""This execution date cannot be used."""
code = 'invalidDate'
class AddRecipientError(UserError):
"""
Failed trying to add a recipient.
"""
code = 'AddRecipientError'
def __init__(self, description=None, message=None):
"""
:param message: error message from the bank, if any
"""
super(AddRecipientError, self).__init__(message or description)
self.message = message
self.description = description
class AddRecipientBankError(AddRecipientError):
"""The new recipient was rejected by the bank with a message."""
code = 'bankMessage'
class AddRecipientTimeout(AddRecipientError):
"""Add new recipient request has timeout"""
code = 'timeout'
class RecipientInvalidIban(AddRecipientError):
code = 'invalidIban'
class RecipientInvalidLabel(AddRecipientError):
code = 'invalidLabel'
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)
def __init__(self, id='0', url=None):
super(BaseAccount, self).__init__(id, url)
@property
def currency_text(self):
return Currency.currency2txt(self.currency)
@property
def ban(self):
""" Bank Account Number part of IBAN"""
if not self.iban:
return NotAvailable
return self.iban[4:]
class Recipient(BaseAccount):
"""
Recipient of a transfer.
"""
enabled_at = DateField('Date of availability')
category = StringField('Recipient category')
iban = StringField('International Bank Account Number')
# Needed for multispaces case
origin_account_id = StringField('Account id which recipient belong to')
def __repr__(self):
return "<%s id=%r label=%r>" % (type(self).__name__, self.id, self.label)
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"
class AccountOwnerType(object):
"""
Specifies the usage of the account
"""
PRIVATE = u'PRIV'
"""private personal account"""
ORGANIZATION = u'ORGA'
"""professional account"""
ASSOCIATION = u'ASSO'
"""association account"""
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 = 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)
# 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')
# market and lifeinssurance accounts
valuation_diff = DecimalField('+/- values total')
valuation_diff_ratio = DecimalField('+/- values ratio')
# 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')
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
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
subscription_date = DateField('Date of subscription of the loan')
maturity_date = DateField('Estimated end date of the loan')
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
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
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('Amount of the transaction')
card = StringField('Card number (if any)')
commission = DecimalField('Commission part on the transaction (in account currency)')
# International
original_amount = DecimalField('Original amount (in another currency)')
original_currency = StringField('Currency of the original amount')
country = StringField('Country of transaction')
# Financial arbitrations
investments = Field('List of investments related to the transaction', list, default=[])
def __repr__(self):
return "" % (self.date, self.label, self.amount)
def unique_id(self, seen=None, account_id=None):
"""
Get an unique ID for the transaction based on date, amount and raw.
:param seen: if given, the method uses this dictionary as a cache to
prevent several transactions with the same values to have the same
unique ID.
:type seen: :class:`dict`
: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(unicode(self.date).encode('utf-8'))
crc = crc32(unicode(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(unicode(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)
class Investment(BaseObject):
"""
Investment in a financial market.
"""
CODE_TYPE_ISIN = u'ISIN'
CODE_TYPE_AMF = u'AMF'
label = StringField('Label of stocks')
code = StringField('Identifier of the stock')
code_type = StringField('Type of stock code (ISIN or AMF)')
description = StringField('Short description of the stock')
quantity = DecimalField('Quantity of stocks')
unitprice = DecimalField('Buy price of one stock')
unitvalue = DecimalField('Current value of one stock')
valuation = DecimalField('Total current valuation of the Investment')
vdate = DateField('Value date of the valuation amount')
diff = DecimalField('Difference between the buy cost and the current valuation')
diff_ratio = DecimalField('Difference in ratio (1 meaning 100%) between the buy cost and the current valuation')
portfolio_share = DecimalField('Ratio (1 meaning 100%) of the current amount relative to the total')
# International
original_currency = StringField('Currency of the original amount')
original_valuation = DecimalField('Original valuation (in another currency)')
original_unitvalue = DecimalField('Original unitvalue (in another currency)')
original_unitprice = DecimalField('Original unitprice (in another currency)')
original_diff = DecimalField('Original diff (in another currency)')
def __repr__(self):
return '' % (self.label, self.code, self.valuation)
# compatibility alias
@property
def diff_percent(self):
return self.diff_ratio
@diff_percent.setter
def diff_percent(self, value):
self.diff_ratio = value
class PocketCondition(Enum):
UNKNOWN = 0
DATE = 1
AVAILABLE = 2
RETIREMENT = 3
WEDDING = 4
DEATH = 5
INDEBTEDNESS = 6
DIVORCE = 7
DISABILITY = 8
BUSINESS_CREATION = 9
BREACH_EMPLOYMENT_CONTRACT = 10
UNLOCKING_EXCEPTIONAL = 11
THIRD_CHILD = 12
EXPIRATION_UNEMPLOYMENT = 13
PURCHASE_APARTMENT = 14
class Pocket(BaseObject):
"""
Pocket
"""
CONDITION_UNKNOWN = PocketCondition.UNKNOWN
CONDITION_DATE = PocketCondition.DATE
CONDITION_AVAILABLE = PocketCondition.AVAILABLE
CONDITION_RETIREMENT = PocketCondition.RETIREMENT
CONDITION_WEDDING = PocketCondition.WEDDING
CONDITION_DEATH = PocketCondition.DEATH
CONDITION_INDEBTEDNESS = PocketCondition.INDEBTEDNESS
CONDITION_DIVORCE = PocketCondition.DIVORCE
CONDITION_DISABILITY = PocketCondition.DISABILITY
CONDITION_BUSINESS_CREATION = PocketCondition.BUSINESS_CREATION
CONDITION_BREACH_EMPLOYMENT_CONTRACT = PocketCondition.BREACH_EMPLOYMENT_CONTRACT
CONDITION_UNLOCKING_EXCEPTIONAL = PocketCondition.UNLOCKING_EXCEPTIONAL
CONDITION_THIRD_CHILD = PocketCondition.THIRD_CHILD
CONDITION_EXPIRATION_UNEMPLOYMENT = PocketCondition.EXPIRATION_UNEMPLOYMENT
CONDITION_PURCHASE_APARTMENT = PocketCondition.PURCHASE_APARTMENT
label = StringField('Label of pocket')
amount = DecimalField('Amount of the pocket')
quantity = DecimalField('Quantity of stocks')
availability_date = DateField('Availability date of the pocket')
condition = EnumField('Withdrawal condition of the pocket', PocketCondition, default=CONDITION_UNKNOWN)
investment = Field('Reference to the investment of the pocket', Investment)
class TransferStep(BrowserQuestion):
def __init__(self, transfer, *values):
super(TransferStep, self).__init__(*values)
self.transfer = transfer
class AddRecipientStep(BrowserQuestion):
def __init__(self, recipient, *values):
super(AddRecipientStep, self).__init__(*values)
self.recipient = recipient
class Transfer(BaseObject, Currency):
"""
Transfer from an account to a recipient.
"""
amount = DecimalField('Amount to transfer')
currency = StringField('Currency', default=None)
fees = DecimalField('Fees', default=None)
exec_date = Field('Date of transfer', date, datetime)
account_id = StringField('ID of origin account')
account_iban = StringField('International Bank Account Number')
account_label = StringField('Label of origin account')
account_balance = DecimalField('Balance of origin account before transfer')
recipient_id = StringField('ID of recipient account')
recipient_iban = StringField('International Bank Account Number')
recipient_label = StringField('Label of recipient account')
label = StringField('Reason')
class CapBank(CapCollection):
"""
Capability of bank websites to see accounts and transactions.
"""
def iter_resources(self, objs, split_path):
"""
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)
return self.iter_accounts()
def iter_accounts(self):
"""
Iter accounts.
:rtype: iter[:class:`Account`]
"""
raise NotImplementedError()
def get_account(self, id):
"""
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)
def iter_history(self, account):
"""
Iter history of transactions on a specific account.
:param account: account to get history
:type account: :class:`Account`
:rtype: iter[:class:`Transaction`]
:raises: :class:`AccountNotFound`
"""
raise NotImplementedError()
def iter_coming(self, account):
"""
Iter coming transactions on a specific account.
:param account: account to get coming transactions
:type account: :class:`Account`
:rtype: iter[:class:`Transaction`]
:raises: :class:`AccountNotFound`
"""
raise NotImplementedError()
class CapCgp(CapBank):
"""
Capability of cgp website to see accounts and transactions.
"""
class CapBankWealth(CapBank):
"""
Capability of bank websites to see investment.
"""
def iter_investment(self, account):
"""
Iter investment of a market account
:param account: account to get investments
:type account: :class:`Account`
:rtype: iter[:class:`Investment`]
:raises: :class:`AccountNotFound`
"""
raise NotImplementedError()
class CapBankPockets(CapBankWealth):
"""
Capability of bank websites to see pockets.
"""
def iter_pocket(self, account):
"""
Iter pocket
:param account: account to get pockets
:type account: :class:`Account`
:rtype: iter[:class:`Pocket`]
:raises: :class:`AccountNotFound`
"""
raise NotImplementedError()
class CapBankTransfer(CapBank):
def iter_transfer_recipients(self, account):
"""
Iter recipients availables for a transfer from a specific account.
:param account: account which initiate the transfer
:type account: :class:`Account`
:rtype: iter[:class:`Recipient`]
:raises: :class:`AccountNotFound`
"""
raise NotImplementedError()
def init_transfer(self, transfer, **params):
"""
Initiate a transfer.
:param :class:`Transfer`
:rtype: :class:`Transfer`
:raises: :class:`TransferError`
"""
raise NotImplementedError()
def execute_transfer(self, transfer, **params):
"""
Execute a transfer.
:param :class:`Transfer`
:rtype: :class:`Transfer`
:raises: :class:`TransferError`
"""
raise NotImplementedError()
def transfer(self, transfer, **params):
"""
Do a transfer from an account to a recipient.
:param :class:`Transfer`
:rtype: :class:`Transfer`
:raises: :class:`TransferError`
"""
if not transfer.amount or transfer.amount <= 0:
raise TransferInvalidAmount('amount must be strictly positive')
t = self.init_transfer(transfer, **params)
for key, value in t.iter_fields():
if hasattr(transfer, key) and key != 'id':
transfer_val = getattr(transfer, key)
try:
if hasattr(self, 'transfer_check_%s' % key):
assert getattr(self, 'transfer_check_%s' % key)(transfer_val, value)
else:
assert transfer_val == value or empty(transfer_val)
except AssertionError:
raise TransferError('%s changed during transfer processing (from %s to %s)' % (key, transfer_val, value))
return self.execute_transfer(t, **params)
def transfer_check_label(self, old, new):
old = re.sub(r'\s+', ' ', old).strip()
new = re.sub(r'\s+', ' ', new).strip()
return unidecode(old) == unidecode(new)
class CapBankTransferAddRecipient(CapBankTransfer):
def new_recipient(self, recipient, **params):
raise NotImplementedError()
def add_recipient(self, recipient, **params):
"""
Add a recipient to the connection.
:param iban: iban of the new recipient.
:type iban: :class:`str`
:param label: label of the new recipient.
:type label: :class`str`
:raises: :class:`BrowserQuestion`
:raises: :class:`AddRecipientError`
:rtype: :class:`Recipient`
"""
if not is_iban_valid(recipient.iban):
raise RecipientInvalidIban('Iban is not valid.')
if not recipient.label:
raise RecipientInvalidLabel('Recipient label is mandatory.')
return self.new_recipient(recipient, **params)
class Rate(BaseObject, Currency):
"""
Currency exchange rate.
"""
currency_from = StringField('The currency to which exchange rates are relative to. When converting 1 EUR to X HUF, currency_fom is EUR.)', default=None)
currency_to = StringField('The currency is converted to. When converting 1 EUR to X HUF, currency_to is HUF.)', default=None)
value = DecimalField('Exchange rate')
datetime = DateField('Collection date and time')
class CapCurrencyRate(CapBank):
"""
Capability of bank websites to get currency exchange rates.
"""
def iter_currencies(self):
"""
Iter available currencies.
:rtype: iter[:class:`Currency`]
"""
raise NotImplementedError()
def get_rate(self, currency_from, currency_to):
"""
Get exchange rate.
:param currency_from: currency to which exchange rate is relative to
:type currency_from: :class:`Currency`
:param currency_to: currency is converted to
:type currency_to: :class`Currency`
:rtype: :class:`Rate`
"""
raise NotImplementedError()