Skip to content

Commit

Permalink
[IMP] payment_stripe: support webhooks
Browse files Browse the repository at this point in the history
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.

opw-2488452
opw-2451463
opw-2449738

BACKPORT of commit: dc4f6ad

Should not be merged beyond 14.0 (14.0 excluded)

closes #69809

Signed-off-by: Simon Goffin (sig) <sig@openerp.com>
  • Loading branch information
Adrien Horgnies authored and simongoffin committed May 10, 2021
1 parent 107004e commit 7a8aca1
Show file tree
Hide file tree
Showing 13 changed files with 534 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .tx/config
Expand Up @@ -507,6 +507,11 @@ file_filter = addons/payment_stripe/i18n/<lang>.po
source_file = addons/payment_stripe/i18n/payment_stripe.pot
source_lang = en

[odoo-12.payment_stripe_checkout_webhook]
file_filter = addons/payment_stripe_checkout_webhook/i18n/<lang>.po
source_file = addons/payment_stripe_checkout_webhook/i18n/payment_stripe_checkout_webhook.pot
source_lang = en

[odoo-12.payment_transfer]
file_filter = addons/payment_transfer/i18n/<lang>.po
source_file = addons/payment_transfer/i18n/payment_transfer.pot
Expand Down
5 changes: 5 additions & 0 deletions addons/payment_stripe_checkout_webhook/__init__.py
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

from . import models
from . import controllers
29 changes: 29 additions & 0 deletions addons/payment_stripe_checkout_webhook/__manifest__.py
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-

{
'name': 'Stripe Payment Acquirer',
'summary': 'Payment Acquirer: Stripe Implementation',
'version': '1.0',
'description': """
Stripe Payment Acquirer checkout webhook
========================================
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.""",
'depends': ['payment_stripe', 'payment_stripe_sca'],
'data': [
'views/payment_views.xml',
],
'images': ['static/description/icon.png'],
'installable': True,
}
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

from . import main
17 changes: 17 additions & 0 deletions addons/payment_stripe_checkout_webhook/controllers/main.py
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
import json
import logging

from odoo.http import request

from odoo import http

_logger = logging.getLogger(__name__)


class StripeController(http.Controller):

@http.route('/payment/stripe/webhook', type='json', auth='public', csrf=False)
def stripe_webhook(self, **kwargs):
request.env['payment.acquirer'].sudo()._handle_stripe_webhook(kwargs)
return 'OK'
@@ -0,0 +1,54 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * payment_stripe_checkout_webhook
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 12.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-05-06 13:53+0000\n"
"PO-Revision-Date: 2021-05-06 13:53+0000\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"

#. module: payment_stripe_checkout_webhook
#: model:ir.model.fields,help:payment_stripe_checkout_webhook.field_payment_acquirer__stripe_webhook_secret
msgid "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."
msgstr ""

#. module: payment_stripe_checkout_webhook
#: model:ir.model,name:payment_stripe_checkout_webhook.model_payment_acquirer
msgid "Payment Acquirer"
msgstr ""

#. module: payment_stripe_checkout_webhook
#: model:ir.model,name:payment_stripe_checkout_webhook.model_payment_transaction
msgid "Payment Transaction"
msgstr ""

#. module: payment_stripe_checkout_webhook
#: model:ir.model.fields,field_description:payment_stripe_checkout_webhook.field_payment_transaction__stripe_payment_intent
msgid "Stripe Payment Intent ID"
msgstr ""

#. module: payment_stripe_checkout_webhook
#: model:ir.model.fields,field_description:payment_stripe_checkout_webhook.field_payment_acquirer__stripe_webhook_secret
msgid "Stripe Webhook Secret"
msgstr ""

#. module: payment_stripe_checkout_webhook
#: code:addons/payment_stripe_checkout_webhook/models/payment.py:54
#, python-format
msgid "Stripe Webhook data does not conform to the expected API."
msgstr ""

#. module: payment_stripe_checkout_webhook
#: code:addons/payment_stripe_checkout_webhook/models/payment.py:148
#, python-format
msgid "Stripe gave us the following info about the problem: '%s'"
msgstr ""

3 changes: 3 additions & 0 deletions addons/payment_stripe_checkout_webhook/models/__init__.py
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

from . import payment
168 changes: 168 additions & 0 deletions addons/payment_stripe_checkout_webhook/models/payment.py
@@ -0,0 +1,168 @@
# coding: utf-8


from datetime import datetime
from hashlib import sha256
import hmac
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.addons.payment.models.payment_acquirer import ValidationError

_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 PaymentAcquirerStripeCheckoutWH(models.Model):
_inherit = 'payment.acquirer'

