[REF] payment_stripe: migrate Stripe to the new payment API
This commits drops the direct payment flow supported by the SetupIntent API in favor of the payment with redirection flow, supported by the Checkout API only. See the merge commit for more details. task-2333040 Co-authored-by: Antoine Vandevenne <anv@odoo.com>
This commit is contained in:
committed by
Antoine Vandevenne (anv)
co-authored by
Antoine Vandevenne
parent
a9ac72d821
commit
4bb840403b
@@ -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, 'stripe')
|
||||
reset_payment_acquirer(cr, registry, 'stripe')
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Stripe Payment Acquirer',
|
||||
'version': '2.0',
|
||||
'category': 'Accounting/Payment Acquirers',
|
||||
'sequence': 380,
|
||||
'summary': 'Payment Acquirer: Stripe Implementation',
|
||||
'version': '1.0',
|
||||
'description': """Stripe Payment Acquirer""",
|
||||
'depends': ['payment'],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
'views/payment_views.xml',
|
||||
'views/payment_stripe_templates.xml',
|
||||
'data/payment_acquirer_data.xml',
|
||||
],
|
||||
'images': ['static/description/icon.png'],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'post_init_hook': 'create_missing_journal_for_acquirers',
|
||||
'post_init_hook': 'create_missing_journals',
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
|
||||
# Support payment method types
|
||||
PMT = namedtuple('PaymentMethodType', ['name', 'countries', 'currencies', 'recurrence'])
|
||||
PAYMENT_METHOD_TYPES = [
|
||||
PMT('card', [], [], 'recurring'),
|
||||
PMT('ideal', ['nl'], ['eur'], 'punctual'),
|
||||
PMT('bancontact', ['be'], ['eur'], 'punctual'),
|
||||
PMT('eps', ['at'], ['eur'], 'punctual'),
|
||||
PMT('giropay', ['de'], ['eur'], 'punctual'),
|
||||
PMT('p24', ['pl'], ['eur', 'pln'], 'punctual'),
|
||||
]
|
||||
|
||||
# Mapping of transaction states to Stripe {Payment,Setup}Intent statuses.
|
||||
# See https://stripe.com/docs/payments/intents#intent-statuses for the exhaustive list of status.
|
||||
INTENT_STATUS_MAPPING = {
|
||||
'draft': ('requires_payment_method', 'requires_confirmation', 'requires_action'),
|
||||
'pending': ('processing',),
|
||||
'done': ('succeeded',),
|
||||
'cancel': ('canceled',),
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
|
||||
@@ -1,62 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import pprint
|
||||
from datetime import datetime
|
||||
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
from odoo.tools import consteq
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StripeController(http.Controller):
|
||||
_success_url = '/payment/stripe/success'
|
||||
_cancel_url = '/payment/stripe/cancel'
|
||||
_checkout_return_url = '/payment/stripe/checkout_return'
|
||||
_validation_return_url = '/payment/stripe/validation_return'
|
||||
WEBHOOK_AGE_TOLERANCE = 10*60 # seconds
|
||||
|
||||
@http.route(['/payment/stripe/success', '/payment/stripe/cancel'], type='http', auth='public')
|
||||
def stripe_success(self, **kwargs):
|
||||
request.env['payment.transaction'].sudo().form_feedback(kwargs, 'stripe')
|
||||
return werkzeug.utils.redirect('/payment/process')
|
||||
@http.route(_checkout_return_url, type='http', auth='public', csrf=False)
|
||||
def stripe_return_from_checkout(self, **data):
|
||||
""" Process the data returned by Stripe after redirection for checkout.
|
||||
|
||||
@http.route(['/payment/stripe/s2s/create_json_3ds'], type='json', auth='public', csrf=False)
|
||||
def stripe_s2s_create_json_3ds(self, verify_validity=False, **kwargs):
|
||||
if not kwargs.get('partner_id'):
|
||||
kwargs = dict(kwargs, partner_id=request.env.user.partner_id.id)
|
||||
token = request.env['payment.acquirer'].browse(int(kwargs.get('acquirer_id'))).with_context(stripe_manual_payment=True).s2s_process(kwargs)
|
||||
:param dict data: The GET params appended to the URL in `_stripe_create_checkout_session`
|
||||
"""
|
||||
# Retrieve the tx and acquirer based on the tx reference included in the return url
|
||||
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data(
|
||||
'stripe', data
|
||||
)
|
||||
acquirer_sudo = tx_sudo.acquirer_id
|
||||
|
||||
if not token:
|
||||
res = {
|
||||
'result': False,
|
||||
}
|
||||
return res
|
||||
# Fetch the PaymentIntent, Charge and PaymentMethod objects from Stripe
|
||||
payment_intent = acquirer_sudo._stripe_make_request(
|
||||
f'payment_intents/{tx_sudo.stripe_payment_intent}', method='GET'
|
||||
)
|
||||
_logger.info("received payment_intents response:\n%s", pprint.pformat(payment_intent))
|
||||
self._include_payment_intent_in_feedback_data(payment_intent, data)
|
||||
|
||||
res = {
|
||||
'result': True,
|
||||
'id': token.id,
|
||||
'short_name': token.short_name,
|
||||
'3d_secure': False,
|
||||
'verified': False,
|
||||
}
|
||||
# Handle the feedback data crafted with Stripe API objects
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data('stripe', data)
|
||||
|
||||
if verify_validity != False:
|
||||
token.validate()
|
||||
res['verified'] = token.verified
|
||||
# Redirect the user to the status page
|
||||
return werkzeug.utils.redirect('/payment/status')
|
||||
|
||||
return res
|
||||
@http.route(_validation_return_url, type='http', auth='public', csrf=False)
|
||||
def stripe_return_from_validation(self, **data):
|
||||
""" Process the data returned by Stripe after redirection for validation.
|
||||
|
||||
@http.route('/payment/stripe/s2s/create_setup_intent', type='json', auth='public', csrf=False)
|
||||
def stripe_s2s_create_setup_intent(self, acquirer_id, **kwargs):
|
||||
acquirer = request.env['payment.acquirer'].browse(int(acquirer_id))
|
||||
res = acquirer.with_context(stripe_manual_payment=True)._create_setup_intent(kwargs)
|
||||
return res.get('client_secret')
|
||||
:param dict data: The GET params appended to the URL in `_stripe_create_checkout_session`
|
||||
"""
|
||||
# Retrieve the acquirer based on the tx reference included in the return url
|
||||
acquirer_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data(
|
||||
'stripe', data
|
||||
).acquirer_id
|
||||
|
||||
@http.route('/payment/stripe/s2s/process_payment_intent', type='json', auth='public', csrf=False)
|
||||
def stripe_s2s_process_payment_intent(self, **post):
|
||||
return request.env['payment.transaction'].sudo().form_feedback(post, 'stripe')
|
||||
# Fetch the Session, SetupIntent and PaymentMethod objects from Stripe
|
||||
checkout_session = acquirer_sudo._stripe_make_request(
|
||||
f'checkout/sessions/{data.get("checkout_session_id")}',
|
||||
payload={'expand[]': 'setup_intent.payment_method'}, # Expand all required objects
|
||||
method='GET'
|
||||
)
|
||||
_logger.info("received checkout/session response:\n%s", pprint.pformat(checkout_session))
|
||||
self._include_setup_intent_in_feedback_data(checkout_session.get('setup_intent', {}), data)
|
||||
|
||||
@http.route('/payment/stripe/webhook', type='json', auth='public', csrf=False)
|
||||
def stripe_webhook(self, **kwargs):
|
||||
data = json.loads(request.httprequest.data)
|
||||
request.env['payment.acquirer'].sudo()._handle_stripe_webhook(data)
|
||||
return 'OK'
|
||||
# Handle the feedback data crafted with Stripe API objects
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data('stripe', data)
|
||||
|
||||
# Redirect the user to the status page
|
||||
return werkzeug.utils.redirect('/payment/status')
|
||||
|
||||
@http.route('/payment/stripe/webhook', type='json', auth='public')
|
||||
def stripe_webhook(self):
|
||||
""" Process the `checkout.session.completed` event sent by Stripe to the webhook.
|
||||
|
||||
:return: An empty string to acknowledge the notification with an HTTP 200 response
|
||||
:rtype: str
|
||||
"""
|
||||
event = json.loads(request.httprequest.data)
|
||||
_logger.info("event received:\n%s", pprint.pformat(event))
|
||||
if event['type'] == 'checkout.session.completed':
|
||||
checkout_session = event['data']['object']
|
||||
|
||||
# Check the source and integrity of the event
|
||||
data = {'reference': checkout_session['client_reference_id']}
|
||||
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data(
|
||||
'stripe', data
|
||||
)
|
||||
if self._verify_webhook_signature(tx_sudo.acquirer_id.stripe_webhook_secret):
|
||||
if checkout_session.get('payment_intent'): # Can be None
|
||||
# Fetch the PaymentIntent, Charge and PaymentMethod objects from Stripe
|
||||
payment_intent = tx_sudo.acquirer_id._stripe_make_request(
|
||||
f'payment_intents/{tx_sudo.stripe_payment_intent}', method='GET'
|
||||
)
|
||||
_logger.info(
|
||||
"received payment_intents response:\n%s", pprint.pformat(payment_intent)
|
||||
)
|
||||
self._include_payment_intent_in_feedback_data(payment_intent, data)
|
||||
if checkout_session.get('setup_intent'): # Can be None
|
||||
# Fetch the SetupIntent and PaymentMethod objects from Stripe
|
||||
setup_intent = tx_sudo.acquirer_id._stripe_make_request(
|
||||
f'setup_intents/{checkout_session.get("setup_intent")}',
|
||||
payload={'expand[]': 'payment_method'},
|
||||
method='GET'
|
||||
)
|
||||
_logger.info(
|
||||
"received setup_intents response:\n%s", pprint.pformat(setup_intent)
|
||||
)
|
||||
self._include_setup_intent_in_feedback_data(setup_intent, data)
|
||||
try:
|
||||
# Handle the feedback data crafted with Stripe API objects as a regular feedback
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data('stripe', data)
|
||||
except ValidationError: # Acknowledge the notification to avoid getting spammed
|
||||
_logger.exception("unable to handle the event data; skipping to acknowledge")
|
||||
return ''
|
||||
|
||||
def _include_payment_intent_in_feedback_data(self, payment_intent, data):
|
||||
data.update({'payment_intent': payment_intent})
|
||||
if payment_intent.get('charges', {}).get('total_count', 0) > 0:
|
||||
charge = payment_intent['charges']['data'][0] # Use the latest charge object
|
||||
data.update({
|
||||
'charge': charge,
|
||||
'payment_method': charge.get('payment_method_details'),
|
||||
})
|
||||
|
||||
def _include_setup_intent_in_feedback_data(self, setup_intent, data):
|
||||
data.update({
|
||||
'setup_intent': setup_intent,
|
||||
'payment_method': setup_intent.get('payment_method')
|
||||
})
|
||||
|
||||
def _verify_webhook_signature(self, webhook_secret):
|
||||
""" Check that the signature computed from the feedback matches the received one.
|
||||
|
||||
See https://stripe.com/docs/webhooks/signatures#verify-manually.
|
||||
|
||||
:param str webhook_secret: The secret webhook key of the acquirer handling the transaction
|
||||
:return: Whether the signatures match
|
||||
:rtype: str
|
||||
"""
|
||||
if not webhook_secret:
|
||||
_logger.warning("ignored webhook event due to undefined webhook secret")
|
||||
return False
|
||||
|
||||
notification_payload = request.httprequest.data.decode('utf-8')
|
||||
signature_entries = request.httprequest.headers.get('Stripe-Signature').split(',')
|
||||
signature_data = {k: v for k, v in [entry.split('=') for entry in signature_entries]}
|
||||
|
||||
# Check the timestamp of the event
|
||||
event_timestamp = int(signature_data['t'])
|
||||
if datetime.utcnow().timestamp() - event_timestamp > self.WEBHOOK_AGE_TOLERANCE:
|
||||
_logger.warning("ignored webhook event due to age tolerance: %s", event_timestamp)
|
||||
return False
|
||||
|
||||
# Compare signatures
|
||||
received_signature = signature_data['v1']
|
||||
signed_payload = f'{event_timestamp}.{notification_payload}'
|
||||
expected_signature = hmac.new(
|
||||
webhook_secret.encode('utf-8'),
|
||||
signed_payload.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
if not consteq(received_signature, expected_signature):
|
||||
_logger.warning("ignored event with invalid signature")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="payment.payment_acquirer_stripe" model="payment.acquirer">
|
||||
<field name="name">Stripe</field>
|
||||
<field name="image_128" type="base64" file="payment_stripe/static/src/img/stripe_icon.png"/>
|
||||
<field name="provider">stripe</field>
|
||||
<field name="company_id" ref="base.main_company"/>
|
||||
<field name="view_template_id" ref="stripe_form"/>
|
||||
<field name="registration_view_template_id" ref="stripe_s2s_form"/>
|
||||
</record>
|
||||
</data>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="payment.payment_acquirer_stripe" model="payment.acquirer">
|
||||
<field name="provider">stripe</field>
|
||||
<field name="support_authorization">False</field>
|
||||
<field name="support_fees_computation">False</field>
|
||||
<field name="support_tokenization">True</field>
|
||||
<field name="allow_tokenization">True</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# -*- 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_token
|
||||
from . import payment_transaction
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import pprint
|
||||
from requests.exceptions import HTTPError
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.http import request
|
||||
from odoo.tools.float_utils import float_round
|
||||
from odoo.tools import consteq
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from odoo.addons.payment_stripe.controllers.main import StripeController
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# The following currencies are integer only, see https://stripe.com/docs/currencies#zero-decimal
|
||||
INT_CURRENCIES = [
|
||||
u'BIF', u'XAF', u'XPF', u'CLP', u'KMF', u'DJF', u'GNF', u'JPY', u'MGA', u'PYG', u'RWF', u'KRW',
|
||||
u'VUV', u'VND', u'XOF'
|
||||
]
|
||||
STRIPE_SIGNATURE_AGE_TOLERANCE = 600 # in seconds
|
||||
|
||||
|
||||
class PaymentAcquirerStripe(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(selection_add=[
|
||||
('stripe', 'Stripe')
|
||||
], ondelete={'stripe': 'set default'})
|
||||
stripe_secret_key = fields.Char(required_if_provider='stripe', groups='base.group_user')
|
||||
stripe_publishable_key = fields.Char(required_if_provider='stripe', groups='base.group_user')
|
||||
stripe_webhook_secret = fields.Char(
|
||||
string='Stripe Webhook Secret', groups='base.group_user',
|
||||
help="If you enable webhooks, this secret is used to verify the electronic "
|
||||
"signature of events sent by Stripe to Odoo. Failing to set this field in Odoo "
|
||||
"will disable the webhook system for this acquirer entirely.")
|
||||
stripe_image_url = fields.Char(
|
||||
"Checkout Image URL", groups='base.group_user',
|
||||
help="A relative or absolute URL pointing to a square image of your "
|
||||
"brand or product. As defined in your Stripe profile. See: "
|
||||
"https://stripe.com/docs/checkout")
|
||||
|
||||
def stripe_form_generate_values(self, tx_values):
|
||||
self.ensure_one()
|
||||
|
||||
base_url = self.get_base_url()
|
||||
stripe_session_data = {
|
||||
'line_items[][amount]': int(tx_values['amount'] if tx_values['currency'].name in INT_CURRENCIES else float_round(tx_values['amount'] * 100, 2)),
|
||||
'line_items[][currency]': tx_values['currency'].name,
|
||||
'line_items[][quantity]': 1,
|
||||
'line_items[][name]': tx_values['reference'],
|
||||
'client_reference_id': tx_values['reference'],
|
||||
'success_url': urls.url_join(base_url, StripeController._success_url) + '?reference=%s' % tx_values['reference'],
|
||||
'cancel_url': urls.url_join(base_url, StripeController._cancel_url) + '?reference=%s' % tx_values['reference'],
|
||||
'payment_intent_data[description]': tx_values['reference'],
|
||||
'customer_email': tx_values.get('partner_email') or tx_values.get('billing_partner_email'),
|
||||
}
|
||||
|
||||
self._add_available_payment_method_types(stripe_session_data, tx_values)
|
||||
|
||||
tx_values['session_id'] = self.with_context(stripe_manual_payment=True)._create_stripe_session(stripe_session_data)
|
||||
|
||||
return tx_values
|
||||
|
||||
@api.model
|
||||
def _add_available_payment_method_types(self, stripe_session_data, tx_values):
|
||||
"""
|
||||
Add payment methods available for the given transaction
|
||||
|
||||
:param stripe_session_data: dictionary to add the payment method types to
|
||||
:param tx_values: values of the transaction to consider the payment method types for
|
||||
"""
|
||||
PMT = namedtuple('PaymentMethodType', ['name', 'countries', 'currencies', 'recurrence'])
|
||||
all_payment_method_types = [
|
||||
PMT('card', [], [], 'recurring'),
|
||||
PMT('ideal', ['nl'], ['eur'], 'punctual'),
|
||||
PMT('bancontact', ['be'], ['eur'], 'punctual'),
|
||||
PMT('eps', ['at'], ['eur'], 'punctual'),
|
||||
PMT('giropay', ['de'], ['eur'], 'punctual'),
|
||||
PMT('p24', ['pl'], ['eur', 'pln'], 'punctual'),
|
||||
]
|
||||
|
||||
existing_icons = [(icon.name or '').lower() for icon in self.env['payment.icon'].search([])]
|
||||
linked_icons = [(icon.name or '').lower() for icon in self.payment_icon_ids]
|
||||
|
||||
# We don't filter out pmt in the case the icon doesn't exist at all as it would be **implicit** exclusion
|
||||
icon_filtered = filter(lambda pmt: pmt.name == 'card' or
|
||||
pmt.name in linked_icons or
|
||||
pmt.name not in existing_icons, all_payment_method_types)
|
||||
country = (tx_values['billing_partner_country'].code or 'no_country').lower()
|
||||
pmt_country_filtered = filter(lambda pmt: not pmt.countries or country in pmt.countries, icon_filtered)
|
||||
currency = (tx_values.get('currency').name or 'no_currency').lower()
|
||||
pmt_currency_filtered = filter(lambda pmt: not pmt.currencies or currency in pmt.currencies, pmt_country_filtered)
|
||||
pmt_recurrence_filtered = filter(lambda pmt: tx_values.get('type') != 'form_save' or pmt.recurrence == 'recurring',
|
||||
pmt_currency_filtered)
|
||||
|
||||
available_payment_method_types = map(lambda pmt: pmt.name, pmt_recurrence_filtered)
|
||||
|
||||
for idx, payment_method_type in enumerate(available_payment_method_types):
|
||||
stripe_session_data[f'payment_method_types[{idx}]'] = payment_method_type
|
||||
|
||||
def _stripe_request(self, url, data=False, method='POST'):
|
||||
self.ensure_one()
|
||||
url = urls.url_join(self._get_stripe_api_url(), url)
|
||||
headers = {
|
||||
'AUTHORIZATION': 'Bearer %s' % self.sudo().stripe_secret_key,
|
||||
'Stripe-Version': '2019-05-16', # SetupIntent need a specific version
|
||||
}
|
||||
resp = requests.request(method, url, data=data, headers=headers)
|
||||
# Stripe can send 4XX errors for payment failure (not badly-formed requests)
|
||||
# check if error `code` is present in 4XX response and raise only if not
|
||||
# cfr https://stripe.com/docs/error-codes
|
||||
# these can be made customer-facing, as they usually indicate a problem with the payment
|
||||
# (e.g. insufficient funds, expired card, etc.)
|
||||
# if the context key `stripe_manual_payment` is set then these errors will be raised as ValidationError,
|
||||
# otherwise, they will be silenced, and the will be returned no matter the status.
|
||||
# This key should typically be set for payments in the present and unset for automated payments
|
||||
# (e.g. through crons)
|
||||
if not resp.ok and self._context.get('stripe_manual_payment') and (400 <= resp.status_code < 500 and resp.json().get('error', {}).get('code')):
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except HTTPError:
|
||||
_logger.error(resp.text)
|
||||
stripe_error = resp.json().get('error', {}).get('message', '')
|
||||
error_msg = " " + (_("Stripe gave us the following info about the problem: '%s'", stripe_error))
|
||||
raise ValidationError(error_msg)
|
||||
return resp.json()
|
||||
|
||||
def _create_stripe_session(self, kwargs):
|
||||
self.ensure_one()
|
||||
resp = self._stripe_request('checkout/sessions', kwargs)
|
||||
if resp.get('payment_intent') and kwargs.get('client_reference_id'):
|
||||
tx = self.env['payment.transaction'].sudo().search([('reference', '=', kwargs['client_reference_id'])])
|
||||
tx.stripe_payment_intent = resp['payment_intent']
|
||||
if 'id' not in resp and 'error' in resp:
|
||||
_logger.error(resp['error']['message'])
|
||||
return resp['id']
|
||||
|
||||
def _create_setup_intent(self, kwargs):
|
||||
self.ensure_one()
|
||||
params = {
|
||||
'usage': 'off_session',
|
||||
}
|
||||
_logger.info('_stripe_create_setup_intent: Sending values to stripe, values:\n%s', pprint.pformat(params))
|
||||
|
||||
res = self._stripe_request('setup_intents', params)
|
||||
|
||||
_logger.info('_stripe_create_setup_intent: Values received:\n%s', pprint.pformat(res))
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_stripe_api_url(self):
|
||||
return 'https://api.stripe.com/v1/'
|
||||
|
||||
@api.model
|
||||
def stripe_s2s_form_process(self, data):
|
||||
if 'card' in data and not data.get('card'):
|
||||
# coming back from a checkout payment and iDeal (or another non-card pm)
|
||||
# can't save the token if it's not a card
|
||||
# note that in the case of a s2s payment, 'card' wont be
|
||||
# in the data dict because we need to fetch it from the stripe server
|
||||
_logger.info('unable to save card info from Stripe since the payment was not done with a card')
|
||||
return self.env['payment.token']
|
||||
last4 = data.get('card', {}).get('last4')
|
||||
if not last4:
|
||||
# PM was created with a setup intent, need to get last4 digits through
|
||||
# yet another call -_-
|
||||
acquirer_id = self.env['payment.acquirer'].browse(int(data['acquirer_id']))
|
||||
pm = data.get('payment_method')
|
||||
res = acquirer_id._stripe_request('payment_methods/%s' % pm, data=False, method='GET')
|
||||
last4 = res.get('card', {}).get('last4', '****')
|
||||
|
||||
payment_token = self.env['payment.token'].sudo().create({
|
||||
'acquirer_id': int(data['acquirer_id']),
|
||||
'partner_id': int(data['partner_id']),
|
||||
'stripe_payment_method': data.get('payment_method'),
|
||||
'name': 'XXXXXXXXXXXX%s' % last4,
|
||||
'acquirer_ref': data.get('customer')
|
||||
})
|
||||
return payment_token
|
||||
|
||||
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:
|
||||
* tokenize: support saving payment data in a payment.tokenize
|
||||
object
|
||||
"""
|
||||
res = super(PaymentAcquirerStripe, self)._get_feature_support()
|
||||
res['tokenize'].append('stripe')
|
||||
return res
|
||||
|
||||
def _handle_stripe_webhook(self, data):
|
||||
"""Process a webhook payload from Stripe.
|
||||
|
||||
Post-process a webhook payload to act upon the matching payment.transaction
|
||||
record in Odoo.
|
||||
"""
|
||||
wh_type = data.get('type')
|
||||
if wh_type != 'checkout.session.completed':
|
||||
_logger.info('unsupported webhook type %s, ignored', wh_type)
|
||||
return False
|
||||
|
||||
_logger.info('handling %s webhook event from stripe', wh_type)
|
||||
|
||||
stripe_object = data.get('data', {}).get('object')
|
||||
if not stripe_object:
|
||||
raise ValidationError('Stripe Webhook data does not conform to the expected API.')
|
||||
if wh_type == 'checkout.session.completed':
|
||||
return self._handle_checkout_webhook(stripe_object)
|
||||
return False
|
||||
|
||||
def _verify_stripe_signature(self):
|
||||
"""
|
||||
:return: true if and only if signature matches hash of payload calculated with secret
|
||||
:raises ValidationError: if signature doesn't match
|
||||
"""
|
||||
if not self.stripe_webhook_secret:
|
||||
raise ValidationError('webhook event received but webhook secret is not configured')
|
||||
signature = request.httprequest.headers.get('Stripe-Signature')
|
||||
body = request.httprequest.data
|
||||
|
||||
sign_data = {k: v for (k, v) in [s.split('=') for s in signature.split(',')]}
|
||||
event_timestamp = int(sign_data['t'])
|
||||
if datetime.utcnow().timestamp() - event_timestamp > STRIPE_SIGNATURE_AGE_TOLERANCE:
|
||||
_logger.error('stripe event is too old, event is discarded')
|
||||
raise ValidationError('event timestamp older than tolerance')
|
||||
|
||||
signed_payload = "%s.%s" % (event_timestamp, body.decode('utf-8'))
|
||||
|
||||
actual_signature = sign_data['v1']
|
||||
expected_signature = hmac.new(self.stripe_webhook_secret.encode('utf-8'),
|
||||
signed_payload.encode('utf-8'),
|
||||
sha256).hexdigest()
|
||||
|
||||
if not consteq(expected_signature, actual_signature):
|
||||
_logger.error(
|
||||
'incorrect webhook signature from Stripe, check if the webhook signature '
|
||||
'in Odoo matches to one in the Stripe dashboard')
|
||||
raise ValidationError('incorrect webhook signature')
|
||||
|
||||
return True
|
||||
|
||||
def _handle_checkout_webhook(self, checkout_object: dir):
|
||||
"""
|
||||
Process a checkout.session.completed Stripe web hook event,
|
||||
mark related payment successful
|
||||
|
||||
:param checkout_object: provided in the request body
|
||||
:return: True if and only if handling went well, False otherwise
|
||||
:raises ValidationError: if input isn't usable
|
||||
"""
|
||||
tx_reference = checkout_object.get('client_reference_id')
|
||||
data = {'reference': tx_reference}
|
||||
try:
|
||||
odoo_tx = self.env['payment.transaction']._stripe_form_get_tx_from_data(data)
|
||||
except ValidationError as e:
|
||||
_logger.info('Received notification for tx %s. Skipped it because of %s', tx_reference, e)
|
||||
return False
|
||||
|
||||
PaymentAcquirerStripe._verify_stripe_signature(odoo_tx.acquirer_id)
|
||||
|
||||
url = 'payment_intents/%s' % odoo_tx.stripe_payment_intent
|
||||
stripe_tx = odoo_tx.acquirer_id._stripe_request(url)
|
||||
|
||||
if 'error' in stripe_tx:
|
||||
error = stripe_tx['error']
|
||||
raise ValidationError("Could not fetch Stripe payment intent related to %s because of %s; see %s" % (
|
||||
odoo_tx, error['message'], error['doc_url']))
|
||||
|
||||
if stripe_tx.get('charges') and stripe_tx.get('charges').get('total_count'):
|
||||
charge = stripe_tx.get('charges').get('data')[0]
|
||||
data.update(charge)
|
||||
|
||||
return odoo_tx.form_feedback(data, 'stripe')
|
||||
|
||||
|
||||
class PaymentTransactionStripe(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
stripe_payment_intent = fields.Char(string='Stripe Payment Intent ID', readonly=True)
|
||||
stripe_payment_intent_secret = fields.Char(string='Stripe Payment Intent Secret', readonly=True)
|
||||
|
||||
def _get_processing_info(self):
|
||||
res = super()._get_processing_info()
|
||||
if self.acquirer_id.provider == 'stripe':
|
||||
stripe_info = {
|
||||
'stripe_payment_intent': self.stripe_payment_intent,
|
||||
'stripe_payment_intent_secret': self.stripe_payment_intent_secret,
|
||||
'stripe_publishable_key': self.acquirer_id.stripe_publishable_key,
|
||||
}
|
||||
res.update(stripe_info)
|
||||
return res
|
||||
|
||||
def form_feedback(self, data, acquirer_name):
|
||||
if data.get('reference') and acquirer_name == 'stripe':
|
||||
transaction = self.env['payment.transaction'].search([('reference', '=', data['reference'])])
|
||||
|
||||
url = 'payment_intents/%s' % transaction.stripe_payment_intent
|
||||
resp = transaction.acquirer_id._stripe_request(url)
|
||||
if resp.get('charges') and resp.get('charges').get('total_count'):
|
||||
resp = resp.get('charges').get('data')[0]
|
||||
|
||||
data.update(resp)
|
||||
_logger.info('Stripe: entering form_feedback with post data %s' % pprint.pformat(data))
|
||||
return super(PaymentTransactionStripe, self).form_feedback(data, acquirer_name)
|
||||
|
||||
def _stripe_create_payment_intent(self, acquirer_ref=None, email=None):
|
||||
if not self.payment_token_id.stripe_payment_method:
|
||||
# old token before using sca, need to fetch data from the api
|
||||
self.payment_token_id._stripe_sca_migrate_customer()
|
||||
|
||||
charge_params = {
|
||||
'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else float_round(self.amount * 100, 2)),
|
||||
'currency': self.currency_id.name.lower(),
|
||||
'off_session': True,
|
||||
'confirm': True,
|
||||
'payment_method': self.payment_token_id.stripe_payment_method,
|
||||
'customer': self.payment_token_id.acquirer_ref,
|
||||
"description": self.reference,
|
||||
}
|
||||
if not self.env.context.get('off_session'):
|
||||
charge_params.update(setup_future_usage='off_session', off_session=False)
|
||||
_logger.info('_stripe_create_payment_intent: Sending values to stripe, values:\n%s', pprint.pformat(charge_params))
|
||||
|
||||
res = self.acquirer_id._stripe_request('payment_intents', charge_params)
|
||||
if res.get('charges') and res.get('charges').get('total_count'):
|
||||
res = res.get('charges').get('data')[0]
|
||||
|
||||
_logger.info('_stripe_create_payment_intent: Values received:\n%s', pprint.pformat(res))
|
||||
return res
|
||||
|
||||
def stripe_s2s_do_transaction(self, **kwargs):
|
||||
self.ensure_one()
|
||||
result = self._stripe_create_payment_intent(acquirer_ref=self.payment_token_id.acquirer_ref, email=self.partner_email)
|
||||
return self._stripe_s2s_validate_tree(result)
|
||||
|
||||
def _create_stripe_refund(self):
|
||||
|
||||
refund_params = {
|
||||
'charge': self.acquirer_reference,
|
||||
'amount': int(float_round(self.amount * 100, 2)), # by default, stripe refund the full amount (we don't really need to specify the value)
|
||||
'metadata[reference]': self.reference,
|
||||
}
|
||||
|
||||
_logger.info('_create_stripe_refund: Sending values to stripe URL, values:\n%s', pprint.pformat(refund_params))
|
||||
res = self.acquirer_id._stripe_request('refunds', refund_params)
|
||||
_logger.info('_create_stripe_refund: Values received:\n%s', pprint.pformat(res))
|
||||
|
||||
return res
|
||||
|
||||
def stripe_s2s_do_refund(self, **kwargs):
|
||||
self.ensure_one()
|
||||
result = self._create_stripe_refund()
|
||||
return self._stripe_s2s_validate_tree(result)
|
||||
|
||||
@api.model
|
||||
def _stripe_form_get_tx_from_data(self, data):
|
||||
""" Given a data dict coming from stripe, verify it and find the related
|
||||
transaction record. """
|
||||
reference = data.get('reference')
|
||||
if not reference:
|
||||
stripe_error = data.get('error', {}).get('message', '')
|
||||
_logger.error('Stripe: invalid reply received from stripe API, looks like '
|
||||
'the transaction failed. (error: %s)', stripe_error or 'n/a')
|
||||
error_msg = _("We're sorry to report that the transaction has failed.")
|
||||
if stripe_error:
|
||||
error_msg += " " + (_("Stripe gave us the following info about the problem: '%s'") %
|
||||
stripe_error)
|
||||
error_msg += " " + _("Perhaps the problem can be solved by double-checking your "
|
||||
"credit card details, or contacting your bank?")
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
tx = self.search([('reference', '=', reference)])
|
||||
if not tx:
|
||||
error_msg = _('Stripe: no order found for reference %s', reference)
|
||||
_logger.error(error_msg)
|
||||
raise ValidationError(error_msg)
|
||||
elif len(tx) > 1:
|
||||
error_msg = _('Stripe: %(count)s orders found for reference %(reference)s', count=len(tx), reference=reference)
|
||||
_logger.error(error_msg)
|
||||
raise ValidationError(error_msg)
|
||||
return tx[0]
|
||||
|
||||
def _stripe_s2s_validate_tree(self, tree):
|
||||
self.ensure_one()
|
||||
if self.state not in ("draft", "pending"):
|
||||
_logger.info('Stripe: trying to validate an already validated tx (ref %s)', self.reference)
|
||||
return True
|
||||
|
||||
status = tree.get('status')
|
||||
tx_id = tree.get('id')
|
||||
tx_secret = tree.get("client_secret")
|
||||
pi_id = tree.get('payment_intent')
|
||||
vals = {
|
||||
"date": fields.datetime.now(),
|
||||
"acquirer_reference": tx_id,
|
||||
"stripe_payment_intent": pi_id or tx_id,
|
||||
"stripe_payment_intent_secret": tx_secret
|
||||
}
|
||||
if status == 'succeeded':
|
||||
self.write(vals)
|
||||
self._set_transaction_done()
|
||||
self.execute_callback()
|
||||
if self.type == 'form_save':
|
||||
s2s_data = {
|
||||
'customer': tree.get('customer'),
|
||||
'payment_method': tree.get('payment_method'),
|
||||
'card': tree.get('payment_method_details').get('card'),
|
||||
'acquirer_id': self.acquirer_id.id,
|
||||
'partner_id': self.partner_id.id
|
||||
}
|
||||
token = self.acquirer_id.stripe_s2s_form_process(s2s_data)
|
||||
self.payment_token_id = token.id
|
||||
if self.payment_token_id:
|
||||
self.payment_token_id.verified = True
|
||||
return True
|
||||
if status in ('processing', 'requires_action'):
|
||||
self.write(vals)
|
||||
self._set_transaction_pending()
|
||||
return True
|
||||
if status == 'requires_payment_method':
|
||||
self._set_transaction_cancel()
|
||||
self.acquirer_id._stripe_request('payment_intents/%s/cancel' % self.stripe_payment_intent)
|
||||
return False
|
||||
else:
|
||||
error = tree.get("failure_message") or tree.get('error', {}).get('message')
|
||||
self._set_transaction_error(error)
|
||||
return False
|
||||
|
||||
def _stripe_form_get_invalid_parameters(self, data):
|
||||
invalid_parameters = []
|
||||
if data.get('amount') != int(self.amount if self.currency_id.name in INT_CURRENCIES else float_round(self.amount * 100, 2)):
|
||||
invalid_parameters.append(('Amount', data.get('amount'), self.amount * 100))
|
||||
if data.get('currency') and data.get('currency').upper() != self.currency_id.name:
|
||||
invalid_parameters.append(('Currency', data.get('currency'), self.currency_id.name))
|
||||
if data.get('payment_intent') and data.get('payment_intent') != self.stripe_payment_intent:
|
||||
invalid_parameters.append(('Payment Intent', data.get('payment_intent'), self.stripe_payment_intent))
|
||||
return invalid_parameters
|
||||
|
||||
def _stripe_form_validate(self, data):
|
||||
return self._stripe_s2s_validate_tree(data)
|
||||
|
||||
|
||||
class PaymentTokenStripe(models.Model):
|
||||
_inherit = 'payment.token'
|
||||
|
||||
stripe_payment_method = fields.Char('Payment Method ID')
|
||||
|
||||
@api.model
|
||||
def stripe_create(self, values):
|
||||
if values.get('stripe_payment_method') and not values.get('acquirer_ref'):
|
||||
partner_id = self.env['res.partner'].browse(values.get('partner_id'))
|
||||
payment_acquirer = self.env['payment.acquirer'].browse(values.get('acquirer_id'))
|
||||
|
||||
# create customer to stipe
|
||||
customer_data = {
|
||||
'email': partner_id.email
|
||||
}
|
||||
cust_resp = payment_acquirer._stripe_request('customers', customer_data)
|
||||
|
||||
# link customer with payment method
|
||||
api_url_payment_method = 'payment_methods/%s/attach' % values['stripe_payment_method']
|
||||
method_data = {
|
||||
'customer': cust_resp.get('id')
|
||||
}
|
||||
payment_acquirer._stripe_request(api_url_payment_method, method_data)
|
||||
return {
|
||||
'acquirer_ref': cust_resp['id'],
|
||||
}
|
||||
return values
|
||||
|
||||
def _stripe_sca_migrate_customer(self):
|
||||
"""Migrate a token from the old implementation of Stripe to the SCA one.
|
||||
|
||||
In the old implementation, it was possible to create a valid charge just by
|
||||
giving the customer ref to ask Stripe to use the default source (= default
|
||||
card). Since we have a one-to-one matching between a saved card, this used to
|
||||
work well - but now we need to specify the payment method for each call and so
|
||||
we have to contact stripe to get the default source for the customer and save it
|
||||
in the payment token.
|
||||
This conversion will happen once per token, the first time it gets used following
|
||||
the installation of the module."""
|
||||
self.ensure_one()
|
||||
url = "customers/%s" % (self.acquirer_ref)
|
||||
data = self.acquirer_id._stripe_request(url, method="GET")
|
||||
sources = data.get('sources', {}).get('data', [])
|
||||
pm_ref = False
|
||||
if sources:
|
||||
if len(sources) > 1:
|
||||
_logger.warning('stripe sca customer conversion: there should be a single saved source per customer!')
|
||||
pm_ref = sources[0].get('id')
|
||||
else:
|
||||
url = 'payment_methods'
|
||||
params = {
|
||||
'type': 'card',
|
||||
'customer': self.acquirer_ref,
|
||||
}
|
||||
payment_methods = self.acquirer_id._stripe_request(url, params, method='GET')
|
||||
cards = payment_methods.get('data', [])
|
||||
if len(cards) > 1:
|
||||
_logger.warning('stripe sca customer conversion: there should be a single saved source per customer!')
|
||||
pm_ref = cards and cards[0].get('id')
|
||||
if not pm_ref:
|
||||
raise ValidationError(_('Unable to convert Stripe customer for SCA compatibility. Is there at least one card for this customer in the Stripe backend?'))
|
||||
self.stripe_payment_method = pm_ref
|
||||
_logger.info('converted old customer ref to sca-compatible record for payment token %s', self.id)
|
||||
@@ -0,0 +1,83 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentAcquirer(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(
|
||||
selection_add=[('stripe', "Stripe")], ondelete={'stripe': 'set default'})
|
||||
stripe_publishable_key = fields.Char(
|
||||
string="Publishable Key", help="The key solely used to identify the account with Stripe",
|
||||
required_if_provider='stripe')
|
||||
stripe_secret_key = fields.Char(
|
||||
string="Secret Key", required_if_provider='stripe', groups='base.group_system')
|
||||
stripe_webhook_secret = fields.Char(
|
||||
string="Webhook Signing Secret",
|
||||
help="If a webhook is enabled on your Stripe account, this signing secret must be set to "
|
||||
"authenticate the messages sent from Stripe to Odoo.",
|
||||
groups='base.group_system')
|
||||
|
||||
def _get_validation_amount(self):
|
||||
""" Override of payment to return the amount for Stripe validation operations.
|
||||
|
||||
:return: The validation amount
|
||||
:rtype: float
|
||||
"""
|
||||
res = super()._get_validation_amount()
|
||||
if self.provider != 'stripe':
|
||||
return res
|
||||
|
||||
return 1.0
|
||||
|
||||
def _stripe_make_request(self, endpoint, payload=None, method='POST'):
|
||||
""" Make a request to Stripe API at the specified endpoint.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param str endpoint: The endpoint to be reached by the request
|
||||
:param dict payload: The payload of the request
|
||||
:param str method: The HTTP method of the request
|
||||
:return The JSON-formatted content of the response
|
||||
:rtype: dict
|
||||
:raise: ValidationError if an HTTP error occurs
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
url = urls.url_join('https://api.stripe.com/v1/', endpoint)
|
||||
headers = {
|
||||
'AUTHORIZATION': f'Bearer {self.stripe_secret_key}',
|
||||
'Stripe-Version': '2019-05-16', # SetupIntent needs a specific version
|
||||
}
|
||||
try:
|
||||
response = requests.request(method, url, data=payload, headers=headers, timeout=60)
|
||||
# Stripe can send 4XX errors for payment failures (not only for badly-formed requests).
|
||||
# Check if an error code is present in the response content and raise only if not.
|
||||
# See https://stripe.com/docs/error-codes.
|
||||
if not response.ok \
|
||||
and 400 <= response.status_code < 500 \
|
||||
and response.json().get('error'): # The 'code' entry is sometimes missing
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError:
|
||||
_logger.exception("invalid API request at %s with data %s", url, payload)
|
||||
error_msg = response.json().get('error', {}).get('message', '')
|
||||
raise ValidationError(
|
||||
"Stripe: " + _(
|
||||
"The communication with the API failed.\n"
|
||||
"Stripe gave us the following info about the problem:\n'%s'", error_msg
|
||||
)
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
_logger.exception("unable to reach endpoint at %s", url)
|
||||
raise ValidationError("Stripe: " + _("Could not establish the connection to the API."))
|
||||
return response.json()
|
||||
@@ -0,0 +1,50 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentToken(models.Model):
|
||||
_inherit = 'payment.token'
|
||||
|
||||
stripe_payment_method = fields.Char(string="Stripe Payment Method ID", readonly=True)
|
||||
|
||||
def _stripe_sca_migrate_customer(self):
|
||||
""" Migrate a token from the old implementation of Stripe to the SCA-compliant one.
|
||||
|
||||
In the old implementation, it was possible to create a Charge by giving only the customer id
|
||||
and let Stripe use the default source (= default payment method). Stripe now requires to
|
||||
specify the payment method for each new PaymentIntent. To do so, we fetch the payment method
|
||||
associated to a customer and save its id on the token.
|
||||
This migration happens once per token created with the old implementation.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Fetch the available payment method of type 'card' for the given customer
|
||||
response_content = self.acquirer_id._stripe_make_request(
|
||||
'payment_methods',
|
||||
payload={
|
||||
'customer': self.acquirer_ref,
|
||||
'type': 'card',
|
||||
'limit': 1, # A new customer is created for each new token. Never > 1 card.
|
||||
},
|
||||
method='GET'
|
||||
)
|
||||
_logger.info("received payment_methods response:\n%s", pprint.pformat(response_content))
|
||||
|
||||
# Store the payment method ID on the token
|
||||
payment_methods = response_content.get('data', [])
|
||||
payment_method_id = payment_methods and payment_methods[0].get('id')
|
||||
if not payment_method_id:
|
||||
raise ValidationError("Stripe: " + _("Unable to convert payment token to new API."))
|
||||
self.stripe_payment_method = payment_method_id
|
||||
_logger.info("converted token with id %s to new API", self.id)
|
||||
@@ -0,0 +1,301 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
from odoo.addons.payment_stripe.const import INTENT_STATUS_MAPPING, PAYMENT_METHOD_TYPES
|
||||
from odoo.addons.payment_stripe.controllers.main import StripeController
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
stripe_payment_intent = fields.Char(string="Stripe Payment Intent ID", readonly=True)
|
||||
|
||||
def _get_specific_processing_values(self, processing_values):
|
||||
""" Override of payment to return Stripe-specific processing values.
|
||||
|
||||
Note: self.ensure_one() from `_get_processing_values`
|
||||
|
||||
:param dict processing_values: The generic processing values of the transaction
|
||||
:return: The dict of acquirer-specific processing values
|
||||
:rtype: dict
|
||||
"""
|
||||
res = super()._get_specific_processing_values(processing_values)
|
||||
if self.provider != 'stripe' or self.operation == 'online_token':
|
||||
return res
|
||||
|
||||
checkout_session = self._stripe_create_checkout_session()
|
||||
return {
|
||||
'publishable_key': self.acquirer_id.stripe_publishable_key,
|
||||
'session_id': checkout_session['id'],
|
||||
}
|
||||
|
||||
def _stripe_create_checkout_session(self):
|
||||
""" Create and return a Checkout Session.
|
||||
|
||||
:return: The Checkout Session
|
||||
:rtype: dict
|
||||
"""
|
||||
# Filter payment method types by available payment method
|
||||
existing_pms = [pm.name.lower() for pm in self.env['payment.icon'].search([])]
|
||||
linked_pms = [pm.name.lower() for pm in self.acquirer_id.payment_icon_ids]
|
||||
pm_filtered_pmts = filter(
|
||||
lambda pmt: pmt.name == 'card'
|
||||
# If the PM (payment.icon) record related to a PMT doesn't exist, don't filter out the
|
||||
# PMT because the user couldn't even have linked it to the acquirer in the first place.
|
||||
or (pmt.name in linked_pms or pmt.name not in existing_pms),
|
||||
PAYMENT_METHOD_TYPES
|
||||
)
|
||||
# Filter payment method types by country code
|
||||
country_code = self.partner_country_id and self.partner_country_id.code.lower()
|
||||
country_filtered_pmts = filter(
|
||||
lambda pmt: not pmt.countries or country_code in pmt.countries, pm_filtered_pmts
|
||||
)
|
||||
# Filter payment method types by currency name
|
||||
currency_name = self.currency_id.name.lower()
|
||||
currency_filtered_pmts = filter(
|
||||
lambda pmt: not pmt.currencies or currency_name in pmt.currencies, country_filtered_pmts
|
||||
)
|
||||
# Filter payment method types by recurrence if the transaction must be tokenized
|
||||
if self.tokenize:
|
||||
recurrence_filtered_pmts = filter(
|
||||
lambda pmt: pmt.recurrence == 'recurring', currency_filtered_pmts
|
||||
)
|
||||
else:
|
||||
recurrence_filtered_pmts = currency_filtered_pmts
|
||||
# Build the session values related to payment method types
|
||||
pmt_values = {}
|
||||
for pmt_id, pmt_name in enumerate(map(lambda pmt: pmt.name, recurrence_filtered_pmts)):
|
||||
pmt_values[f'payment_method_types[{pmt_id}]'] = pmt_name
|
||||
|
||||
# Create the session according to the operation and return it
|
||||
customer = self._stripe_create_customer()
|
||||
common_session_values = {
|
||||
**pmt_values,
|
||||
'client_reference_id': self.reference,
|
||||
# Assign a customer to the session so that Stripe automatically attaches the payment
|
||||
# method to it in a validation flow. In checkout flow, a customer is automatically
|
||||
# created if not provided but we still do it here to avoid requiring the customer to
|
||||
# enter his email on the checkout page.
|
||||
'customer': customer['id'],
|
||||
}
|
||||
base_url = self.acquirer_id._get_base_url()
|
||||
if self.operation == 'online_redirect':
|
||||
return_url = f'{urls.url_join(base_url, StripeController._checkout_return_url)}' \
|
||||
f'?reference={self.reference}'
|
||||
# Specify a future usage for the payment intent to:
|
||||
# 1. attach the payment method to the created customer
|
||||
# 2. trigger a 3DS check if one if required, while the customer is still present
|
||||
future_usage = 'off_session' if self.tokenize else None
|
||||
checkout_session = self.acquirer_id._stripe_make_request(
|
||||
'checkout/sessions', payload={
|
||||
**common_session_values,
|
||||
'mode': 'payment',
|
||||
'success_url': return_url,
|
||||
'cancel_url': return_url,
|
||||
'line_items[0][price_data][currency]': self.currency_id.name,
|
||||
'line_items[0][price_data][product_data][name]': self.reference,
|
||||
'line_items[0][price_data][unit_amount]': payment_utils.to_minor_currency_units(
|
||||
self.amount, self.currency_id
|
||||
),
|
||||
'line_items[0][quantity]': 1,
|
||||
'payment_intent_data[description]': self.reference,
|
||||
'payment_intent_data[setup_future_usage]': future_usage,
|
||||
}
|
||||
)
|
||||
self.stripe_payment_intent = checkout_session['payment_intent']
|
||||
else: # 'validation'
|
||||
# {CHECKOUT_SESSION_ID} is a template filled by Stripe when the Session is created
|
||||
return_url = f'{urls.url_join(base_url, StripeController._validation_return_url)}' \
|
||||
f'?reference={self.reference}&checkout_session_id={{CHECKOUT_SESSION_ID}}'
|
||||
checkout_session = self.acquirer_id._stripe_make_request(
|
||||
'checkout/sessions', payload={
|
||||
**common_session_values,
|
||||
'mode': 'setup',
|
||||
'success_url': return_url,
|
||||
'cancel_url': return_url,
|
||||
}
|
||||
)
|
||||
return checkout_session
|
||||
|
||||
def _stripe_create_customer(self):
|
||||
""" Create and return a Customer.
|
||||
|
||||
:return: The Customer
|
||||
:rtype: dict
|
||||
"""
|
||||
customer = self.acquirer_id._stripe_make_request(
|
||||
'customers', payload={
|
||||
'address[city]': self.partner_city or None,
|
||||
'address[country]': self.partner_country_id.code or None,
|
||||
'address[line1]': self.partner_address or None,
|
||||
'address[postal_code]': self.partner_zip or None,
|
||||
'address[state]': self.partner_state_id.name or None,
|
||||
'description': f'ODOO_PARTNER_{self.partner_id.id}',
|
||||
'email': self.partner_email,
|
||||
'name': self.partner_name,
|
||||
'phone': self.partner_phone or None,
|
||||
}
|
||||
)
|
||||
return customer
|
||||
|
||||
def _send_payment_request(self):
|
||||
""" Override of payment to send a payment request to Stripe with a confirmed PaymentIntent.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: None
|
||||
:raise: UserError if the transaction is not linked to a token
|
||||
"""
|
||||
super()._send_payment_request()
|
||||
if self.provider != 'stripe':
|
||||
return
|
||||
|
||||
# Make the payment request to Stripe
|
||||
if not self.token_id:
|
||||
raise UserError("Stripe: " + _("The transaction is not linked to a token."))
|
||||
|
||||
payment_intent = self._stripe_create_payment_intent()
|
||||
feedback_data = {
|
||||
'reference': self.reference,
|
||||
'payment_intent': payment_intent,
|
||||
}
|
||||
_logger.info("entering _handle_feedback_data with data:\n%s", pprint.pformat(feedback_data))
|
||||
self._handle_feedback_data('stripe', feedback_data)
|
||||
|
||||
def _stripe_create_payment_intent(self):
|
||||
""" Create and return a PaymentIntent.
|
||||
|
||||
:return: The Payment Intent
|
||||
:rtype: dict
|
||||
"""
|
||||
if not self.token_id.stripe_payment_method: # Pre-SCA token -> migrate it
|
||||
self.token_id._stripe_sca_migrate_customer()
|
||||
|
||||
payment_intent = self.acquirer_id._stripe_make_request('payment_intents', payload={
|
||||
'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id),
|
||||
'currency': self.currency_id.name.lower(),
|
||||
'confirm': True,
|
||||
'customer': self.token_id.acquirer_ref,
|
||||
'off_session': True,
|
||||
'payment_method': self.token_id.stripe_payment_method,
|
||||
'description': self.reference,
|
||||
})
|
||||
return payment_intent
|
||||
|
||||
@api.model
|
||||
def _get_tx_from_feedback_data(self, provider, data):
|
||||
""" Override of payment to find the transaction based on Stripe 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 inconsistent data were received
|
||||
:raise: ValidationError if the data match no transaction
|
||||
"""
|
||||
tx = super()._get_tx_from_feedback_data(provider, data)
|
||||
if provider != 'stripe':
|
||||
return tx
|
||||
|
||||
reference = data.get('reference')
|
||||
if not reference:
|
||||
raise ValidationError("Stripe: " + _("Received data with missing merchant reference"))
|
||||
|
||||
tx = self.search([('reference', '=', reference), ('provider', '=', 'stripe')])
|
||||
if not tx:
|
||||
raise ValidationError(
|
||||
"Stripe: " + _("No transaction found matching reference %s.", reference)
|
||||
)
|
||||
return tx
|
||||
|
||||
def _process_feedback_data(self, data):
|
||||
""" Override of payment to process the transaction based on Adyen data.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param dict data: The feedback data build from information passed to the return route.
|
||||
Depending on the operation of the transaction, the entries with the keys
|
||||
'payment_intent', 'charge', 'setup_intent' and 'payment_method' can be
|
||||
populated with their corresponding Stripe API objects.
|
||||
:return: None
|
||||
:raise: ValidationError if inconsistent data were received
|
||||
"""
|
||||
super()._process_feedback_data(data)
|
||||
if self.provider != 'stripe':
|
||||
return
|
||||
|
||||
# Handle the intent status
|
||||
if self.operation == 'online_redirect' or self.operation == 'online_token':
|
||||
intent_status = data.get('payment_intent', {}).get('status')
|
||||
else: # 'validation'
|
||||
intent_status = data.get('setup_intent', {}).get('status')
|
||||
if not intent_status:
|
||||
raise ValidationError(
|
||||
"Stripe: " + _("Received data with missing intent status.")
|
||||
)
|
||||
|
||||
if intent_status in INTENT_STATUS_MAPPING['draft']:
|
||||
pass
|
||||
elif intent_status in INTENT_STATUS_MAPPING['pending']:
|
||||
self._set_pending()
|
||||
elif intent_status in INTENT_STATUS_MAPPING['done']:
|
||||
if self.tokenize:
|
||||
self._stripe_tokenize_from_feedback_data(data)
|
||||
self._set_done()
|
||||
elif intent_status in INTENT_STATUS_MAPPING['cancel']:
|
||||
self._set_canceled()
|
||||
else: # Classify unknown intent statuses as `error` tx state
|
||||
_logger.warning("received data with invalid intent status: %s", intent_status)
|
||||
self._set_error(
|
||||
"Stripe: " + _("Received data with invalid intent status: %s", intent_status)
|
||||
)
|
||||
|
||||
def _stripe_tokenize_from_feedback_data(self, data):
|
||||
""" Create a new token based on the feedback data.
|
||||
|
||||
:param dict data: The feedback data built with Stripe objects. See `_process_feedback_data`.
|
||||
:return: None
|
||||
"""
|
||||
if self.operation == 'online_redirect':
|
||||
payment_method_id = data.get('charge', {}).get('payment_method')
|
||||
customer_id = data.get('charge', {}).get('customer')
|
||||
else: # 'validation'
|
||||
payment_method_id = data.get('setup_intent', {}).get('payment_method', {}).get('id')
|
||||
customer_id = data.get('setup_intent', {}).get('customer')
|
||||
payment_method = data.get('payment_method')
|
||||
if not payment_method_id or not payment_method:
|
||||
_logger.warning("requested tokenization with payment method missing from feedback data")
|
||||
return
|
||||
|
||||
if payment_method.get('type') != 'card':
|
||||
# Only 'card' payment methods can be tokenized. This case should normally not happen as
|
||||
# non-recurring payment methods are not shown to the customer if the "Save my payment
|
||||
# details checkbox" is shown. Still, better be on the safe side..
|
||||
_logger.warning("requested tokenization of non-recurring payment method")
|
||||
return
|
||||
|
||||
token = self.env['payment.token'].create({
|
||||
'acquirer_id': self.acquirer_id.id,
|
||||
'name': payment_utils.build_token_name(payment_method['card'].get('last4')),
|
||||
'partner_id': self.partner_id.id,
|
||||
'acquirer_ref': customer_id,
|
||||
'verified': True,
|
||||
'stripe_payment_method': payment_method_id,
|
||||
})
|
||||
self.write({
|
||||
'token_id': token,
|
||||
'tokenize': False,
|
||||
})
|
||||
_logger.info(
|
||||
"created token with id %s for partner with id %s", token.id, self.partner_id.id
|
||||
)
|
||||
@@ -1,190 +1,35 @@
|
||||
odoo.define('payment_stripe.payment_form', function (require) {
|
||||
"use strict";
|
||||
odoo.define('payment_stripe.payment_form', require => {
|
||||
'use strict';
|
||||
|
||||
var ajax = require('web.ajax');
|
||||
var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var PaymentForm = require('payment.payment_form');
|
||||
const checkoutForm = require('payment.checkout_form');
|
||||
const manageForm = require('payment.manage_form');
|
||||
|
||||
var qweb = core.qweb;
|
||||
var _t = core._t;
|
||||
const stripeMixin = {
|
||||
|
||||
ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb);
|
||||
|
||||
PaymentForm.include({
|
||||
|
||||
willStart: function () {
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
return ajax.loadJS("https://js.stripe.com/v3/");
|
||||
})
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* called when clicking on pay now or add payment event to create token for credit card/debit card.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
* @param {DOMElement} checkedRadio
|
||||
* @param {Boolean} addPmEvent
|
||||
*/
|
||||
_createStripeToken: function (ev, $checkedRadio, addPmEvent) {
|
||||
var self = this;
|
||||
if (ev.type === 'submit') {
|
||||
var button = $(ev.target).find('*[type="submit"]')[0]
|
||||
} else {
|
||||
var button = ev.target;
|
||||
}
|
||||
this.disableButton(button);
|
||||
var acquirerID = this.getAcquirerIdFromRadio($checkedRadio);
|
||||
var acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID);
|
||||
var inputsForm = $('input', acquirerForm);
|
||||
if (this.options.partnerId === undefined) {
|
||||
console.warn('payment_form: unset partner_id when adding new token; things could go wrong');
|
||||
}
|
||||
|
||||
var formData = self.getFormData(inputsForm);
|
||||
var stripe = this.stripe;
|
||||
var card = this.stripe_card_element;
|
||||
if (card._invalid) {
|
||||
return;
|
||||
}
|
||||
return this._rpc({
|
||||
route: '/payment/stripe/s2s/create_setup_intent',
|
||||
params: {'acquirer_id': formData.acquirer_id}
|
||||
}).then(function(intent_secret){
|
||||
return stripe.handleCardSetup(intent_secret, card);
|
||||
}).then(function(result) {
|
||||
if (result.error) {
|
||||
return Promise.reject({"message": {"data": { "arguments": [result.error.message]}}});
|
||||
} else {
|
||||
_.extend(formData, {"payment_method": result.setupIntent.payment_method});
|
||||
return self._rpc({
|
||||
route: formData.data_set,
|
||||
params: formData,
|
||||
})
|
||||
/**
|
||||
* Redirect the customer to Stripe hosted payment page.
|
||||
*
|
||||
* @override method from payment.payment_form_mixin
|
||||
* @private
|
||||
* @param {string} provider - The provider of the payment option's acquirer
|
||||
* @param {number} paymentOptionId - The id of the payment option handling the transaction
|
||||
* @param {object} processingValues - The processing values of the transaction
|
||||
* @return {undefined}
|
||||
*/
|
||||
_processRedirectPayment: function (provider, paymentOptionId, processingValues) {
|
||||
if (provider !== 'stripe') {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
}).then(function(result) {
|
||||
if (addPmEvent) {
|
||||
if (formData.return_url) {
|
||||
window.location = formData.return_url;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
$checkedRadio.val(result.id);
|
||||
self.el.submit();
|
||||
}
|
||||
}).guardedCatch(function (error) {
|
||||
// We don't want to open the Error dialog since
|
||||
// we already have a container displaying the error
|
||||
if (error.event) {
|
||||
error.event.preventDefault();
|
||||
}
|
||||
// if the rpc fails, pretty obvious
|
||||
self.enableButton(button);
|
||||
self.displayError(
|
||||
_t('Unable to save card'),
|
||||
_t("We are not able to add your payment method at the moment. ") +
|
||||
self._parseError(error)
|
||||
);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* called when clicking a Stripe radio if configured for s2s flow; instanciates the card and bind it to the widget.
|
||||
*
|
||||
* @private
|
||||
* @param {DOMElement} checkedRadio
|
||||
*/
|
||||
_bindStripeCard: function ($checkedRadio) {
|
||||
var acquirerID = this.getAcquirerIdFromRadio($checkedRadio);
|
||||
var acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID);
|
||||
var inputsForm = $('input', acquirerForm);
|
||||
var formData = this.getFormData(inputsForm);
|
||||
var stripe = Stripe(formData.stripe_publishable_key);
|
||||
var element = stripe.elements();
|
||||
var card = element.create('card', {hidePostalCode: true});
|
||||
card.mount('#card-element');
|
||||
card.on('ready', function(ev) {
|
||||
card.focus();
|
||||
});
|
||||
card.addEventListener('change', function (event) {
|
||||
var displayError = document.getElementById('card-errors');
|
||||
displayError.textContent = '';
|
||||
if (event.error) {
|
||||
displayError.textContent = event.error.message;
|
||||
}
|
||||
});
|
||||
this.stripe = stripe;
|
||||
this.stripe_card_element = card;
|
||||
},
|
||||
/**
|
||||
* destroys the card element and any stripe instance linked to the widget.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_unbindStripeCard: function () {
|
||||
if (this.stripe_card_element) {
|
||||
this.stripe_card_element.destroy();
|
||||
}
|
||||
this.stripe = undefined;
|
||||
this.stripe_card_element = undefined;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateNewPaymentDisplayStatus: function () {
|
||||
var $checkedRadio = this.$('input[type="radio"]:checked');
|
||||
|
||||
if ($checkedRadio.length !== 1) {
|
||||
return;
|
||||
}
|
||||
var provider = $checkedRadio.data('provider')
|
||||
if (provider === 'stripe') {
|
||||
// always re-init stripe (in case of multiple acquirers for stripe, make sure the stripe instance is using the right key)
|
||||
this._unbindStripeCard();
|
||||
if (this.isNewPaymentRadio($checkedRadio)) {
|
||||
this._bindStripeCard($checkedRadio);
|
||||
}
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
const stripeJS = Stripe(processingValues['publishable_key']);
|
||||
stripeJS.redirectToCheckout({
|
||||
sessionId: processingValues['session_id']
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
};
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
payEvent: function (ev) {
|
||||
ev.preventDefault();
|
||||
var $checkedRadio = this.$('input[type="radio"]:checked');
|
||||
checkoutForm.include(stripeMixin);
|
||||
manageForm.include(stripeMixin);
|
||||
|
||||
// first we check that the user has selected a stripe as s2s payment method
|
||||
if ($checkedRadio.length === 1 && this.isNewPaymentRadio($checkedRadio) && $checkedRadio.data('provider') === 'stripe') {
|
||||
return this._createStripeToken(ev, $checkedRadio);
|
||||
} else {
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
addPmEvent: function (ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
var $checkedRadio = this.$('input[type="radio"]:checked');
|
||||
|
||||
// first we check that the user has selected a stripe as add payment method
|
||||
if ($checkedRadio.length === 1 && this.isNewPaymentRadio($checkedRadio) && $checkedRadio.data('provider') === 'stripe') {
|
||||
return this._createStripeToken(ev, $checkedRadio, true);
|
||||
} else {
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
odoo.define('payment_stripe.processing', function (require) {
|
||||
'use strict';
|
||||
|
||||
var ajax = require('web.ajax');
|
||||
var rpc = require('web.rpc')
|
||||
var publicWidget = require('web.public.widget');
|
||||
|
||||
var PaymentProcessing = publicWidget.registry.PaymentProcessing;
|
||||
|
||||
return PaymentProcessing.include({
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._authInProgress = false;
|
||||
},
|
||||
willStart: function () {
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
return ajax.loadJS("https://js.stripe.com/v3/");
|
||||
})
|
||||
},
|
||||
_stripeAuthenticate: function (tx) {
|
||||
var stripe = Stripe(tx.stripe_publishable_key);
|
||||
return stripe.handleCardPayment(tx.stripe_payment_intent_secret)
|
||||
.then(function(result) {
|
||||
if (result.error) {
|
||||
return Promise.reject({"message": {"data": { "message": result.error.message}}});
|
||||
}
|
||||
return rpc.query({
|
||||
route: '/payment/stripe/s2s/process_payment_intent',
|
||||
params: _.extend({}, result.paymentIntent, {reference: tx.reference}),
|
||||
});
|
||||
}).then(function() {
|
||||
window.location = '/payment/process';
|
||||
}).guardedCatch(function () {
|
||||
this._authInProgress = false;
|
||||
});
|
||||
},
|
||||
processPolledData: function(transactions) {
|
||||
this._super.apply(this, arguments);
|
||||
for (var itx=0; itx < transactions.length; itx++) {
|
||||
var tx = transactions[itx];
|
||||
if (tx.acquirer_provider === 'stripe' && tx.state === 'pending' && tx.stripe_payment_intent_secret && !this._authInProgress) {
|
||||
this._authInProgress = true;
|
||||
this._stripeAuthenticate(tx);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
odoo.define('payment_stripe.stripe', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ajax = require('web.ajax');
|
||||
var core = require('web.core');
|
||||
|
||||
var qweb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb);
|
||||
|
||||
if ($.blockUI) {
|
||||
// our message needs to appear above the modal dialog
|
||||
$.blockUI.defaults.baseZ = 2147483647; //same z-index as StripeCheckout
|
||||
$.blockUI.defaults.css.border = '0';
|
||||
$.blockUI.defaults.css["background-color"] = '';
|
||||
$.blockUI.defaults.overlayCSS["opacity"] = '0.9';
|
||||
}
|
||||
|
||||
require('web.dom_ready');
|
||||
if (!$('.o_payment_form').length) {
|
||||
return Promise.reject("DOM doesn't contain '.o_payment_form'");
|
||||
}
|
||||
|
||||
var observer = new MutationObserver(function (mutations, observer) {
|
||||
for (var i = 0; i < mutations.length; ++i) {
|
||||
for (var j = 0; j < mutations[i].addedNodes.length; ++j) {
|
||||
if (mutations[i].addedNodes[j].tagName.toLowerCase() === "form" && mutations[i].addedNodes[j].getAttribute('provider') === 'stripe') {
|
||||
_redirectToStripeCheckout($(mutations[i].addedNodes[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function displayError(message) {
|
||||
var wizard = $(qweb.render('stripe.error', {'msg': message || _t('Payment error')}));
|
||||
wizard.appendTo($('body')).modal({'keyboard': true});
|
||||
if ($.blockUI) {
|
||||
$.unblockUI();
|
||||
}
|
||||
$("#o_payment_form_pay").removeAttr('disabled');
|
||||
}
|
||||
|
||||
|
||||
function _redirectToStripeCheckout(providerForm) {
|
||||
// Open Checkout with further options
|
||||
if ($.blockUI) {
|
||||
var msg = _t("Just one more second, We are redirecting you to Stripe...");
|
||||
$.blockUI({
|
||||
'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' +
|
||||
' <br />' + msg +
|
||||
'</h2>'
|
||||
});
|
||||
}
|
||||
|
||||
var paymentForm = $('.o_payment_form');
|
||||
if (!paymentForm.find('i').length) {
|
||||
paymentForm.append('<i class="fa fa-spinner fa-spin"/>');
|
||||
paymentForm.attr('disabled', 'disabled');
|
||||
}
|
||||
|
||||
var _getStripeInputValue = function (name) {
|
||||
return providerForm.find('input[name="' + name + '"]').val();
|
||||
};
|
||||
|
||||
var stripe = Stripe(_getStripeInputValue('stripe_key'));
|
||||
|
||||
stripe.redirectToCheckout({
|
||||
sessionId: _getStripeInputValue('session_id')
|
||||
}).then(function (result) {
|
||||
if (result.error) {
|
||||
displayError(result.error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$.getScript("https://js.stripe.com/v3/", function (data, textStatus, jqxhr) {
|
||||
observer.observe(document.body, {childList: true});
|
||||
_redirectToStripeCheckout($('form[provider="stripe"]'));
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="stripe.error">
|
||||
<div role="dialog" class="modal fade">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<header class="modal-header">
|
||||
<h4 class="modal-title">Error</h4>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
|
||||
</header>
|
||||
<main class="modal-body">
|
||||
<t t-esc="msg"></t>
|
||||
</main>
|
||||
<footer class="modal-footer">
|
||||
<a role="button" href="#" class="btn btn-link btn-sm" data-dismiss="modal">Close</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,2 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import common
|
||||
from . import test_stripe
|
||||
|
||||
@@ -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 StripeCommon(PaymentCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.stripe = cls._prepare_acquirer('stripe', update_values={
|
||||
'stripe_secret_key': 'sk_test_KJtHgNwt2KS3xM7QJPr4O5E8',
|
||||
'stripe_publishable_key': 'pk_test_QSPnimmb4ZhtkEy3Uhdm4S6J',
|
||||
'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprLVXT5jBLxB',
|
||||
'payment_icon_ids': [(5, 0, 0)],
|
||||
})
|
||||
|
||||
cls.acquirer = cls.stripe
|
||||
@@ -1,31 +0,0 @@
|
||||
checkout_session_signature = 't=1591264652,v1=1f0d3e035d8de956396b1d91727267fbbf483253e7702e46357b4d2bfa078ba4,v0=20d76342f4704d49f8f89db03acff7cf04afa48ca70a22d608b4649b332c1f51'
|
||||
checkout_session_body = b'{\n "id": "evt_1GqFpHAlCFm536g8NYSLoccF",\n "object": "event",\n "api_version": "2019-05-16",\n "created": 1591264651,\n "data": {\n "object": {\n "id": "cs_test_SI8yz61JCZ4gxd7Z5oGfQSn9ZbubC6SZF3bJTxvy2PVqSd3dzbDV1kyd",\n "object": "checkout.session",\n "billing_address_collection": null,\n "cancel_url": "https://httpbin.org/post",\n "client_reference_id": null,\n "customer": "cus_HP3xLqXMIwBfTg",\n "customer_email": null,\n "display_items": [\n {\n "amount": 1500,\n "currency": "usd",\n "custom": {\n "description": "comfortable cotton t-shirt",\n "images": null,\n "name": "t-shirt"\n },\n "quantity": 2,\n "type": "custom"\n }\n ],\n "livemode": false,\n "locale": null,\n "metadata": {\n },\n "mode": "payment",\n "payment_intent": "pi_1GqFpCAlCFm536g8HsBSvSEt",\n "payment_method_types": [\n "card"\n ],\n "setup_intent": null,\n "shipping": null,\n "shipping_address_collection": null,\n "submit_type": null,\n "subscription": null,\n "success_url": "https://httpbin.org/post"\n }\n },\n "livemode": false,\n "pending_webhooks": 2,\n "request": {\n "id": null,\n "idempotency_key": null\n },\n "type": "checkout.session.completed"\n}'
|
||||
|
||||
checkout_session_object = {'billing_address_collection': None,
|
||||
'cancel_url': 'https://httpbin.org/post',
|
||||
'client_reference_id': "tx_ref_test_handle_checkout_webhook",
|
||||
'customer': 'cus_HOgyjnjdgY6pmY',
|
||||
'customer_email': None,
|
||||
'display_items': [{'amount': 1500,
|
||||
'currency': 'usd',
|
||||
'custom': {'description': 'comfortable '
|
||||
'cotton '
|
||||
't-shirt',
|
||||
'images': None,
|
||||
'name': 't-shirt'},
|
||||
'quantity': 2,
|
||||
'type': 'custom'}],
|
||||
'id': 'cs_test_sbTG0yGwTszAqFUP8Ulecr1bUwEyQEo29M8taYvdP7UA6Qr37qX6uA6w',
|
||||
'livemode': False,
|
||||
'locale': None,
|
||||
'metadata': {},
|
||||
'mode': 'payment',
|
||||
'object': 'checkout.session',
|
||||
'payment_intent': 'pi_1GptaRAlCFm536g8AfCF6Zi0',
|
||||
'payment_method_types': ['card'],
|
||||
'setup_intent': None,
|
||||
'shipping': None,
|
||||
'shipping_address_collection': None,
|
||||
'submit_type': None,
|
||||
'subscription': None,
|
||||
'success_url': 'https://httpbin.org/post'}
|
||||
@@ -1,318 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import odoo
|
||||
from odoo import fields
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.addons.payment.tests.common import PaymentAcquirerCommon
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from unittest.mock import patch
|
||||
from . import stripe_mocks
|
||||
from ..models.payment import STRIPE_SIGNATURE_AGE_TOLERANCE
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
|
||||
class StripeCommon(PaymentAcquirerCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls, chart_template_ref=None):
|
||||
super().setUpClass(chart_template_ref=chart_template_ref)
|
||||
cls.stripe = cls.env.ref('payment.payment_acquirer_stripe')
|
||||
cls.stripe.write({
|
||||
'stripe_secret_key': 'sk_test_KJtHgNwt2KS3xM7QJPr4O5E8',
|
||||
'stripe_publishable_key': 'pk_test_QSPnimmb4ZhtkEy3Uhdm4S6J',
|
||||
'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprLVXT5jBLxB',
|
||||
'state': 'test',
|
||||
})
|
||||
cls.token = cls.env['payment.token'].create({
|
||||
'name': 'Test Card',
|
||||
'acquirer_id': cls.stripe.id,
|
||||
'acquirer_ref': 'cus_G27S7FqQ2w3fuH',
|
||||
'stripe_payment_method': 'pm_1FW3DdAlCFm536g8eQoSCejY',
|
||||
'partner_id': cls.buyer.id,
|
||||
'verified': True,
|
||||
})
|
||||
cls.ideal_icon = cls.env.ref("payment.payment_icon_cc_ideal")
|
||||
cls.bancontact_icon = cls.env.ref("payment.payment_icon_cc_bancontact")
|
||||
cls.p24_icon = cls.env.ref("payment.payment_icon_cc_p24")
|
||||
cls.eps_icon = cls.env.ref("payment.payment_icon_cc_eps")
|
||||
cls.giropay_icon = cls.env.ref("payment.payment_icon_cc_giropay")
|
||||
cls.all_icons = [cls.ideal_icon, cls.bancontact_icon, cls.p24_icon, cls.eps_icon, cls.giropay_icon]
|
||||
cls.stripe.write({'payment_icon_ids': [(5, 0, 0)]})
|
||||
from .common import StripeCommon
|
||||
|
||||
|
||||
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external')
|
||||
@tagged('post_install', '-at_install')
|
||||
class StripeTest(StripeCommon):
|
||||
|
||||
def run(self, result=None):
|
||||
with mute_logger('odoo.addons.payment.models.payment_acquirer', 'odoo.addons.payment_stripe.models.payment'):
|
||||
StripeCommon.run(self, result)
|
||||
def test_processing_values(self):
|
||||
dummy_session_id = 'cs_test_sbTG0yGwTszAqFUP8Ulecr1bUwEyQEo29M8taYvdP7UA6Qr37qX6uA6w'
|
||||
tx = self.create_transaction(flow='redirect') # We don't really care what the flow is here.
|
||||
|
||||
def test_10_stripe_s2s(self):
|
||||
self.assertEqual(self.stripe.state, 'test', 'test without test environment')
|
||||
# Create transaction
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'reference': 'stripe_test_10_%s' % fields.datetime.now().strftime('%Y%m%d_%H%M%S'),
|
||||
'currency_id': self.currency_euro.id,
|
||||
'acquirer_id': self.stripe.id,
|
||||
'partner_id': self.buyer_id,
|
||||
'payment_token_id': self.token.id,
|
||||
'type': 'server2server',
|
||||
'amount': 115.0
|
||||
})
|
||||
tx.with_context(off_session=True).stripe_s2s_do_transaction()
|
||||
# Ensure no external API call is done, we only want to check the processing values logic
|
||||
def mock_stripe_create_checkout_session(self):
|
||||
return {'id': dummy_session_id}
|
||||
with patch.object(
|
||||
type(self.env['payment.transaction']),
|
||||
'_stripe_create_checkout_session',
|
||||
mock_stripe_create_checkout_session,
|
||||
), mute_logger('odoo.addons.payment.models.payment_transaction'):
|
||||
processing_values = tx._get_processing_values()
|
||||
|
||||
# Check state
|
||||
self.assertEqual(tx.state, 'done', 'Stripe: Transcation has been discarded.')
|
||||
self.assertEqual(processing_values['publishable_key'], self.stripe.stripe_publishable_key)
|
||||
self.assertEqual(processing_values['session_id'], dummy_session_id)
|
||||
|
||||
def test_20_stripe_form_render(self):
|
||||
self.assertEqual(self.stripe.state, 'test', 'test without test environment')
|
||||
|
||||
# ----------------------------------------
|
||||
# Test: button direct rendering
|
||||
# ----------------------------------------
|
||||
|
||||
# render the button
|
||||
self.stripe.render('SO404', 320.0, self.currency_euro.id, values=self.buyer_values).decode('utf-8')
|
||||
|
||||
def test_30_stripe_form_management(self):
|
||||
self.assertEqual(self.stripe.state, 'test', 'test without test environment')
|
||||
ref = 'stripe_test_30_%s' % fields.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'amount': 4700.0,
|
||||
'acquirer_id': self.stripe.id,
|
||||
'currency_id': self.currency_euro.id,
|
||||
'reference': ref,
|
||||
'partner_name': 'Norbert Buyer',
|
||||
'partner_country_id': self.country_france.id,
|
||||
'payment_token_id': self.token.id,
|
||||
})
|
||||
res = tx.with_context(off_session=True)._stripe_create_payment_intent()
|
||||
tx.stripe_payment_intent = res.get('payment_intent')
|
||||
|
||||
# typical data posted by Stripe after client has successfully paid
|
||||
stripe_post_data = {'reference': ref}
|
||||
# validate it
|
||||
tx.form_feedback(stripe_post_data, 'stripe')
|
||||
self.assertEqual(tx.state, 'done', 'Stripe: validation did not put tx into done state')
|
||||
self.assertEqual(tx.acquirer_reference, stripe_post_data.get('id'), 'Stripe: validation did not update tx id')
|
||||
|
||||
def test_add_available_payment_method_types_local_enabled(self):
|
||||
self.stripe.payment_icon_ids = [(6, 0, [i.id for i in self.all_icons])]
|
||||
tx_values = {
|
||||
'billing_partner_country': self.env.ref('base.be'),
|
||||
'currency': self.env.ref('base.EUR'),
|
||||
'type': 'form'
|
||||
}
|
||||
stripe_session_data = {}
|
||||
|
||||
self.stripe._add_available_payment_method_types(stripe_session_data, tx_values)
|
||||
|
||||
actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')}
|
||||
self.assertEqual({'card', 'bancontact'}, actual)
|
||||
|
||||
def test_add_available_payment_method_types_local_enabled_2(self):
|
||||
self.stripe.payment_icon_ids = [(6, 0, [i.id for i in self.all_icons])]
|
||||
tx_values = {
|
||||
'billing_partner_country': self.env.ref('base.pl'),
|
||||
'currency': self.env.ref('base.PLN'),
|
||||
'type': 'form'
|
||||
}
|
||||
stripe_session_data = {}
|
||||
|
||||
self.stripe._add_available_payment_method_types(stripe_session_data, tx_values)
|
||||
|
||||
actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')}
|
||||
self.assertEqual({'card', 'p24'}, actual)
|
||||
|
||||
def test_add_available_payment_method_types_pmt_does_not_exist(self):
|
||||
self.bancontact_icon.unlink()
|
||||
tx_values = {
|
||||
'billing_partner_country': self.env.ref('base.be'),
|
||||
'currency': self.env.ref('base.EUR'),
|
||||
'type': 'form'
|
||||
}
|
||||
stripe_session_data = {}
|
||||
|
||||
self.stripe._add_available_payment_method_types(stripe_session_data, tx_values)
|
||||
|
||||
actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')}
|
||||
self.assertEqual({'card', 'bancontact'}, actual)
|
||||
|
||||
def test_add_available_payment_method_types_local_disabled(self):
|
||||
tx_values = {
|
||||
'billing_partner_country': self.env.ref('base.be'),
|
||||
'currency': self.env.ref('base.EUR'),
|
||||
'type': 'form'
|
||||
}
|
||||
stripe_session_data = {}
|
||||
|
||||
self.stripe._add_available_payment_method_types(stripe_session_data, tx_values)
|
||||
|
||||
actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')}
|
||||
self.assertEqual({'card'}, actual)
|
||||
|
||||
def test_add_available_payment_method_types_local_all_but_bancontact(self):
|
||||
self.stripe.payment_icon_ids = [(4, icon.id) for icon in self.all_icons if icon.name.lower() != 'bancontact']
|
||||
tx_values = {
|
||||
'billing_partner_country': self.env.ref('base.be'),
|
||||
'currency': self.env.ref('base.EUR'),
|
||||
'type': 'form'
|
||||
}
|
||||
stripe_session_data = {}
|
||||
|
||||
self.stripe._add_available_payment_method_types(stripe_session_data, tx_values)
|
||||
|
||||
actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')}
|
||||
self.assertEqual({'card'}, actual)
|
||||
|
||||
def test_add_available_payment_method_types_recurrent(self):
|
||||
tx_values = {
|
||||
'billing_partner_country': self.env.ref('base.be'),
|
||||
'currency': self.env.ref('base.EUR'),
|
||||
'type': 'form_save'
|
||||
}
|
||||
stripe_session_data = {}
|
||||
|
||||
self.stripe._add_available_payment_method_types(stripe_session_data, tx_values)
|
||||
|
||||
actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')}
|
||||
self.assertEqual({'card'}, actual)
|
||||
|
||||
def test_discarded_webhook(self):
|
||||
self.assertFalse(self.env['payment.acquirer']._handle_stripe_webhook(dict(type='payment.intent.succeeded')))
|
||||
|
||||
def test_handle_checkout_webhook_no_secret(self):
|
||||
self.stripe.stripe_webhook_secret = None
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['payment.acquirer']._handle_stripe_webhook(dict(type='checkout.session.completed'))
|
||||
|
||||
@patch('odoo.addons.payment_stripe.models.payment.request')
|
||||
@patch('odoo.addons.payment_stripe.models.payment.datetime')
|
||||
def test_handle_checkout_webhook(self, dt, request):
|
||||
# pass signature verification
|
||||
dt.utcnow.return_value.timestamp.return_value = 1591264652
|
||||
request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature}
|
||||
request.httprequest.data = stripe_mocks.checkout_session_body
|
||||
# test setup
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'reference': 'tx_ref_test_handle_checkout_webhook',
|
||||
'currency_id': self.currency_euro.id,
|
||||
'acquirer_id': self.stripe.id,
|
||||
'partner_id': self.buyer_id,
|
||||
'payment_token_id': self.token.id,
|
||||
'type': 'server2server',
|
||||
'amount': 30
|
||||
})
|
||||
res = tx.with_context(off_session=True)._stripe_create_payment_intent()
|
||||
tx.stripe_payment_intent = res.get('payment_intent')
|
||||
stripe_object = stripe_mocks.checkout_session_object
|
||||
|
||||
actual = self.stripe._handle_checkout_webhook(stripe_object)
|
||||
|
||||
self.assertTrue(actual)
|
||||
|
||||
@patch('odoo.addons.payment_stripe.models.payment.request')
|
||||
@patch('odoo.addons.payment_stripe.models.payment.datetime')
|
||||
def test_handle_checkout_webhook_wrong_amount(self, dt, request):
|
||||
# pass signature verification
|
||||
dt.utcnow.return_value.timestamp.return_value = 1591264652
|
||||
request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature}
|
||||
request.httprequest.data = stripe_mocks.checkout_session_body
|
||||
# test setup
|
||||
bad_tx = self.env['payment.transaction'].create({
|
||||
'reference': 'tx_ref_test_handle_checkout_webhook_wrong_amount',
|
||||
'currency_id': self.currency_euro.id,
|
||||
'acquirer_id': self.stripe.id,
|
||||
'partner_id': self.buyer_id,
|
||||
'payment_token_id': self.token.id,
|
||||
'type': 'server2server',
|
||||
'amount': 10
|
||||
})
|
||||
wrong_amount_stripe_payment_intent = bad_tx.with_context(off_session=True)._stripe_create_payment_intent()
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'reference': 'tx_ref_test_handle_checkout_webhook',
|
||||
'currency_id': self.currency_euro.id,
|
||||
'acquirer_id': self.stripe.id,
|
||||
'partner_id': self.buyer_id,
|
||||
'payment_token_id': self.token.id,
|
||||
'type': 'server2server',
|
||||
'amount': 30
|
||||
})
|
||||
tx.stripe_payment_intent = wrong_amount_stripe_payment_intent.get('payment_intent')
|
||||
stripe_object = stripe_mocks.checkout_session_object
|
||||
|
||||
actual = self.env['payment.acquirer']._handle_checkout_webhook(stripe_object)
|
||||
|
||||
self.assertFalse(actual)
|
||||
|
||||
def test_handle_checkout_webhook_no_odoo_tx(self):
|
||||
stripe_object = stripe_mocks.checkout_session_object
|
||||
|
||||
actual = self.stripe._handle_checkout_webhook(stripe_object)
|
||||
|
||||
self.assertFalse(actual)
|
||||
|
||||
@patch('odoo.addons.payment_stripe.models.payment.request')
|
||||
@patch('odoo.addons.payment_stripe.models.payment.datetime')
|
||||
def test_handle_checkout_webhook_no_stripe_tx(self, dt, request):
|
||||
# pass signature verification
|
||||
dt.utcnow.return_value.timestamp.return_value = 1591264652
|
||||
request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature}
|
||||
request.httprequest.data = stripe_mocks.checkout_session_body
|
||||
# test setup
|
||||
self.env['payment.transaction'].create({
|
||||
'reference': 'tx_ref_test_handle_checkout_webhook',
|
||||
'currency_id': self.currency_euro.id,
|
||||
'acquirer_id': self.stripe.id,
|
||||
'partner_id': self.buyer_id,
|
||||
'payment_token_id': self.token.id,
|
||||
'type': 'server2server',
|
||||
'amount': 30
|
||||
})
|
||||
stripe_object = stripe_mocks.checkout_session_object
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.stripe._handle_checkout_webhook(stripe_object)
|
||||
|
||||
@patch('odoo.addons.payment_stripe.models.payment.request')
|
||||
@patch('odoo.addons.payment_stripe.models.payment.datetime')
|
||||
def test_verify_stripe_signature(self, dt, request):
|
||||
dt.utcnow.return_value.timestamp.return_value = 1591264652
|
||||
request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature}
|
||||
request.httprequest.data = stripe_mocks.checkout_session_body
|
||||
|
||||
actual = self.stripe._verify_stripe_signature()
|
||||
|
||||
self.assertTrue(actual)
|
||||
|
||||
@patch('odoo.addons.payment_stripe.models.payment.request')
|
||||
@patch('odoo.addons.payment_stripe.models.payment.datetime')
|
||||
def test_verify_stripe_signature_tampered_body(self, dt, request):
|
||||
dt.utcnow.return_value.timestamp.return_value = 1591264652
|
||||
request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature}
|
||||
request.httprequest.data = stripe_mocks.checkout_session_body.replace(b'1500', b'10')
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.stripe._verify_stripe_signature()
|
||||
|
||||
@patch('odoo.addons.payment_stripe.models.payment.request')
|
||||
@patch('odoo.addons.payment_stripe.models.payment.datetime')
|
||||
def test_verify_stripe_signature_wrong_secret(self, dt, request):
|
||||
dt.utcnow.return_value.timestamp.return_value = 1591264652
|
||||
request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature}
|
||||
request.httprequest.data = stripe_mocks.checkout_session_body
|
||||
self.stripe.write({
|
||||
'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprL_TAMPERED',
|
||||
})
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.stripe._verify_stripe_signature()
|
||||
|
||||
@patch('odoo.addons.payment_stripe.models.payment.request')
|
||||
@patch('odoo.addons.payment_stripe.models.payment.datetime')
|
||||
def test_verify_stripe_signature_too_old(self, dt, request):
|
||||
dt.utcnow.return_value.timestamp.return_value = 1591264652 + STRIPE_SIGNATURE_AGE_TOLERANCE + 1
|
||||
request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature}
|
||||
request.httprequest.data = stripe_mocks.checkout_session_body
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.stripe._verify_stripe_signature()
|
||||
def test_validation_amount(self):
|
||||
self.assertEqual(self.stripe._get_validation_amount(), 1.0)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_frontend" inherit_id="web.assets_frontend">
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script src="https://js.stripe.com/v3/"/>
|
||||
<script type="text/javascript" src="/payment_stripe/static/src/js/payment_form.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,38 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<template id="stripe_form">
|
||||
<input type="hidden" name="data_set" t-att-data-action-url="tx_url" data-remove-me=""/>
|
||||
<input type='hidden' name='session_id' t-att-value='session_id'/>
|
||||
<input type="hidden" name="stripe_key" t-att-value="acquirer.stripe_publishable_key"/>
|
||||
<script type="text/javascript">
|
||||
odoo.define(function (require) {
|
||||
var ajax = require('web.ajax');
|
||||
ajax.loadJS("/payment_stripe/static/src/js/stripe.js");
|
||||
});
|
||||
</script>
|
||||
</template>
|
||||
|
||||
<template id="stripe_s2s_form">
|
||||
<input type="hidden" name="data_set" value="/payment/stripe/s2s/create_json_3ds"/>
|
||||
<input type="hidden" name="acquirer_id" t-att-value="id"/>
|
||||
<input type="hidden" name="stripe_publishable_key" t-att-value="acq.sudo().stripe_publishable_key"/>
|
||||
<input type="hidden" name="currency_id" t-att-value="currency_id"/>
|
||||
<input t-if="return_url" type="hidden" name="return_url" t-att-value="return_url"/>
|
||||
<input t-if="partner_id" type="hidden" name="partner_id" t-att-value="partner_id"/>
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<div id="payment-form">
|
||||
<div id="card-element" class="m-3"/>
|
||||
<div id="card-errors" class="m-3 text-danger"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="assets_frontend" inherit_id="web.assets_frontend">
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script type="text/javascript" src="/payment_stripe/static/src/js/payment_form.js"></script>
|
||||
<script type="text/javascript" src="/payment_stripe/static/src/js/payment_processing.js"></script>
|
||||
|
||||
</xpath>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,22 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="acquirer_form_stripe" model="ir.ui.view">
|
||||
<field name="name">payment.acquirer.form.inherit</field>
|
||||
|
||||
<record id="payment_acquirer_form" model="ir.ui.view">
|
||||
<field name="name">Stripe Acquirer Form</field>
|
||||
<field name="model">payment.acquirer</field>
|
||||
<field name="inherit_id" ref="payment.acquirer_form"/>
|
||||
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr='//group[@name="acquirer"]' position='inside'>
|
||||
<xpath expr="//group[@name='acquirer']" position="inside">
|
||||
<group attrs="{'invisible': [('provider', '!=', 'stripe')]}">
|
||||
<field name="stripe_secret_key" attrs="{'required':[ ('provider', '=', 'stripe'), ('state', '!=', 'disabled')]}" password="True"/>
|
||||
<field name="stripe_publishable_key" attrs="{'required':[ ('provider', '=', 'stripe'), ('state', '!=', 'disabled')]}" password="True"/>
|
||||
<field name="stripe_publishable_key" attrs="{'required':[('provider', '=', 'stripe'), ('state', '!=', 'disabled')]}" password="True"/>
|
||||
<field name="stripe_secret_key" attrs="{'required':[('provider', '=', 'stripe'), ('state', '!=', 'disabled')]}" password="True"/>
|
||||
<field name="stripe_webhook_secret" password="True"/>
|
||||
</group>
|
||||
</xpath>
|
||||
<xpath expr='//group[@name="acquirer_config"]' position='after'>
|
||||
<group attrs="{'invisible': [('provider', '!=', 'stripe')]}">
|
||||
<field name="stripe_image_url"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user