diff options
| author | Thibault Delavallée <tde@odoo.com> | 2017-09-01 16:42:07 +0200 |
|---|---|---|
| committer | Thibault Delavallée <tde@odoo.com> | 2017-09-06 11:41:25 +0200 |
| commit | 01f69f65fe59941a75a093dd180cab836af0dbb2 (patch) | |
| tree | c402df3a3618f82375e9141f91a74ce5343396ad /addons | |
| parent | 699aa47b00a1a64315dbb2107b33977b9a90d67f (diff) | |
[IMP] account_payment: add invoice payment support in customer portal
This commit adds support of payment in the account customer portal. This
is done in the account_payment module. This way customers can now pay
invoices directly on the customer portal and have access to the status
of their invoices. It replaces the old website payment mechanism that
was only allowing to pay without generating payments and reconciliating
them.
Two routes are defined in this commit, one for form-based payments and
one for server2server-based payment. Please refer to commit 1fdb10f0ac20a15a9fb5c7e8e65a5dd80955bccd
for more details about the new payment form.
Tools methods are added to handle transaction / invoice matching, the
check of invoice and the automatic payment and reconciliation.
Diffstat (limited to 'addons')
| -rw-r--r-- | addons/account_payment/__init__.py | 1 | ||||
| -rw-r--r-- | addons/account_payment/controllers/__init__.py | 5 | ||||
| -rw-r--r-- | addons/account_payment/controllers/payment.py | 103 | ||||
| -rw-r--r-- | addons/account_payment/controllers/portal.py | 13 | ||||
| -rw-r--r-- | addons/account_payment/models/payment.py | 157 | ||||
| -rw-r--r-- | addons/account_payment/views/account_portal_templates.xml | 116 |
6 files changed, 392 insertions, 3 deletions
diff --git a/addons/account_payment/__init__.py b/addons/account_payment/__init__.py index e9917144f69..3ea0f0cac45 100644 --- a/addons/account_payment/__init__.py +++ b/addons/account_payment/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import controllers from . import models
\ No newline at end of file diff --git a/addons/account_payment/controllers/__init__.py b/addons/account_payment/controllers/__init__.py new file mode 100644 index 00000000000..f453df57491 --- /dev/null +++ b/addons/account_payment/controllers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import portal +from . import payment
\ No newline at end of file diff --git a/addons/account_payment/controllers/payment.py b/addons/account_payment/controllers/payment.py new file mode 100644 index 00000000000..ecd331d2d9c --- /dev/null +++ b/addons/account_payment/controllers/payment.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug import url_encode + +from odoo import http, _ +from odoo.http import request, route + + +class PaymentPortal(http.Controller): + + @route('/invoice/pay/<int:invoice_id>/form_tx', type='json', auth="public", website=True) + def invoice_pay_form(self, acquirer_id, invoice_id, save_token=False, access_token=None, **kwargs): + """ Json method that creates a payment.transaction, used to create a + transaction when the user clicks on 'pay now' button on the payment + form. + + :return html: form containing all values related to the acquirer to + redirect customers to the acquirer website """ + success_url = kwargs.get('success_url', '/my') + callback_method = kwargs.get('callback_method', '') + + invoice_sudo = request.env['account.invoice'].sudo().browse(invoice_id) + if not invoice_sudo: + return False + + try: + acquirer = request.env['payment.acquirer'].browse(int(acquirer_id)) + except: + return False + + token = request.env['payment.token'].sudo() # currently no support of payment tokens + tx = request.env['payment.transaction'].sudo()._check_or_create_invoice_tx( + invoice_sudo, + acquirer, + payment_token=token, + tx_type='form_save' if save_token else 'form', + add_tx_values={ + 'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', invoice_sudo._name)], limit=1).id, + 'callback_res_id': invoice_sudo.id, + 'callback_method': callback_method, + }) + + # set the transaction id into the session + request.session['portal_invoice_%s_transaction_id' % invoice_sudo.id] = tx.id + + return tx.render_invoice_button( + invoice_sudo, + success_url, + submit_txt=_('Pay & Confirm'), + render_values={ + 'type': 'form_save' if save_token else 'form', + 'alias_usage': _('If we store your payment information on our server, subscription payments will be made automatically.'), + } + ) + + @http.route('/invoice/pay/<int:invoice_id>/s2s_token_tx', type='http', auth='public', website=True) + def invoice_pay_token(self, invoice_id, pm_id=None, **kwargs): + """ Use a token to perform a s2s transaction """ + error_url = kwargs.get('error_url', '/my') + success_url = kwargs.get('success_url', '/my') + callback_method = kwargs.get('callback_method', '') + access_token = kwargs.get('access_token') + params = {} + if access_token: + params['access_token'] = access_token + + invoice_sudo = request.env['account.invoice'].sudo().browse(invoice_id) + if not invoice_sudo: + params['error'] = 'pay_invoice_invalid_doc' + return request.redirect('%s?%s' % (error_url, url_encode(params))) + + try: + token = request.env['payment.token'].sudo().browse(int(pm_id)) + except (ValueError, TypeError): + token = False + if not token: + params['error'] = 'pay_invoice_invalid_token' + return request.redirect('%s?%s' % (error_url, url_encode(params))) + + # find an existing tx or create a new one + tx = request.env['payment.transaction'].sudo()._check_or_create_invoice_tx( + invoice_sudo, + token.acquirer_id, + payment_token=token, + tx_type='server2server', + add_tx_values={ + 'callback_model_id': request.env['ir.model'].sudo().search([('model', '=', invoice_sudo._name)], limit=1).id, + 'callback_res_id': invoice_sudo.id, + 'callback_method': callback_method, + }) + + # set the transaction id into the session + request.session['portal_invoice_%s_transaction_id' % invoice_sudo.id] = tx.id + + # proceed to the payment + res = tx.confirm_invoice_token() + if res is not True: + params['error'] = res + return request.redirect('%s?%s' % (error_url, url_encode(params))) + + params['success'] = 'pay_invoice' + return request.redirect('%s?%s' % (success_url, url_encode(params))) diff --git a/addons/account_payment/controllers/portal.py b/addons/account_payment/controllers/portal.py new file mode 100644 index 00000000000..3f3c804e607 --- /dev/null +++ b/addons/account_payment/controllers/portal.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.account.controllers.portal import CustomerPortal +from odoo.http import request + + +class CustomerPortal(CustomerPortal): + + def _invoice_get_page_view_values(self, invoice, access_token, **kwargs): + values = super(CustomerPortal, self)._invoice_get_page_view_values(invoice, access_token, **kwargs) + values.update(request.env['payment.acquirer']._get_available_payment_input(invoice.partner_id, invoice.company_id)) + return values diff --git a/addons/account_payment/models/payment.py b/addons/account_payment/models/payment.py index 7fa7899be93..b0dfeb691bb 100644 --- a/addons/account_payment/models/payment.py +++ b/addons/account_payment/models/payment.py @@ -1,10 +1,165 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models +import logging + +from odoo import fields, models, _ +from odoo.tools import float_compare + +_logger = logging.getLogger(__name__) class PaymentTransaction(models.Model): _inherit = 'payment.transaction' account_invoice_id = fields.Many2one('account.invoice', string='Invoice') + + def form_feedback(self, data, acquirer_name): + """ Override to confirm the invoice, if defined, and if the transaction is done. """ + tx = None + res = super(PaymentTransaction, self).form_feedback(data, acquirer_name) + + # fetch the tx + tx_find_method_name = '_%s_form_get_tx_from_data' % acquirer_name + if hasattr(self, tx_find_method_name): + tx = getattr(self, tx_find_method_name)(data) + + if tx and tx.account_invoice_id: + _logger.info( + '<%s> transaction <%s> processing form feedback for invoice <%s>: tx ref:%s, tx amount: %s', + acquirer_name, tx.id, tx.account_invoice_id.id, tx.reference, tx.amount) + tx._confirm_invoice() + + return res + + def confirm_invoice_token(self): + """ Confirm a transaction token and call SO confirmation if it is a success. + + :return: True if success; error string otherwise """ + self.ensure_one() + if self.payment_token_id and self.partner_id == self.account_invoice_id.partner_id: + try: + s2s_result = self.s2s_do_transaction() + except Exception as e: + _logger.warning( + _("<%s> transaction (%s) failed : <%s>") % + (self.acquirer_id.provider, self.id, str(e))) + return 'pay_invoice_tx_fail' + + valid_state = 'authorized' if self.acquirer_id.capture_manually else 'done' + if not s2s_result or self.state != valid_state: + _logger.warning( + _("<%s> transaction (%s) invalid state : %s") % + (self.acquirer_id.provider, self.id, self.state_mesage)) + return 'pay_invoice_tx_state' + + try: + # Auto-confirm SO if necessary + return self._confirm_invoice() + except Exception as e: + _logger.warning( + _("<%s> transaction (%s) invoice confirmation failed : <%s>") % + (self.acquirer_id.provider, self.id, str(e))) + return 'pay_invoice_tx_confirm' + return 'pay_invoice_tx_token' + + def _confirm_invoice(self): + """ Check tx state, confirm and pay potential invoice """ + self.ensure_one() + # check tx state, confirm the potential SO + if self.account_invoice_id.state != 'open': + _logger.warning('<%s> transaction STATE INCORRECT for invoice %s (ID %s, state %s)', self.acquirer_id.provider, self.account_invoice_id.number, self.account_invoice_id.id, self.account_invoice_id.state) + return 'pay_invoice_invalid_doc_state' + if not float_compare(self.amount, self.account_invoice_id.amount_total, 2) == 0: + _logger.warning('<%s> transaction AMOUNT MISMATCH for invoice %s (ID %s)', self.acquirer_id.provider, self.account_invoice_id.number, self.account_invoice_id.id) + return 'pay_invoice_tx_amount' + + if self.state == 'authorized' and self.acquirer_id.capture_manually: + _logger.info('<%s> transaction authorized, nothing to do with invoice %s (ID %s)', self.acquirer_id.provider, self.account_invoice_id.number, self.account_invoice_id.id) + elif self.state == 'done': + _logger.info('<%s> transaction completed, paying invoice %s (ID %s)', self.acquirer_id.provider, self.account_invoice_id.number, self.account_invoice_id.id) + self._pay_invoice() + else: + _logger.warning('<%s> transaction MISMATCH for invoice %s (ID %s)', self.acquirer_id.provider, self.account_invoice_id.number, self.account_invoice_id.id) + return 'pay_invoice_tx_state' + return True + + def _pay_invoice(self): + self.ensure_one() + + # force company to ensure journals/accounts etc. are correct + # company_id needed for default_get on account.journal + # force_company needed for company_dependent fields + ctx_company = {'company_id': self.account_invoice_id.company_id.id, + 'force_company': self.account_invoice_id.company_id.id} + invoice = self.account_invoice_id.with_context(**ctx_company) + + if not self.acquirer_id.journal_id: + default_journal = self.env['account.journal'].search([('type', '=', 'bank')], limit=1) + if not default_journal: + _logger.warning('<%s> transaction completed, could not auto-generate payment for invoice %s (ID %s) (no journal set on acquirer)', + self.acquirer_id.provider, self.account_invoice_id.number, self.account_invoice_id.id) + return False + self.acquirer_id.journal_id = default_journal + + invoice.pay_and_reconcile(self.acquirer_id.journal_id, pay_amount=invoice.amount_total) + invoice.payment_ids.write({'payment_transaction_id': self.ids[0]}) + _logger.info('<%s> transaction <%s> completed, reconciled invoice %s (ID %s))', + self.acquirer_id.provider, self.ids[0], invoice.number, invoice.id) + + return True + + def render_invoice_button(self, invoice, return_url, submit_txt=None, render_values=None): + values = { + 'return_url': return_url, + 'partner_id': invoice.partner_id.id, + } + if render_values: + values.update(render_values) + return self.acquirer_id.with_context(submit_class='btn btn-primary', submit_txt=submit_txt or _('Pay Now')).sudo().render( + self.reference, + invoice.amount_total, + invoice.currency_id.id, + values=values, + ) + + def _check_or_create_invoice_tx(self, invoice, acquirer, payment_token=None, tx_type='form', add_tx_values=None): + tx = self + if not tx: + tx = self.search([('reference', '=', invoice.number)], limit=1) + + if tx and tx.state in ['error', 'cancel']: # filter incorrect states + tx = False + if (tx and acquirer and tx.acquirer_id != acquirer) or (tx and tx.account_invoice_id != invoice): # filter unmatching + tx = False + if tx and tx.payment_token_id and payment_token and payment_token != tx.payment_token_id: # new or distinct token + tx = False + + # still draft tx, no more info -> create a new one + if tx and tx.state == 'draft': + tx = False + + if not tx: + tx_values = { + 'acquirer_id': acquirer.id, + 'type': tx_type, + 'amount': invoice.amount_total, + 'currency_id': invoice.currency_id.id, + 'partner_id': invoice.partner_id.id, + 'partner_country_id': invoice.partner_id.country_id.id, + 'reference': self.get_next_reference(invoice.number), + 'account_invoice_id': invoice.id, + } + if add_tx_values: + tx_values.update(add_tx_values) + if payment_token and payment_token.sudo().partner_id == invoice.partner_id: + tx_values['payment_token_id'] = payment_token.id + + tx = self.create(tx_values) + + # update invoice + invoice.write({ + 'payment_tx_id': tx.id, + }) + + return tx diff --git a/addons/account_payment/views/account_portal_templates.xml b/addons/account_payment/views/account_portal_templates.xml index 12dc68013af..86541440506 100644 --- a/addons/account_payment/views/account_portal_templates.xml +++ b/addons/account_payment/views/account_portal_templates.xml @@ -5,10 +5,122 @@ </xpath> <xpath expr="//t[@t-foreach='invoices']/tr/td[last()]" position="before"> <td> - <a t-if="invoice.state == 'open'" t-attf-href="/website_payment/pay?reference=#{invoice.number}&amount=#{invoice.residual}&currency_id=#{invoice.currency_id.id}&country_id=#{invoice.partner_id.country_id.id}" alt="Pay Now" class="btn btn-xs btn-primary"> + <a t-if="invoice.state == 'open'" t-att-href="'/my/invoices/%s#portal_pay' % invoice.id" alt="Pay Now" class="btn btn-xs btn-primary"> <i class="fa fa-arrow-circle-right"/><span class='hidden-xs'> Pay Now</span> </a> </td> </xpath> </template> -</odoo> + + <template id="portal_invoice_page_inherit_payment" name="Payment on My Invoices" inherit_id="account.portal_invoice_page"> + <xpath expr="//div[@id='o_portal_account_actions']/a" position="before"> + <t t-set="tx_ids" t-value="invoice.payment_tx_ids.filtered(lambda tx: tx.state in ('pending', 'authorized', 'done'))"/> + <a t-if="not tx_ids and invoice.state == 'open' and invoice.amount_total" + class="btn btn-primary pull-right ml8" href="#portal_pay"> + <i class="fa fa-arrow-circle-right"/> Pay Now + </a> + <a t-if="tx_ids and invoice.state != 'paid'" + class="btn btn-warning ml8 pull-right" disabled="disabled"> + <i class="fa fa-check-circle"/> Pending + </a> + <a t-if="tx_ids and invoice.state == 'paid'" + class="btn btn-success ml8 pull-right" disabled="disabled"> + <i class="fa fa-check-circle"/> Paid + </a> + </xpath> + <xpath expr="//div[hasclass('panel-body')]" position="after"> + <div class="panel-body" t-if="not tx_ids and invoice.state == 'open' and invoice.amount_total" id="portal_pay"> + <div t-if="pms or s2s_acquirers or form_acquirers" id="payment_method" class="col-md-offset-3 col-md-6"> + <h4 class="mb24">Pay with</h4> + <t t-call="payment.payment_tokens_list"> + <t t-set="mode" t-value="'payment'"/> + <t t-set="partner_id" t-value="invoice.partner_id.id"/> + <t t-set="success_url" t-value="'/my/invoices/%s' % invoice.id"/> + <t t-set="error_url" t-value="'/my/invoices/%s' % invoice.id"/> + <t t-set="access_token" t-value="access_token or ''"/> + <t t-set="callback_method" t-value="''"/> + <t t-set="form_action" t-value="'/invoice/pay/' + str(invoice.id) + '/s2s_token_tx/'"/> + <t t-set="prepare_tx_url" t-value="'/invoice/pay/' + str(invoice.id) + '/form_tx/'"/> + <t t-set="submit_txt" t-value="'Pay Now'"/> + <t t-set="icon_class" t-value="'fa-lock'"/> + </t> + </div> + </div> + </xpath> + </template> + + <template id="portal_invoice_report" name="Invoice portal page inherit payment" inherit_id="account.portal_invoice_report"> + <xpath expr="//h4" position="after"> + <t t-set="tx_ids" t-value="invoice.payment_tx_ids.filtered(lambda tx: tx.state in ('pending', 'authorized', 'done'))"/> + <t t-if="tx_ids"> + <div> + <strong>Transactions</strong> + </div> + <div> + <t t-foreach="tx_ids" t-as="tx"> + <div> + <span t-esc="tx.reference"/> + <span class="text-muted" t-field="tx.create_date"/> + <t t-if="tx.state == 'done'"> + <span class="label label-success orders_label_text_align"><i class="fa fa-fw fa-check"/> Done</span> + </t> + <t t-if="tx.state != 'done'"> + <span class="label label-info orders_label_text_align"><i class="fa fa-fw fa-clock-o"/> Waiting</span> + <t t-if="tx.state_message"><br /><span t-esc="tx.state_message"/></t> + </t> + </div> + </t> + </div> + </t> + </xpath> + </template> + + <template id="portal_invoice_error" name="Invoice error display: payment errors" + inherit_id="account.portal_invoice_error"> + <xpath expr="//t[@name='generic']" position="after"> + <t t-if="error == 'pay_invoice_invalid_doc'"> + There was an error processing your payment: invalid invoice. + </t> + <t t-if="error == 'pay_invoice_invalid_doc_state'"> + There was an error processing your payment: invalid invoice state. + </t> + <t t-if="error == 'pay_invoice_invalid_token'"> + There was en error processing your payment: invalid credit card ID. + </t> + + <t t-if="error == 'pay_invoice_tx_amount'"> + There was an error processing your payment: transaction amount issue.<br /> + <t t-if="invoice.payment_tx_id"> + <t t-esc="invoice.payment_tx_id.amount"/> / <t t-esc="invoice.amount_total"/> + </t> + </t> + <t t-if="error == 'pay_invoice_tx_fail'"> + There was an error processing your payment: transaction failed.<br /> + <t t-if="invoice.payment_tx_id and invoice.payment_tx_id.state_message"> + <t t-esc="invoice.payment_tx_id.state_message"/> + </t> + </t> + <t t-if="error == 'pay_invoice_tx_state'"> + There was an error processing your payment: transaction issue.<br /> + <t t-if="invoice.payment_tx_id and invoice.payment_tx_id.state_message"> + <t t-esc="invoice.payment_tx_id.state_message"/> + </t> + </t> + <t t-if="error == 'pay_invoice_tx_confirm'"> + There was an error processing your payment: impossible to validate invoice. + </t> + <t t-if="error == 'pay_invoice_tx_token'"> + There was an error processing your payment: issue with credit card ID validation. + </t> + </xpath> + </template> + + <template id="portal_invoice_success" name="Invoice success display: payment success" + inherit_id="account.portal_invoice_success"> + <xpath expr="//a[hasclass('close')]" position="after"> + <t t-if="success == 'pay_invoice'"> + Invoice successfully paid. + </t> + </xpath> + </template> +</odoo>
\ No newline at end of file |
