[IMP] payment_stripe: support webhooks

Allow configuring a webhook in Stripe to send s2s notifications to Odoo
when a Checkout payment is completed. Note that SetupIntent and
PaymentIntent events are not listened to, since they are handled 'live'
with the customer actively present; the main use case for Stripe
webhooks is a Checkout session that gets interrupted before the customer
is redirected to Odoo (e.g. network loss, browser crash, closing the
tab, etc.)

The webhook should be configured to send its events to
<base_url>/payment/stripe/webhook and should only subscribe to
checkout.session.completed events to avoid spamming the Odoo server with
useless notifications.

closes odoo/odoo#52754

Signed-off-by: Damien Bouvy (dbo) <dbo@odoo.com>
This commit is contained in:
Adrien Horgnies
2020-08-17 14:16:51 +00:00
committed by Damien Bouvy
parent 7eee8f5cf5
commit dc4f6ad04f
5 changed files with 287 additions and 4 deletions
@@ -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'
+100 -4
View File
@@ -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':
@@ -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'}
+147
View File
@@ -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()
@@ -9,6 +9,7 @@
<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_webhook_secret" password="True"/>
</group>
</xpath>
<xpath expr='//group[@name="acquirer_config"]' position='after'>