pages.py 60.9 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
# -*- coding: utf-8 -*-

# Copyright(C) 2012 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 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
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with weboob. If not, see <http://www.gnu.org/licenses/>.

20
from __future__ import division
21
from __future__ import unicode_literals
22 23

from base64 import b64decode
24
from collections import OrderedDict
25
import re
26
from io import BytesIO
27

28
from decimal import Decimal
29
from datetime import datetime
30

31 32
from weboob.browser.pages import LoggedPage, HTMLPage, JsonPage, pagination, FormNotFound
from weboob.browser.elements import ItemElement, method, ListElement, TableElement, SkipItem, DictElement
33
from weboob.browser.filters.standard import Date, CleanDecimal, Regexp, CleanText, Env, Upper, Field, Eval, Format, Currency
34
from weboob.browser.filters.html import Link, Attr, TableCell
35
from weboob.capabilities import NotAvailable
36 37 38 39
from weboob.capabilities.bank import (
    Account, Investment, Recipient, TransferError, TransferBankError, Transfer,
    AddRecipientBankError, Loan,
)
40
from weboob.capabilities.bill import Subscription, Document
41
from weboob.tools.capabilities.bank.investments import is_isin_valid
42
from weboob.tools.capabilities.bank.transactions import FrenchTransaction
43
from weboob.tools.capabilities.bank.iban import is_rib_valid, rib2iban, is_iban_valid
44
from weboob.tools.captcha.virtkeyboard import GridVirtKeyboard
45
from weboob.tools.compat import unicode
46
from weboob.exceptions import NoAccountsException, BrowserUnavailable, ActionNeeded
47
from weboob.browser.filters.json import Dict
48

49 50 51 52 53 54 55 56
def MyDecimal(*args, **kwargs):
    kwargs.update(replace_dots=True)
    return CleanDecimal(*args, **kwargs)

class MyTableCell(TableCell):
    def __init__(self, *names, **kwargs):
        super(MyTableCell, self).__init__(*names, **kwargs)
        self.td = './tr[%s]/td'
57

58
def fix_form(form):
59
    keys = ['MM$HISTORIQUE_COMPTE$btnCumul', 'Cartridge$imgbtnMessagerie', 'MM$m_CH$ButtonImageFondMessagerie',
60 61 62 63 64
            'MM$m_CH$ButtonImageMessagerie']
    for name in keys:
        form.pop(name, None)


65 66 67 68
def float_to_decimal(f):
    return Decimal(str(f))


69
class LoginPage(JsonPage):
70 71 72 73 74
    def on_load(self):
        error_msg = self.doc.get('error')
        if error_msg and 'Le service est momentanément indisponible' in error_msg:
            raise BrowserUnavailable(error_msg)

75 76 77 78
    def get_response(self):
        return self.doc


79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
class CaissedepargneKeyboard(GridVirtKeyboard):
    color = (255, 255, 255)
    margin = 3, 3
    symbols = {'0': 'ef8d775a73b751c5fbee06e2d537785c',
               '1': 'bf51842846c3045f76355de32e4689c7',
               '2': 'e4c057317b7ceb17241a0ae4c26844c4',
               '3': 'c28c0c109a63f034d0f7c0f7ffdb364c',
               '4': '6ea6a5152efb1d12c33f9cbf9476caec',
               '5': '7ec4b424b5db7e7b2a54e6300fdb7515',
               '6': 'a1fa95fc856804f978f20ad42c60f6d7',
               '7': '64646adaa5a0b2506880970d8e928156',
               '8': '4abcc6b24fa77f3756b96257962615eb',
               '9': '3f41daf8ca5f250be5df91fe24079735'}

    def __init__(self, image, symbols):
94 95
        image = BytesIO(b64decode(image.encode('ascii')))
        super(CaissedepargneKeyboard, self).__init__(symbols, 5, 3, image, self.color, convert='RGB')
96 97 98 99 100 101 102

    def check_color(self, pixel):
        for c in pixel:
            if c < 250:
                return True


103 104
class GarbagePage(LoggedPage, HTMLPage):
    def on_load(self):
105 106
        go_back_link = Link('//a[@class="btn"]', default=NotAvailable)(self.doc)

107
        if go_back_link is not NotAvailable:
108
            assert len(go_back_link) != 1
109
            go_back_link = re.search('\(~deibaseurl\)(.*)$', go_back_link).group(1)
110 111

            self.browser.location('%s%s' % (self.browser.BASEURL, go_back_link))
112

113

114
class MessagePage(GarbagePage):
115 116 117
    def get_message(self):
        return CleanText('//form[contains(@name, "leForm")]//span')(self.doc)

118
    def submit(self):
119
        form = self.get_form(name='leForm')
120

121
        form['signatur1'] = ['on']
122

123 124 125 126 127
        form.submit()


class _LogoutPage(HTMLPage):
    def on_load(self):
128
        raise BrowserUnavailable(CleanText('//*[@class="messErreur"]')(self.doc))
129

130

131
class ErrorPage(_LogoutPage):
132
    pass
133

134

135 136
class UnavailablePage(HTMLPage):
    def on_load(self):
137
        raise BrowserUnavailable(CleanText('//div[@id="message_error_hs"]')(self.doc))
138

139

140
class Transaction(FrenchTransaction):
141
    PATTERNS = [(re.compile(r'^CB (?P<text>.*?) FACT (?P<dd>\d{2})(?P<mm>\d{2})(?P<yy>\d{2})\b', re.IGNORECASE),
142
                                                            FrenchTransaction.TYPE_CARD),
143
                (re.compile('^RET(RAIT)? DAB (?P<dd>\d+)-(?P<mm>\d+)-.*', re.IGNORECASE),
Romain Bignon's avatar
Romain Bignon committed
144
                                                            FrenchTransaction.TYPE_WITHDRAWAL),
145
                (re.compile(r'^RET(RAIT)? DAB (?P<text>.*?) (?P<dd>\d{2})(?P<mm>\d{2})(?P<yy>\d{2}) (?P<HH>\d{2})H(?P<MM>\d{2})\b', re.IGNORECASE),
146
                                                            FrenchTransaction.TYPE_WITHDRAWAL),
147 148 149 150 151 152 153 154
                (re.compile('^VIR(EMENT)?(\.PERIODIQUE)? (?P<text>.*)', re.IGNORECASE),
                                                            FrenchTransaction.TYPE_TRANSFER),
                (re.compile('^PRLV (?P<text>.*)', re.IGNORECASE),
                                                            FrenchTransaction.TYPE_ORDER),
                (re.compile('^CHEQUE.*', re.IGNORECASE),    FrenchTransaction.TYPE_CHECK),
                (re.compile('^(CONVENTION \d+ )?COTIS(ATION)? (?P<text>.*)', re.IGNORECASE),
                                                            FrenchTransaction.TYPE_BANK),
                (re.compile(r'^\* (?P<text>.*)', re.IGNORECASE),
155
                                                            FrenchTransaction.TYPE_BANK),
156 157 158
                (re.compile('^REMISE (?P<text>.*)', re.IGNORECASE),
                                                            FrenchTransaction.TYPE_DEPOSIT),
                (re.compile('^(?P<text>.*)( \d+)? QUITTANCE .*', re.IGNORECASE),
159
                                                            FrenchTransaction.TYPE_ORDER),
160 161
                (re.compile('^CB [\d\*]+ TOT DIF .*', re.IGNORECASE),
                                                            FrenchTransaction.TYPE_CARD_SUMMARY),
162 163
                (re.compile('^CB [\d\*]+ (?P<text>.*)', re.IGNORECASE),
                                                            FrenchTransaction.TYPE_CARD),
164
                (re.compile(r'^CB (?P<text>.*?) (?P<dd>\d{2})(?P<mm>\d{2})(?P<yy>\d{2})\b', re.IGNORECASE),
Jonathan Schmidt's avatar
Jonathan Schmidt committed
165
                                                            FrenchTransaction.TYPE_CARD),
166
                (re.compile(r'\*CB (?P<text>.*?) (?P<dd>\d{2})(?P<mm>\d{2})(?P<yy>\d{2})\b', re.IGNORECASE),
Jonathan Schmidt's avatar
Jonathan Schmidt committed
167
                                                            FrenchTransaction.TYPE_CARD),
168
                (re.compile(r'^FAC CB (?P<text>.*?) (?P<dd>\d{2})/(?P<mm>\d{2})\b', re.IGNORECASE),
169
                                                            FrenchTransaction.TYPE_CARD),
170
                (re.compile(r'^\*?CB (?P<text>.*)', re.IGNORECASE), FrenchTransaction.TYPE_CARD),
171 172
               ]

