[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:
Prakash Prajapati
2021-03-30 09:25:51 +02:00
committed by Antoine Vandevenne (anv)
co-authored by Antoine Vandevenne
parent a9ac72d821
commit 4bb840403b
22 changed files with 721 additions and 1302 deletions
+6 -5
View File
@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import controllers
from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers
from odoo.addons.payment import reset_payment_provider
from . import models
from odoo.addons.payment import reset_payment_acquirer
from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook
def uninstall_hook(cr, registry):
reset_payment_provider(cr, registry, 'stripe')
reset_payment_acquirer(cr, registry, 'stripe')
+4 -6
View File
@@ -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',
}
+24
View File
@@ -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
+150 -41
View File
@@ -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>
+4 -2
View File
@@ -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
-516
View File
@@ -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>
+3 -1
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.payment.tests.common import PaymentCommon
class 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'}
+22 -308
View File
@@ -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)
+11
View File
@@ -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>
+8 -11
View File
@@ -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>