stripe_webhook_secret = fields.Char(
string='Stripe Webhook Secret', groups='base.group_system',
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.")

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):
"""
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 = {'metadata': {'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

PaymentAcquirerStripeCheckoutWH._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)
data['metadata']['reference'] = tx_reference

return odoo_tx.form_feedback(data, 'stripe')

def _stripe_request(self, url, data=False, method='POST'):
self.ensure_one()
url = urls.url_join('https://%s/' % 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
}
TIMEOUT = 10
resp = requests.request(method, url, data=data, headers=headers, timeout=TIMEOUT)
# 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()


class PaymentTransactionStripeCheckoutWH(models.Model):
_inherit = 'payment.transaction'

stripe_payment_intent = fields.Char(string='Stripe Payment Intent ID', readonly=True)

@api.multi
def _stripe_s2s_validate_tree(self, tree):
result = super()._stripe_s2s_validate_tree(tree)

pi_id = tree.get('payment_intent')
if pi_id:
self.write({
"stripe_payment_intent": pi_id,
})

return result
2 changes: 2 additions & 0 deletions addons/payment_stripe_checkout_webhook/tests/__init__.py
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_stripe
50 changes: 50 additions & 0 deletions addons/payment_stripe_checkout_webhook/tests/stripe_mocks.py
@@ -0,0 +1,50 @@
from unittest.mock import MagicMock

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'}

missing_tx_resp = MagicMock()
missing_tx_resp.ok = False
missing_tx_resp.status_code = 404
missing_tx_resp.json.return_value = {
'error': {
'code': 'resource_missing',
'doc_url': 'https://stripe.com/docs/error-codes/resource-missing',
'message': "No such payment_intent: 'False'",
'param': 'intent',
'type': 'invalid_request_error'
}
}

wrong_amount_tx_resp = MagicMock()
wrong_amount_tx_resp.ok = True
wrong_amount_tx_resp.json.return_value = {'id': 'pi_1IjSc5AlCFm536g8geIfiu2u', 'object': 'payment_intent', 'amount': 1000, 'amount_capturable': 0, 'amount_received': 1000, 'application': None, 'application_fee_amount': None, 'canceled_at': None, 'cancellation_reason': None, 'capture_method': 'automatic', 'charges': {'object': 'list', 'data': [{'id': 'ch_1IjSc5AlCFm536g8aJPBlRvx', 'object': 'charge', 'amount': 1000, 'amount_captured': 1000, 'amount_refunded': 0, 'application': None, 'application_fee': None, 'application_fee_amount': None, 'balance_transaction': 'txn_1IjSc5AlCFm536g8Hn7aOMp3', 'billing_details': {'address': {'city': None, 'country': 'BE', 'line1': None, 'line2': None, 'postal_code': None, 'state': None}, 'email': 'dbo+test@odoo.com', 'name': 'PLOP', 'phone': None}, 'calculated_statement_descriptor': 'ODOO S.A.', 'captured': True, 'created': 1619198181, 'currency': 'eur', 'customer': 'cus_G27S7FqQ2w3fuH', 'description': 'tx_ref_test_handle_checkout_webhook_wrong_amount', 'destination': None, 'dispute': None, 'disputed': False, 'failure_code': None, 'failure_message': None, 'fraud_details': {}, 'invoice': None, 'livemode': False, 'metadata': {}, 'on_behalf_of': None, 'order': None, 'outcome': {'network_status': 'approved_by_network', 'reason': None, 'risk_level': 'normal', 'risk_score': 32, 'seller_message': 'Payment complete.', 'type': 'authorized'}, 'paid': True, 'payment_intent': 'pi_1IjSc5AlCFm536g8geIfiu2u', 'payment_method': 'pm_1FW3DdAlCFm536g8eQoSCejY', 'payment_method_details': {'card': {'brand': 'visa', 'checks': {'address_line1_check': None, 'address_postal_code_check': None, 'cvc_check': None}, 'country': 'US', 'exp_month': 9, 'exp_year': 2038, 'fingerprint': 'PWV3YLlpVXzInJPm', 'funding': 'credit', 'installments': None, 'last4': '1111', 'network': 'visa', 'three_d_secure': None, 'wallet': None}, 'type': 'card'}, 'receipt_email': None, 'receipt_number': None, 'receipt_url': 'https://pay.stripe.com/receipts/acct_19NebtAlCFm536g8/ch_1IjSc5AlCFm536g8aJPBlRvx/rcpt_JMAyoY0wxSLNJzIS9xgZXhrlGv6SD03', 'refunded': False, 'refunds': {'object': 'list', 'data': [], 'has_more': False, 'total_count': 0, 'url': '/v1/charges/ch_1IjSc5AlCFm536g8aJPBlRvx/refunds'}, 'review': None, 'shipping': None, 'source': None, 'source_transfer': None, 'statement_descriptor': None, 'statement_descriptor_suffix': None, 'status': 'succeeded', 'transfer_data': None, 'transfer_group': None}], 'has_more': False, 'total_count': 1, 'url': '/v1/charges?payment_intent=pi_1IjSc5AlCFm536g8geIfiu2u'}, 'client_secret': 'pi_1IjSc5AlCFm536g8geIfiu2u_secret_Gi682Dw6PXpohs1mbH5kE4xrl', 'confirmation_method': 'automatic', 'created': 1619198181, 'currency': 'eur', 'customer': 'cus_G27S7FqQ2w3fuH', 'description': 'tx_ref_test_handle_checkout_webhook_wrong_amount', 'invoice': None, 'last_payment_error': None, 'livemode': False, 'metadata': {}, 'next_action': None, 'on_behalf_of': None, 'payment_method': 'pm_1FW3DdAlCFm536g8eQoSCejY', 'payment_method_options': {'card': {'installments': None, 'network': None, 'request_three_d_secure': 'automatic'}}, 'payment_method_types': ['card'], 'receipt_email': None, 'review': None, 'setup_future_usage': None, 'shipping': None, 'source': None, 'statement_descriptor': None, 'statement_descriptor_suffix': None, 'status': 'succeeded', 'transfer_data': None, 'transfer_group': None}

0 comments on commit 7a8aca1

Please sign in to comment.