[REF] payment_paypal: migrate PayPal to the new payment API

See the merge commit for more details.

task-2333036

Co-authored-by: Antoine Vandevenne <anv@odoo.com>
Co-authored-by: Victor Feyens <vfe@odoo.com>
This commit is contained in:
Adrien Horgnies
2021-03-30 09:25:51 +02:00
committed by Antoine Vandevenne (anv)
co-authored by Antoine Vandevenne Victor Feyens
parent 61a02a7330
commit 7b165cd555
15 changed files with 434 additions and 557 deletions
+6 -5
View File
@@ -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')
+3 -4
View File
@@ -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',
}
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+72 -97
View File
@@ -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
)
)
@@ -1,14 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<odoo noupdate="1">
<record id="payment.payment_acquirer_paypal" model="payment.acquirer">
<field name="name">Paypal</field>
<field name="image_128" type="base64" file="payment_paypal/static/src/img/paypal_icon.png"/>
<field name="provider">paypal</field>
<field name="company_id" ref="base.main_company"/>
<field name="view_template_id" ref="paypal_form"/>
</record>
<record id="payment.payment_acquirer_paypal" model="payment.acquirer">
<field name="provider">paypal</field>
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">True</field>
<field name="support_tokenization">False</field>
</record>
</data>
</odoo>
@@ -1,5 +1,6 @@
<?xml version="1.0" ?>
<odoo>
<template id="mail_template_paypal_invite_user_to_configure">
<div>
<p>
@@ -9,7 +10,10 @@
Kindly follow the instructions given by PayPal to create your account.<br/>
Then, help us complete your Paypal credentials in Odoo.<br/><br/>
</p>
<a t-attf-href="/web#id=#{acquirer.id}&amp;model=payment.acquirer&amp;view_type=form" style="background-color: #875A7B; padding: 10px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 12px;">Set Paypal credentials</a>
<a t-attf-href="/web#id=#{acquirer.id}&amp;model=payment.acquirer&amp;view_type=form"
style="background-color: #875A7B; padding: 10px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 12px;">
Set Paypal credentials
</a>
<p>
<br/><br/>
Thanks,<br/>
@@ -17,4 +21,5 @@
</p>
</div>
</template>
</odoo>
+3 -2
View File
@@ -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
-240
View File
@@ -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
@@ -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()
@@ -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)
)
+4 -2
View File
@@ -1,2 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_paypal
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_paypal
+18
View File
@@ -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
+66 -130
View File
@@ -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')
@@ -1,40 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<template id="paypal_form">
<div>
<input type="hidden" name="data_set" t-att-data-action-url="tx_url" data-remove-me=""/>
<input type="hidden" name="cmd" t-att-value="cmd"/>
<input type="hidden" name="business" t-att-value="business"/>
<input type="hidden" name="bn" value="OdooInc_SP" />
<input type="hidden" name="item_name" t-att-value="item_name"/>
<input type="hidden" name="item_number" t-att-value="item_number"/>
<input type="hidden" name="amount" t-att-value="amount"/>
<input t-if="handling" type="hidden" name="handling"
t-att-value="handling"/>
<input type="hidden" name="currency_code" t-att-value="currency_code"/>
<!-- partner / address data -->
<input type="hidden" name="address1" t-att-value="address1"/>
<input type="hidden" name="city" t-att-value="city"/>
<input type="hidden" name="country" t-att-value="country"/>
<input type="hidden" name="email" t-att-value="email"/>
<input type="hidden" name="first_name" t-att-value="first_name"/>
<input type="hidden" name="last_name" t-att-value="last_name"/>
<input type="hidden" name="zip" t-att-value="zip_code"/>
<input type="hidden" name="rm" value="2"/>
<input t-if='state' type="hidden" name="state"
t-att-value='state'/>
<!-- after payment parameters -->
<input t-if='custom' type="hidden" name="custom"
t-att-value='custom'/>
<!-- URLs -->
<input t-if="paypal_return" type="hidden" name='return'
t-att-value="paypal_return"/>
<input t-if="acquirer.paypal_use_ipn" type="hidden" name='notify_url'
t-att-value="notify_url"/>
<input t-if="cancel_return" type="hidden" name="cancel_return"
t-att-value="cancel_return"/>
</div>
</template>
</data>
<!-- https://developer.paypal.com/docs/paypal-payments-standard/integration-guide/formbasics -->
<template id="redirect_form">
<form t-att-action="api_url" method="post">
<input type="hidden" name="address1" t-att-value="address1"/>
<input type="hidden" name="amount" t-att-value="amount"/>
<input type="hidden" name="business" t-att-value="business"/>
<input type="hidden" name="cancel_return" t-att-value="return_url"/>
<input type="hidden" name="city" t-att-value="city"/>
<input type="hidden" name="cmd" value="_xclick"/>
<input type="hidden" name="country" t-att-value="country"/>
<input type="hidden" name="currency_code" t-att-value="currency_code"/>
<input type="hidden" name="email" t-att-value="email"/>
<input type="hidden" name="first_name" t-att-value="first_name"/>
<input t-if="handling"
type="hidden" name="handling" t-att-value="handling"/>
<input type="hidden" name="item_name" t-att-value="item_name"/>
<input type="hidden" name="item_number" t-att-value="item_number"/>
<input type="hidden" name="last_name" t-att-value="last_name"/>
<input type="hidden" name="lc" t-att-value="lc"/>
<input t-if="notify_url"
type="hidden" name="notify_url" t-att-value="notify_url"/>
<input type="hidden" name="return" t-att-value="return_url"/>
<input type="hidden" name="rm" value="2"/>
<input t-if="state"
type="hidden" name="state" t-att-value="state"/>
<input type="hidden" name="zip" t-att-value="zip_code"/>
</form>
</template>
</odoo>
+38 -28
View File
@@ -1,34 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="acquirer_form_paypal" model="ir.ui.view">
<field name="name">acquirer.form.paypal</field>
<field name="model">payment.acquirer</field>
<field name="inherit_id" ref="payment.acquirer_form"/>
<field name="arch" type="xml">
<xpath expr='//group[@name="acquirer"]' position='inside'>
<group attrs="{'invisible': [('provider', '!=', 'paypal')]}">
<field name="paypal_email_account" attrs="{'required':[ ('provider', '=', 'paypal'), ('state', '!=', 'disabled')]}"/>
<field name="paypal_seller_account"/>
<field name="paypal_pdt_token"/>
<field name="paypal_use_ipn" attrs="{'required':[ ('provider', '=', 'paypal'), ('state', '!=', 'disabled')]}"/>
<a colspan="2" href="https://www.odoo.com/documentation/user/online/ecommerce/shopper_experience/paypal.html" target="_blank">How to configure your paypal account?</a>
</group>
</xpath>
</field>
</record>
<record id="payment_acquirer_form" model="ir.ui.view">
<field name="name">PayPal Acquirer Form</field>
<field name="model">payment.acquirer</field>
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
<field name="arch" type="xml">
<xpath expr='//group[@name="acquirer"]' position='inside'>
<group attrs="{'invisible': [('provider', '!=', 'paypal')]}">
<field name="paypal_email_account"
attrs="{'required':[('provider', '=', 'paypal'), ('state', '!=', 'disabled')]}"/>
<field name="paypal_seller_account"/>
<!-- This field should no longer be used but is kept in debug mode for the time
being, until we are sure that the verification protocol of IPN can be used
for DPT notifications -->
<field name="paypal_pdt_token" groups="base.group_no_one"/>
<field name="paypal_use_ipn"
attrs="{'required':[('provider', '=', 'paypal'), ('state', '!=', 'disabled')]}"/>
<a href="https://www.odoo.com/documentation/user/general/payment_acquirers/paypal.html"
target="_blank"
colspan="2">
How to configure your paypal account?
</a>
</group>
</xpath>
</field>
</record>
<record id="transaction_form_paypal" model="ir.ui.view">
<field name="name">acquirer.transaction.form.paypal</field>
<field name="model">payment.transaction</field>
<field name="inherit_id" ref="payment.transaction_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='acquirer_reference']" position="after">
<field name="paypal_txn_type" readonly="1" attrs="{'invisible': [('provider', '!=', 'paypal')]}"/>
</xpath>
</field>
</record>
<record id="payment_transaction_form" model="ir.ui.view">
<field name="name">PayPal Transaction Form</field>
<field name="model">payment.transaction</field>
<field name="inherit_id" ref="payment.payment_transaction_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='acquirer_reference']" position="after">
<field name="paypal_type"
readonly="1"
attrs="{'invisible': [('provider', '!=', 'paypal')]}"
groups="base.group_no_one"/>
</xpath>
</field>
</record>
</data>
</odoo>