173

174
class IndexPage(LoggedPage, HTMLPage):
175 176
    ACCOUNT_TYPES = {u'Epargne liquide':            Account.TYPE_SAVINGS,
                     u'Compte Courant':             Account.TYPE_CHECKING,
177
                     u'COMPTE A VUE':               Account.TYPE_CHECKING,
178
                     u'COMPTE CHEQUE':              Account.TYPE_CHECKING,
179
                     u'Mes comptes':                Account.TYPE_CHECKING,
180
                     u'CPT DEPOT PART.':            Account.TYPE_CHECKING,
181
                     u'CPT DEPOT PROF.':            Account.TYPE_CHECKING,
182
                     u'Mon épargne':                Account.TYPE_SAVINGS,
Romain Bignon's avatar
Romain Bignon committed
183
                     u'Mes autres comptes':         Account.TYPE_SAVINGS,
184 185
                     u'Compte Epargne et DAT':      Account.TYPE_SAVINGS,
                     u'Plan et Contrat d\'Epargne': Account.TYPE_SAVINGS,
186 187
                     u'COMPTE SUR LIVRET':          Account.TYPE_SAVINGS,
                     u'LIVRET DEV.DURABLE':         Account.TYPE_SAVINGS,
188 189
                     u'LDD Solidaire':              Account.TYPE_SAVINGS,
                     u'LIVRET A':                   Account.TYPE_SAVINGS,
190 191 192 193 194 195
                     u'LIVRET JEUNE':               Account.TYPE_SAVINGS,
                     u'LIVRET GRAND PRIX':          Account.TYPE_SAVINGS,
                     u'LEP':                        Account.TYPE_SAVINGS,
                     u'LEL':                        Account.TYPE_SAVINGS,
                     u'CPT PARTS SOCIALES':         Account.TYPE_SAVINGS,
                     u'PEL 16 2013':                Account.TYPE_SAVINGS,
196
                     u'Titres':                     Account.TYPE_MARKET,
197
                     u'Compte titres':              Account.TYPE_MARKET,
198 199 200
                     u'Mes crédits immobiliers':    Account.TYPE_LOAN,
                     u'Mes crédits renouvelables':  Account.TYPE_LOAN,
                     u'Mes crédits consommation':   Account.TYPE_LOAN,
201 202
                     u'PEA NUMERAIRE':              Account.TYPE_PEA,
                     u'PEA':                        Account.TYPE_PEA,
203 204
                    }

205
    def build_doc(self, content):
206
        content = content.strip(b'\x00')
207 208
        return super(IndexPage, self).build_doc(content)

209
    def on_load(self):
210 211 212 213 214 215 216 217

        # For now, we have to handle this because after this warning message,
        # the user is disconnected (even if all others account are reachable)
        if 'NA_OIC_QCF' in self.browser.url:
            message = CleanText(self.doc.xpath('//span[contains(@id, "MM_NA_OIC_QCF")]/p'))(self)
            if message and "investissement financier (QCF) n’est plus valide à ce jour ou que vous avez refusé d’y répondre" in message:
                raise ActionNeeded(message)

Baptiste Delpey's avatar
Baptiste Delpey committed
218
        # This page is sometimes an useless step to the market website.
219 220 221
        bourse_link = Link(u'//div[@id="MM_COMPTE_TITRE_pnlbourseoic"]//a[contains(text(), "Accédez à la consultation")]', default=None)(self.doc)

        if bourse_link:
222
            self.browser.location(bourse_link)
Baptiste Delpey's avatar
Baptiste Delpey committed
223

224 225 226
    def need_auth(self):
        return bool(CleanText(u'//span[contains(text(), "Authentification non rejouable")]')(self.doc))

227
    def check_no_loans(self):
228 229
        return not bool(CleanText(u'//table[@class="menu"]//div[contains(., "Crédits")]')(self.doc)) and \
               not bool(CleanText(u'//table[@class="header-navigation_main"]//a[contains(., "Crédits")]')(self.doc))
230

231 232 233
    def check_measure_accounts(self):
        return not CleanText(u'//div[@class="MessageErreur"]/ul/li[contains(text(), "Aucun compte disponible")]')(self.doc)

234
    def check_no_accounts(self):
235 236
        no_account_message = CleanText(u'//span[@id="MM_LblMessagePopinError"]/p[contains(text(), "Aucun compte disponible")]')(self.doc)

237 238
        if no_account_message:
            raise NoAccountsException(no_account_message)
239

240 241 242 243 244 245 246 247 248 249 250 251
    def find_and_replace(self, info, acc_id):
        # The site might be broken: id in js: 4097800039137N418S00197, id in title: 1379418S001 (N instead of 9)
        # So we seek for a 1 letter difference and replace if found .... (so sad)
        for i in range(len(info['id']) - len(acc_id) + 1):
            sub_part = info['id'][i:i+len(acc_id)]
            z = zip(sub_part, acc_id)
            if len([tuple_letter for tuple_letter in z if len(set(tuple_letter)) > 1]) == 1:
                info['link'] = info['link'].replace(sub_part, acc_id)
                info['id'] = info['id'].replace(sub_part, acc_id)
                return

    def _get_account_info(self, a, accounts):
Baptiste Delpey's avatar
Baptiste Delpey committed
252
        m = re.search("PostBack(Options)?\([\"'][^\"']+[\"'],\s*['\"]([HISTORIQUE_\w|SYNTHESE_ASSURANCE_CNP|BOURSE|COMPTE_TITRE][\d\w&]+)?['\"]", a.attrib.get('href', ''))
253 254 255 256 257 258 259
        if m is None:
            return None
        else:
            # it is in form CB&12345[&2]. the last part is only for new website
            # and is necessary for navigation.
            link = m.group(2)
            parts = link.split('&')
260
            info = {}
261
            info['link'] = link
262
            id = re.search("([\d]+)", a.attrib.get('title', ''))
263 264
            if len(parts) > 1:
                info['type'] = parts[0]
265 266
                info['id'] = info['_id'] = parts[1]
                if id or info['id'] in [acc._info['_id'] for acc in accounts.values()]:
267
                    _id = id.group(1) if id else next(iter({k for k, v in accounts.items() if info['id'] == v._info['_id']}))
268
                    self.find_and_replace(info, _id)
269 270
            else:
                info['type'] = link
271
                info['id'] = info['_id'] = id.group(1)
272
            if info['type'] in ('SYNTHESE_ASSURANCE_CNP', 'SYNTHESE_EPARGNE', 'ASSURANCE_VIE'):
273
                info['acc_type'] = Account.TYPE_LIFE_INSURANCE
Baptiste Delpey's avatar
Baptiste Delpey committed
274
            if info['type'] in ('BOURSE', 'COMPTE_TITRE'):
275 276 277
                info['acc_type'] = Account.TYPE_MARKET
            return info

278
    def _add_account(self, accounts, link, label, account_type, balance):
279
        info = self._get_account_info(link, accounts)
280
        if info is None:
281
            self.logger.warning('Unable to parse account %r: %r' % (label, link))
282 283 284 285
            return

        account = Account()
        account.id = info['id']
286 287
        if is_rib_valid(info['id']):
            account.iban = rib2iban(info['id'])
288 289
        account._info = info
        account.label = label
290
        account.type = self.ACCOUNT_TYPES.get(label, info['acc_type'] if 'acc_type' in info else account_type)
291 292
        if 'PERP' in account.label:
            account.type = Account.TYPE_PERP
293 294 295 296 297

        balance = balance or self.get_balance(account)
        account.balance = Decimal(FrenchTransaction.clean_amount(balance)) if balance and balance is not NotAvailable else NotAvailable

        account.currency = account.get_currency(balance) if balance and balance is not NotAvailable else NotAvailable
298 299
        account._card_links = []

300
        if account._info['type'] == 'HISTORIQUE_CB' and account.id in accounts:
301 302 303
            a = accounts[account.id]
            if not a.coming:
                a.coming = Decimal('0.0')
304 305
            if account.balance and account.balance is not NotAvailable:
                a.coming += account.balance
