# Copyright(C) 2019 Budget Insight
#
# This file is part of a woob module.
#
# This woob 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 woob 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 woob module. If not, see .
import re
from decimal import Decimal
from woob.browser.pages import HTMLPage, LoggedPage
from woob.browser.elements import ListElement, ItemElement, method, TableElement
from woob.browser.filters.standard import (
CleanText, CleanDecimal, Date, Regexp, Field, Currency,
MapIn, Eval, Title, Env,
)
from woob.browser.filters.html import Link, TableCell, Attr
from woob.capabilities.base import NotAvailable, empty
from woob.capabilities.bank import Account
from woob.capabilities.bank.wealth import Investment, Pocket
from woob.tools.capabilities.bank.transactions import FrenchTransaction
class Transaction(FrenchTransaction):
PATTERNS = [
(re.compile(r'^(?P.*[Vv]ersement.*)'), FrenchTransaction.TYPE_DEPOSIT),
(re.compile(r'^(?P([Aa]rbitrage|[Pp]rélèvements.*))'), FrenchTransaction.TYPE_ORDER),
(re.compile(r'^(?P([Rr]etrait|[Pp]aiement.*))'), FrenchTransaction.TYPE_WITHDRAWAL),
(re.compile(r'^(?P.*)'), FrenchTransaction.TYPE_BANK),
]
class LoginPage(HTMLPage):
ENCODING = 'utf-8'
def login(self, login, password, recaptcha_reponse):
form = self.get_form(name='bloc_ident')
form['_cm_user'] = login
form['_cm_pwd'] = password
form['g-recaptcha-response'] = recaptcha_reponse
form.submit(allow_redirects=False)
def get_captcha_site_key(self):
return Attr('//div[@class="g-recaptcha"]', 'data-sitekey', default='')(self.doc)
class ActionNeededPage(HTMLPage, LoggedPage):
def get_message(self):
return CleanText('//p[@role="heading"]')(self.doc)
def get_skip_url(self):
return Link('//a[contains(., "PASSER CETTE ETAPE")]', default=None)(self.doc)
ACCOUNT_TYPES = {
"pargne entreprise": Account.TYPE_PEE,
"pargne groupe": Account.TYPE_PEE,
"pargne retraite": Account.TYPE_PERCO,
"courant bloqué": Account.TYPE_RSP,
}
class AccountsPage(LoggedPage, HTMLPage):
@method
class iter_accounts(ListElement):
item_xpath = '//th[text()= "Nom du support" or text()="Nom du profil" or text()="Nom du compte"]/ancestor::table/ancestor::table'
class item(ItemElement):
klass = Account
balance_xpath = './/span[contains(text(), "Montant total")]/following-sibling::span'
obj_label = CleanText('./tbody/tr/th[1]//div')
obj_balance = CleanDecimal.French(balance_xpath)
obj_currency = Currency(balance_xpath)
obj_type = MapIn(Field('label'), ACCOUNT_TYPES, Account.TYPE_UNKNOWN)
obj_company_name = CleanText('(//p[contains(@class, "profil_entrep")]/text())[1]')
obj_number = NotAvailable
def obj__entreprise_or_epargnants(self):
if Env('is_entreprise')(self):
return 'entreprise'
return 'epargnants'
def obj_id(self):
# Use customer number + label to build account id
number = Regexp(
CleanText('//div[@id="ei_tpl_fullSite"]//div[contains(@class, "ei_tpl_profil_content")]/p'),
r'(\w+)$',
default="",
)(self)
return Field('label')(self) + number
def iter_invest_rows(self, account):
"""
Process each invest row, extract elements needed to get
pocket and valuation diff information.
There are even PERCO rows where invests are located into a 'repartition' element.
This 'repartition' element contains all the investments of the account when you mouseover it.
Returns (row, el_repartition, el_pocket, el_diff)
"""
for row in self.doc.xpath('//th/div[contains(., "%s")]/ancestor::table//table/tbody/tr' % account.label):
id_repartition = row.xpath('.//td[1]//span[contains(@id, "rootSpan")]/@id')
id_pocket = row.xpath('.//td[2]//span[contains(@id, "rootSpan")]/@id')
id_diff = row.xpath('.//td[3]//span[contains(@id, "rootSpan")]/@id')
if not any(id_repartition or id_pocket or id_diff):
continue
yield (
row,
self.doc.xpath('//div[contains(@id, "dv::s::%s")]' % id_repartition[0].rsplit(':', 1)[0])[0] if id_repartition else None,
row.xpath('//div[contains(@id, "dv::s::%s")]' % id_diff[0].rsplit(':', 1)[0])[0] if id_diff else None,
)
def get_investment_form(self, form_param):
return self.get_form(
id='I0:P5:F',
submit='.//input[@name = "%s"]' % form_param
)
def iter_investments(self, account):
for row, elem_repartition, elem_diff in self.iter_invest_rows(account=account):
# If elements can be found in elem_repartition,
# all the investments can be retrieved in it.
if elem_repartition is not None:
for elem in elem_repartition.xpath('.//table//tr[position() > 2]'):
inv = Investment()
inv._account = account
inv.label = CleanText('.//td[1]//a')(elem)
inv._form_param = NotAvailable
inv._details_url = Link('.//td[1]//a', default=NotAvailable)(elem)
inv.valuation = CleanDecimal.French('.//td[2]', default=NotAvailable)(elem)
yield inv
else:
inv = Investment()
inv._account = account
inv.label = CleanText('.//td[1]')(row)
inv._form_param = CleanText('.//td[1]/input/@name')(row)
inv._details_url = Link('.//td[1]//a', default=NotAvailable)(row)
inv.valuation = CleanDecimal.French('.//td[2]')(row)
if account._entreprise_or_epargnants == 'entreprise':
inv.quantity = CleanDecimal.French('.//td[3]', default=NotAvailable)(row)
inv.unitvalue = CleanDecimal.French('.//td[4]', default=NotAvailable)(row)
portfolio_share = CleanDecimal.French('.//td[5]', default=NotAvailable)(row)
if not empty(portfolio_share):
inv.portfolio_share = portfolio_share / 100
# On all Cmes children the row shows percentages and the popup shows absolute values in currency.
# On Cmes it is mirrored, the popup contains the percentage.
is_mirrored = '%' in row.text_content()
if not is_mirrored:
inv.diff = CleanDecimal.French('.//td[3]', default=NotAvailable)(row)
if elem_diff is not None:
inv.diff_ratio = Eval(
lambda x: x / 100,
CleanDecimal.French(Regexp(CleanText('.'), r'([+-]?[\d\s]+[\d,]+)\s*%'))
)(elem_diff)
else:
if elem_diff is not None:
inv.diff = CleanDecimal.French('.', default=NotAvailable)(elem_diff)
inv.diff_ratio = Eval(
lambda x: x / 100,
CleanDecimal.French(Regexp(CleanText('.//td[3]'), r'([+-]?[\d\s]+[\d,]+)\s*%'))
)(row)
yield inv
def iter_ccb_pockets(self, account):
# CCB accounts have a specific table with more columns and specific attributes
for row in self.doc.xpath('//th/div[contains(., "%s")]/ancestor::table//table/tbody/tr' % account.label):
pocket = Pocket()
pocket._account = account
pocket.investment = None
pocket.label = CleanText('.//td[1]')(row)
pocket.amount = CleanDecimal.French('.//td[last()]')(row)
if 'DISPONIBLE' in CleanText('.//td[2]')(row):
pocket.condition = Pocket.CONDITION_AVAILABLE
pocket.availability_date = NotAvailable
else:
pocket.condition = Pocket.CONDITION_DATE
pocket.availability_date = Date(CleanText('.//td[2]'), dayfirst=True)(row)
yield pocket
class InvestmentPage(LoggedPage, HTMLPage):
def get_asset_management_url(self):
return Link('//a[.//span[text()="Fiche valeur"]]', default=None)(self.doc)
@method
class fill_investment(ItemElement):
# Sometimes there is a 'LIBELLES EN EURO' string joined with the category so we remove it
def obj_asset_category(self):
asset_category = Title(
CleanText(
'//tr[th[text()="Classification AMF"]]/td',
replace=[('LIBELLES EN EURO', '')]
)
)(self)
if asset_category == 'Sans Classification':
return NotAvailable
return asset_category
def obj_srri(self):
# Extract the value from '1/7' or '6/7' for instance
srri = Regexp(CleanText('//tr[th[text()="Niveau de risque"]]/td'), r'(\d+)\s?/7', default=None)(self)
if srri:
return int(srri)
return NotAvailable
def obj_recommended_period(self):
period = CleanText('//tr[th[text()="Durée de placement recommandée"]]/td')(self)
if period != 'NC':
return period
return NotAvailable
def get_form_url(self):
form = self.get_form(id='C:P:F')
return form.url
def get_performance(self):
return Eval(lambda x: x / 100, CleanDecimal.French('//p[contains(@class, "plusvalue--value")]'))(self.doc)
def get_investment_details(self):
return Link('//a[text()="Mes avoirs" or text()="Mon épargne"]', default=NotAvailable)(self.doc)
def go_back(self):
go_back_url = Link('//a[@id="C:A"]')(self.doc)
self.browser.location(go_back_url)
POCKET_CONDITIONS = {
'retraite': Pocket.CONDITION_RETIREMENT,
'disponibilites': Pocket.CONDITION_DATE,
'immediate': Pocket.CONDITION_AVAILABLE,
}
class InvestmentDetailsPage(LoggedPage, HTMLPage):
@method
class fill_investment(ItemElement):
def obj_quantity(self):
# Total quantity of the investments accross all contracts (PEE & PER).
total_quantity = CleanDecimal.French('//tr[th[text()="Nombre de parts"]]//em')(self)
# Quantity of the investments for PER contracts.
per_quantity = CleanDecimal.French(
'//tr[contains(td, "A votre retraite")]/td[3]',
default=NotAvailable,
)(self)
if Env('account_type')(self) != Account.TYPE_PERCO:
if not empty(per_quantity):
# If there is a per_quantity, we need to substract it to total_quantity.
return total_quantity - per_quantity
return total_quantity
else:
# If there is a PER split in 2 parts (Libre & Piloté), we cannot fetch the quantity
# displayed on the InvestmentDetailsPage because it's an aggregate of both plans.
# To avoid any data discrepancy, we must compute quantity for PERs.
if not empty(self.obj.valuation) and not empty(self.obj.unitvalue):
return Decimal.quantize(
Decimal(self.obj.valuation / self.obj.unitvalue),
Decimal('0.0001'),
)
return NotAvailable
obj_unitvalue = CleanDecimal.French(
'//tr[th[contains(text(), "Valeur de la part")]]//em',
default=NotAvailable
)
obj_vdate = Date(
Regexp(
CleanText('//tr//th[contains(text(), "Valeur de la part")]'),
r'Valeur de la part au (.*)',
default=NotAvailable
),
default=NotAvailable,
dayfirst=True
)
def go_back(self):
go_back_url = Link('//a[@id="C:A"]')(self.doc)
self.browser.location(go_back_url)
@method
class iter_pockets(TableElement):
item_xpath = '//table[contains(caption/span/text(), "Détail par échéance")]/tbody/tr'
head_xpath = '//table[contains(caption/span/text(), "Détail par échéance")]/thead//th'
col_condition = 'Echéance'
col_amount = 'Montant investi'
col_quantity = 'Nombre de parts'
class item(ItemElement):
klass = Pocket
def condition(self):
if Field('condition')(self) == Pocket.CONDITION_RETIREMENT:
if Env('acc')(self).type == Account.TYPE_PERCO:
return True
else:
if Env('acc')(self).type == Account.TYPE_PEE:
return True
return False
obj_investment = Env('inv')
obj_amount = CleanDecimal.French(TableCell('amount'))
obj_quantity = CleanDecimal.French(TableCell('quantity'), default=NotAvailable)
def obj_label(self):
return Env('inv')(self).label
def obj_condition(self):
condition_text = CleanText(TableCell('condition'), transliterate=True)(self)
condition = MapIn(self, POCKET_CONDITIONS, Pocket.CONDITION_UNKNOWN).filter(condition_text.lower())
if condition == Pocket.CONDITION_UNKNOWN:
self.page.logger.warning('Unhandled availability condition for pockets: %s', condition_text)
return condition
def obj_availability_date(self):
if Field('condition')(self) == Pocket.CONDITION_DATE:
return Date(
Regexp(CleanText(TableCell('condition')), r'Disponibilités (.*)'),
dayfirst=True,
)(self)
class OperationPage(LoggedPage, HTMLPage):
# Most '_account_label' correspond 'account.label', but there are exceptions
ACCOUNTS_SPE_LABELS = {
'CCB': 'Compte courant bloqué',
}
@method
class get_transactions(ListElement):
item_xpath = '//tr[@id]'
class item(ItemElement):
klass = Transaction
obj_amount = CleanDecimal.French('./th[@scope="rowgroup"][2]')
obj_label = CleanText('(//p[contains(@id, "smltitle")])[2]')
obj_raw = Transaction.Raw(Field('label'))
obj_date = Date(
Regexp(
CleanText('(//p[contains(@id, "smltitle")])[1]'),
r'(\d{1,2}/\d{1,2}/\d+)'
),
dayfirst=True
)
def obj__account_label(self):
account_label = CleanText('./th[@scope="rowgroup"][1]')(self)
return self.page.ACCOUNTS_SPE_LABELS.get(account_label, account_label)
@method
class get_entreprise_transactions(TableElement):
item_xpath = '//table[contains(@class, "repartition")]//tbody//tr[position() > 1]'
head_xpath = '//table[contains(@class, "repartition")]//thead//th'
col_label = 'Support'
col_amount = 'Montant versé'
def store(self, obj):
# This code enables indexing transaction_id when there
# are several transactions with the exact same id.
tr_id = obj.id
n = 1
while tr_id in self.objects:
tr_id = '%s-%s' % (obj.id, n)
n += 1
obj.id = tr_id
self.objects[obj.id] = obj
return obj
class item(ItemElement):
klass = Transaction
obj_id = Regexp(
CleanText('//span[contains(text(), "Détail du")]'),
r'n° (\d+)'
)
obj_amount = CleanDecimal.French(TableCell('amount'))
obj_label = CleanText(TableCell('label'))
obj_raw = Transaction.Raw(Field('label'))
obj_date = Date(
CleanText('(//th[text()="Date d\'effet"]//following::td)[1]'),
dayfirst=True
)
class OperationsListPage(LoggedPage, HTMLPage):
def __init__(self, *a, **kw):
self._cache = []
super(OperationsListPage, self).__init__(*a, **kw)
def get_operations_idx(self, entreprise_or_epargnants):
if entreprise_or_epargnants == 'entreprise':
return [i.split('=')[-1] for i in self.doc.xpath('//a[contains(@href, "GoOperationDetail")]/@href')]
return [i.split(':')[-1] for i in self.doc.xpath('.//input[contains(@name, "_FID_GoOperationDetails")]/@name')]