Skip to content
pages.py 66.4 KiB
Newer Older
# -*- coding: utf-8 -*-

# Copyright(C) 2010-2012 Julien Veyssier
Romain Bignon's avatar
Romain Bignon committed
# This file is part of weboob.
Romain Bignon's avatar
Romain Bignon committed
# weboob is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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
Romain Bignon's avatar
Romain Bignon committed
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
Romain Bignon's avatar
Romain Bignon committed
# You should have received a copy of the GNU Affero General Public License
# along with weboob. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals

import hashlib
from decimal import Decimal, InvalidOperation
from dateutil.relativedelta import relativedelta
Baptiste Delpey's avatar
Baptiste Delpey committed
from datetime import date, datetime
from random import randint
from collections import OrderedDict

Baptiste Delpey's avatar
Baptiste Delpey committed
from weboob.browser.pages import HTMLPage, FormNotFound, LoggedPage, pagination
from weboob.browser.elements import ListElement, ItemElement, SkipItem, method, TableElement
from weboob.browser.filters.standard import Filter, Env, CleanText, CleanDecimal, Field, \
    Regexp, Async, AsyncLoad, Date, Format, Type, Currency
from weboob.browser.filters.html import Link, Attr, TableCell, ColumnNotFound
from weboob.exceptions import BrowserIncorrectPassword, ParseError, NoAccountsException, ActionNeeded, BrowserUnavailable
Romain Bignon's avatar
Romain Bignon committed
from weboob.capabilities import NotAvailable
Baptiste Delpey's avatar
Baptiste Delpey committed
from weboob.capabilities.base import empty
from weboob.capabilities.bank import Account, Investment, Recipient, TransferError, TransferBankError, \
    Transfer, AddRecipientError, AddRecipientStep, Loan
from weboob.capabilities.contact import Advisor
from weboob.capabilities.profile import Profile
Baptiste Delpey's avatar
Baptiste Delpey committed
from weboob.tools.capabilities.bank.iban import is_iban_valid
from weboob.tools.capabilities.bank.transactions import FrenchTransaction
from weboob.tools.compat import urlparse, parse_qs, urljoin, range, unicode
from weboob.tools.date import parse_french_date
from weboob.tools.value import Value
def MyDecimal(*args, **kwargs):
    kwargs.update(replace_dots=True, default=NotAvailable)
    return CleanDecimal(*args, **kwargs)

def MyDate(*args, **kwargs):
    kwargs.update(dayfirst=True, default=NotAvailable)
    return Date(*args, **kwargs)

Baptiste Delpey's avatar
Baptiste Delpey committed
class RedirectPage(LoggedPage, HTMLPage):
    def on_load(self):
        super(RedirectPage, self).on_load()
Baptiste Delpey's avatar
Baptiste Delpey committed
        link = self.doc.xpath('//a[@id="P:F_1.R2:link"]')
        if link:
            self.browser.location(link[0].attrib['href'])


Baptiste Delpey's avatar
Baptiste Delpey committed
class NewHomePage(LoggedPage, HTMLPage):
    def on_load(self):
        self.browser.is_new_website = True
        super(NewHomePage, self).on_load()
Baptiste Delpey's avatar
Baptiste Delpey committed

Romain Bignon's avatar
Romain Bignon committed
class LoginPage(HTMLPage):
    REFRESH_MAX = 10.0

    def on_load(self):
        error_msg_xpath = '//div[has-class("err")]//p[contains(text(), "votre mot de passe est faux")]'
        if self.doc.xpath(error_msg_xpath):
            raise BrowserIncorrectPassword(CleanText(error_msg_xpath)(self.doc))

    def convert_uncodable_char_to_xml_entity(self, word):
        final_word = ''
        for char in word:
            try:
                char.encode('cp1252')
            except UnicodeEncodeError:
                char = '&#{};'.format(ord(char))
            final_word += char
        return final_word

    def login(self, login, passwd):
Romain Bignon's avatar
Romain Bignon committed
        form = self.get_form(xpath='//form[contains(@name, "ident")]')
Romain Bignon's avatar
Romain Bignon committed
        form['_cm_user'] = login
        # format password like password sent by firefox or chromium browser
        form['_cm_pwd'] = self.convert_uncodable_char_to_xml_entity(passwd)
Romain Bignon's avatar
Romain Bignon committed
        form.submit()
Vincent Paredes's avatar
Vincent Paredes committed
    @property
    def logged(self):
        return self.doc.xpath('//div[@id="e_identification_ok"]')

Romain Bignon's avatar
Romain Bignon committed
class LoginErrorPage(HTMLPage):
    def on_load(self):
        raise BrowserIncorrectPassword(CleanText('//div[has-class("blocmsg")]')(self.doc))
Romain Bignon's avatar
Romain Bignon committed
class EmptyPage(LoggedPage, HTMLPage):
    REFRESH_MAX = 10.0
Romain Bignon's avatar
Romain Bignon committed

    def on_load(self):
        # Action needed message is like "Votre Carte de Clés Personnelles numéro 3 est révoquée."
        action_needed = CleanText('//p[contains(text(), "Votre Carte de Clés Personnelles") and contains(text(), "est révoquée")]')(self.doc)
        if action_needed:
            raise ActionNeeded(action_needed)
        maintenance = CleanText('//td[@class="ALERTE"]/p/span[contains(text(), "Dans le cadre de l\'amélioration de nos services, nous vous informons que le service est interrompu"]')(self.doc)
        if maintenance:
            raise BrowserUnavailable(maintenance)
Vincent Paredes's avatar
Vincent Paredes committed
class UserSpacePage(LoggedPage, HTMLPage):
    def on_load(self):
        if self.doc.xpath('//form[@id="GoValider"]'):
            raise ActionNeeded(u"Le site du contrat Banque à Distance a besoin d'informations supplémentaires")
        super(UserSpacePage, self).on_load()