306 307 308 309 310
            a._card_links.append(account._info)
            return

        accounts[account.id] = account

311
    def get_balance(self, account):
312
        if account.type not in (Account.TYPE_LIFE_INSURANCE, Account.TYPE_PERP):
313
            return NotAvailable
314
        page = self.go_history(account._info).page
315
        balance = page.doc.xpath('.//tr[td[ends-with(@id,"NumContrat")]/a[contains(text(),$id)]]/td[@class="somme"]', id=account.id)
316
        if len(balance) > 0:
317
            balance = CleanText('.')(balance[0])
318
            balance = balance if balance != u'' else NotAvailable
319
        else: # sometimes the accounts are attached but no info is available
320 321
            balance = NotAvailable
        self.go_list()
322 323
        return balance

324 325 326 327 328 329 330 331 332
    def get_measure_balance(self, account):
        for tr in self.doc.xpath('//table[@cellpadding="1"]/tr[not(@class)]'):
            if re.search('[A-Z]*(\d{3,})', CleanText('./td/a[@class="NumeroDeCompte"]')(tr)).group() in account.id:
                return re.search('\s\d{1,3}(?:[\s.,]\d{3})*(?:[\s.,]\d{2})', CleanText('./td/a[@class="NumeroDeCompte"]')(tr)).group()
        return NotAvailable

    def get_measure_ids(self):
        accounts_id = []
        for a in self.doc.xpath('//table[@cellpadding="1"]/tr/td[2]/a'):
333
            accounts_id.append(re.search("(\d{6,})", Attr('.', 'href')(a)).group(1))
334 335
        return accounts_id

336
    def get_list(self):
337
        accounts = OrderedDict()
338

339
        # Old website
340
        self.browser.new_website = False
341
        for table in self.doc.xpath('//table[@cellpadding="1"]'):
342 343 344 345
            account_type = Account.TYPE_UNKNOWN
            for tr in table.xpath('./tr'):
                tds = tr.findall('td')
                if tr.attrib.get('class', '') == 'DataGridHeader':
346
                    account_type = self.ACCOUNT_TYPES.get(tds[1].text.strip()) or\
347 348
                                   self.ACCOUNT_TYPES.get(CleanText('.')(tds[2])) or\
                                   self.ACCOUNT_TYPES.get(CleanText('.')(tds[3]), Account.TYPE_UNKNOWN)
349
                else:
350 351
                    # On the same row, there are many accounts (for example a
                    # check accound and a card one).
352 353
                    if len(tds) > 4:
                        for i, a in enumerate(tds[2].xpath('./a')):
354 355
                            label = CleanText('.')(a)
                            balance = CleanText('.')(tds[-2].xpath('./a')[i])
356 357 358 359
                            self._add_account(accounts, a, label, account_type, balance)
                    # Only 4 tds on banque de la reunion website.
                    elif len(tds) == 4:
                        for i, a in enumerate(tds[1].xpath('./a')):
360 361
                            label = CleanText('.')(a)
                            balance = CleanText('.')(tds[-1].xpath('./a')[i])
362
                            self._add_account(accounts, a, label, account_type, balance)
363 364 365

        if len(accounts) == 0:
            # New website
366
            self.browser.new_website = True
367
            for table in self.doc.xpath('//div[@class="panel"]'):
368 369 370
                title = table.getprevious()
                if title is None:
                    continue
371
                account_type = self.ACCOUNT_TYPES.get(CleanText('.')(title), Account.TYPE_UNKNOWN)
372 373
                for tr in table.xpath('.//tr'):
                    tds = tr.findall('td')
374
                    for i in range(len(tds)):
375 376 377 378 379
                        a = tds[i].find('a')
                        if a is not None:
                            break

                    if a is None:
380
                        continue
381

382 383 384
                    # sometimes there's a tooltip span to ignore next to <strong>
                    # (perhaps only on creditcooperatif)
                    label = CleanText('./strong')(tds[0])
385
                    balance = CleanText('.')(tds[-1])
386 387

                    self._add_account(accounts, a, label, account_type, balance)
388

389
        return accounts.values()
390

391 392 393
    def is_access_error(self):
        error_message = u"Vous n'êtes pas autorisé à accéder à cette fonction"
        if error_message in CleanText('//div[@class="MessageErreur"]')(self.doc):
394
            return True
395 396 397

        return False

398 399 400 401 402 403 404 405 406 407 408 409 410
    def go_loans_conso(self, tr):

        link = tr.xpath('./td/a[contains(@id, "IdaCreditPerm")]')
        m = re.search('CREDITCONSO&(\w+)', link[0].attrib['href'])
        if m:
            account = m.group(1)

        form = self.get_form(name="main")
        form['__EVENTTARGET'] = 'MM$SYNTHESE_CREDITS'
        form['__EVENTARGUMENT'] = 'ACTIVDESACT_CREDITCONSO&%s' % account
        form['m_ScriptManager'] = 'MM$m_UpdatePanel|MM$SYNTHESE_CREDITS'
        form.submit()

411 412
    def get_loan_list(self):
        accounts = OrderedDict()
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430

        # Old website
        for tr in self.doc.xpath('//table[@cellpadding="1"]/tr[not(@class) and td[a]]'):
            tds = tr.findall('td')

            account = Account()
            account.id = CleanText('./a')(tds[2]).split('-')[0].strip()
            account.label = CleanText('./a')(tds[2]).split('-')[-1].strip()
            account.type = Account.TYPE_LOAN
            account.balance = -CleanDecimal('./a', replace_dots=True)(tds[4])
            account.currency = account.get_currency(CleanText('./a')(tds[4]))
            accounts[account.id] = account

        if len(accounts) == 0:
            # New website
            for table in self.doc.xpath('//div[@class="panel"]'):
                title = table.getprevious()
                if title is None:
431
                    continue
432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458
                if "immobiliers" not in CleanText('.')(title):
                    account_type = self.ACCOUNT_TYPES.get(CleanText('.')(title), Account.TYPE_UNKNOWN)
                    for tr in table.xpath('./table/tbody/tr[contains(@id,"MM_SYNTHESE_CREDITS") and contains(@id,"IdTrGlobal")]'):
                        tds = tr.findall('td')
                        if len(tds) == 0 :
                            continue
                        for i in tds[0].xpath('.//a/strong'):
                            label = i.text.strip()
                            break
                        if len(tds) == 3 and Decimal(FrenchTransaction.clean_amount(CleanText('.')(tds[-2]))) and any(cls in Attr('.', 'id')(tr) for cls in ['dgImmo', 'dgConso']) == False:
                            # in case of Consumer credit or revolving credit, we substract avalaible amount with max amout
                            # to get what was spend
                            balance = Decimal(FrenchTransaction.clean_amount(CleanText('.')(tds[-2]))) - Decimal(FrenchTransaction.clean_amount(CleanText('.')(tds[-1])))
                        else:
                            balance = Decimal(FrenchTransaction.clean_amount(CleanText('.')(tds[-1])))
                        account = Loan()
                        account.id = label.split(' ')[-1]
                        account.label = unicode(label)
                        account.type = account_type
                        account.balance = -abs(balance)
                        account.currency = account.get_currency(CleanText('.')(tds[-1]))
                        account._card_links = []

                        if "renouvelables" in CleanText('.')(title):
                            self.go_loans_conso(tr)
                            d = self.browser.loans_conso()
                            if d:
459 460 461
                                account.total_amount = float_to_decimal(d['contrat']['creditMaxAutorise'])
                                account.available_amount = float_to_decimal(d['situationCredit']['disponible'])
                                account.next_payment_amount = float_to_decimal(d['situationCredit']['mensualiteEnCours'])
462
                        accounts[account.id] = account
463
        return accounts.values()
464

465 466
    @method
    class get_real_estate_loans(ListElement):
467 468
        # beware the html response is slightly different from what can be seen with the browser
        # because of some JS most likely: use the native HTML response to build the xpath
469
        item_xpath = '//h3[contains(text(), "immobiliers")]//following-sibling::div[@class="panel"][1]//div[@id[starts-with(.,"MM_SYNTHESE_CREDITS")] and contains(@id, "IdDivDetail")]'
