diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py
index 8d5f5bf65df..763a4d05a2a 100644
--- a/addons/payment_stripe/controllers/main.py
+++ b/addons/payment_stripe/controllers/main.py
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
+import json
import logging
+import pprint
import werkzeug
from odoo import http
@@ -52,3 +54,9 @@ class StripeController(http.Controller):
@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')
+
+ @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'
\ No newline at end of file
diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py
index ca19e288185..7e6a685c868 100644
--- a/addons/payment_stripe/models/payment.py
+++ b/addons/payment_stripe/models/payment.py
@@ -1,16 +1,22 @@
# 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 collections import namedtuple
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.models.payment_acquirer import ValidationError
from odoo.addons.payment_stripe.controllers.main import StripeController
_logger = logging.getLogger(__name__)
@@ -20,6 +26,7 @@ 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):
@@ -30,6 +37,11 @@ class PaymentAcquirerStripe(models.Model):
], 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 "
@@ -180,6 +192,90 @@ class PaymentAcquirerStripe(models.Model):
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'
@@ -187,7 +283,6 @@ class PaymentTransactionStripe(models.Model):
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':
@@ -298,10 +393,11 @@ class PaymentTransactionStripe(models.Model):
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": tx_id,
+ "stripe_payment_intent": pi_id or tx_id,
"stripe_payment_intent_secret": tx_secret
}
if status == 'succeeded':
diff --git a/addons/payment_stripe/tests/stripe_mocks.py b/addons/payment_stripe/tests/stripe_mocks.py
new file mode 100644
index 00000000000..6364fbf115a
--- /dev/null
+++ b/addons/payment_stripe/tests/stripe_mocks.py
@@ -0,0 +1,31 @@
+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'}
diff --git a/addons/payment_stripe/tests/test_stripe.py b/addons/payment_stripe/tests/test_stripe.py
index 92b3a8d1be7..4102fd48319 100644
--- a/addons/payment_stripe/tests/test_stripe.py
+++ b/addons/payment_stripe/tests/test_stripe.py
@@ -1,7 +1,11 @@
# -*- coding: utf-8 -*-
import odoo
from odoo import fields
+from odoo.exceptions import ValidationError
from odoo.addons.payment.tests.common import PaymentAcquirerCommon
+from unittest.mock import patch
+from . import stripe_mocks
+from ..models.payment import STRIPE_SIGNATURE_AGE_TOLERANCE
from odoo.tools import mute_logger
@@ -14,6 +18,7 @@ class StripeCommon(PaymentAcquirerCommon):
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({
@@ -107,3 +112,145 @@ class StripeTest(StripeCommon):
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):
+ with self.assertRaises(ValidationError):
+ 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()
diff --git a/addons/payment_stripe/views/payment_views.xml b/addons/payment_stripe/views/payment_views.xml
index b66c75c71cc..74d9af04a24 100644
--- a/addons/payment_stripe/views/payment_views.xml
+++ b/addons/payment_stripe/views/payment_views.xml
@@ -9,6 +9,7 @@
+