# -*- coding: utf-8 -*-
# Copyright(C) 2018 Célande Adrien
#
# This file is part of a weboob module.
#
# This weboob module 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.
#
# This weboob module 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 this weboob module. If not, see .
from __future__ import unicode_literals
import re
from datetime import date
from decimal import Decimal
from weboob.tools.date import parse_french_date
from weboob.exceptions import BrowserIncorrectPassword, BrowserUnavailable, ActionNeeded, ParseError
from weboob.capabilities.base import find_object
from weboob.browser.pages import JsonPage, LoggedPage, HTMLPage
from weboob.capabilities import NotAvailable
from weboob.capabilities.bank import Account, Investment
from weboob.tools.capabilities.bank.investments import is_isin_valid
from weboob.capabilities.profile import Person
from weboob.browser.filters.standard import CleanText, CleanDecimal, Env, Eval
from weboob.browser.filters.json import Dict
from weboob.browser.elements import DictElement, ItemElement, method
from weboob.tools.capabilities.bank.transactions import FrenchTransaction
class Transaction(FrenchTransaction):
PATTERNS = [(re.compile(r'^.*Virement (?P.*)'), FrenchTransaction.TYPE_TRANSFER),
(re.compile(r'PRELEV SEPA (?P.*)'), FrenchTransaction.TYPE_ORDER),
(re.compile(r'.*Prélèvement.*'), FrenchTransaction.TYPE_ORDER),
(re.compile(r'^(REGL|Rgt)(?P.*)'), FrenchTransaction.TYPE_ORDER),
(re.compile(r'^(?P.*) Carte \d+\s+ LE (?P\d{2})/(?P\d{2})/(?P\d{2})'),
FrenchTransaction.TYPE_CARD),
(re.compile(r'^Débit mensuel.*'), FrenchTransaction.TYPE_CARD_SUMMARY),
(re.compile(r"^Retrait d'espèces à un DAB (?P.*) CARTE [X\d]+ LE (?P\d{2})/(?P\d{2})/(?P\d{2})"),
FrenchTransaction.TYPE_WITHDRAWAL),
(re.compile(r'^Paiement de chèque (?P.*)'), FrenchTransaction.TYPE_CHECK),
(re.compile(r'^(Cotisation|Intérêts) (?P.*)'), FrenchTransaction.TYPE_BANK),
(re.compile(r'^(Remise Chèque|Remise de chèque)\s*(?P.*)'), FrenchTransaction.TYPE_DEPOSIT),
(re.compile(r'^Versement (?P.*)'), FrenchTransaction.TYPE_DEPOSIT),
(re.compile(r'^(?P.*)LE (?P\d{2})/(?P\d{2})/(?P\d{2})\s*(?P.*)'),
FrenchTransaction.TYPE_UNKNOWN),
]
class MyJsonPage(LoggedPage, JsonPage):
def get_content(self):
return self.doc.get('content', {})
class HomePage(LoggedPage, HTMLPage):
pass
class LoginPage(LoggedPage, HTMLPage):
pass
class UniversePage(MyJsonPage):
def get_universes(self):
universe_data = self.get_content()
universes = {}
universes[universe_data['universKey']] = universe_data['title']
for universe in universe_data.get('menus', {}):
universes[universe['universKey']] = universe['title']
return universes
class TokenPage(MyJsonPage):
pass
class MoveUniversePage(LoggedPage, HTMLPage):
pass
class SwitchPage(LoggedPage, JsonPage):
pass
class LoansPage(MyJsonPage):
def iter_loans(self, current_univers):
for content in self.get_content():
a = Account()
a.id = "%s.%s" % (content['comptePrets'].strip(), content['numeroDossier'].strip())
a.type = Account.TYPE_LOAN
a.label = ' '.join([content['intitule'].strip(), content['libellePrets'].strip()])
a.balance = -Decimal(str(content['montantCapitalDu']['valeur']))
a.currency = content['montantCapitalDu']['monnaie']['code'].strip()
a._univers = current_univers
yield a
class AccountsPage(MyJsonPage):
ACCOUNT_TYPES = {
'000': Account.TYPE_CHECKING, # Compte à vue
'001': Account.TYPE_SAVINGS, # Livret Ile de France
'011': Account.TYPE_CARD, # Carte bancaire
'020': Account.TYPE_SAVINGS, # Compte sur livret
'021': Account.TYPE_SAVINGS,
'022': Account.TYPE_SAVINGS, # Livret d'épargne populaire
'023': Account.TYPE_SAVINGS, # LDD Solidaire
'025': Account.TYPE_SAVINGS, # Livret Fidélis
'027': Account.TYPE_SAVINGS, # Livret A
'037': Account.TYPE_SAVINGS,
'070': Account.TYPE_SAVINGS, # Compte Epargne Logement
'077': Account.TYPE_SAVINGS, # Livret Bambino
'078': Account.TYPE_SAVINGS, # Livret jeunes
'080': Account.TYPE_SAVINGS, # Plan épargne logement
'081': Account.TYPE_SAVINGS,
'086': Account.TYPE_SAVINGS, # Compte épargne Moisson
'097': Account.TYPE_CHECKING, # Solde en devises
'730': Account.TYPE_DEPOSIT, # Compte à terme Optiplus
'999': Account.TYPE_MARKET, # no label, we use 'Portefeuille Titres' if needed
}
def iter_accounts(self, accnum, current_univers):
seen = set()
accounts_list = []
for content in self.get_content():
if accnum != '00000000000' and content['numero'] != accnum:
continue
for poste in content['postes']:
a = Account()
a._number = content['numeroLong']
a._nature = poste['codeNature']
a._codeSousPoste = poste['codeSousPoste'] if 'codeSousPoste' in poste else None
a._consultable = poste['consultable']
a._univers = current_univers
a.id = '%s.%s' % (a._number, a._nature)
a.type = self.ACCOUNT_TYPES.get(poste['codeNature'], Account.TYPE_UNKNOWN)
if a.type == Account.TYPE_UNKNOWN:
self.logger.warning("unknown type %s" % poste['codeNature'])
if a.type == Account.TYPE_CARD:
a.parent = find_object(accounts_list, _number=a._number, type=Account.TYPE_CHECKING)
if 'numeroDossier' in poste and poste['numeroDossier']:
a._file_number = poste['numeroDossier']
a.id += '.%s' % a._file_number
if poste['postePortefeuille']:
a.label = '{} Portefeuille Titres'.format(content['intitule'].strip())
a.balance = Decimal(str(poste['montantTitres']['valeur']))
a.currency = poste['montantTitres']['monnaie']['code'].strip()
if not a.balance and not a.currency and 'dateTitres' not in poste:
continue
accounts_list.append(a)
if 'libelle' not in poste:
continue
a.label = ' '.join([content['intitule'].strip(), poste['libelle'].strip()])
if poste['numeroDossier']:
a.label = '{} n°{}'.format(a.label, poste['numeroDossier'])
a.balance = Decimal(str(poste['solde']['valeur']))
a.currency = poste['solde']['monnaie']['code'].strip()
# Some accounts may have balance currency
if 'Solde en devises' in a.label and a.currency != u'EUR':
a.id += str(poste['monnaie']['codeSwift'])
if a.id in seen:
# some accounts like "compte à terme fidélis" have the same _number and _nature
# but in fact are kind of closed, so worthless...
self.logger.warning('ignored account id %r (%r) because it is already used', a.id, poste.get('numeroDossier'))
continue
seen.add(a.id)
accounts_list.append(a)
return accounts_list
class IbanPage(MyJsonPage):
def set_iban(self, account):
iban_response = self.get_content()
account.iban = iban_response.get('iban', NotAvailable)
class LifeInsurancesPage(LoggedPage, JsonPage):
@method
class iter_lifeinsurances(DictElement):
item_xpath = 'content'
class iter_accounts(DictElement):
item_xpath = 'avoirs/contrats'
def get_owner(self):
return CleanText(Dict('titulaire'))(self)
class item(ItemElement):
klass = Account
obj_balance = CleanDecimal(Dict('valorisation'))
obj_type = Account.TYPE_LIFE_INSURANCE
obj_currency = 'EUR'
obj__univers = Env('univers')
def obj_id(self):
return Eval(str, Dict('numero'))(self)
def obj_label(self):
return '%s - %s' % (CleanText(Dict('libelleProduit'))(self), self.parent.get_owner())
def obj__parent_number(self):
return CleanText(Dict('cptRattachement'))(self).rstrip('0')
# Investments are already present in this JSON,
# so we fill the lists of Investment objects now
class obj__investments(DictElement):
item_xpath = 'allocations'
class item(ItemElement):
klass = Investment
obj_label = CleanText(Dict('libelle'))
obj_valuation = CleanDecimal(Dict('montant'))
def obj_code_type(self):
if is_isin_valid(CleanText(Dict('code'))(self)):
return Investment.CODE_TYPE_ISIN
return NotAvailable
def obj_code(self):
code = CleanText(Dict('code'))(self)
if is_isin_valid(code):
return code
return NotAvailable
class SearchPage(LoggedPage, JsonPage):
def get_transaction_list(self):
result = self.doc
if int(result['erreur']['code']) != 0:
raise BrowserUnavailable("API sent back an error code")
return result['content']['operations']
def iter_history(self, account, operation_list, seen, today, coming):
transactions = []
for op in reversed(operation_list):
t = Transaction()
t.id = str(op['id'])
if op['id'] in seen:
raise ParseError('There are several transactions with the same ID, probably an infinite loop')
seen.add(t.id)
d = date.fromtimestamp(op.get('dateDebit', op.get('dateOperation'))/1000)
op['details'] = [re.sub(r'\s+', ' ', i).replace('\x00', '') for i in op['details'] if i] # sometimes they put "null" elements...
label = re.sub(r'\s+', ' ', op['libelle']).replace('\x00', '')
raw = ' '.join([label] + op['details'])
t.rdate = date.fromtimestamp(op.get('dateOperation', op.get('dateDebit'))/1000)
vdate = date.fromtimestamp(op.get('dateValeur', op.get('dateDebit', op.get('dateOperation')))/1000)
t.parse(d, raw, vdate=vdate)
t.amount = Decimal(str(op['montant']))
if 'categorie' in op:
t.category = op['categorie']
t.label = label
t._coming = op['intraday']
if t._coming:
# coming transactions have a random uuid id (inconsistent between requests)
t.id = ''
t._coming |= (t.date > today)
if t.type == Transaction.TYPE_CARD and account.type == Account.TYPE_CARD:
t.type = Transaction.TYPE_DEFERRED_CARD
if abs(t.rdate.year - t.date.year) < 2:
transactions.append(t)
else:
self.logger.warning(
'rdate(%s) and date(%s) of the transaction %s are far far away, skip it',
t.rdate,
t.date,
t.label
)
return transactions
class ProfilePage(MyJsonPage):
def get_profile(self):
profile = Person()
content = self.get_content()
profile.name = content['prenom'] + ' ' + content['nom']
profile.address = content['adresse'] + ' ' + content['codePostal'] + ' ' + content['ville']
profile.country = content['pays']
profile.birth_date = parse_french_date(content['dateNaissance']).date()
return profile
class EmailsPage(MyJsonPage):
def set_email(self, profile):
content = self.get_content()
if 'emailPart' in content:
profile.email = content['emailPart']
class ErrorPage(LoggedPage, HTMLPage):
def on_load(self):
if 'gestion-des-erreurs/erreur-pwd' in self.url:
raise BrowserIncorrectPassword(CleanText('//h3')(self.doc))
if 'gestion-des-erreurs/opposition' in self.url:
# need a case to retrieve the error message
raise BrowserIncorrectPassword('Votre compte a été désactivé')
if '/pages-gestion-des-erreurs/erreur-technique' in self.url:
errmsg = CleanText('//h4')(self.doc)
raise BrowserUnavailable(errmsg)
if '/pages-gestion-des-erreurs/message-tiers-oppose' in self.url:
# need a case to retrieve the error message
raise ActionNeeded("Impossible de se connecter au compte car l'identification en 2 étapes a été activée")
class ErrorCodePage(HTMLPage):
def on_load(self):
code = re.search(r'\/\?errorCode=(\d+)', self.url).group(1)
page = self.browser.open('/particuliers/compte-bancaire/comptes-en-ligne/bredconnect-compte-ligne?errorCode=%s' % code).page
msg = CleanText('//label[contains(@class, "error")]', default=None)(page.doc)
# 20100: invalid login/password
# 139: dispobank user trying to connect to Bred
if code in ('20100', '139'):
raise BrowserIncorrectPassword(msg)
# 20104 & 1000: unknown error during login
elif code in ('20104', '1000'):
raise BrowserUnavailable(msg)
assert False, 'Error %s is not handled yet.' % code