Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[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. 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
1 parent
107004e
commit 7a8aca1
Showing
13 changed files
with
534 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
} |
3 changes: 3 additions & 0 deletions
3
addons/payment_stripe_checkout_webhook/controllers/__init__.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# -*- coding: utf-8 -*- | ||
|
||
from . import main |
17 changes: 17 additions & 0 deletions
17
addons/payment_stripe_checkout_webhook/controllers/main.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' |
54 changes: 54 additions & 0 deletions
54
addons/payment_stripe_checkout_webhook/i18n/payment_stripe_checkout_webhook.pot
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 "" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# -*- coding: utf-8 -*- | ||
|
||
from . import payment |
168 changes: 168 additions & 0 deletions
168
addons/payment_stripe_checkout_webhook/models/payment.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
# -*- coding: utf-8 -*- | ||
from . import test_stripe |
50 changes: 50 additions & 0 deletions
50
addons/payment_stripe_checkout_webhook/tests/stripe_mocks.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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} |
Oops, something went wrong.