Romain Bignon's avatar
Romain Bignon committed
class ChangePasswordPage(LoggedPage, HTMLPage):
    def on_load(self):
        raise BrowserIncorrectPassword('Please change your password')

class item_account_generic(ItemElement):
    klass = Account
    TYPES = OrderedDict([
        ('Credits Promoteurs',      Account.TYPE_CHECKING),  # it doesn't fit loan's model
        ('Compte Cheque',           Account.TYPE_CHECKING),
        ('Compte Courant',          Account.TYPE_CHECKING),
        ('Cpte Courant',            Account.TYPE_CHECKING),
        ('Contrat Personnel',       Account.TYPE_CHECKING),
        ('Cc Contrat Personnel',    Account.TYPE_CHECKING),
        ('C/C',                     Account.TYPE_CHECKING),
        ('Start',                   Account.TYPE_CHECKING),
        ('Comptes courants',        Account.TYPE_CHECKING),
        ('Catip',                   Account.TYPE_DEPOSIT),
        ('Cic Immo',                Account.TYPE_LOAN),
        ('Credit',                  Account.TYPE_LOAN),
        ('Crédits',                 Account.TYPE_LOAN),
        ('Mcne',                    Account.TYPE_LOAN),
        ('Nouveau Prêt',            Account.TYPE_LOAN),
        ('Pret',                    Account.TYPE_LOAN),
        ('Regroupement De Credits', Account.TYPE_LOAN),
        ('Nouveau Pret 0%',         Account.TYPE_LOAN),
        ('Passeport Credit',        Account.TYPE_REVOLVING_CREDIT),
        ('Allure Libre',            Account.TYPE_REVOLVING_CREDIT),
        ('Preference',              Account.TYPE_REVOLVING_CREDIT),
        ('Plan 4',                  Account.TYPE_REVOLVING_CREDIT),
        ('Pea',                     Account.TYPE_PEA),
        ('Compte De Liquidite Pea', Account.TYPE_PEA),
        ('Compte Epargne',          Account.TYPE_SAVINGS),
        ('Etalis',                  Account.TYPE_SAVINGS),
        ('Ldd',                     Account.TYPE_SAVINGS),
        ('Livret',                  Account.TYPE_SAVINGS),
        ("Plan D'Epargne",          Account.TYPE_SAVINGS),
        ('Tonic Croissance',        Account.TYPE_SAVINGS),
        ('Capital Expansion',       Account.TYPE_SAVINGS),
        ('\xc9pargne',              Account.TYPE_SAVINGS),
        ('Compte Garantie Titres',  Account.TYPE_MARKET),
    REVOLVING_LOAN_LABELS = [
    ]

    def condition(self):
        if len(self.el.xpath('./td')) < 2:
            return False

        first_td = self.el.xpath('./td')[0]

        return (("i" in first_td.attrib.get('class', '') or "p" in first_td.attrib.get('class', ''))
                and (first_td.find('a') is not None or (first_td.find('.//span') is not None
                and "cartes" in first_td.findtext('.//span') and first_td.find('./div/a') is not None)))

    class Label(Filter):
        def filter(self, text):
            return text.lstrip(' 0123456789').title()

    class Type(Filter):
        def filter(self, label):
            for pattern, actype in item_account_generic.TYPES.items():
                if label.startswith(pattern):
                    return actype
            return Account.TYPE_UNKNOWN

    obj_id = Env('id')
    obj__card_number = None
    obj_label = Label(CleanText('./td[1]/a/text() | ./td[1]/a/span[@class and not(contains(@class, "doux"))] | ./td[1]/div/a[has-class("cb")]'))
    obj_coming = Env('coming')
    obj_balance = Env('balance')
    obj_currency = FrenchTransaction.Currency('./td[2] | ./td[3]')
    obj__card_links = []

    def obj__link_id(self):
        page = self.page.browser.open(Link('./td[1]//a')(self)).page
        if page and page.doc.xpath('//div[@class="fg"]/a[contains(@href, "%s")]' % Field('id')(self)):
            return urljoin(page.url, Link('//div[@class="fg"]/a')(page.doc))
        return Link('./td[1]//a')(self)

    def obj_type(self):
        t = self.Type(Field('label'))(self)
        # sometimes, using the label is not enough to infer the account's type.
        # this is a fallback that uses the account's group label
        if t == 0:
            return self.Type(CleanText('./preceding-sibling::tr/th[contains(@class, "rupture eir_tblshowth")][1]'))(self)
        return t
    obj__is_inv = False
    obj__is_webid = Env('_is_webid')

    def parse(self, el):
        link = el.xpath('./td[1]//a')[0].get('href', '')
        if 'POR_SyntheseLst' in link:
            raise SkipItem()

        url = urlparse(link)
        p = parse_qs(url.query)
        if 'rib' not in p and 'webid' not in p:
            raise SkipItem()

        for td in el.xpath('./td[2] | ./td[3]'):
            try:
                balance = CleanDecimal('.', replace_dots=True)(td)
            except InvalidOperation:
                continue
            else:
                break
        else:
            if 'lien_inter_sites' in link:
                raise SkipItem()
            else:
                raise ParseError('Unable to find balance for account %s' % CleanText('./td[1]/a')(el))

        self.env['_is_webid'] = False

        if "cartes" in CleanText('./td[1]')(el):
            # handle cb differed card
            if "cartes" in CleanText('./preceding-sibling::tr[1]/td[1]', replace=[(' ', '')])(el):
                # In case it's the second month of card history present, we need to ignore the first
                # one to get the attach accoount
                id_xpath = './preceding-sibling::tr[2]/td[1]/a/node()[contains(@class, "doux")]'
            else:
                # first month of history, the previous tr is the attached account
                id_xpath = './preceding-sibling::tr[1]/td[1]/a/node()[contains(@class, "doux")]'
        else:
            # classical account
            id_xpath = './td[1]/a/node()[contains(@class, "doux")]'

        id = CleanText(id_xpath, replace=[(' ', '')])(el)
        if not id:
            if 'rib' in p:
                id = p['rib'][0]
            else:
                id = p['webid'][0]
                self.env['_is_webid'] = True

        page = self.page.browser.open(link).page
        if isinstance(page, RevolvingLoansList):
            # some revolving loans are listed on an other page. On the accountList, there is just a link for this page
            # that's why we don't handle it here
            raise SkipItem()

        # Handle cards
        if id in self.parent.objects:
            # on old website we want card's history in account's history
            if not page.browser.is_new_website:
                account = self.parent.objects[id]
                if not account.coming:
                    account.coming = Decimal('0.0')
                date = parse_french_date(Regexp(Field('label'), 'Fin (.+) (\d{4})', '01 \\1 \\2')(self)) + relativedelta(day=31)
                if date > datetime.now() - relativedelta(day=1):
                    account.coming += balance
                account._card_links.append(link)
            else:
                multiple_cards_xpath = '//select[@name="Data_SelectedCardItemKey"]/option[contains(text(),"Carte")]'
                single_card_xpath = '//span[has-class("_c1 fg _c1")]'
                card_xpath = multiple_cards_xpath + ' | ' + single_card_xpath
                for elem in page.doc.xpath(card_xpath):
                    card_id = Regexp(CleanText('.', symbols=' '), '([\dx]{16})')(elem)
                    if any(card_id in a.id for a in page.browser.accounts_list):
                        continue

                    card = Account()
                    card.type = Account.TYPE_CARD
                    card.id = card._card_number = card_id
                    card._link_id = link
                    card._is_inv = card._is_webid = False
                    card.parent = self.parent.objects[id]

                    pattern = 'Carte\s(\w+).*\d{4}\s([A-Za-z\s]+)(.*)'
                    m = re.search(pattern, CleanText('.')(elem))
                    card.label = "%s %s %s" % (m.group(1), card_id, m.group(2))
                    card.balance = Decimal('0.0')
                    card.currency = card.get_currency(m.group(3))
                    card._card_pages = [page]
                    card.coming = Decimal('0.0')
                    #handling the case were the month is the coming one. There won't be next_month here.
                    date = parse_french_date(Regexp(Field('label'), 'Fin (.+) (\d{4})', '01 \\1 \\2')(self)) + relativedelta(day=31)
                    if date > datetime.now() - relativedelta(day=1):
                        card.coming = CleanDecimal(replace_dots=True).filter(m.group(3))
                    next_month = Link('./following-sibling::tr[contains(@class, "encours")][1]/td[1]//a', default=None)(self)
                    if next_month:
                        card_page = page.browser.open(next_month).page
                        for e in card_page.doc.xpath(card_xpath):
                            if card.id == Regexp(CleanText('.', symbols=' '), '([\dx]{16})')(e):
                                m = re.search(pattern, CleanText('.')(e))
                                card._card_pages.append(card_page)
                                card.coming += CleanDecimal(replace_dots=True).filter(m.group(3))
                                break

                    self.page.browser.accounts_list.append(card)

            raise SkipItem()

        self.env['id'] = id

        # Handle real balances
        coming = page.find_amount(u"Opérations à venir") if page else None
        accounting = page.find_amount(u"Solde comptable") if page else None

        if accounting is not None and accounting + (coming or Decimal('0')) != balance:
            self.page.logger.warning('%s + %s != %s' % (accounting, coming, balance))

        if accounting is not None:
            balance = accounting

        self.env['balance'] = balance
        self.env['coming'] = coming or NotAvailable

    def is_revolving(self, label):
        return any(revolving_loan_label in label
                   for revolving_loan_label in item_account_generic.REVOLVING_LOAN_LABELS)


class AccountsPage(LoggedPage, HTMLPage):
    def on_load(self):
        super(AccountsPage, self).on_load()

        no_account_message = CleanText('//td[contains(text(), "Votre contrat de banque à distance ne vous donne accès à aucun compte.")]')(self.doc)
        if no_account_message:
            raise NoAccountsException(no_account_message)

Romain Bignon's avatar
Romain Bignon committed
    @method
    class iter_accounts(ListElement):
        item_xpath = '//div[has-class("a_blocappli")]//tr'
Romain Bignon's avatar
Romain Bignon committed
        flush_at_end = True

        class item_account(item_account_generic):
            def condition(self):
                type = Field('type')(self)
                return item_account_generic.condition(self) and type != Account.TYPE_LOAN
        class item_loan(item_account_generic):
            klass = Loan
            load_details = Link('.//a') & AsyncLoad
            obj_total_amount = Async('details') & MyDecimal('//div[@id="F4:expContent"]/table/tbody/tr[1]/td[1]/text()')
            obj_rate = Async('details') & MyDecimal('//div[@id="F4:expContent"]/table/tbody/tr[2]/td[1]')
            obj_account_label = Async('details') & CleanText('//div[@id="F4:expContent"]/table/tbody/tr[1]/td[2]')
            obj_nb_payments_left = Async('details') & Type(CleanText(
                '//div[@id="F4:expContent"]/table/tbody/tr[2]/td[2]/text()'), type=int, default=NotAvailable)
            obj_subscription_date = Async('details') & MyDate(Regexp(CleanText(
                '//*[@id="F4:expContent"]/table/tbody/tr[1]/th[1]'), ' (\d{2}/\d{2}/\d{4})', default=NotAvailable))
            obj_maturity_date = Async('details') & MyDate(
                CleanText('//div[@id="F4:expContent"]/table/tbody/tr[4]/td[2]'))
            obj_next_payment_amount = Async('details') & MyDecimal('//div[@id="F4:expContent"]/table/tbody/tr[3]/td[2]')
            obj_next_payment_date = Async('details') & MyDate(
                CleanText('//div[@id="F4:expContent"]/table/tbody/tr[3]/td[1]'))
            obj_last_payment_amount = Async('details') & MyDecimal('//td[@id="F2_0.T12"]')
            obj_last_payment_date = Async('details') & \
                MyDate(CleanText('//div[@id="F8:expContent"]/table/tbody/tr[1]/td[1]'))
            def condition(self):
                type = Field('type')(self)
                label = Field('label')(self)
                details_link = Link('.//a', default=None)(self)

                # mobile accounts are leading to a 404 error when parsing history
                # furthermore this is not exactly a loan account
                if re.search(r'Le\sMobile\s+([0-9]{2}\s?){5}', label):
                    return False

                    details = self.page.browser.open(details_link)
                    if details.page:
                        closed_loan = 'cloturé' in CleanText(
                            '//form[@id="P:F"]//div[@class="blocmsg info"]//p', default='')(details.page.doc)
                return (item_account_generic.condition(self)
                        and type == Account.TYPE_LOAN
        class item_revolving_loan(item_account_generic):
            klass = Loan
            load_details = Link('.//a') & AsyncLoad
            obj_total_amount = Async('details') & MyDecimal('//main[@id="ei_tpl_content"]/div/div[2]/table/tbody/tr/td[3]')
            def obj_used_amount(self):
                return -Field('balance')(self)
            def condition(self):
                type = Field('type')(self)
                label = Field('label')(self)
                return (item_account_generic.condition(self) and type == Account.TYPE_LOAN
    def get_advisor_link(self):
        return Link('//div[@id="e_conseiller"]/a', default=None)(self.doc)

    @method
    class get_advisor(ItemElement):
        klass = Advisor

        obj_name = CleanText('//div[@id="e_conseiller"]/a')

    @method
    class get_profile(ItemElement):
        klass = Profile

        obj_name = CleanText('//div[@id="e_identification_ok_content"]//strong[1]')

class NewAccountsPage(NewHomePage, AccountsPage):
ntome's avatar
ntome committed
    def get_agency(self):
        return Regexp(CleanText('//script[contains(text(), "lien_caisse")]', default=''),
                      r'(https://[^"]+)', default='')(self.doc)

    @method
    class get_advisor(ItemElement):
        klass = Advisor

        obj_name = Regexp(CleanText('//script[contains(text(), "Espace Conseiller")]'), 'consname.+?([\w\s]+)')

    @method
    class get_profile(ItemElement):
        klass = Profile

        obj_name = CleanText('//p[contains(@class, "master_nom")]')


class AdvisorPage(LoggedPage, HTMLPage):
    @method
    class update_advisor(ItemElement):
        obj_email = CleanText('//table//*[@itemprop="email"]')
        obj_phone = CleanText('//table//*[@itemprop="telephone"]', replace=[(' ', '')])
        obj_mobile = NotAvailable
        obj_fax = CleanText('//table//*[@itemprop="faxNumber"]', replace=[(' ', '')])
        obj_agency = CleanText('//div/*[@itemprop="name"]')
        obj_address = Format('%s %s %s', CleanText('//table//*[@itemprop="streetAddress"]'),
                                         CleanText('//table//*[@itemprop="postalCode"]'),
                                         CleanText('//table//*[@itemprop="addressLocality"]'))
Baptiste Delpey's avatar
Baptiste Delpey committed
class CardsActivityPage(LoggedPage, HTMLPage):
    def companies_link(self):
        companies_link = []
        for tr in self.doc.xpath('//table[@summary="Liste des titulaires de contrats cartes"]//tr'):
            companies_link.append(Link(tr.xpath('.//a'))(self))
        return companies_link
Romain Bignon's avatar
Romain Bignon committed
class Pagination(object):
    def next_page(self):
        try:
            form = self.page.get_form('//form[@id="paginationForm" or @id="frmSTARCpag"]')
Romain Bignon's avatar
Romain Bignon committed
        except FormNotFound:
Baptiste Delpey's avatar
Baptiste Delpey committed
            return self.next_month()
Romain Bignon's avatar
Romain Bignon committed

        text = CleanText.clean(form.el)
        m = re.search('(\d+)/(\d+)', text or '', flags=re.MULTILINE)
Romain Bignon's avatar
Romain Bignon committed
        if not m:
Baptiste Delpey's avatar
Baptiste Delpey committed
            return self.next_month()
Romain Bignon's avatar
Romain Bignon committed

        cur = int(m.group(1))
        last = int(m.group(2))

        if cur == last:
Baptiste Delpey's avatar
Baptiste Delpey committed
            return self.next_month()
Romain Bignon's avatar
Romain Bignon committed

        form['imgOpePagSui.x'] = randint(1, 29)
        form['imgOpePagSui.y'] = randint(1, 17)

Romain Bignon's avatar
Romain Bignon committed
        form['page'] = str(cur + 1)
        return form.request

Baptiste Delpey's avatar
Baptiste Delpey committed
    def next_month(self):
        try:
            form = self.page.get_form('//form[@id="frmStarcLstOpe"]')
Baptiste Delpey's avatar
Baptiste Delpey committed
        except FormNotFound:
            return

        try:
            form['moi'] = self.page.doc.xpath('//select[@id="moi"]/option[@selected]/following-sibling::option')[0].attrib['value']
Baptiste Delpey's avatar
Baptiste Delpey committed
        except IndexError:
            return
Baptiste Delpey's avatar
Baptiste Delpey committed


class CardsListPage(LoggedPage, HTMLPage):
    @pagination
    @method
    class iter_cards(TableElement):
        item_xpath = '//table[has-class("liste")]/tbody/tr'
        head_xpath = '//table[has-class("liste")]/thead//tr/th'
Baptiste Delpey's avatar
Baptiste Delpey committed

        col_owner = 'Porteur'
        col_card = 'Carte'

        def next_page(self):
            try:
                form = self.page.get_form('//form[contains(@id, "frmStarcLstCtrPag")]')
                form['imgCtrPagSui.x'] =  randint(1, 29)
                form['imgCtrPagSui.y'] =  randint(1, 17)
                m = re.search('(\d+)/(\d+)', CleanText('.')(form.el))
                if m and int(m.group(1)) < int(m.group(2)):
                    return form.request
            except FormNotFound:
                return

Baptiste Delpey's avatar
Baptiste Delpey committed
        class item(ItemElement):
            klass = Account

            load_details = Field('_link_id') & AsyncLoad

            obj_number = Field('_link_id') & Regexp(pattern='ctr=(\d+)')
            obj__card_number = Env('id', default="")
            obj_id = Format('%s%s', Env('id', default=""), Field('number'))
            obj_label = Format('%s %s %s', CleanText(TableCell('card')), Env('id', default=""), CleanText(TableCell('owner')))
            obj_coming = CleanDecimal('./td[@class="i d" or @class="p d"][2]', replace_dots=True, default=NotAvailable)
            obj_balance = Decimal('0.00')
            obj_currency = FrenchTransaction.Currency(CleanText('./td[small][1]'))
Baptiste Delpey's avatar
Baptiste Delpey committed
            obj_type = Account.TYPE_CARD
            obj__card_pages = Env('page')
Baptiste Delpey's avatar
Baptiste Delpey committed
            obj__is_inv = False
            obj__is_webid = False

            def obj__pre_link(self):
                return self.page.url

            def obj__link_id(self):
                return Link(TableCell('card')(self)[0].xpath('./a'))(self)

            def parse(self, el):
                page = Async('details').loaded_page(self)
                self.env['page'] = [page]
                if len(page.doc.xpath('//caption[contains(text(), "débits immédiats")]')):
                    raise SkipItem()

                # Handle multi cards
                options = page.doc.xpath('//select[@id="iso"]/option')
                for option in options:
                    card = Account()
                    card_list_page = page.browser.open(Link('//form//a[text()="Contrat"]', default=None)(page.doc)).page
                    xpath = '//table[has-class("liste")]/tbody/tr'
                    active_card = CleanText('%s[td[text()="Active"]][1]/td[2]' % xpath, replace=[(' ', '')], default=None)(card_list_page.doc)
                    _id = CleanText('.', replace=[(' ', '')])(option)
                    if active_card == _id:
                        for attr in self._attrs:
                            self.handle_attr(attr, getattr(self, 'obj_%s' % attr))
                            setattr(card, attr, getattr(self.obj, attr))
                        card._card_number = _id
                        card.id = _id + card.number
                        card.label = card.label.replace('  ', ' %s ' % _id)

                        self.page.browser.accounts_list.append(card)

                # Skip multi and expired cards
                if len(options) or len(page.doc.xpath('//span[@id="ERREUR"]')):
                    raise SkipItem()

                # 1 card : we have to check on another page to get id
                page = page.browser.open(Link('//form//a[text()="Contrat"]', default=None)(page.doc)).page
                xpath = '//table[has-class("liste")]/tbody/tr'
                active_card = CleanText('%s[td[text()="Active"]][1]/td[2]' % xpath, replace=[(' ', '')], default=None)(page.doc)

                if not active_card and len(page.doc.xpath(xpath)) != 1:
                    raise SkipItem()
                self.env['id'] = active_card or CleanText('%s[1]/td[2]' % xpath, replace=[(' ', '')])(page.doc)
Baptiste Delpey's avatar
Baptiste Delpey committed
class Transaction(FrenchTransaction):
    PATTERNS = [(re.compile('^VIR(EMENT)? (?P<text>.*)'), FrenchTransaction.TYPE_TRANSFER),
                (re.compile('^(PRLV|Plt|PRELEVEMENT) (?P<text>.*)'),        FrenchTransaction.TYPE_ORDER),
Baptiste Delpey's avatar
Baptiste Delpey committed
                (re.compile('^(?P<text>.*) CARTE \d+ PAIEMENT CB\s+(?P<dd>\d{2})(?P<mm>\d{2}) ?(.*)$'),
                                                          FrenchTransaction.TYPE_CARD),
                (re.compile('^PAIEMENT PSC\s+(?P<dd>\d{2})(?P<mm>\d{2}) (?P<text>.*) CARTE \d+ ?(.*)$'),
                                                          FrenchTransaction.TYPE_CARD),
                (re.compile('^(?P<text>RELEVE CARTE.*)'), FrenchTransaction.TYPE_CARD_SUMMARY),
Baptiste Delpey's avatar
Baptiste Delpey committed
                (re.compile('^RETRAIT DAB (?P<dd>\d{2})(?P<mm>\d{2}) (?P<text>.*) CARTE [\*\d]+'),
                                                          FrenchTransaction.TYPE_WITHDRAWAL),
                (re.compile('^CHEQUE( (?P<text>.*))?$'),  FrenchTransaction.TYPE_CHECK),
                (re.compile('^(F )?COTIS\.? (?P<text>.*)'), FrenchTransaction.TYPE_BANK),
                (re.compile('^(REMISE|REM CHQ) (?P<text>.*)'), FrenchTransaction.TYPE_DEPOSIT),
               ]

    _is_coming = False

Romain Bignon's avatar
Romain Bignon committed

class OperationsPage(LoggedPage, HTMLPage):
    def go_on_history_tab(self):
        form = self.get_form(id='I1:fm')
        form['_FID_DoShowListView'] = ''
        form.submit()

Romain Bignon's avatar
Romain Bignon committed
    @method
    class get_history(Pagination, Transaction.TransactionsElement):
        head_xpath = '//table[has-class("liste")]//thead//tr/th'
        item_xpath = '//table[has-class("liste")]//tbody/tr'
Romain Bignon's avatar
Romain Bignon committed

        class item(Transaction.TransactionElement):
Baptiste Delpey's avatar
Baptiste Delpey committed
            condition = lambda self: len(self.el.xpath('./td')) >= 3 and len(self.el.xpath('./td[@class="i g" or @class="p g" or contains(@class, "_c1")]')) > 0
Romain Bignon's avatar
Romain Bignon committed
            class OwnRaw(Filter):
                def __call__(self, item):
                    el = TableCell('raw')(item)[0]

                    # Remove hidden parts of labels:
                    # hideifscript: Date de valeur XX/XX/XXXX
                    # fd: Avis d'opéré
                    # survey to add other regx
                    parts = [re.sub('Détail|Date de valeur\s+:\s+\d{2}/\d{2}(/\d{4})?', '', txt.strip()) for txt in el.itertext() if len(txt.strip()) > 0]
Romain Bignon's avatar
Romain Bignon committed
                    # To simplify categorization of CB, reverse order of parts to separate
                    # location and institution.
                    if parts[0] == u"Cliquer pour déplier ou plier le détail de l'opération":
                        parts.pop(0)
Romain Bignon's avatar
Romain Bignon committed
                    if parts[0].startswith('PAIEMENT CB'):
                        parts.reverse()

Romain Bignon's avatar
Romain Bignon committed

            obj_raw = Transaction.Raw(OwnRaw())
Romain Bignon's avatar
Romain Bignon committed

    def find_amount(self, title):
        try:
            td = self.doc.xpath('//th[contains(text(), $title)]/../td', title=title)[0]
Romain Bignon's avatar
Romain Bignon committed
        except IndexError:
            return None
        else:
            return Decimal(FrenchTransaction.clean_amount(td.text))
            a = self.doc.xpath('//a[contains(text(), "Opérations à venir")]')[0]
Romain Bignon's avatar
Romain Bignon committed
        except IndexError:
Baptiste Delpey's avatar
Baptiste Delpey committed
class CardsOpePage(OperationsPage):
    def select_card(self, card_number):
        if CleanText('//select[@id="iso"]', default=None)(self.doc):
            form = self.get_form('//p[has-class("restriction")]')
            card_number = ' '.join([card_number[j*4:j*4+4] for j in range(len(card_number)//4+1)]).strip()
            form['iso'] = Attr('//option[text()="%s"]' % card_number, 'value')(self.doc)
            moi = Attr('//select[@id="moi"]/option[@selected]', 'value', default=None)(self.doc)
            if moi:
                form['moi'] = moi
            return self.browser.open(form.url, data=dict(form)).page
        return self

Baptiste Delpey's avatar
Baptiste Delpey committed
    @method
    class get_history(Pagination, Transaction.TransactionsElement):
        head_xpath = '//table[has-class("liste")]//thead//tr/th'
        item_xpath = '//table[has-class("liste")]/tr'
Baptiste Delpey's avatar
Baptiste Delpey committed

        col_city = 'Ville'
        col_original_amount = "Montant d'origine"
        col_amount = 'Montant'
Baptiste Delpey's avatar
Baptiste Delpey committed

        class item(Transaction.TransactionElement):
            condition = lambda self: len(self.el.xpath('./td')) >= 5

            obj_raw = obj_label = Format('%s %s', TableCell('raw') & CleanText, TableCell('city') & CleanText)
            obj_original_amount = CleanDecimal(TableCell('original_amount'), default=NotAvailable, replace_dots=True)
            obj_original_currency = FrenchTransaction.Currency(TableCell('original_amount'))
Baptiste Delpey's avatar
Baptiste Delpey committed
            obj_type = Transaction.TYPE_DEFERRED_CARD
            obj_rdate = Transaction.Date(TableCell('date'))
            obj_date = obj_vdate = Env('date')
            obj__is_coming = Env('_is_coming')

            obj__gross_amount = CleanDecimal(Env('amount'), replace_dots=True)
            obj_commission = CleanDecimal(Format('-%s', Env('commission')), replace_dots=True, default=NotAvailable)
Baptiste Delpey's avatar
Baptiste Delpey committed

            def obj_amount(self):
                commission = Field('commission')(self)
                gross = Field('_gross_amount')(self)
                if empty(commission):
                    return gross
                return (abs(gross) - abs(commission)).copy_sign(gross)

Baptiste Delpey's avatar
Baptiste Delpey committed
            def parse(self, el):
                self.env['date'] = Date(Regexp(CleanText('//td[contains(text(), "Total prélevé")]'), ' (\d{2}/\d{2}/\d{4})', \
                                               default=NotAvailable), default=NotAvailable)(self)
                if not self.env['date']:
                        d = CleanText('//select[@id="moi"]/option[@selected]')(self) or \
                            re.search('pour le mois de (.*)', ''.join(w.strip() for w in self.page.doc.xpath('//div[@class="a_blocongfond"]/text()'))).group(1)
                    except AttributeError:
                        d = Regexp(CleanText('//p[has-class("restriction")]'), 'pour le mois de ((?:\w+\s+){2})', flags=re.UNICODE)(self)
                    self.env['date'] = (parse_french_date('%s %s' % ('1', d)) + relativedelta(day=31)).date()
Baptiste Delpey's avatar
Baptiste Delpey committed
                self.env['_is_coming'] = date.today() < self.env['date']
                amount = CleanText(TableCell('amount'))(self).split('dont frais')
                self.env['amount'] = amount[0]
                self.env['commission'] = amount[1] if len(amount) > 1 else NotAvailable
Baptiste Delpey's avatar
Baptiste Delpey committed


Romain Bignon's avatar
Romain Bignon committed
class ComingPage(OperationsPage, LoggedPage):
    @method
    class get_history(Pagination, Transaction.TransactionsElement):
        head_xpath = '//table[has-class("liste")]//thead//tr/th/text()'
        item_xpath = '//table[has-class("liste")]//tbody/tr'
        col_date = u"Date de l'annonce"
        class item(Transaction.TransactionElement):
Romain Bignon's avatar
Romain Bignon committed
            obj__is_coming = True
Romain Bignon's avatar
Romain Bignon committed
class CardPage(OperationsPage, LoggedPage):
    def select_card(self, card_number):
        for option in self.doc.xpath('//select[@name="Data_SelectedCardItemKey"]/option'):
            card_id = Regexp(CleanText('.', symbols=' '), '([\dx]+)')(option)
            if card_id != card_number:
                continue
            if Attr('.', 'selected', default=None)(option):
                break

            form = self.get_form(id="I1:fm")
            form['_FID_DoChangeCardDetails'] = ""
            form['Data_SelectedCardItemKey'] = Attr('.', 'value')(option)
            return self.browser.open(form.url, data=dict(form)).page
        return self

Romain Bignon's avatar
Romain Bignon committed
    @method
    class get_history(Pagination, ListElement):
        class list_cards(ListElement):
            item_xpath = '//table[has-class("liste")]/tbody/tr/td/a'
Romain Bignon's avatar
Romain Bignon committed
            class item(ItemElement):
                def __iter__(self):
                    card_link = self.el.get('href')
Baptiste Delpey's avatar
Baptiste Delpey committed
                    page = self.page.browser.location(card_link).page
Romain Bignon's avatar
Romain Bignon committed

                    for op in page.get_history():
                        yield op

        class list_history(Transaction.TransactionsElement):
            head_xpath = '//table[has-class("liste")]//thead/tr/th'
            item_xpath = '//table[has-class("liste")]/tbody/tr'
            col_commerce = 'Commerce'
            col_ville = 'Ville'
Romain Bignon's avatar
Romain Bignon committed

                return not CleanText('//td[contains(., "Aucun mouvement")]', default=False)(self)
Romain Bignon's avatar
Romain Bignon committed
            def parse(self, el):
                label = CleanText('//*[contains(text(), "Achats")]')(el)
                if not label:
                    return
                try:
                    label = re.findall('(\d+ [^ ]+ \d+)', label)[-1]
                except IndexError:
                    return
Romain Bignon's avatar
Romain Bignon committed
                # use the trick of relativedelta to get the last day of month.
                self.env['debit_date'] = (parse_french_date(label) + relativedelta(day=31)).date()
Romain Bignon's avatar
Romain Bignon committed

            class item(Transaction.TransactionElement):
                condition = lambda self: len(self.el.xpath('./td')) >= 4
Romain Bignon's avatar
Romain Bignon committed

                obj_raw = Transaction.Raw(Env('raw'))
                obj_type = Env('type')
                obj_date = Env('debit_date')
                obj_rdate = Transaction.Date(TableCell('date'))
                obj_amount = Env('amount')
                obj_original_amount = Env('original_amount')
                obj_original_currency = Env('original_currency')
                obj__differed_date = Env('differed_date')

                def parse(self, el):
                    try:
                        self.env['raw'] = "%s %s" % (CleanText().filter(TableCell('commerce')(self)[0].text), CleanText().filter(TableCell('ville')(self)[0].text))
                    except (ColumnNotFound, AttributeError):
                        self.env['raw'] = "%s" % (CleanText().filter(TableCell('commerce')(self)[0].text))

                    self.env['type'] = Transaction.TYPE_DEFERRED_CARD \
                                       if CleanText('//a[contains(text(), "Prélevé fin")]', default=None) else Transaction.TYPE_CARD
                    self.env['differed_date'] = parse_french_date(Regexp(CleanText('//*[contains(text(), "Achats")]'), 'au[\s]+(.*)')(self)).date()
                    amount = TableCell('credit')(self)[0]
                    if self.page.browser.is_new_website:
                        if not len(amount.xpath('./div')):
                            amount = TableCell('debit')(self)[0]
                        original_amount = amount.xpath('./div')[1].text if len(amount.xpath('./div')) > 1 else None
                        amount = amount.xpath('./div')[0]
                    else:
Baptiste Delpey's avatar
Baptiste Delpey committed
                        try:
                            original_amount = amount.xpath('./span')[0].text
                        except IndexError:
                            original_amount = None
                    self.env['amount'] = CleanDecimal(replace_dots=True).filter(amount.text)
                    self.env['original_amount'] = CleanDecimal(replace_dots=True).filter(original_amount) \
                                                  if original_amount is not None else NotAvailable
                    self.env['original_currency'] = Account.get_currency(original_amount[1:-1]) \
                                                  if original_amount is not None else NotAvailable
class LIAccountsPage(LoggedPage, HTMLPage):
    @method
    class iter_li_accounts(ListElement):
        item_xpath = '//table[@class]/tbody/tr[count(td)>4]'

        class item(ItemElement):
            klass = Account

Baptiste Delpey's avatar
Baptiste Delpey committed
            load_details = Attr('.//a', 'href', default=NotAvailable) & AsyncLoad
            obj__link_id = Async('details', Link('//li/a[contains(text(), "Mouvements")]', default=NotAvailable))
Baptiste Delpey's avatar
Baptiste Delpey committed
            obj__link_inv = Link('./td[1]/a', default=NotAvailable)
            obj_id = CleanText('./td[2]', replace=[(' ', '')])
            obj_label = CleanText('./td[1]')
            obj_balance = CleanDecimal('./td[3]', replace_dots=True, default=NotAvailable)
            obj_currency = FrenchTransaction.Currency('./td[3]')
            obj__card_links = []
            obj_type = Account.TYPE_LIFE_INSURANCE
            obj__is_inv = True

    @method
    class iter_history(ListElement):
        item_xpath = '//table[has-class("liste")]/tbody/tr'

        class item(ItemElement):
            klass = FrenchTransaction

            obj_date = obj_rdate = Transaction.Date(CleanText('./td[1]'))
            obj_raw = CleanText('./td[2]')
            obj_amount  = CleanDecimal('./td[4]', replace_dots=True, default=Decimal('0'))
            obj_original_currency = FrenchTransaction.Currency('./td[4]')
            obj_type = Transaction.TYPE_BANK
            obj__is_coming = False

            def obj_commission(self):
                gross_amount = CleanDecimal('./td[3]', replace_dots=True, default=NotAvailable)(self)
                if gross_amount:
                    return gross_amount - Field('amount')(self)
                return NotAvailable

    @method
    class iter_investment(TableElement):
        item_xpath = '//table[has-class("liste")]/tbody/tr[count(td)>=7]'
        head_xpath = '//table[has-class("liste")]/thead/tr/th'
        col_label = 'Support'
        col_unitprice = re.compile(r"^Prix d'achat moyen")
        col_vdate = re.compile(r'Date de cotation')
        col_unitvalue = 'Valeur de la part'
        col_quantity = 'Nombre de parts'
        col_valuation = 'Valeur atteinte'

        class item(ItemElement):
            klass = Investment

            obj_label = CleanText(TableCell('label'))
            obj_unitprice = CleanDecimal(TableCell('unitprice', default=NotAvailable), default=NotAvailable, replace_dots=True)
            obj_vdate = Date(CleanText(TableCell('vdate'), replace=[('-', '')]), default=NotAvailable, dayfirst=True)
            obj_unitvalue = CleanDecimal(TableCell('unitvalue'), default=NotAvailable, replace_dots=True)
            obj_quantity = CleanDecimal(TableCell('quantity'), default=NotAvailable, replace_dots=True)
            obj_valuation = CleanDecimal(TableCell('valuation'), default=Decimal(0), replace_dots=True)

            def obj_code(self):
Baptiste Delpey's avatar
Baptiste Delpey committed
                link = Link(TableCell('label')(self)[0].xpath('./a'), default=NotAvailable)(self)
                if not link:
                    return NotAvailable
                return Regexp(pattern='isin=([A-Z\d]+)&?', default=NotAvailable).filter(link)

class PorPage(LoggedPage, HTMLPage):
    TYPES = {"PLAN D'EPARGNE EN ACTIONS": Account.TYPE_PEA,
             'P.E.A': Account.TYPE_PEA

    def get_type(self, label):
        for pattern, actype in self.TYPES.items():
            if label.startswith(pattern):
                return actype
        return Account.TYPE_MARKET

    def find_amount(self, title):
        return None

    def add_por_accounts(self, accounts):
        for ele in self.doc.xpath('//select[contains(@name, "POR_Synthese")]/option'):
            for a in accounts:
                # we have to create another account instead of just update it
                if a.id.startswith(ele.attrib['value']) and not a.balance:
                    a.type = self.get_type(a.label)
                    break
            else:
                acc = Account()
                acc.id = ele.attrib['value']
                if acc.id == '9999':
                    # fake account
                    continue
                acc.label = unicode(re.sub("\d", '', ele.text).strip())
                acc._link_id = None
                acc.type = self.get_type(acc.label)
                acc._is_inv = True
                accounts.append(acc)

    def fill(self, acc):
        self.send_form(acc)
        ele = self.browser.page.doc.xpath('.//table[has-class("fiche bourse")]')[0]
        balance = CleanDecimal(ele.xpath('.//td[contains(@id, "Valorisation")]'), default=Decimal(0), replace_dots=True)(ele)
        acc.balance = balance + acc.balance if acc.balance else balance
        acc.valuation_diff = CleanDecimal(ele.xpath('.//td[contains(@id, "Variation")]'), default=Decimal(0), replace_dots=True)(ele)
        if balance:
            acc.currency = Currency('.//td[contains(@id, "Valorisation")]')(ele)
        else:
            # - Table element's textual content also contains dates with slashes.
            # They produce a false match when looking for the currency
            # (Slashes are matched with the Peruvian currency 'S/').
            # - The remaining part of the table textual may contain different
            # balances with their currencies though, so keep this part.
            #
            # Solution: remove the date
            text_content = CleanText('.')(ele)
            date_pattern = r"\d{2}/\d{2}/\d{4}"
            no_date = re.sub(date_pattern, '', text_content)
            acc.currency = Currency().filter(no_date)

    def send_form(self, account):
        form = self.get_form(name="frmMere")
        form['POR_SyntheseEntete1$esdselLstPor'] = re.sub('\D', '', account.id)
        form.submit()

    @method
    class iter_investment(TableElement):
        item_xpath = '//table[@id="bwebDynamicTable"]/tbody/tr[not(@id="LigneTableVide")]'
        head_xpath = '//table[@id="bwebDynamicTable"]/thead/tr/th/@abbr'

        col_label = 'Valeur'
        col_unitprice = re.compile('Prix de revient')
        col_unitvalue = 'Cours'
        col_quantity = 'Quantité / Montant nominal'
        col_valuation = 'Valorisation'
        col_diff = '+/- Value latente'

        class item(ItemElement):
            klass = Investment

            obj_label = CleanText(TableCell('label'), default=NotAvailable)
            obj_code = CleanText('.//td[1]/a/@title') & Regexp(pattern='^([^ ]+)')
            obj_quantity = CleanDecimal(TableCell('quantity'), default=Decimal(0), replace_dots=True)
            obj_unitprice = CleanDecimal(TableCell('unitprice'), default=Decimal(0), replace_dots=True)
            obj_valuation = CleanDecimal(TableCell('valuation'), default=Decimal(0), replace_dots=True)
            obj_diff = CleanDecimal(TableCell('diff'), default=Decimal(0), replace_dots=True)
            def obj_unitvalue(self):
                r = CleanText(TableCell('unitvalue'))(self)
                if r[-1] == '%':
                    return None
                else:
                    return CleanDecimal(TableCell('unitvalue'), default=Decimal(0), replace_dots=True)(self)

            def obj_vdate(self):
                td = TableCell('unitvalue')(self)[0]
                return Date(Regexp(Attr('./img', 'title', default=''), r'Cours au : (\d{2}/\d{2}/\d{4})\b', default=None), dayfirst=True, default=NotAvailable)(td)