470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493

        class iter_account(TableElement):
            item_xpath = './table[@class="static"][1]/tbody'
            head_xpath = './table[@class="static"][1]/tbody/tr/th'

            col_total_amount = u'Capital Emprunté'
            col_rate = u'Taux d’intérêt nominal'
            col_balance = u'Capital Restant Dû'
            col_last_payment_date = u'Dernière échéance'
            col_next_payment_amount = u'Montant prochaine échéance'
            col_next_payment_date = u'Prochaine échéance'

            def parse(self, el):
                self.env['id'] = CleanText("./h2")(el).split()[-1]
                self.env['label'] = CleanText("./h2")(el)

            class item(ItemElement):

                klass = Loan

                obj_id = Env('id')
                obj_label = Env('label')
                obj_type = Loan.TYPE_LOAN
                obj_total_amount = MyDecimal(MyTableCell("total_amount"))
494
                obj_rate = Eval(lambda x: x / 100, MyDecimal(MyTableCell("rate", default=NotAvailable), default=NotAvailable))
495
                obj_balance = MyDecimal(MyTableCell("balance"), sign=lambda x: -1)
496 497 498 499 500 501
                obj_currency = Currency(MyTableCell("balance"))
                obj_last_payment_date = Date(CleanText(MyTableCell("last_payment_date")))
                obj_next_payment_amount = MyDecimal(MyTableCell("next_payment_amount"))
                obj_next_payment_date = Date(CleanText(MyTableCell("next_payment_date")))


Romain Bignon's avatar
Romain Bignon committed
502
    def go_list(self):
503
        form = self.get_form(name='main')
504

505 506 507 508 509 510
        form['__EVENTARGUMENT'] = "CPTSYNT0"

        if "MM$m_CH$IsMsgInit" in form:
            # Old website
            form['__EVENTTARGET'] = "Menu_AJAX"
            form['m_ScriptManager'] = "m_ScriptManager|Menu_AJAX"
511
        else:
512 513 514
            # New website
            form['__EVENTTARGET'] = "MM$m_PostBack"
            form['m_ScriptManager'] = "MM$m_UpdatePanel|MM$m_PostBack"
515

516
        fix_form(form)
517 518

        form.submit()
Romain Bignon's avatar
Romain Bignon committed
519

520 521 522 523 524 525 526 527
    # On some pages, navigate to indexPage does not lead to the list of measures, so we need this form ...
    def go_measure_list(self):
        form = self.get_form(name='main')

        form['__EVENTARGUMENT'] = "MESLIST0"
        form['__EVENTTARGET'] = 'Menu_AJAX'
        form['m_ScriptManager'] = 'm_ScriptManager|Menu_AJAX'

528
        fix_form(form)
529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547

        form.submit()

    # This function goes to the accounts page of one measure giving its id
    def go_measure_accounts_list(self, measure_id):
        form = self.get_form(name='main')

        form['__EVENTARGUMENT'] = "CPTSYNT0"

        if "MM$m_CH$IsMsgInit" in form:
            # Old website
            form['__EVENTTARGET'] = "MM$SYNTHESE_MESURES"
            form['m_ScriptManager'] = "MM$m_UpdatePanel|MM$SYNTHESE_MESURES"
            form['__EVENTARGUMENT'] = measure_id
        else:
            # New website
            form['__EVENTTARGET'] = "MM$m_PostBack"
            form['m_ScriptManager'] = "MM$m_UpdatePanel|MM$m_PostBack"

548
        fix_form(form)
549 550 551

        form.submit()

552
    def go_loan_list(self):
553
        form = self.get_form(name='main')
554

555 556 557 558
        form['__EVENTARGUMENT'] = "CRESYNT0"

        if "MM$m_CH$IsMsgInit" in form:
            # Old website
559
            pass
560 561 562 563 564
        else:
            # New website
            form['__EVENTTARGET'] = "MM$m_PostBack"
            form['m_ScriptManager'] = "MM$m_UpdatePanel|MM$m_PostBack"

565
        fix_form(form)
566 567

        form.submit()
568

569
    def go_history(self, info, is_cbtab=False):
570 571 572 573 574
        form = self.get_form(name='main')

        form['__EVENTTARGET'] = 'MM$%s' % (info['type'] if is_cbtab else 'SYNTHESE')
        form['__EVENTARGUMENT'] = info['link']

575
        if "MM$m_CH$IsMsgInit" in form and (form['MM$m_CH$IsMsgInit'] == "0" or info['type'] == 'ASSURANCE_VIE'):
576 577
            form['m_ScriptManager'] = "MM$m_UpdatePanel|MM$SYNTHESE"

578
        fix_form(form)
579
        return form.submit()
580

581 582 583 584
    def get_form_to_detail(self, transaction):
        m = re.match('.*\("(.*)", "(DETAIL_OP&[\d]+).*\)\)', transaction._link)
        # go to detailcard page
        form = self.get_form(name='main')
585
        form['__EVENTTARGET'] = m.group(1)
586
        form['__EVENTARGUMENT'] = m.group(2)
587
        fix_form(form)
588 589
        return form

590 591
    def get_history(self):
        i = 0
Romain Bignon's avatar
Romain Bignon committed
592
        ignore = False
593
        for tr in self.doc.xpath('//table[@cellpadding="1"]/tr') + self.doc.xpath('//tr[@class="rowClick" or @class="rowHover"]'):
Romain Bignon's avatar
Romain Bignon committed
594 595
            tds = tr.findall('td')

596
            if len(tds) < 4:
Romain Bignon's avatar
Romain Bignon committed
597 598
                continue

599 600 601
            # if there are more than 4 columns, ignore the first one.
            i = min(len(tds) - 4, 1)

602
            if tr.attrib.get('class', '') == 'DataGridHeader':
Romain Bignon's avatar
Romain Bignon committed
603 604 605 606
                if tds[2].text == u'Titulaire':
                    ignore = True
                else:
                    ignore = False
607 608
                continue

Romain Bignon's avatar
Romain Bignon committed
609 610
            if ignore:
                continue
611

612 613 614 615 616
            # Remove useless details
            detail = tr.cssselect('div.detail')
            if len(detail) > 0:
                detail[0].drop_tree()

617
            t = Transaction()
618

619 620
            date = u''.join([txt.strip() for txt in tds[i+0].itertext()])
            raw = u' '.join([txt.strip() for txt in tds[i+1].itertext()])
621 622 623
            debit = u''.join([txt.strip() for txt in tds[-2].itertext()])
            credit = u''.join([txt.strip() for txt in tds[-1].itertext()])

624
            t.parse(date, re.sub(r'[ ]+', ' ', raw))
625

626
            card_debit_date = self.doc.xpath(u'//span[@id="MM_HISTORIQUE_CB_m_TableTitle3_lblTitle"] | //label[contains(text(), "débiter le")]')
627 628
            if card_debit_date:
                t.rdate = Date(dayfirst=True).filter(date)
629
                m = re.search(r'\b(\d{2}/\d{2}/\d{4})\b', card_debit_date[0].text)
630 631 632
                assert m
                t.date = Date(dayfirst=True).filter(m.group(1))
            if t.date is NotAvailable:
633
                continue
634
            if 'tot dif' in t.raw.lower():
635
                t._link = Link(tr.xpath('./td/a'))(self.doc)
636
                t.deleted = True
637

638 639 640 641
            t.set_amount(credit, debit)
            yield t

            i += 1
Romain Bignon's avatar
Romain Bignon committed
642 643

    def go_next(self):
644 645
        # <a id="MM_HISTORIQUE_CB_lnkSuivante" class="next" href="javascript:WebForm_DoPostBackWithOptions(new WebForm_PostBackOptions(&quot;MM$HISTORIQUE_CB$lnkSuivante&quot;, &quot;&quot;, true, &quot;&quot;, &quot;&quot;, false, true))">Suivant<span class="arrow">></span></a>

646
        link = self.doc.xpath('//a[contains(@id, "lnkSuivante")]')
647
        if len(link) == 0 or 'disabled' in link[0].attrib:
Romain Bignon's avatar
Romain Bignon committed
648 649
            return False

650 651 652 653 654
        account_type = 'COMPTE'
        m = re.search('HISTORIQUE_(\w+)', link[0].attrib['href'])
        if m:
            account_type = m.group(1)

