diff --git a/modules/cic/__init__.py b/modules/cic/__init__.py new file mode 100644 index 00000000..1269164e --- /dev/null +++ b/modules/cic/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2010-2011 Julien Veyssier +# +# 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 . + + +from .backend import CICBackend + +__all__ = ['CICBackend'] diff --git a/modules/cic/backend.py b/modules/cic/backend.py new file mode 100644 index 00000000..d6b0393b --- /dev/null +++ b/modules/cic/backend.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2010-2011 Julien Veyssier +# +# 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 . + +from __future__ import with_statement + +from decimal import Decimal + +from weboob.capabilities.bank import ICapBank, AccountNotFound, Recipient, Account +from weboob.tools.backend import BaseBackend, BackendConfig +from weboob.tools.value import ValueBackendPassword + +from .browser import CICBrowser + + +__all__ = ['CICBackend'] + + +class CICBackend(BaseBackend, ICapBank): + NAME = 'cic' + MAINTAINER = 'Romain Bignon' + EMAIL = 'romain@weboob.org' + VERSION = '0.d' + DESCRIPTION = u'CIC French bank website' + LICENSE = 'AGPLv3+' + CONFIG = BackendConfig(ValueBackendPassword('login', label='Account ID', regexp='^\d{1,13}\w$', masked=False), + ValueBackendPassword('password', label='Password of account')) + BROWSER = CICBrowser + + def create_default_browser(self): + return self.create_browser(self.config['login'].get(), self.config['password'].get()) + + def iter_accounts(self): + for account in self.browser.get_accounts_list(): + yield account + + def get_account(self, _id): + account = self.browser.get_account(_id) + if account: + return account + else: + raise AccountNotFound() + + def iter_history(self, account): + for history in self.browser.get_history(account): + yield history + + def iter_transfer_recipients(self, ignored): + for account in self.browser.get_accounts_list().itervalues(): + recipient = Recipient() + recipient.id = account.id + recipient.label = account.label + yield recipient + + def transfer(self, account, to, amount, reason=None): + if isinstance(account, Account): + account = account.id + + try: + assert account.isdigit() + assert to.isdigit() + amount = Decimal(amount) + except (AssertionError, ValueError): + raise AccountNotFound() + + with self.browser: + return self.browser.transfer(account, to, amount, reason) diff --git a/modules/cic/browser.py b/modules/cic/browser.py new file mode 100644 index 00000000..69b2fa62 --- /dev/null +++ b/modules/cic/browser.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2010-2011 Julien Veyssier +# +# 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 . + + +from weboob.tools.browser import BaseBrowser, BrowserIncorrectPassword +from weboob.capabilities.bank import Transfer, TransferError +from datetime import datetime + +from .pages import LoginPage, LoginErrorPage, AccountsPage, UserSpacePage, \ + OperationsPage, NoOperationsPage, InfoPage, TransfertPage + +__all__ = ['CICBrowser'] + + +# Browser +class CICBrowser(BaseBrowser): + PROTOCOL = 'https' + DOMAIN = 'www.cic.fr' + ENCODING = 'iso-8859-1' + USER_AGENT = BaseBrowser.USER_AGENTS['wget'] + PAGES = {'https://www.cic.fr/.*/fr/banques/particuliers/index.html': LoginPage, + 'https://www.cic.fr/.*/fr/identification/default.cgi': LoginErrorPage, + 'https://www.cic.fr/.*/fr/banque/situation_financiere.cgi': AccountsPage, + 'https://www.cic.fr/.*/fr/banque/espace_personnel.aspx': UserSpacePage, + 'https://www.cic.fr/.*/fr/banque/mouvements.cgi.*': OperationsPage, + 'https://www.cic.fr/.*/fr/banque/nr/nr_devbooster.aspx.*': OperationsPage, + 'https://www.cic.fr/.*/fr/banque/operations_carte\.cgi.*': OperationsPage, + 'https://www.cic.fr/.*/fr/banque/CR/arrivee\.asp.*': NoOperationsPage, + 'https://www.cic.fr/.*/fr/banque/BAD.*': InfoPage, + 'https://www.cic.fr/.*/fr/banque/.*Vir.*': TransfertPage + } + + def __init__(self, *args, **kwargs): + BaseBrowser.__init__(self, *args, **kwargs) + #self.SUB_BANKS = ['cmdv','cmcee','cmse', 'cmidf', 'cmsmb', 'cmma', 'cmmabn', 'cmc', 'cmlaco', 'cmnormandie', 'cmm'] + #self.currentSubBank = None + + def is_logged(self): + return self.page and not self.is_on_page(LoginPage) and not self.is_on_page(LoginErrorPage) + + def home(self): + return self.location('https://www.cic.fr/sb/fr/banques/particuliers/index.html') + + def login(self): + assert isinstance(self.username, basestring) + assert isinstance(self.password, basestring) + + if not self.is_on_page(LoginPage): + self.location('https://www.cic.fr/', no_login=True) + + self.page.login(self.username, self.password) + + if not self.is_logged() or self.is_on_page(LoginErrorPage): + raise BrowserIncorrectPassword() + + self.SUB_BANKS = ['cmdv', 'cmcee', 'cmse', 'cmidf', 'cmsmb', 'cmma', 'cmmabn', 'cmc', 'cmlaco', 'cmnormandie', 'cmm', 'sb'] + self.getCurrentSubBank() + + def get_accounts_list(self): + if not self.is_on_page(AccountsPage): + self.location('https://www.cic.fr/%s/fr/banque/situation_financiere.cgi' % self.currentSubBank) + return self.page.get_list() + + def get_account(self, id): + assert isinstance(id, basestring) + + l = self.get_accounts_list() + for a in l: + if a.id == id: + return a + + return None + + def getCurrentSubBank(self): + # the account list and history urls depend on the sub bank of the user + current_url = self.geturl() + current_url_parts = current_url.split('/') + for subbank in self.SUB_BANKS: + if subbank in current_url_parts: + self.currentSubBank = subbank + + def get_history(self, account): + page_url = account._link_id + #operations_count = 0 + l_ret = [] + while (page_url): + if page_url.startswith('/'): + self.location(page_url) + else: + self.location('https://%s/%s/fr/banque/%s' % (self.DOMAIN, self.currentSubBank, page_url)) + + if not self.is_on_page(OperationsPage): + break + + for op in self.page.get_history(): + l_ret.append(op) + page_url = self.page.next_page_url() + + return l_ret + + def transfer(self, account, to, amount, reason=None): + # access the transfer page + transfert_url = 'WI_VPLV_VirUniSaiCpt.asp?RAZ=ALL&Cat=6&PERM=N&CHX=A' + self.location('https://%s/%s/fr/banque/%s' % (self.DOMAIN, self.currentSubBank, transfert_url)) + + # fill the form + self.select_form(name='FormVirUniSaiCpt') + self['IDB'] = [account[-1]] + self['ICR'] = [to[-1]] + self['MTTVIR'] = '%s' % str(amount).replace('.', ',') + if reason != None: + self['LIBDBT'] = reason + self['LIBCRT'] = reason + self.submit() + + # look for known errors + content = unicode(self.response().get_data(), self.ENCODING) + insufficient_amount_message = u'Montant insuffisant.' + maximum_allowed_balance_message = u'Solde maximum autorisé dépassé.' + + if content.find(insufficient_amount_message) != -1: + raise TransferError('The amount you tried to transfer is too low.') + + if content.find(maximum_allowed_balance_message) != -1: + raise TransferError('The maximum allowed balance for the target account has been / would be reached.') + + # look for the known "all right" message + ready_for_transfer_message = u'Confirmez un virement entre vos comptes' + if not content.find(ready_for_transfer_message): + raise TransferError('The expected message "%s" was not found.' % ready_for_transfer_message) + + # submit the confirmation form + self.select_form(name='FormVirUniCnf') + submit_date = datetime.now() + self.submit() + + # look for the known "everything went well" message + content = unicode(self.response().get_data(), self.ENCODING) + transfer_ok_message = u'Votre virement a été exécuté ce jour' + if not content.find(transfer_ok_message): + raise TransferError('The expected message "%s" was not found.' % transfer_ok_message) + + # We now have to return a Transfer object + transfer = Transfer(submit_date.strftime('%Y%m%d%H%M%S')) + transfer.amount = amount + transfer.origin = account + transfer.recipient = to + transfer.date = submit_date + return transfer + + #def get_coming_operations(self, account): + # if not self.is_on_page(AccountComing) or self.page.account.id != account.id: + # self.location('/NS_AVEEC?ch4=%s' % account._link_id) + # return self.page.get_operations() diff --git a/modules/cic/favicon.png b/modules/cic/favicon.png new file mode 100644 index 00000000..2925f407 Binary files /dev/null and b/modules/cic/favicon.png differ diff --git a/modules/cic/pages.py b/modules/cic/pages.py new file mode 100644 index 00000000..9e441ea2 --- /dev/null +++ b/modules/cic/pages.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2010-2012 Julien Veyssier +# +# 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 . + + +from urlparse import urlparse, parse_qs +from decimal import Decimal +import re + +from weboob.tools.browser import BasePage +from weboob.capabilities.bank import Account +from weboob.tools.capabilities.bank.transactions import FrenchTransaction + +class LoginPage(BasePage): + def login(self, login, passwd): + self.browser.select_form(name='ident') + self.browser['_cm_user'] = login + self.browser['_cm_pwd'] = passwd + self.browser.submit(nologin=True) + +class LoginErrorPage(BasePage): + pass + +class InfoPage(BasePage): + pass + +class TransfertPage(BasePage): + pass + +class UserSpacePage(BasePage): + pass + +class AccountsPage(BasePage): + def get_list(self): + ids = set() + + for tr in self.document.getiterator('tr'): + first_td = tr.getchildren()[0] + if (first_td.attrib.get('class', '') == 'i g' or first_td.attrib.get('class', '') == 'p g') \ + and first_td.find('a') is not None: + account = Account() + account.label = u"%s"%first_td.find('a').text.strip().lstrip(' 0123456789').title() + account._link_id = first_td.find('a').get('href', '') + if account._link_id.startswith('POR_SyntheseLst'): + continue + + url = urlparse(account._link_id) + p = parse_qs(url.query) + if not 'rib' in p: + continue + + account.id = p['rib'][0] + + if account.id in ids: + continue + + ids.add(account.id) + + s = tr.getchildren()[2].text + if s.strip() == "": + s = tr.getchildren()[1].text + balance = u'' + for c in s: + if c.isdigit() or c == '-': + balance += c + if c == ',': + balance += '.' + account.balance = Decimal(balance) + yield account + + def next_page_url(self): + """ TODO pouvoir passer à la page des comptes suivante """ + return 0 + +class Transaction(FrenchTransaction): + PATTERNS = [(re.compile('^VIR(EMENT)? (?P.*)'), FrenchTransaction.TYPE_TRANSFER), + (re.compile('^PRLV (?P.*)'), FrenchTransaction.TYPE_ORDER), + (re.compile('^(?P.*) CARTE \d+ PAIEMENT CB (?P
\d{2})(?P\d{2}) ?(.*)$'), + FrenchTransaction.TYPE_CARD), + (re.compile('^RETRAIT DAB (?P
\d{2})(?P\d{2}) (?P.*) CARTE \d+'), + FrenchTransaction.TYPE_WITHDRAWAL), + (re.compile('^CHEQUE$'), FrenchTransaction.TYPE_CHECK), + (re.compile('^COTIS\.? (?P.*)'), FrenchTransaction.TYPE_BANK), + (re.compile('^REMISE (?P.*)'), FrenchTransaction.TYPE_DEPOSIT), + ] + + +class OperationsPage(BasePage): + def get_history(self): + index = 0 + for tr in self.document.getiterator('tr'): + # columns can be: + # - date | value | operation | debit | credit | contre-valeur + # - date | value | operation | debit | credit + # - date | operation | debit | credit + # That's why we skip any extra columns, and take operation, debit + # and credit from last instead of first indexes. + tds = tr.getchildren()[:5] + if len(tds) < 4: + continue + + if tds[0].attrib.get('class', '') == 'i g' or \ + tds[0].attrib.get('class', '') == 'p g' or \ + tds[0].attrib.get('class', '').endswith('_c1 c _c1'): + operation = Transaction(index) + index += 1 + + # Find different parts of label + parts = [] + if len(tds[-3].findall('a')) > 0: + parts = [a.text.strip() for a in tds[-3].findall('a')] + else: + parts.append(tds[-3].text.strip()) + if tds[-3].find('br') is not None: + parts.append(tds[-3].find('br').tail.strip()) + + # To simplify categorization of CB, reverse order of parts to separate + # location and institution. + if parts[0].startswith('PAIEMENT CB'): + parts.reverse() + + operation.parse(date=tds[0].text, + raw=u' '.join(parts)) + + if tds[-1].text is not None and len(tds[-1].text) > 2: + s = tds[-1].text.strip() + elif tds[-1].text is not None and len(tds[-2].text) > 2: + s = tds[-2].text.strip() + else: + s = "0" + balance = u'' + for c in s: + if c.isdigit() or c == "-": + balance += c + if c == ',': + balance += '.' + operation.amount = Decimal(balance) + yield operation + + def next_page_url(self): + """ TODO pouvoir passer à la page des opérations suivantes """ + return 0 + +class NoOperationsPage(OperationsPage): + def get_history(self): + return iter([]) diff --git a/modules/cic/test.py b/modules/cic/test.py new file mode 100644 index 00000000..2391716b --- /dev/null +++ b/modules/cic/test.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2010-2011 Julien Veyssier +# +# 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 . + + +from weboob.tools.test import BackendTest + +class CICTest(BackendTest): + BACKEND = 'cic' + + def test_cic(self): + l = list(self.backend.iter_accounts()) + if len(l) > 0: + a = l[0] + list(self.backend.iter_history(a))