[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:
committed by
Damien Bouvy
parent
7eee8f5cf5
commit
dc4f6ad04f
@@ -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'
|
||||
@@ -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'}
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user