655 656 657 658 659 660 661 662
        form = self.get_form(name='main')

        form['__EVENTTARGET'] = "MM$HISTORIQUE_%s$lnkSuivante" % account_type
        form['__EVENTARGUMENT'] = ''

        if "MM$m_CH$IsMsgInit" in form and form['MM$m_CH$IsMsgInit'] == "N":
            form['m_ScriptManager'] = "MM$m_UpdatePanel|MM$HISTORIQUE_COMPTE$lnkSuivante"

663
        fix_form(form)
664
        form.submit()
Romain Bignon's avatar
Romain Bignon committed
665 666

        return True
667

668
    def go_life_insurance(self, account):
669 670 671 672
        # The site shows nothing about life insurance accounts except balance, links are disabled
        if 'measure_id' in account._info:
            return

673
        link = self.doc.xpath('//tr[td[contains(., ' + account.id + ') ]]//a')[0]
674
        m = re.search("PostBackOptions?\([\"']([^\"']+)[\"'],\s*['\"]((REDIR_ASS_VIE)?[\d\w&]+)?['\"]", link.attrib.get('href', ''))
675
        if m is not None:
676 677 678 679 680
            form = self.get_form(name='main')

            form['__EVENTTARGET'] = m.group(1)
            form['__EVENTARGUMENT'] = m.group(2)

681 682 683 684 685 686
            if "MM$m_CH$IsMsgInit" not in form:
                # Not available on new website
                pass

            form['MM$m_CH$IsMsgInit'] = "0"
            form['m_ScriptManager'] = "MM$m_UpdatePanel|MM$SYNTHESE"
687

688
            fix_form(form)
689
            form.submit()
690

691 692 693
    def transfer_link(self):
        return self.doc.xpath(u'//a[span[contains(text(), "Effectuer un virement")]] | //a[contains(text(), "Réaliser un virement")]')

694 695
    def go_transfer_via_history(self, account):
        self.go_history(account._info)
696 697 698 699 700

        # check that transfer is available for the connection before try to go on transfer page
        # otherwise website will continually crash
        if self.transfer_link():
            self.browser.page.go_transfer(account)
701 702

    def go_transfer(self, account):
703
        link = self.transfer_link()
704 705 706 707
        if len(link) == 0:
            return self.go_transfer_via_history(account)
        else:
            link = link[0]
708 709
        m = re.search("PostBackOptions?\([\"']([^\"']+)[\"'],\s*['\"]([^\"']+)?['\"]", link.attrib.get('href', ''))
        form = self.get_form(name='main')
710 711
        if 'MM$HISTORIQUE_COMPTE$btnCumul' in form:
            del form['MM$HISTORIQUE_COMPTE$btnCumul']
712 713 714 715
        form['__EVENTTARGET'] = m.group(1)
        form['__EVENTARGUMENT'] = m.group(2)
        form.submit()

716 717 718
    def transfer_unavailable(self):
        return CleanText(u'//li[contains(text(), "Pour accéder à cette fonctionnalité, vous devez disposer d’un moyen d’authentification renforcée")]')(self.doc)

719 720 721 722 723
    def loan_unavailable_msg(self):
        msg = CleanText('//span[@id="MM_LblMessagePopinError"] | //p[@id="MM_ERREUR_PAGE_BLANCHE_pAlert"]')(self.doc)
        if msg:
            return msg

724 725 726 727 728 729 730
    def go_subscription(self):
        form = self.get_form(name='main')
        form['m_ScriptManager'] = 'MM$m_UpdatePanel|MM$Menu_Ajax'
        form['__EVENTTARGET'] = 'MM$Menu_Ajax'
        form['__EVENTARGUMENT'] = 'CPTEDOC&codeMenu=WCE0'
        form.submit()

731

732 733 734 735 736 737 738
class ConsLoanPage(JsonPage):
    def get_conso(self):
        return self.doc


class LoadingPage(HTMLPage):
    def on_load(self):
739 740 741 742 743
        # CTX cookie seems to corrupt the request fetching info about "credit
        # renouvelable" and to lead to a 409 error
        if 'CTX' in self.browser.session.cookies.keys():
            del self.browser.session.cookies['CTX']

744 745 746
        form = self.get_form(id="REROUTAGE")
        form.submit()

747

748 749 750 751 752 753 754 755
class NatixisRedirectPage(LoggedPage, HTMLPage):
    def on_load(self):
        try:
            form = self.get_form(id="NaAssurance")
        except FormNotFound:
            form = self.get_form(id="formRoutage")
        form.submit()

756

757
class MarketPage(LoggedPage, HTMLPage):
758
    def is_error(self):
759
        try:
760
            return self.doc.xpath('//caption')[0].text == "Erreur"
761 762
        except IndexError:
            return False
763 764
        except AssertionError:
            return True
765

766
    def parse_decimal(self, td, percentage=False):
767
        value = CleanText('.')(td)
768
        if value and value != '-':
769 770
            if percentage:
                return Decimal(FrenchTransaction.clean_amount(value)) / 100
771 772 773 774 775
            return Decimal(FrenchTransaction.clean_amount(value))
        else:
            return NotAvailable

    def submit(self):
776 777 778
        form = self.get_form(nr=0)

        form.submit()
779 780

    def iter_investment(self):
781
        for tbody in self.doc.xpath(u'//table[@summary="Contenu du portefeuille valorisé"]/tbody'):
782
            inv = Investment()
783 784
            inv.label = CleanText('.')(tbody.xpath('./tr[1]/td[1]/a/span')[0])
            inv.code = CleanText('.')(tbody.xpath('./tr[1]/td[1]/a')[0]).split(' - ')[1]
785
            inv.code_type = Investment.CODE_TYPE_ISIN if is_isin_valid(inv.code) else NotAvailable
786 787 788 789 790 791 792 793
            inv.quantity = self.parse_decimal(tbody.xpath('./tr[2]/td[2]')[0])
            inv.unitvalue = self.parse_decimal(tbody.xpath('./tr[2]/td[3]')[0])
            inv.unitprice = self.parse_decimal(tbody.xpath('./tr[2]/td[5]')[0])
            inv.valuation = self.parse_decimal(tbody.xpath('./tr[2]/td[4]')[0])
            inv.diff = self.parse_decimal(tbody.xpath('./tr[2]/td[7]')[0])

            yield inv

794
    def get_valuation_diff(self, account):
795
        val = CleanText(self.doc.xpath(u'//td[contains(text(), "values latentes")]/following-sibling::*[1]'))
796
        account.valuation_diff = CleanDecimal(Regexp(val, '([^\(\)]+)'), replace_dots=True)(self)
797

798
    def is_on_right_portfolio(self, account):
799
        return len(self.doc.xpath('//form[@class="choixCompte"]//option[@selected and contains(text(), $id)]', id=account._info['id']))
800 801

    def get_compte(self, account):
802
        return self.doc.xpath('//option[contains(text(), $id)]/@value', id=account._info['id'])[0]
803

804
    def come_back(self):
805
        link = Link(u'//div/a[contains(text(), "Accueil accès client")]', default=NotAvailable)(self.doc)
806 807 808 809
        if link:
            self.browser.location(link)


810 811
class LifeInsurance(MarketPage):
    def get_cons_repart(self):
812
        return self.doc.xpath('//tr[@id="sousMenuConsultation3"]/td/div/a')[0].attrib['href']
813

814
    def get_cons_histo(self):
815
        return self.doc.xpath('//tr[@id="sousMenuConsultation4"]/td/div/a')[0].attrib['href']
816 817

    def iter_history(self):
818
        for tr in self.doc.xpath(u'//table[@class="boursedetail"]/tbody/tr[td]'):
819 820
            t = Transaction()

821 822
            t.label = CleanText('.')(tr.xpath('./td[2]')[0])
            t.date = Date(dayfirst=True).filter(CleanText('.')(tr.xpath('./td[1]')[0]))
823 824 825 826
            t.amount = self.parse_decimal(tr.xpath('./td[3]')[0])

            yield t

827
    def iter_investment(self):
828
        for tr in self.doc.xpath(u'//table[@class="boursedetail"]/tr[@class and not(@class="total")]'):
829 830

            inv = Investment()
831
            libelle = CleanText('.')(tr.xpath('./td[1]')[0]).split(' ')
832
            inv.label, inv.code = self.split_label_code(libelle)
833
            inv.code_type = Investment.CODE_TYPE_ISIN if is_isin_valid(inv.code) else NotAvailable
