Skip to content
Snippets Groups Projects
Commit f0d75ba8 authored by mafo-odoo's avatar mafo-odoo
Browse files

[FIX] payment_stripe: add idempotency key to prevent multiple payments

Video 1 (Issue): https://drive.google.com/file/d/1oXYcDJgaT9gmhkjE08yJlXwIL1qPwS1Y/view?usp=sharing



Issue:
When using the register payment with a token with any of the payment acquirers,
if there is a concurrent access error during the reconciliation process,
the payment intent is sent multiple times to the acquirer, making the card charged multiple times.

Steps to reproduce:

-Have a V14 database (only tested this version) with sale_mmanagement, payment_stripe and invoicing
-Configure Stripe with your public and secret key (2FA is now enforced for Stripe accounts, therefore,
 we don't have a generic test account anymore. You have to create your own.It is quite fast and easy to do)
-Have a portal user PU with an already registered payment token PT
-Go to Invoicing
-Create a new invoice I:
-Customer PU
-Add anything in invoice lines
-Confirm I
-Register a payment for I:
-Journal: Stripe
-Saved Payment token: PT
AT THIS STEP, YOU MUST ENSURE A CONCURRENT ACCESS ERROR WILL RAISE DURING THE RECONCILIATION
-Create Payment

Log analysis:
A first payment intent is sent to Stripe. The card is charged and Stripe answers that all went as expected.
We try to process the payment, but a concurrent access error occurs.
A retry is done.
A payment intent is sent again to Stripe, The card is charged AGAIN and Stripe answers that all went as expected.
We try to process the payment, but a concurrent access error occurs.

For each retry, the intent is sent and the card is charged.

If the first retry succeeds, then Odoo can finish the process. There will be only 1 payment transaction on Odoo's side
(others have been rollbacked) but there will be 3 on Stripe's side and the card will be charged 3 times.

This PR mitigate this behaviour.
It doesn't address the root cause but by adding the idempotency key to the headers with the hash of the transaction
reference and the database UUID, we prevent mutliple payments to happen.

OPW-2662964

closes odoo/odoo#101243

Signed-off-by: default avatarAntoine Vandevenne (anv) <anv@odoo.com>
parent beb580c2
No related branches found
No related tags found
No related merge requests found
......@@ -2,7 +2,7 @@
from collections import namedtuple
from datetime import datetime
from hashlib import sha256
from hashlib import sha1, sha256
import hmac
import json
import logging
......@@ -109,13 +109,15 @@ class PaymentAcquirerStripe(models.Model):
for idx, payment_method_type in enumerate(available_payment_method_types):
stripe_session_data[f'payment_method_types[{idx}]'] = payment_method_type
def _stripe_request(self, url, data=False, method='POST'):
def _stripe_request(self, url, data=False, method='POST', idempotency_key=None):
self.ensure_one()
url = urls.url_join(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
}
'Stripe-Version': '2019-05-16', # SetupIntent need a specific version
}
if method == 'POST' and idempotency_key:
headers['Idempotency-Key'] = idempotency_key
resp = requests.request(method, url, data=data, headers=headers)
# 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
......@@ -333,8 +335,10 @@ class PaymentTransactionStripe(models.Model):
if not self.env.context.get('off_session'):
charge_params.update(setup_future_usage='off_session', off_session=False)
_logger.info('_stripe_create_payment_intent: Sending values to stripe, values:\n%s', pprint.pformat(charge_params))
res = self.acquirer_id._stripe_request('payment_intents', charge_params)
# Create an idempotency key using the hash of the transaction reference and the database UUID
database_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
idempotency_key = sha1((database_uuid + self.reference).encode("utf-8")).hexdigest()
res = self.acquirer_id._stripe_request('payment_intents', charge_params, idempotency_key=idempotency_key)
if res.get('charges') and res.get('charges').get('total_count'):
res = res.get('charges').get('data')[0]
......@@ -355,7 +359,10 @@ class PaymentTransactionStripe(models.Model):
}
_logger.info('_create_stripe_refund: Sending values to stripe URL, values:\n%s', pprint.pformat(refund_params))
res = self.acquirer_id._stripe_request('refunds', refund_params)
# Create an idempotency key using the hash of the transaction reference and the database UUID
database_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
idempotency_key = sha1((database_uuid + self.reference).encode("utf-8")).hexdigest()
res = self.acquirer_id._stripe_request('refunds', refund_params, idempotency_key=idempotency_key)
_logger.info('_create_stripe_refund: Values received:\n%s', pprint.pformat(res))
return res
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment