[FIX] payment_ogone: last commit
[odoo/odoo.git] / addons / payment_ogone / models / ogone.py
1 # -*- coding: utf-'8' "-*-"
2
3 from hashlib import sha1
4 import logging
5 from lxml import etree, objectify
6 from pprint import pformat
7 import time
8 from urllib import urlencode
9 import urllib2
10 import urlparse
11
12 from openerp.addons.payment.models.payment_acquirer import ValidationError
13 from openerp.addons.payment_ogone.controllers.main import OgoneController
14 from openerp.addons.payment_ogone.data import ogone
15 from openerp.osv import osv, fields
16 from openerp.tools import float_round
17 from openerp.tools.float_utils import float_compare
18
19 _logger = logging.getLogger(__name__)
20
21
22 class PaymentAcquirerOgone(osv.Model):
23     _inherit = 'payment.acquirer'
24
25     def _get_ogone_urls(self, cr, uid, environment, context=None):
26         """ Ogone URLS:
27
28          - standard order: POST address for form-based
29
30         @TDETODO: complete me
31         """
32         return {
33             'ogone_standard_order_url': 'https://secure.ogone.com/ncol/%s/orderstandard_utf8.asp' % (environment,),
34             'ogone_direct_order_url': 'https://secure.ogone.com/ncol/%s/orderdirect_utf8.asp' % (environment,),
35             'ogone_direct_query_url': 'https://secure.ogone.com/ncol/%s/querydirect_utf8.asp' % (environment,),
36             'ogone_afu_agree_url': 'https://secure.ogone.com/ncol/%s/AFU_agree.asp' % (environment,),
37         }
38
39     def _get_providers(self, cr, uid, context=None):
40         providers = super(PaymentAcquirerOgone, self)._get_providers(cr, uid, context=context)
41         providers.append(['ogone', 'Ogone'])
42         return providers
43
44     _columns = {
45         'ogone_pspid': fields.char('PSPID', required_if_provider='ogone'),
46         'ogone_userid': fields.char('API User ID', required_if_provider='ogone'),
47         'ogone_password': fields.char('API User Password', required_if_provider='ogone'),
48         'ogone_shakey_in': fields.char('SHA Key IN', size=32, required_if_provider='ogone'),
49         'ogone_shakey_out': fields.char('SHA Key OUT', size=32, required_if_provider='ogone'),
50     }
51
52     def _ogone_generate_shasign(self, acquirer, inout, values):
53         """ Generate the shasign for incoming or outgoing communications.
54
55         :param browse acquirer: the payment.acquirer browse record. It should
56                                 have a shakey in shaky out
57         :param string inout: 'in' (openerp contacting ogone) or 'out' (ogone
58                              contacting openerp). In this last case only some
59                              fields should be contained (see e-Commerce basic)
60         :param dict values: transaction values
61
62         :return string: shasign
63         """
64         assert inout in ('in', 'out')
65         assert acquirer.provider == 'ogone'
66         key = getattr(acquirer, 'ogone_shakey_' + inout)
67
68         def filter_key(key):
69             if inout == 'in':
70                 return True
71             else:
72                 # SHA-OUT keys
73                 # source https://viveum.v-psp.com/Ncol/Viveum_e-Com-BAS_EN.pdf
74                 keys = [
75                     'AAVADDRESS',
76                     'AAVCHECK',
77                     'AAVMAIL',
78                     'AAVNAME',
79                     'AAVPHONE',
80                     'AAVZIP',
81                     'ACCEPTANCE',
82                     'ALIAS',
83                     'AMOUNT',
84                     'BIC',
85                     'BIN',
86                     'BRAND',
87                     'CARDNO',
88                     'CCCTY',
89                     'CN',
90                     'COMPLUS',
91                     'CREATION_STATUS',
92                     'CURRENCY',
93                     'CVCCHECK',
94                     'DCC_COMMPERCENTAGE',
95                     'DCC_CONVAMOUNT',
96                     'DCC_CONVCCY',
97                     'DCC_EXCHRATE',
98                     'DCC_EXCHRATESOURCE',
99                     'DCC_EXCHRATETS',
100                     'DCC_INDICATOR',
101                     'DCC_MARGINPERCENTAGE',
102                     'DCC_VALIDHOURS',
103                     'DIGESTCARDNO',
104                     'ECI',
105                     'ED',
106                     'ENCCARDNO',
107                     'FXAMOUNT',
108                     'FXCURRENCY',
109                     'IBAN',
110                     'IP',
111                     'IPCTY',
112                     'NBREMAILUSAGE',
113                     'NBRIPUSAGE',
114                     'NBRIPUSAGE_ALLTX',
115                     'NBRUSAGE',
116                     'NCERROR',
117                     'NCERRORCARDNO',
118                     'NCERRORCN',
119                     'NCERRORCVC',
120                     'NCERRORED',
121                     'ORDERID',
122                     'PAYID',
123                     'PM',
124                     'SCO_CATEGORY',
125                     'SCORING',
126                     'STATUS',
127                     'SUBBRAND',
128                     'SUBSCRIPTION_ID',
129                     'TRXDATE',
130                     'VC'
131                 ]
132                 return key.upper() in keys
133
134         items = sorted((k.upper(), v) for k, v in values.items())
135         sign = ''.join('%s=%s%s' % (k, v, key) for k, v in items if v and filter_key(k))
136         sign = sign.encode("utf-8")
137         shasign = sha1(sign).hexdigest()
138         return shasign
139
140     def ogone_form_generate_values(self, cr, uid, id, partner_values, tx_values, context=None):
141         base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url')
142         acquirer = self.browse(cr, uid, id, context=context)
143
144         ogone_tx_values = dict(tx_values)
145         temp_ogone_tx_values = {
146             'PSPID': acquirer.ogone_pspid,
147             'ORDERID': tx_values['reference'],
148             'AMOUNT': '%d' % int(float_round(tx_values['amount'], 2) * 100),
149             'CURRENCY': tx_values['currency'] and tx_values['currency'].name or '',
150             'LANGUAGE':  partner_values['lang'],
151             'CN':  partner_values['name'],
152             'EMAIL':  partner_values['email'],
153             'OWNERZIP':  partner_values['zip'],
154             'OWNERADDRESS':  partner_values['address'],
155             'OWNERTOWN':  partner_values['city'],
156             'OWNERCTY':  partner_values['country'] and partner_values['country'].name or '',
157             'OWNERTELNO': partner_values['phone'],
158             'ACCEPTURL': '%s' % urlparse.urljoin(base_url, OgoneController._accept_url),
159             'DECLINEURL': '%s' % urlparse.urljoin(base_url, OgoneController._decline_url),
160             'EXCEPTIONURL': '%s' % urlparse.urljoin(base_url, OgoneController._exception_url),
161             'CANCELURL': '%s' % urlparse.urljoin(base_url, OgoneController._cancel_url),
162         }
163         if ogone_tx_values.get('return_url'):
164             temp_ogone_tx_values['PARAMPLUS'] = 'return_url=%s' % ogone_tx_values.pop('return_url')
165         shasign = self._ogone_generate_shasign(acquirer, 'in', temp_ogone_tx_values)
166         temp_ogone_tx_values['SHASIGN'] = shasign
167         ogone_tx_values.update(temp_ogone_tx_values)
168         return partner_values, ogone_tx_values
169
170     def ogone_get_form_action_url(self, cr, uid, id, context=None):
171         acquirer = self.browse(cr, uid, id, context=context)
172         return self._get_ogone_urls(cr, uid, acquirer.environment, context=context)['ogone_standard_order_url']
173
174
175 class PaymentTxOgone(osv.Model):
176     _inherit = 'payment.transaction'
177     # ogone status
178     _ogone_valid_tx_status = [5, 9]
179     _ogone_wait_tx_status = [41, 50, 51, 52, 55, 56, 91, 92, 99]
180     _ogone_pending_tx_status = [46]   # 3DS HTML response
181     _ogone_cancel_tx_status = [1]
182
183     _columns = {
184         'ogone_3ds': fields.boolean('3DS Activated'),
185         'ogone_3ds_html': fields.html('3DS HTML'),
186         'ogone_complus': fields.char('Complus'),
187         'ogone_payid': fields.char('PayID', help='Payment ID, generated by Ogone')
188     }
189
190     # --------------------------------------------------
191     # FORM RELATED METHODS
192     # --------------------------------------------------
193
194     def _ogone_form_get_tx_from_data(self, cr, uid, data, context=None):
195         """ Given a data dict coming from ogone, verify it and find the related
196         transaction record. """
197         reference, pay_id, shasign = data.get('orderID'), data.get('PAYID'), data.get('SHASIGN')
198         if not reference or not pay_id or not shasign:
199             error_msg = 'Ogone: received data with missing reference (%s) or pay_id (%s) or shashign (%s)' % (reference, pay_id, shasign)
200             _logger.error(error_msg)
201             raise ValidationError(error_msg)
202
203         # find tx -> @TDENOTE use paytid ?
204         tx_ids = self.search(cr, uid, [('reference', '=', reference)], context=context)
205         if not tx_ids or len(tx_ids) > 1:
206             error_msg = 'Ogone: received data for reference %s' % (reference)
207             if not tx_ids:
208                 error_msg += '; no order found'
209             else:
210                 error_msg += '; multiple order found'
211             _logger.error(error_msg)
212             raise ValidationError(error_msg)
213         tx = self.pool['payment.transaction'].browse(cr, uid, tx_ids[0], context=context)
214
215         # verify shasign
216         shasign_check = self.pool['payment.acquirer']._ogone_generate_shasign(tx.acquirer_id, 'out', data)
217         if shasign_check.upper() != shasign.upper():
218             error_msg = 'Ogone: invalid shasign, received %s, computed %s, for data %s' % (shasign, shasign_check, data)
219             _logger.error(error_msg)
220             raise ValidationError(error_msg)
221
222         return tx
223
224     def _ogone_form_get_invalid_parameters(self, cr, uid, tx, data, context=None):
225         invalid_parameters = []
226
227         # TODO: txn_id: shoudl be false at draft, set afterwards, and verified with txn details
228         if tx.acquirer_reference and data.get('PAYID') != tx.acquirer_reference:
229             invalid_parameters.append(('PAYID', data.get('PAYID'), tx.acquirer_reference))
230         # check what is buyed
231         if float_compare(float(data.get('amount', '0.0')), tx.amount, 2) != 0:
232             invalid_parameters.append(('amount', data.get('amount'), '%.2f' % tx.amount))
233         if data.get('currency') != tx.currency_id.name:
234             invalid_parameters.append(('currency', data.get('currency'), tx.currency_id.name))
235
236         return invalid_parameters
237
238     def _ogone_form_validate(self, cr, uid, tx, data, context=None):
239         if tx.state == 'done':
240             _logger.warning('Ogone: trying to validate an already validated tx (ref %s)' % tx.reference)
241             return True
242
243         status = int(data.get('STATUS', '0'))
244         if status in self._ogone_valid_tx_status:
245             tx.write({
246                 'state': 'done',
247                 'date_validate': data['TRXDATE'],
248                 'acquirer_reference': data['PAYID'],
249             })
250             return True
251         elif status in self._ogone_cancel_tx_status:
252             tx.write({
253                 'state': 'cancel',
254                 'acquirer_reference': data.get('PAYID'),
255             })
256         elif status in self._ogone_pending_tx_status:
257             tx.write({
258                 'state': 'pending',
259                 'acquirer_reference': data.get('PAYID'),
260             })
261         else:
262             error = 'Ogone: feedback error: %(error_str)s\n\n%(error_code)s: %(error_msg)s' % {
263                 'error_str': data.get('NCERROR'),
264                 'error_code': data.get('NCERRORPLUS'),
265                 'error_msg': ogone.OGONE_ERROR_MAP.get(data.get('NCERRORPLUS')),
266             }
267             _logger.info(error)
268             tx.write({
269                 'state': 'error',
270                 'state_message': error,
271                 'acquirer_reference': data.get('PAYID'),
272             })
273             return False
274
275     # --------------------------------------------------
276     # S2S RELATED METHODS
277     # --------------------------------------------------
278
279     def ogone_s2s_create_alias(self, cr, uid, id, values, context=None):
280         """ Create an alias at Ogone via batch.
281
282          .. versionadded:: pre-v8 saas-3
283          .. warning::
284
285             Experimental code. You should not use it before OpenERP v8 official
286             release.
287         """
288         tx = self.browse(cr, uid, id, context=context)
289         assert tx.type == 'server2server', 'Calling s2s dedicated method for a %s acquirer' % tx.type
290         alias = 'OPENERP-%d-%d' % (tx.partner_id.id, tx.id)
291
292         expiry_date = '%s%s' % (values['expiry_date_mm'], values['expiry_date_yy'][2:])
293         line = 'ADDALIAS;%(alias)s;%(holder_name)s;%(number)s;%(expiry_date)s;%(brand)s;%(pspid)s'
294         line = line % dict(values, alias=alias, expiry_date=expiry_date, pspid=tx.acquirer_id.ogone_pspid)
295
296         tx_data = {
297             'FILE_REFERENCE': 'OPENERP-NEW-ALIAS-%s' % time.time(),    # something unique,
298             'TRANSACTION_CODE': 'ATR',
299             'OPERATION': 'SAL',
300             'NB_PAYMENTS': 1,   # even if we do not actually have any payment, ogone want it to not be 0
301             'FILE': line,
302             'REPLY_TYPE': 'XML',
303             'PSPID': tx.acquirer_id.ogone_pspid,
304             'USERID': tx.acquirer_id.ogone_userid,
305             'PSWD': tx.acquirer_id.ogone_password,
306             'PROCESS_MODE': 'CHECKANDPROCESS',
307         }
308
309         # TODO: fix URL computation
310         request = urllib2.Request(tx.acquirer_id.ogone_afu_agree_url, urlencode(tx_data))
311         result = urllib2.urlopen(request).read()
312
313         try:
314             tree = objectify.fromstring(result)
315         except etree.XMLSyntaxError:
316             _logger.exception('Invalid xml response from ogone')
317             return None
318
319         error_code = error_str = None
320         if hasattr(tree, 'PARAMS_ERROR'):
321             error_code = tree.NCERROR.text
322             error_str = 'PARAMS ERROR: %s' % (tree.PARAMS_ERROR.text or '',)
323         else:
324             node = tree.FORMAT_CHECK
325             error_node = getattr(node, 'FORMAT_CHECK_ERROR', None)
326             if error_node is not None:
327                 error_code = error_node.NCERROR.text
328                 error_str = 'CHECK ERROR: %s' % (error_node.ERROR.text or '',)
329
330         if error_code:
331             error_msg = ogone.OGONE_ERROR_MAP.get(error_code)
332             error = '%s\n\n%s: %s' % (error_str, error_code, error_msg)
333             _logger.error(error)
334             raise Exception(error)      # TODO specific exception
335
336         tx.write({'partner_reference': alias})
337         return True
338
339     def ogone_s2s_generate_values(self, cr, uid, id, custom_values, context=None):
340         """ Generate valid Ogone values for a s2s tx.
341
342          .. versionadded:: pre-v8 saas-3
343          .. warning::
344
345             Experimental code. You should not use it before OpenERP v8 official
346             release.
347         """
348         tx = self.browse(cr, uid, id, context=context)
349         tx_data = {
350             'PSPID': tx.acquirer_id.ogone_pspid,
351             'USERID': tx.acquirer_id.ogone_userid,
352             'PSWD': tx.acquirer_id.ogone_password,
353             'OrderID': tx.reference,
354             'amount':  '%d' % int(float_round(tx.amount, 2) * 100),  # tde check amount or str * 100 ?
355             'CURRENCY': tx.currency_id.name,
356             'LANGUAGE': tx.partner_lang,
357             'OPERATION': 'SAL',
358             'ECI': 2,   # Recurring (from MOTO)
359             'ALIAS': tx.partner_reference,
360             'RTIMEOUT': 30,
361         }
362         if custom_values.get('ogone_cvc'):
363             tx_data['CVC'] = custom_values.get('ogone_cvc')
364         if custom_values.pop('ogone_3ds', None):
365             tx_data.update({
366                 'FLAG3D': 'Y',   # YEAH!!
367             })
368             if custom_values.get('ogone_complus'):
369                 tx_data['COMPLUS'] = custom_values.get('ogone_complus')
370             if custom_values.get('ogone_accept_url'):
371                 pass
372
373         shasign = self.pool['payment.acquirer']._ogone_generate_shasign(tx.acquirer_id, 'in', tx_data)
374         tx_data['SHASIGN'] = shasign
375         return tx_data
376
377     def ogone_s2s_feedback(self, cr, uid, data, context=None):
378         """
379          .. versionadded:: pre-v8 saas-3
380          .. warning::
381
382             Experimental code. You should not use it before OpenERP v8 official
383             release.
384         """
385         pass
386
387     def ogone_s2s_execute(self, cr, uid, id, values, context=None):
388         """
389          .. versionadded:: pre-v8 saas-3
390          .. warning::
391
392             Experimental code. You should not use it before OpenERP v8 official
393             release.
394         """
395         tx = self.browse(cr, uid, id, context=context)
396
397         tx_data = self.ogone_s2s_generate_values(cr, uid, id, values, context=context)
398         _logger.info('Generated Ogone s2s data %s', pformat(tx_data))  # debug
399
400         request = urllib2.Request(tx.acquirer_id.ogone_direct_order_url, urlencode(tx_data))
401         result = urllib2.urlopen(request).read()
402         _logger.info('Contacted Ogone direct order; result %s', result)  # debug
403
404         tree = objectify.fromstring(result)
405         payid = tree.get('PAYID')
406
407         query_direct_data = dict(
408             PSPID=tx.acquirer_id.ogone_pspid,
409             USERID=tx.acquirer_id.ogone_userid,
410             PSWD=tx.acquirer_id.ogone_password,
411             ID=payid,
412         )
413         query_direct_url = 'https://secure.ogone.com/ncol/%s/querydirect.asp' % (tx.acquirer_id.environment,)
414
415         tries = 2
416         tx_done = False
417         tx_status = False
418         while not tx_done or tries > 0:
419             try:
420                 tree = objectify.fromstring(result)
421             except etree.XMLSyntaxError:
422                 # invalid response from ogone
423                 _logger.exception('Invalid xml response from ogone')
424                 raise
425
426             # see https://secure.ogone.com/ncol/paymentinfos1.asp
427             VALID_TX = [5, 9]
428             WAIT_TX = [41, 50, 51, 52, 55, 56, 91, 92, 99]
429             PENDING_TX = [46]   # 3DS HTML response
430             # other status are errors...
431
432             status = tree.get('STATUS')
433             if status == '':
434                 status = None
435             else:
436                 status = int(status)
437
438             if status in VALID_TX:
439                 tx_status = True
440                 tx_done = True
441
442             elif status in PENDING_TX:
443                 html = str(tree.HTML_ANSWER)
444                 tx_data.update(ogone_3ds_html=html.decode('base64'))
445                 tx_status = False
446                 tx_done = True
447
448             elif status in WAIT_TX:
449                 time.sleep(1500)
450
451                 request = urllib2.Request(query_direct_url, urlencode(query_direct_data))
452                 result = urllib2.urlopen(request).read()
453                 _logger.debug('Contacted Ogone query direct; result %s', result)
454
455             else:
456                 error_code = tree.get('NCERROR')
457                 if not ogone.retryable(error_code):
458                     error_str = tree.get('NCERRORPLUS')
459                     error_msg = ogone.OGONE_ERROR_MAP.get(error_code)
460                     error = 'ERROR: %s\n\n%s: %s' % (error_str, error_code, error_msg)
461                     _logger.info(error)
462                     raise Exception(error)
463
464             tries = tries - 1
465
466         if not tx_done and tries == 0:
467             raise Exception('Cannot get transaction status...')
468
469         return tx_status