834 835
            inv.quantity = self.parse_decimal(tr.xpath('./td[2]')[0])
            inv.unitvalue = self.parse_decimal(tr.xpath('./td[3]')[0])
836
            date = CleanText('.')(tr.xpath('./td[4]')[0])
837
            inv.vdate = Date(dayfirst=True).filter(date) if date and date != '-' else NotAvailable
838
            inv.valuation = self.parse_decimal(tr.xpath('./td[5]')[0])
839
            inv.diff_percent = self.parse_decimal(tr.xpath('./td[6]')[0], percentage=True)
840 841 842 843 844 845 846 847 848

            yield inv

    def split_label_code(self, libelle):
        m = re.search('FR\d+', libelle[-1])
        if m:
            return ' '.join(libelle[:-1]), libelle[-1]
        else:
            return ' '.join(libelle), NotAvailable
849 850


851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892
class NatixisLIHis(LoggedPage, JsonPage):
    @method
    class get_history(DictElement):
        item_xpath = None

        class item(ItemElement):
            klass = Transaction

            obj_amount = Eval(float_to_decimal, Dict('montantNet'))
            obj_raw = CleanText(Dict('libelle', default=''))
            obj_vdate = Date(Dict('dateValeur', default=NotAvailable), default=NotAvailable)
            obj_date = Date(Dict('dateEffet', default=NotAvailable), default=NotAvailable)
            obj_investments = NotAvailable
            obj_type = Transaction.TYPE_BANK

            def validate(self, obj):
                return obj.raw and obj.date


class NatixisLIInv(LoggedPage, JsonPage):
    @method
    class get_investments(DictElement):
        item_xpath = 'detailContratVie/valorisation/supports'

        class item(ItemElement):
            klass = Investment

            obj_label = CleanText(Dict('nom'))
            obj_code = CleanText(Dict('codeIsin'))

            def obj_vdate(self):
                dt = Dict('dateValeurUniteCompte', default=None)(self)
                if dt is None:
                    dt = self.page.doc['detailContratVie']['valorisation']['date']
                return Date().filter(dt)

            obj_valuation = Eval(float_to_decimal, Dict('montant'))
            obj_quantity = Eval(float_to_decimal, Dict('nombreUnitesCompte'))
            obj_unitvalue = Eval(float_to_decimal, Dict('valeurUniteCompte'))
            obj_portfolio_share = Eval(lambda x: float_to_decimal(x) / 100, Dict('repartition'))


893 894 895 896 897 898 899 900 901 902 903 904
class MyRecipient(ItemElement):
    klass = Recipient

    # Assume all recipients currency is euros.
    obj_currency = u'EUR'

    def obj_enabled_at(self):
        return datetime.now().replace(microsecond=0)


class TransferErrorPage(object):
    def on_load(self):
905 906 907 908
        errors_xpaths = ['//div[h2[text()="Information"]]/p[contains(text(), "Il ne pourra pas être crédité avant")]',
                         '//span[@id="MM_LblMessagePopinError"]/p | //div[h2[contains(text(), "Erreur de saisie")]]/p[1] | //span[@class="error"]/strong',
                         '//div[@id="MM_m_CH_ValidationSummary" and @class="MessageErreur"]',
        ]
909

910 911 912 913
        for error_xpath in errors_xpaths:
            error = CleanText(error_xpath)(self.doc)
            if error:
                raise TransferBankError(message=error)
914 915


916 917 918 919 920
class MeasurePage(IndexPage):
    def is_here(self):
        return self.doc.xpath('//span[contains(text(), "Liste de vos mesures")]')


921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966
class MyRecipients(ListElement):
    def parse(self, obj):
        self.item_xpath = self.page.RECIPIENT_XPATH

    class Item(MyRecipient):
        def validate(self, obj):
            return self.obj_id(self) != self.env['account_id']

        obj_id = Env('id')
        obj_iban = Env('iban')
        obj_bank_name = Env('bank_name')
        obj_category = Env('category')
        obj_label = Env('label')

        def parse(self, el):
            value = Attr('.', 'value')(self)
            # Autres comptes
            if value == 'AC':
                raise SkipItem()
            self.env['category'] = u'Interne' if value[0] == 'I' else u'Externe'
            if self.env['category'] == u'Interne':
                # TODO use after 'I'?
                _id = Regexp(CleanText('.'), r'- (\w+\d\w+)')(self) # at least one digit
                accounts = list(self.page.browser.get_accounts_list()) + list(self.page.browser.get_loans_list())
                match = [acc for acc in accounts if _id in acc.id]
                assert len(match) == 1
                match = match[0]
                self.env['id'] = match.id
                self.env['iban'] = match.iban
                self.env['bank_name'] = u"Caisse d'Épargne"
                self.env['label'] = match.label
            # Usual case `E-` or `UE-`
            elif value[1] == '-' or value[2] == '-':
                full = CleanText('.')(self)
                if full.startswith('- '):
                    self.logger.warning('skipping recipient without a label: %r', full)
                    raise SkipItem()

                # <recipient name> - <account number or iban> - <bank name (optional)> <optional last dash>
                mtc = re.match('(?P<label>.+) - (?P<id>[^-]+) -(?P<bank> [^-]*)?-?$', full)
                assert mtc
                self.env['id'] = self.env['iban'] = mtc.group('id')
                self.env['bank_name'] = (mtc.group('bank') and mtc.group('bank').strip()) or NotAvailable
                self.env['label'] = mtc.group('label')
            # Fcking corner case
            else:
967 968 969 970 971 972
                # former regex: '(?P<id>.+) - (?P<label>[^-]+) -( [^-]*)?-?$'
                # the strip is in case the string ends by ' -'
                mtc = CleanText('.')(self).strip(' -').split(' - ')
                # it needs to contain, at least, the id and the label
                assert len(mtc) >= 2
                self.env['id'] = mtc[0]
973 974
                self.env['iban'] = NotAvailable
                self.env['bank_name'] = NotAvailable
975
                self.env['label'] = mtc[1]
976 977


978
class TransferPage(TransferErrorPage, IndexPage):
979 980
    RECIPIENT_XPATH = '//select[@id="MM_VIREMENT_SAISIE_VIREMENT_ddlCompteCrediter"]/option'

981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997
    def is_here(self):
        return bool(CleanText(u'//h2[contains(text(), "Effectuer un virement")]')(self.doc))

    def can_transfer(self, account):
        for o in self.doc.xpath('//select[@id="MM_VIREMENT_SAISIE_VIREMENT_ddlCompteDebiter"]/option'):
            if Regexp(CleanText('.'), '- (\d+)')(o) in account.id:
                return True

    def get_origin_account_value(self, account):
        origin_value = [Attr('.', 'value')(o) for o in self.doc.xpath('//select[@id="MM_VIREMENT_SAISIE_VIREMENT_ddlCompteDebiter"]/option') if
                        Regexp(CleanText('.'), '- (\d+)')(o) in account.id]
        if len(origin_value) != 1:
            raise TransferError('error during origin account matching')
        return origin_value[0]

    def get_recipient_value(self, recipient):
        if recipient.category == u'Externe':
998
            recipient_value = [Attr('.', 'value')(o) for o in self.doc.xpath(self.RECIPIENT_XPATH) if
999 1000
                               Regexp(CleanText('.'), ' - (.*) -', default=NotAvailable)(o) == recipient.iban]
        elif recipient.category == u'Interne':
1001
            recipient_value = [Attr('.', 'value')(o) for o in self.doc.xpath(self.RECIPIENT_XPATH) if
1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019
                               Regexp(CleanText('.'), '- (\d+)', default=NotAvailable)(o) and Regexp(CleanText('.'), '- (\d+)', default=NotAvailable)(o) in recipient.id]
        if len(recipient_value) != 1:
            raise TransferError('error during recipient matching')
        return recipient_value[0]

    def init_transfer(self, account, recipient, transfer):
        form = self.get_form(name='main')
        form['MM$VIREMENT$SAISIE_VIREMENT$ddlCompteDebiter'] = self.get_origin_account_value(account)
        form['MM$VIREMENT$SAISIE_VIREMENT$ddlCompteCrediter'] = self.get_recipient_value(recipient)
        form['MM$VIREMENT$SAISIE_VIREMENT$txtLibelleVirement'] = transfer.label
        form['MM$VIREMENT$SAISIE_VIREMENT$txtMontant$m_txtMontant'] = unicode(transfer.amount)
        form['__EVENTTARGET'] = 'MM$VIREMENT$m_WizardBar$m_lnkNext$m_lnkButton'
        if transfer.exec_date != datetime.today().date():
            form['MM$VIREMENT$SAISIE_VIREMENT$radioVirement'] = 'differe'
            form['MM$VIREMENT$SAISIE_VIREMENT$m_DateDiffere$txtDate'] = transfer.exec_date.strftime('%d/%m/%Y')
        form.submit()

    @method
