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))