[IMP] payment_acquirer: improvements:
[odoo/odoo.git] / addons / payment_acquirer / models / payment_acquirer.py
1 # -*- coding: utf-'8' "-*-"
2
3 import logging
4
5 from openerp.osv import osv, fields
6
7 _logger = logging.getLogger(__name__)
8
9
10 def _partner_format_address(address1=False, address2=False):
11     return ' '.join((address1 or '', address2 or ''))
12
13
14 def _partner_split_name(partner_name):
15     return [' '.join(partner_name.split()[-1:]), ' '.join(partner_name.split()[:-1])]
16
17
18 class ValidationError(ValueError):
19     """ Used for value error when validating transaction data coming from acquirers. """
20     pass
21
22
23 class PaymentAcquirer(osv.Model):
24     """ Acquirer Model. Each specific acquirer can extend the model by adding
25     its own fields, using the acquirer_name as a prefix for the new fields.
26     Using the required_if_provider='<name>' attribute on fields it is possible
27     to have required fields that depend on a specific acquirer.
28
29     Each acquirer has a link to an ir.ui.view record that is a template of
30     a button used to display the payment form. See examples in ``payment_acquirer_ogone``
31     and ``payment_acquirer_paypal`` modules.
32
33     Methods that should be added in an acquirer-specific implementation:
34
35      - ``<name>_form_generate_values(self, cr, uid, id, reference, amount, currency,
36        partner_id=False, partner_values=None, tx_custom_values=None, context=None)``:
37        method that generates the values used to render the form button template.
38      - ``<name>_get_form_action_url(self, cr, uid, id, context=None):``: method
39        that returns the url of the button form. It is used for example in
40        ecommerce application, if you want to post some data to the acquirer.
41      - ``<name>_compute_fees(self, cr, uid, id, amount, currency_id, country_id,
42        context=None)``: computed the fees of the acquirer, using generic fields
43        defined on the acquirer model (see fields definition).
44
45     Each acquirer should also define controllers to handle communication between
46     OpenERP and the acquirer. It generally consists in return urls given to the
47     button form and that the acquirer uses to send the customer back after the
48     transaction, with transaction details given as a POST request.
49     """
50     _name = 'payment.acquirer'
51     _description = 'Payment Acquirer'
52
53     _columns = {
54         'name': fields.char('Name', required=True),
55         'company_id': fields.many2one('res.company', 'Company', required=True),
56         'message': fields.html('Message', help='Message displayed to help payment and validation'),
57         'view_template_id': fields.many2one('ir.ui.view', 'Form Button Template', required=True),
58         'env': fields.selection(
59             [('test', 'Test'), ('prod', 'Production')],
60             string='Environment'),
61         'portal_published': fields.boolean(
62             'Visible in Portal',
63             help="Make this payment acquirer available (Customer invoices, etc.)"),
64         # Fees
65         'fees_active': fields.boolean('Compute fees'),
66         'fees_dom_fixed': fields.float('Fixed domestic fees'),
67         'fees_dom_var': fields.float('Variable domestic fees (in percents)'),
68         'fees_int_fixed': fields.float('Fixed international fees'),
69         'fees_int_var': fields.float('Variable international fees (in percents)'),
70     }
71
72     _defaults = {
73         'company_id': lambda self, cr, uid, obj, ctx=None: self.pool['res.users'].browse(cr, uid, uid).company_id.id,
74         'env': 'test',
75         'portal_published': True,
76     }
77
78     def _check_required_if_provider(self, cr, uid, ids, context=None):
79         """ If the field has 'required_if_provider="<name>"' attribute, then it
80         required if record.name is <name>. """
81         for acquirer in self.browse(cr, uid, ids, context=context):
82             if any(c for c, f in self._all_columns.items() if getattr(f.column, 'required_if_provider', None) == acquirer.name and not acquirer[c]):
83                 return False
84         return True
85
86     _constraints = [
87         (_check_required_if_provider, 'Required fields not filled', ['required for this provider']),
88     ]
89
90     def get_form_action_url(self, cr, uid, id, context=None):
91         """ Returns the form action URL, for form-based acquirer implementations. """
92         acquirer = self.browse(cr, uid, id, context=context)
93         if hasattr(self, '%s_get_form_action_url' % acquirer.name):
94             return getattr(self, '%s_get_form_action_url' % acquirer.name)(cr, uid, id, context=context)
95         return False
96
97     def form_preprocess_values(self, cr, uid, id, reference, amount, currency_id, tx_id, partner_id, partner_values, tx_values, context=None):
98         """  Pre process values before giving them to the acquirer-specific render
99         methods. Those methods will receive:
100
101              - partner_values: will contain name, lang, email, zip, address, city,
102                country_id (int or False), country (browse or False), phone, reference
103              - tx_values: will contain refernece, amount, currency_id (int or False),
104                currency (browse or False), partner (browse or False)
105         """
106         acquirer = self.browse(cr, uid, id, context=context)
107
108         if tx_id:
109             tx = self.browse(cr, uid, id, context=context)
110             tx_data = {
111                 'reference': tx.reference,
112                 'amount': tx.amount,
113                 'currency_id': tx.currency_id.id,
114                 'currency': tx.currency_id,
115                 'partner': tx.partner_id,
116             }
117             partner_data = {
118                 'name': tx.partner_name,
119                 'lang': tx.partner_lang,
120                 'email': tx.partner_email,
121                 'zip': tx.partner_zip,
122                 'address': tx.partner_address,
123                 'city': tx.partner_city,
124                 'country_id': tx.partner_country_id.id,
125                 'country': tx.partner_country_id,
126                 'phone': tx.partner_phone,
127                 'reference': tx.partner_reference,
128             }
129         else:
130             if partner_id:
131                 partner = self.pool['res.partner'].browse(cr, uid, partner_id, context=context)
132                 partner_data = {
133                     'name': partner.name,
134                     'lang': partner.lang,
135                     'email': partner.email,
136                     'zip': partner.zip,
137                     'city': partner.city,
138                     'address': _partner_format_address(partner.street, partner.street2),
139                     'country_id': partner.country_id.id,
140                     'country': partner.country_id,
141                     'phone': partner.phone,
142                 }
143             else:
144                 partner, partner_data = False, {}
145             partner_data.update(partner_values)
146
147             if currency_id:
148                 currency = self.pool['res.currency'].browse(cr, uid, currency_id, context=context)
149             else:
150                 currency = self.pool['res.users'].browse(cr, uid, uid, context=context).company_id.currency_id
151             tx_data = {
152                 'reference': reference,
153                 'amount': amount,
154                 'currency_id': currency.id,
155                 'currency': currency,
156                 'partner': partner,
157             }
158
159         # update tx values
160         tx_data.update(tx_values)
161
162         # update partner values
163         if not partner_data.get('address'):
164             partner_data['address'] = _partner_format_address(partner_data.get('street', ''), partner_data.get('street2', ''))
165         if not partner_data.get('country') and partner_data.get('country_id'):
166             partner_data['country'] = self.pool['res.country'].browse(cr, uid, partner_data.get('country_id'), context=context)
167         partner_data.update({
168             'first_name': _partner_split_name(partner_data['name'])[0],
169             'last_name': _partner_split_name(partner_data['name'])[1],
170         })
171
172         # compute fees
173         fees_method_name = '%s_compute_fees' % acquirer.name
174         if hasattr(self, fees_method_name):
175             tx_data['fees'] = getattr(self, fees_method_name)(
176                 cr, uid, id, tx_data['amount'], tx_data['currency_id'],
177                 partner_data['country_id'], context=None)
178
179         return (partner_data, tx_data)
180
181     def render(self, cr, uid, id, reference, amount, currency_id, tx_id=None, partner_id=False, partner_values=None, tx_values=None, context=None):
182         """ Renders the form template of the given acquirer as a qWeb template.
183         All templates will receive:
184
185          - acquirer: the payment.acquirer browse record
186          - user: the current user browse record
187          - currency: currency browse record
188          - amount: amount of the transaction
189          - reference: reference of the transaction
190          - partner: the current partner browse record, if any (not necessarily set)
191          - partner_values: a dictionary of partner-related values
192          - tx_values: a dictionary of transaction related values that depends on
193                       the acquirer. Some specific keys should be managed in each
194                       provider, depending on the features it offers:
195
196           - 'feedback_url': feedback URL, controler that manage answer of the acquirer
197                             (without base url) -> FIXME
198           - 'return_url': URL for coming back after payment validation (wihout
199                           base url) -> FIXME
200           - 'cancel_url': URL if the client cancels the payment -> FIXME
201           - 'error_url': URL if there is an issue with the payment -> FIXME
202
203          - context: OpenERP context dictionary
204
205         :param string reference: the transaction reference
206         :param float amount: the amount the buyer has to pay
207         :param res.currency browse record currency: currency
208         :param int tx_id: id of a transaction; if set, bypasses all other given
209                           values and only render the already-stored transaction
210         :param res.partner browse record partner_id: the buyer
211         :param dict partner_values: a dictionary of values for the buyer (see above)
212         :param dict tx_custom_values: a dictionary of values for the transction
213                                       that is given to the acquirer-specific method
214                                       generating the form values
215         :param dict context: OpenERP context
216         """
217         if context is None:
218             context = {}
219         if tx_values is None:
220             tx_values = {}
221         if partner_values is None:
222             partner_values = {}
223         acquirer = self.browse(cr, uid, id, context=context)
224
225         # pre-process values
226         partner_values, tx_values = self.form_preprocess_values(
227             cr, uid, id, reference, amount, currency_id, tx_id, partner_id,
228             partner_values, tx_values, context=context)
229
230         # call <name>_form_generate_values to update the tx dict with acqurier specific values
231         cust_method_name = '%s_form_generate_values' % (acquirer.name)
232         if hasattr(self, cust_method_name):
233             method = getattr(self, cust_method_name)
234             partner_values, tx_values = method(cr, uid, id, partner_values, tx_values, context=context)
235
236         qweb_context = {
237             'tx_url': context.get('tx_url', self.get_form_action_url(cr, uid, id, context=context)),
238             'submit_class': context.get('submit_class', 'btn btn-link'),
239             'submit_txt': context.get('submit_txt'),
240             'acquirer': acquirer,
241             'user': self.pool.get("res.users").browse(cr, uid, uid, context=context),
242             'reference': tx_values['reference'],
243             'amount': tx_values['amount'],
244             'currency': tx_values['currency'],
245             'partner': tx_values.get('partner'),
246             'partner_values': partner_values,
247             'tx_values': tx_values,
248             'context': context,
249         }
250
251         # because render accepts view ids but not qweb -> need to use the xml_id
252         return self.pool['ir.ui.view'].render(cr, uid, acquirer.view_template_id.xml_id, qweb_context, engine='ir.qweb', context=context)
253
254
255 class PaymentTransaction(osv.Model):
256     """ Transaction Model. Each specific acquirer can extend the model by adding
257     its own fields.
258
259     Methods that can be added in an acquirer-specific implementation:
260
261      - ``<name>_create``: method receiving values used when creating a new
262        transaction and that returns a dictionary that will update those values.
263        This method can be used to tweak some transaction values.
264
265     Methods defined for convention, depending on your controllers:
266
267      - ``<name>_form_feedback(self, cr, uid, data, context=None)``: method that
268        handles the data coming from the acquirer after the transaction. It will
269        generally receives data posted by the acquirer after the transaction.
270     """
271     _name = 'payment.transaction'
272     _description = 'Payment Transaction'
273     _inherit = ['mail.thread']
274     _order = 'id desc'
275     _rec_name = 'reference'
276
277     _columns = {
278         'date_create': fields.datetime('Creation Date', readonly=True, required=True),
279         'date_validate': fields.datetime('Validation Date'),
280         'acquirer_id': fields.many2one(
281             'payment.acquirer', 'Acquirer',
282             required=True,
283         ),
284         'type': fields.selection(
285             [('server2server', 'Server To Server'), ('form', 'Form')],
286             string='Type', required=True),
287         'state': fields.selection(
288             [('draft', 'Draft'), ('pending', 'Pending'),
289              ('done', 'Done'), ('error', 'Error'),
290              ('cancel', 'Canceled')
291              ], 'Status', required=True,
292             track_visiblity='onchange'),
293         'state_message': fields.text('Message',
294                                      help='Field used to store error and/or validation messages for information'),
295         # payment
296         'amount': fields.float('Amount', required=True,
297                                help='Amount in cents',
298                                track_visibility='always'),
299         'fees': fields.float('Fees', help='Fees amount; set by the system because depends on the acquirer'),
300         'currency_id': fields.many2one('res.currency', 'Currency', required=True),
301         'reference': fields.char('Order Reference', required=True),
302         'acquirer_reference': fields.char('Acquirer Order Reference',
303                                           help='Reference of the TX as stored in the acquirer database'),
304         # duplicate partner / transaction data to store the values at transaction time
305         'partner_id': fields.many2one('res.partner', 'Partner'),
306         'partner_name': fields.char('Partner Name'),
307         'partner_lang': fields.char('Lang'),
308         'partner_email': fields.char('Email'),
309         'partner_zip': fields.char('Zip'),
310         'partner_address': fields.char('Address'),
311         'partner_city': fields.char('City'),
312         'partner_country_id': fields.many2one('res.country', 'Country', required=True),
313         'partner_phone': fields.char('Phone'),
314         'partner_reference': fields.char('Buyer Reference'),
315     }
316
317     _sql_constraints = [
318         ('reference_uniq', 'UNIQUE(reference)', 'The payment transaction reference must be unique!'),
319     ]
320
321     _defaults = {
322         'date_create': fields.datetime.now,
323         'type': 'form',
324         'state': 'draft',
325         'partner_lang': 'en_US',
326     }
327
328     def create(self, cr, uid, values, context=None):
329         Acquirer = self.pool['payment.acquirer']
330
331         if values.get('partner_id'):  # @TDENOTE: not sure
332             values.update(self.on_change_partner_id(cr, uid, None, values.get('partner_id'), context=context)['values'])
333
334         # call custom create method if defined (i.e. ogone_create for ogone)
335         if values.get('acquirer_id'):
336             acquirer = self.pool['payment.acquirer'].browse(cr, uid, values.get('acquirer_id'), context=context)
337
338             # compute fees
339             custom_method_name = '%s_compute_fees' % acquirer.name
340             if hasattr(Acquirer, custom_method_name):
341                 values['fees'] = getattr(Acquirer, custom_method_name)(
342                     cr, uid, acquirer.id, values.get('amount', 0.0), values.get('currency_id'), values.get('country_id'), context=None)
343
344             # custom create
345             custom_method_name = '%s_create' % acquirer.name
346             if hasattr(self, custom_method_name):
347                 values.update(getattr(self, custom_method_name)(cr, uid, values, context=context))
348
349         return super(PaymentTransaction, self).create(cr, uid, values, context=context)
350
351     def on_change_partner_id(self, cr, uid, ids, partner_id, context=None):
352         partner = None
353         if partner_id:
354             partner = self.pool['res.partner'].browse(cr, uid, partner_id, context=context)
355         return {'values': {
356             'partner_name': partner and partner.name or False,
357             'partner_lang': partner and partner.lang or 'en_US',
358             'partner_email': partner and partner.email or False,
359             'partner_zip': partner and partner.zip or False,
360             'partner_address': _partner_format_address(partner and partner.street or '', partner and partner.street2 or ''),
361             'partner_city': partner and partner.city or False,
362             'partner_country_id': partner and partner.country_id.id or False,
363             'partner_phone': partner and partner.phone or False,
364         }}
365
366     # --------------------------------------------------
367     # FORM RELATED METHODS
368     # --------------------------------------------------
369
370     def form_feedback(self, cr, uid, data, acquirer_name, context=None):
371         invalid_parameters, tx = None, None
372
373         tx_find_method_name = '_%s_form_get_tx_from_data' % acquirer_name
374         if hasattr(self, tx_find_method_name):
375             tx = getattr(self, tx_find_method_name)(cr, uid, data, context=context)
376
377         invalid_param_method_name = '_%s_form_get_invalid_parameters' % acquirer_name
378         if hasattr(self, invalid_param_method_name):
379             invalid_parameters = getattr(self, invalid_param_method_name)(cr, uid, tx, data, context=context)
380
381         if invalid_parameters:
382             _error_message = '%s: incorrect tx data:\n' % (acquirer_name)
383             for item in invalid_parameters:
384                 _error_message += '\t%s: received %s instead of %s\n' % (item[0], item[1], item[2])
385             _logger.error(_error_message)
386             return False
387
388         feedback_method_name = '_%s_form_validate' % acquirer_name
389         if hasattr(self, feedback_method_name):
390             return getattr(self, feedback_method_name)(cr, uid, tx, data, context=context)
391
392         return True
393
394     # --------------------------------------------------
395     # SERVER2SERVER RELATED METHODS
396     # --------------------------------------------------
397
398     def s2s_create(self, cr, uid, values, cc_values, context=None):
399         tx_id, tx_result = self.s2s_send(cr, uid, values, cc_values, context=context)
400         self.s2s_feedback(cr, uid, tx_id, tx_result, context=context)
401         return tx_id
402
403     def s2s_send(self, cr, uid, values, cc_values, context=None):
404         """ Create and send server-to-server transaction.
405
406         :param dict values: transaction values
407         :param dict cc_values: credit card values that are not stored into the
408                                payment.transaction object. Acquirers should
409                                handle receiving void or incorrect cc values.
410                                Should contain :
411
412                                 - holder_name
413                                 - number
414                                 - cvc
415                                 - expiry_date
416                                 - brand
417                                 - expiry_date_yy
418                                 - expiry_date_mm
419         """
420         tx_id, result = None, None
421
422         if values.get('acquirer_id'):
423             acquirer = self.pool['payment.acquirer'].browse(cr, uid, values.get('acquirer_id'), context=context)
424             custom_method_name = '_%s_s2s_send' % acquirer.name
425             if hasattr(self, custom_method_name):
426                 tx_id, result = getattr(self, custom_method_name)(cr, uid, values, cc_values, context=context)
427
428         if tx_id is None and result is None:
429             tx_id = super(PaymentTransaction, self).create(cr, uid, values, context=context)
430         return (tx_id, result)
431
432     def s2s_feedback(self, cr, uid, tx_id, data, context=None):
433         """ Handle the feedback of a server-to-server transaction. """
434         tx = self.browse(cr, uid, tx_id, context=context)
435         invalid_parameters = None
436
437         invalid_param_method_name = '_%s_s2s_get_invalid_parameters' % tx.acquirer_id.name
438         if hasattr(self, invalid_param_method_name):
439             invalid_parameters = getattr(self, invalid_param_method_name)(cr, uid, tx, data, context=context)
440
441         if invalid_parameters:
442             _error_message = '%s: incorrect tx data:\n' % (tx.acquirer_id.name)
443             for item in invalid_parameters:
444                 _error_message += '\t%s: received %s instead of %s\n' % (item[0], item[1], item[2])
445             _logger.error(_error_message)
446             return False
447
448         feedback_method_name = '_%s_s2s_validate' % tx.acquirer_id.name
449         if hasattr(self, feedback_method_name):
450             return getattr(self, feedback_method_name)(cr, uid, tx, data, context=context)
451
452         return True
453
454     def s2s_get_tx_status(self, cr, uid, tx_id, context=None):
455         """ Get the tx status. """
456         tx = self.browse(cr, uid, tx_id, context=context)
457
458         invalid_param_method_name = '_%s_s2s_get_tx_status' % tx.acquirer_id.name
459         if hasattr(self, invalid_param_method_name):
460             return getattr(self, invalid_param_method_name)(cr, uid, tx, context=context)
461
462         return True