1020 1021
    class iter_recipients(MyRecipients):
        pass
1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033

    def continue_transfer(self, origin_label, recipient, label):
        form = self.get_form(name='main')
        type_ = 'intra' if recipient.category == u'Interne' else 'sepa'
        fill = lambda s, t: s % (t.upper(), t.capitalize())
        form['__EVENTTARGET'] = 'MM$VIREMENT$m_WizardBar$m_lnkNext$m_lnkButton'
        form[fill('MM$VIREMENT$SAISIE_VIREMENT_%s$m_Virement%s$txtIdentBenef', type_)] = recipient.label
        form[fill('MM$VIREMENT$SAISIE_VIREMENT_%s$m_Virement%s$txtIdent', type_)] = origin_label
        form[fill('MM$VIREMENT$SAISIE_VIREMENT_%s$m_Virement%s$txtRef', type_)] = label
        form[fill('MM$VIREMENT$SAISIE_VIREMENT_%s$m_Virement%s$txtMotif', type_)] = label
        form.submit()

1034 1035 1036 1037 1038 1039 1040
    def go_add_recipient(self):
        form = self.get_form(name='main')
        link = self.doc.xpath(u'//a[span[contains(text(), "Ajouter un compte bénéficiaire")]]')[0]
        m = re.search("PostBackOptions?\([\"']([^\"']+)[\"'],\s*['\"]([^\"']+)?['\"]", link.attrib.get('href', ''))
        form['__EVENTTARGET'] = m.group(1)
        form['__EVENTARGUMENT'] = m.group(2)
        form.submit()
1041

1042

1043
class TransferConfirmPage(TransferErrorPage, IndexPage):
1044 1045 1046 1047 1048 1049
    def build_doc(self, content):
        # The page have some <wbr> tags in the label content (spaces added each 40 characters if the character is not a space).
        # Consequently the label can't be matched with the original one. We delete these tags.
        content = content.replace(b'<wbr>', b'')
        return super(TransferErrorPage, self).build_doc(content)

1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064
    def is_here(self):
        return bool(CleanText(u'//h2[contains(text(), "Confirmer mon virement")]')(self.doc))

    def confirm(self):
        form = self.get_form(name='main')
        form['__EVENTTARGET'] = 'MM$VIREMENT$m_WizardBar$m_lnkNext$m_lnkButton'
        form.submit()

    def create_transfer(self, account, recipient, transfer):
        transfer = Transfer()
        transfer.currency = FrenchTransaction.Currency('.//tr[td[contains(text(), "Montant")]]/td[not(@class)] | \
                                                        .//tr[th[contains(text(), "Montant")]]/td[not(@class)]')(self.doc)
        transfer.amount = CleanDecimal('.//tr[td[contains(text(), "Montant")]]/td[not(@class)] | \
                                        .//tr[th[contains(text(), "Montant")]]/td[not(@class)]', replace_dots=True)(self.doc)
        transfer.account_iban = account.iban
1065 1066 1067 1068
        if recipient.category == u'Externe':
            for word in Upper(CleanText(u'.//tr[th[contains(text(), "Compte à créditer")]]/td[not(@class)]'))(self.doc).split():
                if is_iban_valid(word):
                    transfer.recipient_iban = word
1069
                    break
1070 1071 1072 1073
            else:
                raise TransferError('Unable to find IBAN (original was %s)' % recipient.iban)
        else:
            transfer.recipient_iban = recipient.iban
1074 1075 1076
        transfer.account_id = unicode(account.id)
        transfer.recipient_id = unicode(recipient.id)
        transfer.exec_date = Date(CleanText('.//tr[th[contains(text(), "En date du")]]/td[not(@class)]'), dayfirst=True)(self.doc)
1077 1078 1079
        transfer.label = (CleanText(u'.//tr[td[contains(text(), "Motif de l\'opération")]]/td[not(@class)]')(self.doc) or
                         CleanText(u'.//tr[td[contains(text(), "Libellé")]]/td[not(@class)]')(self.doc) or
                         CleanText(u'.//tr[th[contains(text(), "Libellé")]]/td[not(@class)]')(self.doc))
1080 1081 1082 1083 1084 1085 1086 1087
        transfer.account_label = account.label
        transfer.recipient_label = recipient.label
        transfer._account = account
        transfer._recipient = recipient
        transfer.account_balance = account.balance
        return transfer


1088 1089 1090 1091 1092 1093 1094 1095 1096 1097
class ProTransferConfirmPage(TransferConfirmPage):
    def is_here(self):
        return bool(CleanText(u'//span[@id="MM_m_CH_lblTitle" and contains(text(), "Confirmez votre virement")]')(self.doc))

    def continue_transfer(self, origin_label, recipient, label):
        # Pro internal transfer initiation doesn't need a second step.
        pass

    def create_transfer(self, account, recipient, transfer):
        t = Transfer()
1098 1099
        t.currency = FrenchTransaction.Currency('//span[@id="MM_VIREMENT_CONF_VIREMENT_MontantVir"] | \
                                                 //span[@id="MM_VIREMENT_CONF_VIREMENT_lblMontantSelect"]')(self.doc)
1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125
        t.amount = CleanDecimal('//span[@id="MM_VIREMENT_CONF_VIREMENT_MontantVir"] | \
                                 //span[@id="MM_VIREMENT_CONF_VIREMENT_lblMontantSelect"]', replace_dots=True)(self.doc)
        t.account_iban = account.iban
        if recipient.category == u'Externe':
            for word in Upper(CleanText('//span[@id="MM_VIREMENT_CONF_VIREMENT_lblCptCrediterResult"]'))(self.doc).split():
                if is_iban_valid(word):
                    t.recipient_iban = word
                    break
            else:
                raise TransferError('Unable to find IBAN (original was %s)' % recipient.iban)
        else:
            t.recipient_iban = recipient.iban
        t.recipient_iban = recipient.iban
        t.account_id = unicode(account.id)
        t.recipient_id = unicode(recipient.id)
        t.account_label = account.label
        t.recipient_label = recipient.label
        t._account = account
        t._recipient = recipient
        t.label = CleanText('//span[@id="MM_VIREMENT_CONF_VIREMENT_Libelle"] | \
                             //span[@id="MM_VIREMENT_CONF_VIREMENT_lblMotifSelect"]')(self.doc)
        t.exec_date = Date(CleanText('//span[@id="MM_VIREMENT_CONF_VIREMENT_DateVir"]'), dayfirst=True)(self.doc)
        t.account_balance = account.balance
        return t


1126 1127 1128 1129 1130 1131 1132 1133 1134
class TransferSummaryPage(TransferErrorPage, IndexPage):
    def is_here(self):
        return bool(CleanText(u'//h2[contains(text(), "Accusé de réception")]')(self.doc))

    def populate_reference(self, transfer):
        transfer.id = Regexp(CleanText(u'//p[contains(text(), "a bien été enregistré")]'), '(\d+)')(self.doc)
        return transfer


1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146
class ProTransferSummaryPage(TransferErrorPage, IndexPage):
    def is_here(self):
        return bool(CleanText('//span[@id="MM_m_CH_lblTitle" and contains(text(), "Accusé de réception")]')(self.doc))

    def populate_reference(self, transfer):
        transfer.id = Regexp(CleanText('//span[@id="MM_VIREMENT_AR_VIREMENT_lblVirementEnregistre"]'), '(\d+( - \d+)?)')(self.doc)
        return transfer


class ProTransferPage(TransferPage):
    RECIPIENT_XPATH = '//select[@id="MM_VIREMENT_SAISIE_VIREMENT_ddlCompteCrediterPro"]/option'

1147
    def is_here(self):
1148
        return CleanText(u'//span[contains(text(), "Créer une liste de virements")] | //span[contains(text(), "Réalisez un virement")]')(self.doc)
1149

