From 7b165cd5550893658b6f39112f2b00747919aa64 Mon Sep 17 00:00:00 2001 From: Adrien Horgnies Date: Fri, 26 Mar 2021 18:30:13 +0100 Subject: [PATCH] [REF] payment_paypal: migrate PayPal to the new payment API See the merge commit for more details. task-2333036 Co-authored-by: Antoine Vandevenne Co-authored-by: Victor Feyens --- addons/payment_paypal/__init__.py | 11 +- addons/payment_paypal/__manifest__.py | 7 +- addons/payment_paypal/controllers/__init__.py | 2 +- addons/payment_paypal/controllers/main.py | 169 ++++++------ .../data/payment_acquirer_data.xml | 18 +- .../data/payment_paypal_email_data.xml | 7 +- addons/payment_paypal/models/__init__.py | 5 +- addons/payment_paypal/models/payment.py | 240 ------------------ .../payment_paypal/models/payment_acquirer.py | 54 ++++ .../models/payment_transaction.py | 125 +++++++++ addons/payment_paypal/tests/__init__.py | 6 +- addons/payment_paypal/tests/common.py | 18 ++ addons/payment_paypal/tests/test_paypal.py | 196 +++++--------- .../views/payment_paypal_templates.xml | 67 +++-- addons/payment_paypal/views/payment_views.xml | 66 +++-- 15 files changed, 434 insertions(+), 557 deletions(-) delete mode 100644 addons/payment_paypal/models/payment.py create mode 100644 addons/payment_paypal/models/payment_acquirer.py create mode 100644 addons/payment_paypal/models/payment_transaction.py create mode 100644 addons/payment_paypal/tests/common.py diff --git a/addons/payment_paypal/__init__.py b/addons/payment_paypal/__init__.py index 5af06123e12..27fdcd688d3 100644 --- a/addons/payment_paypal/__init__.py +++ b/addons/payment_paypal/__init__.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import models from . import controllers -from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers -from odoo.addons.payment import reset_payment_provider +from . import models + +from odoo.addons.payment import reset_payment_acquirer +from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook + def uninstall_hook(cr, registry): - reset_payment_provider(cr, registry, 'paypal') + reset_payment_acquirer(cr, registry, 'paypal') diff --git a/addons/payment_paypal/__manifest__.py b/addons/payment_paypal/__manifest__.py index bfbebc982c9..0935c755827 100644 --- a/addons/payment_paypal/__manifest__.py +++ b/addons/payment_paypal/__manifest__.py @@ -1,11 +1,11 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'Paypal Payment Acquirer', + 'version': '2.0', 'category': 'Accounting/Payment Acquirers', 'sequence': 365, 'summary': 'Payment Acquirer: Paypal Implementation', - 'version': '1.0', 'description': """Paypal Payment Acquirer""", 'depends': ['payment'], 'data': [ @@ -14,8 +14,7 @@ 'data/payment_acquirer_data.xml', 'data/payment_paypal_email_data.xml', ], - 'installable': True, 'application': True, - 'post_init_hook': 'create_missing_journal_for_acquirers', + 'post_init_hook': 'create_missing_journals', 'uninstall_hook': 'uninstall_hook', } diff --git a/addons/payment_paypal/controllers/__init__.py b/addons/payment_paypal/controllers/__init__.py index 65a8c12013d..80ee4da1c5e 100644 --- a/addons/payment_paypal/controllers/__init__.py +++ b/addons/payment_paypal/controllers/__init__.py @@ -1,3 +1,3 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/payment_paypal/controllers/main.py b/addons/payment_paypal/controllers/main.py index 29a199ec1eb..61aab19f3e1 100644 --- a/addons/payment_paypal/controllers/main.py +++ b/addons/payment_paypal/controllers/main.py @@ -1,119 +1,94 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. -import json import logging import pprint import requests import werkzeug -from werkzeug import urls -from odoo import http -from odoo.addons.payment.models.payment_acquirer import ValidationError +from odoo import _, http +from odoo.exceptions import ValidationError from odoo.http import request _logger = logging.getLogger(__name__) class PaypalController(http.Controller): - _notify_url = '/payment/paypal/ipn/' _return_url = '/payment/paypal/dpn/' - _cancel_url = '/payment/paypal/cancel/' + _notify_url = '/payment/paypal/ipn/' - def _parse_pdt_response(self, response): - """ Parse a text response for a PDT verification. + @http.route(_return_url, type='http', auth='public', methods=['POST'], csrf=False) + def paypal_dpn(self, **data): + """ Route used by the PDT notification. - :param str response: text response, structured in the following way: - STATUS\nkey1=value1\nkey2=value2...\n - or STATUS\nError message...\n - :rtype tuple(str, dict) - :return: tuple containing the STATUS str and the key/value pairs - parsed as a dict + The "PDT notification" is actually POST data sent along the user redirection. """ - lines = [line for line in response.split('\n') if line] - status = lines.pop(0) + _logger.info("beginning DPN with post data:\n%s", pprint.pformat(data)) + self._validate_data_authenticity(**data) + request.env['payment.transaction']._handle_feedback_data('paypal', data) + return werkzeug.utils.redirect('/payment/status') - pdt_post = {} - for line in lines: - split = line.split('=', 1) - if len(split) == 2: - pdt_post[split[0]] = urls.url_unquote_plus(split[1]) - else: - _logger.warning('Paypal: error processing pdt response: %s', line) - - return status, pdt_post - - def paypal_validate_data(self, **post): - """ Paypal IPN: three steps validation to ensure data correctness - - - step 1: return an empty HTTP 200 response -> will be done at the end - by returning '' - - step 2: POST the complete, unaltered message back to Paypal (preceded - by cmd=_notify-validate or _notify-synch for PDT), with same encoding - - step 3: paypal send either VERIFIED or INVALID (single word) for IPN - or SUCCESS or FAIL (+ data) for PDT - - Once data is validated, process it. """ - res = False - post['cmd'] = '_notify-validate' - reference = post.get('item_number') - tx = None - if reference: - tx = request.env['payment.transaction'].sudo().search([('reference', '=', reference)]) - if not tx: - # we have seemingly received a notification for a payment that did not come from - # odoo, acknowledge it otherwise paypal will keep trying - _logger.warning('received notification for unknown payment reference') - return False - paypal_url = tx.acquirer_id.paypal_get_form_action_url() - pdt_request = bool(post.get('amt')) # check for specific pdt param - if pdt_request: - # this means we are in PDT instead of DPN like before - # fetch the PDT token - post['at'] = tx and tx.acquirer_id.paypal_pdt_token or '' - post['cmd'] = '_notify-synch' # command is different in PDT than IPN/DPN - urequest = requests.post(paypal_url, post) - urequest.raise_for_status() - resp = urequest.text - if pdt_request: - resp, post = self._parse_pdt_response(resp) - if resp in ['VERIFIED', 'SUCCESS']: - _logger.info('Paypal: validated data') - res = request.env['payment.transaction'].sudo().form_feedback(post, 'paypal') - if not res and tx: - tx._set_transaction_error('Validation error occured. Please contact your administrator.') - elif resp in ['INVALID', 'FAIL']: - _logger.warning('Paypal: answered INVALID/FAIL on data verification') - if tx: - tx._set_transaction_error('Invalid response from Paypal. Please contact your administrator.') - else: - _logger.warning('Paypal: unrecognized paypal answer, received %s instead of VERIFIED/SUCCESS or INVALID/FAIL (validation: %s)' % (resp, 'PDT' if pdt_request else 'IPN/DPN')) - if tx: - tx._set_transaction_error('Unrecognized error from Paypal. Please contact your administrator.') - return res - - @http.route('/payment/paypal/ipn/', type='http', auth='public', methods=['POST'], csrf=False) - def paypal_ipn(self, **post): - """ Paypal IPN. """ - _logger.info('Beginning Paypal IPN form_feedback with post data %s', pprint.pformat(post)) # debug + @http.route(_notify_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False) + def paypal_ipn(self, **data): + """ Route used by the IPN. """ + _logger.info("beginning IPN with post data:\n%s", pprint.pformat(data)) try: - self.paypal_validate_data(**post) - except ValidationError: - _logger.exception('Unable to validate the Paypal payment') + self._validate_data_authenticity(**data) + request.env['payment.transaction']._handle_feedback_data('paypal', data) + except ValidationError: # Acknowledge the notification to avoid getting spammed + _logger.exception("unable to handle the IPN data; skipping to acknowledge the notif") return '' - @http.route('/payment/paypal/dpn', type='http', auth="public", methods=['POST', 'GET'], csrf=False) - def paypal_dpn(self, **post): - """ Paypal DPN """ - _logger.info('Beginning Paypal DPN form_feedback with post data %s', pprint.pformat(post)) # debug - try: - res = self.paypal_validate_data(**post) - except ValidationError: - _logger.exception('Unable to validate the Paypal payment') - return werkzeug.utils.redirect('/payment/process') + def _validate_data_authenticity(self, **data): + """ Validate the authenticity of data received through DPN or IPN - @http.route('/payment/paypal/cancel', type='http', auth="public", csrf=False) - def paypal_cancel(self, **post): - """ When the user cancels its Paypal payment: GET on this route """ - _logger.info('Beginning Paypal cancel with post data %s', pprint.pformat(post)) # debug - return werkzeug.utils.redirect('/payment/process') + The verification is done in three steps: + - 1: POST the complete, unaltered, message back to Paypal (preceded by + `cmd=_notify-validate`), in the same encoding. + - 2: PayPal sends back either 'VERIFIED' or 'INVALID'. + - 3: Return an empty HTTP 200 response (done at the end of the route method). + See https://developer.paypal.com/docs/api-basics/notifications/ipn/IPNIntro + + As per https://developer.paypal.com/docs/api-basics/notifications/payment-data-transfer/, + PDT notifications should be verified in a similar but different manner: + - The transaction ID should be retrieved from the GET param `tx`. + - The POST should use `_notify-synch` (as per previous versions of this method) as `cmd`, + and only have as params the transaction ID and the PDT Identity Token (under the key + `at`, as per previous versions of this method). + - The payment data should be parsed from the response of the check request. + In practice, however, the transaction ID is never given by PayPal and the documentation + has no mention of `_notify_synch` nor `at`. Because of this, PDT cannot be verified as + prescribed by the documentation. + Nevertheless, previous versions of this method used a bad heuristic (assessing the presence + of the optional, PDT-specific, param `amt`) to determine whether the notification was a PDT. + Since PDT notifications have in practice always been successfully authenticated by using the + IPN protocol, this method does explicitly that for both PDT and IPN. + + :param dict data: The data whose authenticity to check + :return: None + :raise: ValidationError if the authenticity could not be verified + """ + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'paypal', data + ) + acquirer_sudo = tx_sudo.acquirer_id + + # Request PayPal for an authenticity check + data['cmd'] = '_notify-validate' + response = requests.post(acquirer_sudo._paypal_get_api_url(), data, timeout=60) + response.raise_for_status() + + # Inspect the response code and raise if not 'VERIFIED'. + response_code = response.text + if response_code == 'VERIFIED': + _logger.info("authenticity of notification data verified") + elif response_code == 'INVALID': + raise ValidationError("PayPal: " + _("Notification data were not acknowledged.")) + else: + raise ValidationError( + "PayPal: " + _( + "Received unrecognized authentication check response code: received %s, " + "expected VERIFIED or INVALID.", + response_code + ) + ) diff --git a/addons/payment_paypal/data/payment_acquirer_data.xml b/addons/payment_paypal/data/payment_acquirer_data.xml index b0e1d49fb40..f68b7eb2b4c 100644 --- a/addons/payment_paypal/data/payment_acquirer_data.xml +++ b/addons/payment_paypal/data/payment_acquirer_data.xml @@ -1,14 +1,12 @@ - - + - - Paypal - - paypal - - - + + paypal + + False + True + False + - diff --git a/addons/payment_paypal/data/payment_paypal_email_data.xml b/addons/payment_paypal/data/payment_paypal_email_data.xml index 2765dd28d3d..7026a97c1ca 100644 --- a/addons/payment_paypal/data/payment_paypal_email_data.xml +++ b/addons/payment_paypal/data/payment_paypal_email_data.xml @@ -1,5 +1,6 @@ + + diff --git a/addons/payment_paypal/models/__init__.py b/addons/payment_paypal/models/__init__.py index ef12533682f..8e04237423a 100644 --- a/addons/payment_paypal/models/__init__.py +++ b/addons/payment_paypal/models/__init__.py @@ -1,3 +1,4 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import payment +from . import payment_acquirer +from . import payment_transaction diff --git a/addons/payment_paypal/models/payment.py b/addons/payment_paypal/models/payment.py deleted file mode 100644 index 7cd53a4bc95..00000000000 --- a/addons/payment_paypal/models/payment.py +++ /dev/null @@ -1,240 +0,0 @@ -# coding: utf-8 - -import json -import logging - -import dateutil.parser -import pytz -from werkzeug import urls - -from odoo import api, fields, models, _ -from odoo.addons.payment.models.payment_acquirer import ValidationError -from odoo.addons.payment_paypal.controllers.main import PaypalController -from odoo.tools.float_utils import float_compare - - -_logger = logging.getLogger(__name__) - - -class AcquirerPaypal(models.Model): - _inherit = 'payment.acquirer' - - provider = fields.Selection(selection_add=[ - ('paypal', 'Paypal') - ], ondelete={'paypal': 'set default'}) - paypal_email_account = fields.Char('Email', required_if_provider='paypal', groups='base.group_user') - paypal_seller_account = fields.Char( - 'Merchant Account ID', groups='base.group_user', - help='The Merchant ID is used to ensure communications coming from Paypal are valid and secured.') - paypal_use_ipn = fields.Boolean('Use IPN', default=True, help='Paypal Instant Payment Notification', groups='base.group_user') - paypal_pdt_token = fields.Char(string='PDT Identity Token', help='Payment Data Transfer allows you to receive notification of successful payments as they are made.', groups='base.group_user') - # Default paypal fees - fees_dom_fixed = fields.Float(default=0.35) - fees_dom_var = fields.Float(default=3.4) - fees_int_fixed = fields.Float(default=0.35) - fees_int_var = fields.Float(default=3.9) - - def _get_feature_support(self): - """Get advanced feature support by provider. - - Each provider should add its technical in the corresponding - key for the following features: - * fees: support payment fees computations - * authorize: support authorizing payment (separates - authorization and capture) - * tokenize: support saving payment data in a payment.tokenize - object - """ - res = super(AcquirerPaypal, self)._get_feature_support() - res['fees'].append('paypal') - return res - - @api.model - def _get_paypal_urls(self, environment): - """ Paypal URLS """ - if environment == 'prod': - return { - 'paypal_form_url': 'https://www.paypal.com/cgi-bin/webscr', - 'paypal_rest_url': 'https://api.paypal.com/v1/oauth2/token', - } - else: - return { - 'paypal_form_url': 'https://www.sandbox.paypal.com/cgi-bin/webscr', - 'paypal_rest_url': 'https://api.sandbox.paypal.com/v1/oauth2/token', - } - - def paypal_compute_fees(self, amount, currency_id, country_id): - """ Compute paypal fees. - - :param float amount: the amount to pay - :param integer country_id: an ID of a res.country, or None. This is - the customer's country, to be compared to - the acquirer company country. - :return float fees: computed fees - """ - if not self.fees_active: - return 0.0 - country = self.env['res.country'].browse(country_id) - if country and self.company_id.sudo().country_id.id == country.id: - percentage = self.fees_dom_var - fixed = self.fees_dom_fixed - else: - percentage = self.fees_int_var - fixed = self.fees_int_fixed - fees = (percentage / 100.0 * amount + fixed) / (1 - percentage / 100.0) - return fees - - def paypal_form_generate_values(self, values): - base_url = self.get_base_url() - - paypal_tx_values = dict(values) - paypal_tx_values.update({ - 'cmd': '_xclick', - 'business': self.paypal_email_account, - 'item_name': '%s: %s' % (self.company_id.name, values['reference']), - 'item_number': values['reference'], - 'amount': values['amount'], - 'currency_code': values['currency'] and values['currency'].name or '', - 'address1': values.get('partner_address'), - 'city': values.get('partner_city'), - 'country': values.get('partner_country') and values.get('partner_country').code or '', - 'state': values.get('partner_state') and (values.get('partner_state').code or values.get('partner_state').name) or '', - 'email': values.get('partner_email'), - 'zip_code': values.get('partner_zip'), - 'first_name': values.get('partner_first_name'), - 'last_name': values.get('partner_last_name'), - 'paypal_return': urls.url_join(base_url, PaypalController._return_url), - 'notify_url': urls.url_join(base_url, PaypalController._notify_url), - 'cancel_return': urls.url_join(base_url, PaypalController._cancel_url), - 'handling': '%.2f' % paypal_tx_values.pop('fees', 0.0) if self.fees_active else False, - 'custom': json.dumps({'return_url': '%s' % paypal_tx_values.pop('return_url')}) if paypal_tx_values.get('return_url') else False, - }) - return paypal_tx_values - - def paypal_get_form_action_url(self): - self.ensure_one() - environment = 'prod' if self.state == 'enabled' else 'test' - return self._get_paypal_urls(environment)['paypal_form_url'] - - -class TxPaypal(models.Model): - _inherit = 'payment.transaction' - - paypal_txn_type = fields.Char('Transaction type') - - # -------------------------------------------------- - # FORM RELATED METHODS - # -------------------------------------------------- - - @api.model - def _paypal_form_get_tx_from_data(self, data): - reference, txn_id = data.get('item_number'), data.get('txn_id') - if not reference or not txn_id: - error_msg = _('Paypal: received data with missing reference (%s) or txn_id (%s)') % (reference, txn_id) - _logger.info(error_msg) - raise ValidationError(error_msg) - - # find tx -> @TDENOTE use txn_id ? - txs = self.env['payment.transaction'].search([('reference', '=', reference)]) - if not txs or len(txs) > 1: - error_msg = 'Paypal: received data for reference %s' % (reference) - if not txs: - error_msg += '; no order found' - else: - error_msg += '; multiple order found' - _logger.info(error_msg) - raise ValidationError(error_msg) - return txs[0] - - def _paypal_form_get_invalid_parameters(self, data): - invalid_parameters = [] - _logger.info('Received a notification from Paypal with IPN version %s', data.get('notify_version')) - if data.get('test_ipn'): - _logger.warning( - 'Received a notification from Paypal using sandbox' - ), - - # TODO: txn_id: shoudl be false at draft, set afterwards, and verified with txn details - if self.acquirer_reference and data.get('txn_id') != self.acquirer_reference: - invalid_parameters.append(('txn_id', data.get('txn_id'), self.acquirer_reference)) - # check what is buyed - if float_compare(float(data.get('mc_gross', '0.0')), (self.amount + self.fees), 2) != 0: - invalid_parameters.append(('mc_gross', data.get('mc_gross'), '%.2f' % (self.amount + self.fees))) # mc_gross is amount + fees - if data.get('mc_currency') != self.currency_id.name: - invalid_parameters.append(('mc_currency', data.get('mc_currency'), self.currency_id.name)) - if 'handling_amount' in data and float_compare(float(data.get('handling_amount')), self.fees, 2) != 0: - invalid_parameters.append(('handling_amount', data.get('handling_amount'), self.fees)) - # check buyer - if self.payment_token_id and data.get('payer_id') != self.payment_token_id.acquirer_ref: - invalid_parameters.append(('payer_id', data.get('payer_id'), self.payment_token_id.acquirer_ref)) - # check seller - if data.get('receiver_id') and self.acquirer_id.paypal_seller_account and data['receiver_id'] != self.acquirer_id.paypal_seller_account: - invalid_parameters.append(('receiver_id', data.get('receiver_id'), self.acquirer_id.paypal_seller_account)) - if not data.get('receiver_id') or not self.acquirer_id.paypal_seller_account: - # Check receiver_email only if receiver_id was not checked. - # In Paypal, this is possible to configure as receiver_email a different email than the business email (the login email) - # In Odoo, there is only one field for the Paypal email: the business email. This isn't possible to set a receiver_email - # different than the business email. Therefore, if you want such a configuration in your Paypal, you are then obliged to fill - # the Merchant ID in the Paypal payment acquirer in Odoo, so the check is performed on this variable instead of the receiver_email. - # At least one of the two checks must be done, to avoid fraudsters. - if data.get('receiver_email') and data.get('receiver_email') != self.acquirer_id.paypal_email_account: - invalid_parameters.append(('receiver_email', data.get('receiver_email'), self.acquirer_id.paypal_email_account)) - if data.get('business') and data.get('business') != self.acquirer_id.paypal_email_account: - invalid_parameters.append(('business', data.get('business'), self.acquirer_id.paypal_email_account)) - - return invalid_parameters - - def _paypal_form_validate(self, data): - status = data.get('payment_status') - former_tx_state = self.state - res = { - 'acquirer_reference': data.get('txn_id'), - 'paypal_txn_type': data.get('payment_type'), - } - if not self.acquirer_id.paypal_pdt_token and not self.acquirer_id.paypal_seller_account and status in ['Completed', 'Processed', 'Pending']: - template = self.env.ref('payment_paypal.mail_template_paypal_invite_user_to_configure', False) - if template: - render_template = template._render({ - 'acquirer': self.acquirer_id, - }, engine='ir.qweb') - mail_body = self.env['mail.render.mixin']._replace_local_links(render_template) - mail_values = { - 'body_html': mail_body, - 'subject': _('Add your Paypal account to Odoo'), - 'email_to': self.acquirer_id.paypal_email_account, - 'email_from': self.acquirer_id.create_uid.email_formatted, - 'author_id': self.acquirer_id.create_uid.partner_id.id, - } - self.env['mail.mail'].sudo().create(mail_values).send() - - if status in ['Completed', 'Processed']: - try: - # dateutil and pytz don't recognize abbreviations PDT/PST - tzinfos = { - 'PST': -8 * 3600, - 'PDT': -7 * 3600, - } - date = dateutil.parser.parse(data.get('payment_date'), tzinfos=tzinfos).astimezone(pytz.utc).replace(tzinfo=None) - except: - date = fields.Datetime.now() - res.update(date=date) - self._set_transaction_done() - if self.state == 'done' and self.state != former_tx_state: - _logger.info('Validated Paypal payment for tx %s: set as done' % (self.reference)) - return self.write(res) - return True - elif status in ['Pending', 'Expired']: - res.update(state_message=data.get('pending_reason', '')) - self._set_transaction_pending() - if self.state == 'pending' and self.state != former_tx_state: - _logger.info('Received notification for Paypal payment %s: set as pending' % (self.reference)) - return self.write(res) - return True - else: - error = 'Received unrecognized status for Paypal payment %s: %s, set as error' % (self.reference, status) - res.update(state_message=error) - self._set_transaction_cancel() - if self.state == 'cancel' and self.state != former_tx_state: - _logger.info(error) - return self.write(res) - return True diff --git a/addons/payment_paypal/models/payment_acquirer.py b/addons/payment_paypal/models/payment_acquirer.py new file mode 100644 index 00000000000..5aae400e9e2 --- /dev/null +++ b/addons/payment_paypal/models/payment_acquirer.py @@ -0,0 +1,54 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, fields, models + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('paypal', "Paypal")], ondelete={'paypal': 'set default'}) + paypal_email_account = fields.Char( + string="Email", + help="The public business email solely used to identify the account with PayPal", + required_if_provider='paypal') + paypal_seller_account = fields.Char( + string="Merchant Account ID", groups='base.group_system') + paypal_pdt_token = fields.Char(string="PDT Identity Token", groups='base.group_system') + paypal_use_ipn = fields.Boolean( + string="Use IPN", help="Paypal Instant Payment Notification", default=True) + + def _paypal_get_api_url(self): + """ Return the API URL according to the acquirer state. + + Note: self.ensure_one() + + :return: The API URL + :rtype: str + """ + self.ensure_one() + + if self.state == 'enabled': + return 'https://www.paypal.com/cgi-bin/webscr' + else: + return 'https://www.sandbox.paypal.com/cgi-bin/webscr' + + def _paypal_send_configuration_reminder(self): + template = self.env.ref( + 'payment_paypal.mail_template_paypal_invite_user_to_configure', raise_if_not_found=False + ) + if template: + render_template = template._render({'acquirer': self}, engine='ir.qweb') + mail_body = self.env['mail.render.mixin']._replace_local_links(render_template) + mail_values = { + 'body_html': mail_body, + 'subject': _("Add your PayPal account to Odoo"), + 'email_to': self.paypal_email_account, + 'email_from': self.create_uid.email_formatted, + 'author_id': self.create_uid.partner_id.id, + } + self.env['mail.mail'].sudo().create(mail_values).send() diff --git a/addons/payment_paypal/models/payment_transaction.py b/addons/payment_paypal/models/payment_transaction.py new file mode 100644 index 00000000000..d679396ee7f --- /dev/null +++ b/addons/payment_paypal/models/payment_transaction.py @@ -0,0 +1,125 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from werkzeug import urls + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_paypal.controllers.main import PaypalController + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + # See https://developer.paypal.com/docs/api-basics/notifications/ipn/IPNandPDTVariables/ + paypal_type = fields.Char( + string="PayPal Transaction Type", help="This has no use in Odoo except for debugging.") + + def _get_specific_rendering_values(self, processing_values): + """ Override of payment to return Paypal-specific rendering values. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic and specific processing values of the transaction + :return: The dict of acquirer-specific processing values + :rtype: dict + """ + res = super()._get_specific_rendering_values(processing_values) + if self.provider != 'paypal': + return res + + base_url = self.acquirer_id._get_base_url() + partner_first_name, partner_last_name = payment_utils.split_partner_name(self.partner_name) + notify_url = self.acquirer_id.paypal_use_ipn \ + and urls.url_join(base_url, PaypalController._notify_url) + return { + 'address1': self.partner_address, + 'amount': self.amount, + 'business': self.acquirer_id.paypal_email_account, + 'city': self.partner_city, + 'country': self.partner_country_id.code, + 'currency_code': self.currency_id.name, + 'email': self.partner_email, + 'first_name': partner_first_name, + 'handling': self.fees, + 'item_name': f"{self.company_id.name}: {self.reference}", + 'item_number': self.reference, + 'last_name': partner_last_name, + 'lc': self.partner_lang, + 'notify_url': notify_url, + 'return_url': urls.url_join(base_url, PaypalController._return_url), + 'state': self.partner_state_id.name, + 'zip_code': self.partner_zip, + 'api_url': self.acquirer_id._paypal_get_api_url(), + } + + @api.model + def _get_tx_from_feedback_data(self, provider, data): + """ Override of payment to find the transaction based on Paypal data. + + :param str provider: The provider of the acquirer that handled the transaction + :param dict data: The feedback data sent by the provider + :return: The transaction if found + :rtype: recordset of `payment.transaction` + :raise: ValidationError if the data match no transaction + """ + tx = super()._get_tx_from_feedback_data(provider, data) + if provider != 'paypal': + return tx + + reference = data.get('item_number') + tx = self.search([('reference', '=', reference), ('provider', '=', 'paypal')]) + if not tx: + raise ValidationError( + "PayPal: " + _("No transaction found matching reference %s.", reference) + ) + return tx + + def _process_feedback_data(self, data): + """ Override of payment to process the transaction based on Paypal data. + + Note: self.ensure_one() + + :param dict data: The feedback data sent by the provider + :return: None + :raise: ValidationError if inconsistent data were received + """ + super()._process_feedback_data(data) + if self.provider != 'paypal': + return + + txn_id = data.get('txn_id') + txn_type = data.get('txn_type') + if not all((txn_id, txn_type)): + raise ValidationError( + "PayPal: " + _( + "Missing value for txn_id (%(txn_id)s) or txn_type (%(txn_type)s).", + txn_id=txn_id, txn_type=txn_type + ) + ) + self.acquirer_reference = txn_id + self.paypal_type = txn_type + + payment_status = data.get('payment_status') + + if payment_status in ('Pending', 'Processed', 'Completed') and not all( + (self.acquirer_id.paypal_pdt_token, self.acquirer_id.paypal_seller_account) + ): # If a payment is made on an account waiting for configuration, send a reminder email + self.acquirer_id._paypal_send_configuration_reminder() + + if payment_status in ('Processed', 'Completed'): + self._set_done() + elif payment_status == 'Pending': + self._set_pending(state_message=data.get('pending_reason')) + elif payment_status == 'Expired': + self._set_canceled() + else: + _logger.info("received data with invalid payment status: %s", payment_status) + self._set_error( + "PayPal: " + _("Received data with invalid payment status: %s", payment_status) + ) diff --git a/addons/payment_paypal/tests/__init__.py b/addons/payment_paypal/tests/__init__.py index 7048b0a3e39..b6f60865913 100644 --- a/addons/payment_paypal/tests/__init__.py +++ b/addons/payment_paypal/tests/__init__.py @@ -1,2 +1,4 @@ -# -*- coding: utf-8 -*- -from . import test_paypal \ No newline at end of file +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_paypal diff --git a/addons/payment_paypal/tests/common.py b/addons/payment_paypal/tests/common.py new file mode 100644 index 00000000000..5a9268069a5 --- /dev/null +++ b/addons/payment_paypal/tests/common.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.payment.tests.common import PaymentCommon + + +class PaypalCommon(PaymentCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.paypal = cls._prepare_acquirer('paypal', update_values={ + 'paypal_email_account': 'dummy@test.mail.com', + 'fees_active': False, + }) + + # Override default values + cls.acquirer = cls.paypal + cls.currency = cls.currency_euro diff --git a/addons/payment_paypal/tests/test_paypal.py b/addons/payment_paypal/tests/test_paypal.py index 38a50a91c50..ccbb4bcd9ef 100644 --- a/addons/payment_paypal/tests/test_paypal.py +++ b/addons/payment_paypal/tests/test_paypal.py @@ -1,102 +1,63 @@ -# -*- coding: utf-8 -*- - -from odoo import fields -from odoo.addons.payment.models.payment_acquirer import ValidationError -from odoo.addons.payment.tests.common import PaymentAcquirerCommon -from odoo.addons.payment_paypal.controllers.main import PaypalController -from werkzeug import urls +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.exceptions import ValidationError from odoo.tools import mute_logger from odoo.tests import tagged -from lxml import objectify +from .common import PaypalCommon +from ..controllers.main import PaypalController -class PaypalCommon(PaymentAcquirerCommon): - - @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - - cls.paypal = cls.env.ref('payment.payment_acquirer_paypal') - cls.paypal.write({ - 'paypal_email_account': 'dummy', - 'state': 'test', - }) - - # some CC - cls.amex = (('378282246310005', '123'), ('371449635398431', '123')) - cls.amex_corporate = (('378734493671000', '123')) - cls.autralian_bankcard = (('5610591081018250', '123')) - cls.dinersclub = (('30569309025904', '123'), ('38520000023237', '123')) - cls.discover = (('6011111111111117', '123'), ('6011000990139424', '123')) - cls.jcb = (('3530111333300000', '123'), ('3566002020360505', '123')) - cls.mastercard = (('5555555555554444', '123'), ('5105105105105100', '123')) - cls.visa = (('4111111111111111', '123'), ('4012888888881881', '123'), ('4222222222222', '123')) - cls.dankord_pbs = (('76009244561', '123'), ('5019717010103742', '123')) - cls.switch_polo = (('6331101999990016', '123')) - - -@tagged('post_install', '-at_install', 'external', '-standard') +@tagged('post_install', '-at_install') class PaypalForm(PaypalCommon): - def test_10_paypal_form_render(self): - base_url = self.env['ir.config_parameter'].get_param('web.base.url') - # be sure not to do stupid things - self.paypal.write({'paypal_email_account': 'tde+paypal-facilitator@odoo.com', 'fees_active': False}) - self.assertEqual(self.paypal.state, 'test', 'test without test environment') - - # ---------------------------------------- - # Test: button direct rendering - # ---------------------------------------- - - # render the button - res = self.paypal.render( - 'test_ref0', 0.01, self.currency_euro.id, - values=self.buyer_values) - - form_values = { - 'cmd': '_xclick', - 'business': 'tde+paypal-facilitator@odoo.com', - 'item_name': '%s: test_ref0' % (self.paypal.company_id.name), - 'item_number': 'test_ref0', - 'first_name': 'Norbert', - 'last_name': 'Buyer', - 'amount': '0.01', - 'bn': 'OdooInc_SP', - 'currency_code': 'EUR', + def _get_expected_values(self): + return_url = self._build_url(PaypalController._return_url) + values = { 'address1': 'Huge Street 2/543', + 'amount': str(self.amount), + 'business': self.paypal.paypal_email_account, + 'cancel_return': return_url, 'city': 'Sin City', - 'zip': '1000', - 'rm': '2', + 'cmd': '_xclick', 'country': 'BE', + 'currency_code': self.currency.name, 'email': 'norbert.buyer@example.com', - 'return': urls.url_join(base_url, PaypalController._return_url), - 'notify_url': urls.url_join(base_url, PaypalController._notify_url), - 'cancel_return': urls.url_join(base_url, PaypalController._cancel_url), - 'custom': '{"return_url": "/payment/process"}', + 'first_name': 'Norbert', + 'item_name': f'{self.paypal.company_id.name}: {self.reference}', + 'item_number': self.reference, + 'last_name': 'Buyer', + 'lc': 'en_US', + 'notify_url': self._build_url(PaypalController._notify_url), + 'return': return_url, + 'rm': '2', + 'zip': '1000', } - # check form result - tree = objectify.fromstring(res) + if self.paypal.fees_active: + fees = self.currency.round(self.paypal._compute_fees(self.amount, self.currency, self.partner.country_id)) + if fees: + # handling input is only specified if truthy + values['handling'] = str(fees) - data_set = tree.xpath("//input[@name='data_set']") - self.assertEqual(len(data_set), 1, 'paypal: Found %d "data_set" input instead of 1' % len(data_set)) - self.assertEqual(data_set[0].get('data-action-url'), 'https://www.sandbox.paypal.com/cgi-bin/webscr', 'paypal: wrong form POST url') - for form_input in tree.input: - if form_input.get('name') in ['submit', 'data_set']: - continue - self.assertEqual( - form_input.get('value'), - form_values[form_input.get('name')], - 'paypal: wrong value for input %s: received %s instead of %s' % (form_input.get('name'), form_input.get('value'), form_values[form_input.get('name')]) - ) + return values - def test_11_paypal_form_with_fees(self): - # be sure not to do stupid things - self.assertEqual(self.paypal.state, 'test', 'test without test environment') + def test_redirect_form_values(self): + tx = self.create_transaction(flow='redirect') + with mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = tx._get_processing_values() - # update acquirer: compute fees + form_info = self._extract_values_from_html_form(processing_values['redirect_form_html']) + self.assertEqual( + form_info['action'], + 'https://www.sandbox.paypal.com/cgi-bin/webscr') + + expected_values = self._get_expected_values() + self.assertDictEqual( + expected_values, form_info['inputs'], + "Paypal: invalid inputs specified in the redirect form.") + + def test_redirect_form_with_fees(self): self.paypal.write({ 'fees_active': True, 'fees_dom_fixed': 1.0, @@ -104,30 +65,19 @@ class PaypalForm(PaypalCommon): 'fees_int_fixed': 1.5, 'fees_int_var': 0.50, }) + expected_values = self._get_expected_values() - # render the button - res = self.paypal.render( - 'test_ref0', 12.50, self.currency_euro.id, - values=self.buyer_values) + tx = self.create_transaction(flow='redirect') + with mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = tx._get_processing_values() + form_info = self._extract_values_from_html_form(processing_values['redirect_form_html']) - # check form result - handling_found = False - tree = objectify.fromstring(res) - - data_set = tree.xpath("//input[@name='data_set']") - self.assertEqual(len(data_set), 1, 'paypal: Found %d "data_set" input instead of 1' % len(data_set)) - self.assertEqual(data_set[0].get('data-action-url'), 'https://www.sandbox.paypal.com/cgi-bin/webscr', 'paypal: wrong form POST url') - for form_input in tree.input: - if form_input.get('name') in ['handling']: - handling_found = True - self.assertEqual(form_input.get('value'), '1.57', 'paypal: wrong computed fees') - self.assertTrue(handling_found, 'paypal: fees_active did not add handling input in rendered form') - - @mute_logger('odoo.addons.payment_paypal.models.payment', 'ValidationError') - def test_20_paypal_form_management(self): - # be sure not to do stupid things - self.assertEqual(self.paypal.state, 'test', 'test without test environment') + self.assertEqual(form_info['action'], 'https://www.sandbox.paypal.com/cgi-bin/webscr') + self.assertDictEqual( + expected_values, form_info['inputs'], + "Paypal: invalid inputs specified in the redirect form.") + def test_feedback_processing(self): # typical data posted by paypal after client has successfully paid paypal_post_data = { 'protection_eligibility': u'Ineligible', @@ -145,14 +95,13 @@ class PaypalForm(PaypalCommon): 'handling_amount': u'0.00', 'payment_date': u'03:21:19 Nov 18, 2013 PST', 'first_name': u'Norbert', - 'item_name': u'test_ref_2', + 'item_name': self.reference, 'address_country': u'France', 'charset': u'windows-1252', - 'custom': u'{"return_url": "/payment/process"}', 'notify_version': u'3.7', 'address_name': u'Norbert Poilu', 'pending_reason': u'multi_currency', - 'item_number': u'test_ref_2', + 'item_number': self.reference, 'receiver_id': u'dummy', 'transaction_subject': u'', 'business': u'dummy', @@ -167,46 +116,33 @@ class PaypalForm(PaypalCommon): 'shipping': u'0.00', 'payer_email': u'tde+buyer@odoo.com', 'payment_type': u'instant', - 'mc_gross': u'1.95', + 'mc_gross': str(self.amount), 'ipn_track_id': u'866df2ccd444b', 'quantity': u'1' } # should raise error about unknown tx with self.assertRaises(ValidationError): - self.env['payment.transaction'].form_feedback(paypal_post_data, 'paypal') + self.env['payment.transaction']._handle_feedback_data('paypal', paypal_post_data) - # create tx - tx = self.env['payment.transaction'].create({ - 'amount': 1.95, - 'acquirer_id': self.paypal.id, - 'currency_id': self.currency_euro.id, - 'reference': 'test_ref_2', - 'partner_name': 'Norbert Buyer', - 'partner_country_id': self.country_france.id}) + tx = self.create_transaction(flow='redirect') - # validate it - tx.form_feedback(paypal_post_data, 'paypal') - # check + # Validate the transaction (pending feedback) + self.env['payment.transaction']._handle_feedback_data('paypal', paypal_post_data) self.assertEqual(tx.state, 'pending', 'paypal: wrong state after receiving a valid pending notification') self.assertEqual(tx.state_message, 'multi_currency', 'paypal: wrong state message after receiving a valid pending notification') self.assertEqual(tx.acquirer_reference, '08D73520KX778924N', 'paypal: wrong txn_id after receiving a valid pending notification') - # update tx - tx.write({ - 'state': 'draft', - 'acquirer_reference': False}) + # Reset the transaction + tx.write({'state': 'draft', 'acquirer_reference': False}) - # update notification from paypal + # Validate the transaction ('completed' feedback) paypal_post_data['payment_status'] = 'Completed' - # validate it - tx.form_feedback(paypal_post_data, 'paypal') - # check + self.env['payment.transaction']._handle_feedback_data('paypal', paypal_post_data) self.assertEqual(tx.state, 'done', 'paypal: wrong state after receiving a valid pending notification') self.assertEqual(tx.acquirer_reference, '08D73520KX778924N', 'paypal: wrong txn_id after receiving a valid pending notification') - self.assertEqual(fields.Datetime.to_string(tx.date), '2013-11-18 11:21:19', 'paypal: wrong validation date') - def test_21_paypal_compute_fees(self): + def test_fees_computation(self): #If the merchant needs to keep 100€, the transaction will be equal to 103.30€. #In this way, Paypal will take 103.30 * 2.9% + 0.30 = 3.30€ #And the merchant will take 103.30 - 3.30 = 100€ @@ -215,5 +151,5 @@ class PaypalForm(PaypalCommon): 'fees_int_fixed': 0.30, 'fees_int_var': 2.90, }) - total_fee = self.paypal.paypal_compute_fees(100, False, False) + total_fee = self.paypal._compute_fees(100, False, False) self.assertEqual(round(total_fee, 2), 3.3, 'Wrong computation of the Paypal fees') diff --git a/addons/payment_paypal/views/payment_paypal_templates.xml b/addons/payment_paypal/views/payment_paypal_templates.xml index 06369005fe2..f9fee3fdb46 100644 --- a/addons/payment_paypal/views/payment_paypal_templates.xml +++ b/addons/payment_paypal/views/payment_paypal_templates.xml @@ -1,40 +1,33 @@ - - - + + + + diff --git a/addons/payment_paypal/views/payment_views.xml b/addons/payment_paypal/views/payment_views.xml index 118375b2b62..2dab9e0a023 100644 --- a/addons/payment_paypal/views/payment_views.xml +++ b/addons/payment_paypal/views/payment_views.xml @@ -1,34 +1,44 @@ - - - acquirer.form.paypal - payment.acquirer - - - - - - - - - How to configure your paypal account? - - - - + + PayPal Acquirer Form + payment.acquirer + + + + + + + + + + + How to configure your paypal account? + + + + + - - acquirer.transaction.form.paypal - payment.transaction - - - - - - - + + PayPal Transaction Form + payment.transaction + + + + + + + -