1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173
    @method
    class iter_recipients(MyRecipients):
        pass

    def init_transfer(self, account, recipient, transfer):
        form = self.get_form(name='main')
        form['MM$VIREMENT$SAISIE_VIREMENT$ddlCompteDebiter'] = self.get_origin_account_value(account)
        form['MM$VIREMENT$SAISIE_VIREMENT$ddlCompteCrediterPro'] = self.get_recipient_value(recipient)
        form['MM$VIREMENT$SAISIE_VIREMENT$Libelle'] = transfer.label
        form['MM$VIREMENT$SAISIE_VIREMENT$m_oDEI_Montant$m_txtMontant'] = unicode(transfer.amount)
        form['__EVENTTARGET'] = 'MM$VIREMENT$m_WizardBar$m_lnkNext$m_lnkButton'
        if transfer.exec_date != datetime.today().date():
            form['MM$VIREMENT$SAISIE_VIREMENT$virement'] = 'rbDiffere'
            form['MM$VIREMENT$SAISIE_VIREMENT$m_DateDiffere$JJ'] = transfer.exec_date.strftime('%d')
            form['MM$VIREMENT$SAISIE_VIREMENT$m_DateDiffere$MM'] = transfer.exec_date.strftime('%m')
            form['MM$VIREMENT$SAISIE_VIREMENT$m_DateDiffere$AA'] = transfer.exec_date.strftime('%y')
        form.submit()

    def go_add_recipient(self):
        form = self.get_form(name='main')
        form['__EVENTTARGET'] = 'MM$VIREMENT$SAISIE_VIREMENT$ddlCompteCrediterPro'
        form['MM$VIREMENT$SAISIE_VIREMENT$ddlCompteCrediterPro'] = 'AC'
        form.submit()

1174 1175 1176 1177 1178

class CanceledAuth(Exception):
    pass


1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200
class SmsPageOption(LoggedPage, HTMLPage):
    pass


class SmsRequestStep(LoggedPage, JsonPage):
    pass


class SmsRequest(LoggedPage, JsonPage):
    def validate_key(self):
        return self.doc['step']['validationUnits'][0].keys()[0]

    def validation_id(self, key):
        return self.doc['step']['validationUnits'][0][key][0]['id']

    def get_saml(self):
        return self.doc['response']['saml2_post']['samlResponse']

    def get_action(self):
        return self.doc['response']['saml2_post']['action']


1201 1202 1203 1204
class SmsPage(LoggedPage, HTMLPage):
    def on_load(self):
        error = CleanText('//p[@class="warning_trials_before"]')(self.doc)
        if error:
1205
            raise AddRecipientBankError(message='Wrongcode, ' + error)
1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221

    def get_prompt_text(self):
        return CleanText(u'//td[@class="auth_info_prompt"]')(self.doc)

    def post_form(self):
        form = self.get_form(name='downloadAuthForm')
        form.submit()

    def check_canceled_auth(self):
        form = self.doc.xpath('//form[@name="downloadAuthForm"]')
        if form:
            self.location('/Pages/Logout.aspx')
            raise CanceledAuth()

    def set_browser_form(self):
        form = self.get_form(name='formAuth')
1222
        self.browser.recipient_form = dict((k, v) for k, v in form.items() if v)
1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236
        self.browser.recipient_form['url'] = form.url


class AuthentPage(LoggedPage, HTMLPage):
    def is_here(self):
        return bool(CleanText(u'//h2[contains(text(), "Authentification réussie")]')(self.doc))

    def go_on(self):
        form = self.get_form(name='main')
        form['__EVENTTARGET'] = 'MM$RETOUR_OK_SOL$m_ChoiceBar$lnkRight'
        form.submit()


class RecipientPage(LoggedPage, HTMLPage):
1237 1238 1239
    EVENTTARGET = 'MM$WIZARD_AJOUT_COMPTE_EXTERNE'
    FORM_FIELD_ADD = 'MM$WIZARD_AJOUT_COMPTE_EXTERNE$COMPTE_EXTERNE_ADD'

1240 1241 1242
    def on_load(self):
        error = CleanText('//span[@id="MM_LblMessagePopinError"]')(self.doc)
        if error:
1243
            raise AddRecipientBankError(message=error)
1244 1245 1246 1247 1248 1249 1250

    def is_here(self):
        return bool(CleanText(u'//h2[contains(text(), "Ajouter un compte bénéficiaire")] |\
                                //h2[contains(text(), "Confirmer l\'ajout d\'un compte bénéficiaire")]')(self.doc))

    def post_recipient(self, recipient):
        form = self.get_form(name='main')
1251 1252
        form['__EVENTTARGET'] = '%s$m_WizardBar$m_lnkNext$m_lnkButton' % self.EVENTTARGET
        form['%s$m_RibIban$txtTitulaireCompte' % self.FORM_FIELD_ADD] = recipient.label
1253
        for i in range(len(recipient.iban) // 4 + 1):
1254
            form['%s$m_RibIban$txtIban%s' % (self.FORM_FIELD_ADD, str(i + 1))] = recipient.iban[4*i:4*i+4]
1255 1256 1257 1258 1259 1260
        form.submit()

    def confirm_recipient(self):
        form = self.get_form(name='main')
        form['__EVENTTARGET'] = 'MM$WIZARD_AJOUT_COMPTE_EXTERNE$m_WizardBar$m_lnkNext$m_lnkButton'
        form.submit()
1261 1262


1263 1264 1265 1266
class ProAddRecipientOtpPage(IndexPage):
    def on_load(self):
        error = CleanText('//div[@id="MM_m_CH_ValidationSummary" and @class="MessageErreur"]')(self.doc)
        if error:
1267
            raise AddRecipientBankError(message='Wrongcode, ' + error)
1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290

    def is_here(self):
        return self.need_auth() and self.doc.xpath('//span[@id="MM_ANR_WS_AUTHENT_ANR_WS_AUTHENT_SAISIE_lblProcedure1"]')

    def set_browser_form(self):
        form = self.get_form(name='main')
        form['__EVENTTARGET'] = 'MM$ANR_WS_AUTHENT$m_WizardBar$m_lnkNext$m_lnkButton'
        self.browser.recipient_form = dict((k, v) for k, v in form.items())
        self.browser.recipient_form['url'] = form.url

    def get_prompt_text(self):
        return CleanText(u'////span[@id="MM_ANR_WS_AUTHENT_ANR_WS_AUTHENT_SAISIE_lblProcedure1"]')(self.doc)


class ProAddRecipientPage(RecipientPage):
    EVENTTARGET = 'MM$WIZARD_AJOUT_COMPTE_TIERS'
    FORM_FIELD_ADD = 'MM$WIZARD_AJOUT_COMPTE_TIERS$COMPTES_TIERS_ADD'

    def is_here(self):
        return CleanText('//span[@id="MM_m_CH_lblTitle" and contains(text(), "Ajoutez un compte tiers")] |\
                          //span[@id="MM_m_CH_lblTitle" and contains(text(), "Confirmez votre ajout")]')(self.doc)


1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312
class TransactionsDetailsPage(LoggedPage, HTMLPage):

    def is_here(self):
        return bool(CleanText(u'//h2[contains(text(), "Débits différés imputés")] | //span[@id="MM_m_CH_lblTitle" and contains(text(), "Débit différé imputé")]')(self.doc))

    @pagination
    @method
    class get_detail(TableElement):
        item_xpath = '//table[@id="MM_ECRITURE_GLOBALE_m_ExDGEcriture"]/tr[not(@class)] | //table[has-class("small special")]//tbody/tr[@class="rowClick"]'
        head_xpath = '//table[@id="MM_ECRITURE_GLOBALE_m_ExDGEcriture"]/tr[@class="DataGridHeader"]/td | //table[has-class("small special")]//thead/tr/th'

        col_date = u'Date'
        col_label = [u'Opération', u'Libellé']
        col_debit = u'Débit'
        col_credit = u'Crédit'

        def next_page(self):
            # only for new website, don't have any accounts with enough deferred card transactions on old webiste
            if self.page.doc.xpath('//a[contains(@id, "lnkSuivante") and not(contains(@disabled,"disabled"))]'):
                form = self.page.get_form(name='main')
                form['__EVENTTARGET'] = "MM$ECRITURE_GLOBALE$lnkSuivante"
                form['__EVENTARGUMENT'] = ''