[IMP] payment_stripe: support refund

Allows users of Stripe to refund their customers directly from Odoo.

task-2678763

closes odoo/odoo#92235

Related: odoo/documentation#2086
Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
This commit is contained in:
Valentin Chevalier
2022-05-25 12:46:08 +02:00
parent b65e6e3e16
commit 930b6860d7
9 changed files with 303 additions and 82 deletions
@@ -321,6 +321,7 @@
<li class="list-inline-item"><i class="fa fa-check"/>Subscriptions</li>
<li class="list-inline-item"><i class="fa fa-check"/>Save Cards</li>
<li class="list-inline-item"><i class="fa fa-check"/>Manual Capture</li>
<li class="list-inline-item"><i class="fa fa-check"/>Refunds</li>
</ul>
</field>
<!--
+9 -2
View File
@@ -80,7 +80,14 @@ class PaymentTransaction(models.Model):
string="Source Transaction",
comodel_name='payment.transaction',
help="The source transaction of related refund transactions",
readonly=True
readonly=True,
)
child_transaction_ids = fields.One2many(
string="Child Transactions",
help="The child transactions of the source transaction.",
comodel_name='payment.transaction',
inverse_name='source_transaction_id',
readonly=True,
)
refunds_count = fields.Integer(string="Refunds Count", compute='_compute_refunds_count')
invoice_ids = fields.Many2many(
@@ -703,7 +710,7 @@ class PaymentTransaction(models.Model):
:param str state_message: The reason for which the transaction is set in 'error' state
:return: None
"""
allowed_states = ('draft', 'pending', 'authorized')
allowed_states = ('draft', 'pending', 'authorized', 'done') # 'done' for Stripe refunds.
target_state = 'error'
txs_to_process = self._update_state(allowed_states, target_state, state_message)
txs_to_process._log_received_message()
+12 -4
View File
@@ -2,6 +2,7 @@
from collections import namedtuple
API_VERSION = '2019-05-16' # The API version of Stripe implemented in this module
# Stripe proxy URL
@@ -18,14 +19,19 @@ PAYMENT_METHOD_TYPES = [
PMT('p24', ['pl'], ['eur', 'pln'], 'punctual'),
]
# Mapping of transaction states to Stripe {Payment,Setup}Intent statuses.
# See https://stripe.com/docs/payments/intents#intent-statuses for the exhaustive list of status.
INTENT_STATUS_MAPPING = {
# Mapping of transaction states to Stripe objects ({Payment,Setup}Intent, Charge, Refund) statuses.
# For each object's exhaustive status list, see:
# https://stripe.com/docs/api/payment_intents/object#payment_intent_object-status
# https://stripe.com/docs/api/setup_intents/object#setup_intent_object-status
# https://stripe.com/docs/api/charges/object#charge_object-status
# https://stripe.com/docs/api/refunds/object#refund_object-status
STATUS_MAPPING = {
'draft': ('requires_payment_method', 'requires_confirmation', 'requires_action'),
'pending': ('processing',),
'pending': ('processing', 'pending'),
'authorized': ('requires_capture',),
'done': ('succeeded',),
'cancel': ('canceled',),
'error': ('failed',),
}
# Events which are handled by the webhook
@@ -33,4 +39,6 @@ HANDLED_WEBHOOK_EVENTS = [
'payment_intent.amount_capturable_updated',
'payment_intent.succeeded',
'setup_intent.succeeded',
'charge.refunded', # A refund has been issued.
'charge.refund.updated', # The refund status has changed, possibly from succeeded to failed.
]
+68 -9
View File
@@ -13,6 +13,7 @@ from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment_stripe import utils as stripe_utils
from odoo.addons.payment_stripe.const import HANDLED_WEBHOOK_EVENTS
@@ -89,26 +90,64 @@ class StripeController(http.Controller):
_logger.info("notification received from Stripe with data:\n%s", pprint.pformat(event))
try:
if event['type'] in HANDLED_WEBHOOK_EVENTS:
intent = event['data']['object'] # PaymentIntent/SetupIntent, depending on the flow
stripe_object = event['data']['object'] # {Payment,Setup}Intent, Charge, or Refund.
# Check the integrity of the event
data = {'reference': intent['description']}
# Check the integrity of the event.
data = {
'reference': stripe_object.get('description'),
'event_type': event['type'],
'object_id': stripe_object['id'],
}
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
'stripe', data
)
self._verify_notification_signature(tx_sudo)
if event['type'].startswith('payment_intent'):
self._include_payment_intent_in_notification_data(intent, data)
else: # Validation
# Handle the notification data.
if event['type'].startswith('payment_intent'): # Payment operation.
self._include_payment_intent_in_notification_data(stripe_object, data)
elif event['type'].startswith('setup_intent'): # Validation operation.
# Fetch the missing PaymentMethod object.
payment_method = tx_sudo.acquirer_id._stripe_make_request(
f'payment_methods/{intent["payment_method"]}', method='GET'
f'payment_methods/{stripe_object["payment_method"]}', method='GET'
)
_logger.info(
"received payment_methods response:\n%s", pprint.pformat(payment_method)
)
intent['payment_method'] = payment_method
self._include_setup_intent_in_notification_data(intent, data)
stripe_object['payment_method'] = payment_method
self._include_setup_intent_in_notification_data(stripe_object, data)
elif event['type'] == 'charge.refunded': # Refund operation (refund creation).
refunds = stripe_object['refunds']['data']
# The refunds linked to this charge are paginated, fetch the remaining refunds.
has_more = stripe_object['refunds']['has_more']
while has_more:
payload = {
'charge': stripe_object['id'],
'starting_after': refunds[-1]['id'],
'limit': 100,
}
additional_refunds = tx_sudo.acquirer_id._stripe_make_request(
'refunds', payload=payload, method='GET'
)
refunds += additional_refunds['data']
has_more = additional_refunds['has_more']
# Process the refunds for which a refund transaction has not been created yet.
processed_refund_ids = tx_sudo.child_transaction_ids.filtered(
lambda tx: tx.operation == 'refund'
).mapped('acquirer_reference')
for refund in filter(lambda r: r['id'] not in processed_refund_ids, refunds):
refund_tx_sudo = self._create_refund_tx_from_refund(tx_sudo, refund)
self._include_refund_in_notification_data(refund, data)
refund_tx_sudo._handle_notification_data('stripe', data)
return '' # Don't handle the notification data for the source transaction.
elif event['type'] == 'charge.refund.updated': # Refund operation (with update).
# A refund was updated by Stripe after it was already processed (possibly to
# cancel it). This can happen when the customer's payment method can no longer
# be topped up (card expired, account closed...). The `tx_sudo` record is the
# refund transaction to update.
self._include_refund_in_notification_data(stripe_object, data)
# Handle the notification data crafted with Stripe API objects
tx_sudo._handle_notification_data('stripe', data)
@@ -133,6 +172,26 @@ class StripeController(http.Controller):
'payment_method': setup_intent.get('payment_method'),
})
@staticmethod
def _include_refund_in_notification_data(refund, notification_data):
notification_data.update(refund=refund)
@staticmethod
def _create_refund_tx_from_refund(source_tx_sudo, refund_object):
""" Create a refund transaction based on Stripe data.
:param recordset source_tx_sudo: The source transaction for which a refund is initiated, as
a sudoed `payment.transaction` record.
:param dict refund_object: The Stripe refund object to create the refund from.
:return: The created refund transaction.
:rtype: recordset of `payment.transaction`
"""
amount_to_refund = refund_object['amount']
converted_amount = payment_utils.to_major_currency_units(
amount_to_refund, source_tx_sudo.currency_id
)
return source_tx_sudo._create_refund_transaction(amount_to_refund=converted_amount)
def _verify_notification_signature(self, tx_sudo):
""" Check that the received signature matches the expected one.
@@ -41,6 +41,7 @@ class PaymentAcquirer(models.Model):
super()._compute_feature_support_fields()
self.filtered(lambda acq: acq.provider == 'stripe').update({
'support_manual_capture': True,
'support_refund': 'partial',
'support_tokenization': True,
})
@@ -10,7 +10,7 @@ from odoo.exceptions import UserError, ValidationError
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment_stripe import utils as stripe_utils
from odoo.addons.payment_stripe.const import INTENT_STATUS_MAPPING, PAYMENT_METHOD_TYPES
from odoo.addons.payment_stripe.const import STATUS_MAPPING, PAYMENT_METHOD_TYPES
from odoo.addons.payment_stripe.controllers.main import StripeController
@@ -195,6 +195,95 @@ class PaymentTransaction(models.Model):
)
self._handle_notification_data('stripe', notification_data)
def _stripe_create_payment_intent(self):
""" Create and return a PaymentIntent.
Note: self.ensure_one()
:return: The Payment Intent
:rtype: dict
"""
if not self.token_id.stripe_payment_method: # Pre-SCA token -> migrate it
self.token_id._stripe_sca_migrate_customer()
response = self.acquirer_id._stripe_make_request(
'payment_intents',
payload=self._stripe_prepare_payment_intent_payload(),
offline=self.operation == 'offline',
)
if 'error' not in response:
payment_intent = response
else: # A processing error was returned in place of the payment intent
error_msg = response['error'].get('message')
self._set_error("Stripe: " + _(
"The communication with the API failed.\n"
"Stripe gave us the following info about the problem:\n'%s'", error_msg
)) # Flag transaction as in error now as the intent status might have a valid value
payment_intent = response['error'].get('payment_intent') # Get the PI from the error
return payment_intent
def _stripe_prepare_payment_intent_payload(self):
""" Prepare the payload for the creation of a payment intent in Stripe format.
Note: This method serves as a hook for modules that would fully implement Stripe Connect.
Note: self.ensure_one()
:return: The Stripe-formatted payload for the payment intent request
:rtype: dict
"""
return {
'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id),
'currency': self.currency_id.name.lower(),
'confirm': True,
'customer': self.token_id.acquirer_ref,
'off_session': True,
'payment_method': self.token_id.stripe_payment_method,
'description': self.reference,
'capture_method': 'manual' if self.acquirer_id.capture_manually else 'automatic',
}
def _send_refund_request(self, amount_to_refund=None, create_refund_transaction=True):
""" Override of payment to send a refund request to Stripe.
Note: self.ensure_one()
:param float amount_to_refund: The amount to refund.
:param bool create_refund_transaction: Whether a refund transaction should be created or
not.
:return: The refund transaction, if any.
:rtype: recordset of `payment.transaction`
"""
if self.provider != 'stripe':
return super()._send_refund_request(
amount_to_refund=amount_to_refund,
create_refund_transaction=create_refund_transaction,
)
refund_tx = super()._send_refund_request(
amount_to_refund=amount_to_refund, create_refund_transaction=True
)
# Make the refund request to stripe.
data = self.acquirer_id._stripe_make_request(
'refunds', payload={
'charge': self.acquirer_reference,
'amount': payment_utils.to_minor_currency_units(
-refund_tx.amount, # Refund transactions' amount is negative, inverse it.
refund_tx.currency_id,
),
}
)
_logger.info(
"Refund request response for transaction wih reference %s:\n%s",
self.reference, pprint.pformat(data)
)
# Handle the refund request response.
notification_data = {}
StripeController._include_refund_in_notification_data(data, notification_data)
refund_tx._handle_notification_data('stripe', notification_data)
return refund_tx
def _send_capture_request(self):
""" Override of payment to send a capture request to Stripe.
@@ -249,54 +338,6 @@ class PaymentTransaction(models.Model):
)
self._handle_notification_data('stripe', notification_data)
def _stripe_create_payment_intent(self):
""" Create and return a PaymentIntent.
Note: self.ensure_one()
:return: The Payment Intent
:rtype: dict
"""
if not self.token_id.stripe_payment_method: # Pre-SCA token -> migrate it
self.token_id._stripe_sca_migrate_customer()
response = self.acquirer_id._stripe_make_request(
'payment_intents',
payload=self._stripe_prepare_payment_intent_payload(),
offline=self.operation == 'offline',
)
if 'error' not in response:
payment_intent = response
else: # A processing error was returned in place of the payment intent
error_msg = response['error'].get('message')
self._set_error("Stripe: " + _(
"The communication with the API failed.\n"
"Stripe gave us the following info about the problem:\n'%s'", error_msg
)) # Flag transaction as in error now as the intent status might have a valid value
payment_intent = response['error'].get('payment_intent') # Get the PI from the error
return payment_intent
def _stripe_prepare_payment_intent_payload(self):
""" Prepare the payload for the creation of a payment intent in Stripe format.
Note: This method serves as a hook for modules that would fully implement Stripe Connect.
Note: self.ensure_one()
:return: The Stripe-formatted payload for the payment intent request
:rtype: dict
"""
return {
'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id),
'currency': self.currency_id.name.lower(),
'confirm': True,
'customer': self.token_id.acquirer_ref,
'off_session': True,
'payment_method': self.token_id.stripe_payment_method,
'description': self.reference,
'capture_method': 'manual' if self.acquirer_id.capture_manually else 'automatic',
}
def _get_tx_from_notification_data(self, provider, notification_data):
""" Override of payment to find the transaction based on Stripe data.
@@ -312,10 +353,17 @@ class PaymentTransaction(models.Model):
return tx
reference = notification_data.get('reference')
if not reference:
if reference:
tx = self.search([('reference', '=', reference), ('provider', '=', 'stripe')])
elif notification_data.get('event_type') == 'charge.refund.updated':
# The webhook notifications sent for `charge.refund.updated` events only contain a
# refund object that has no 'description' (the merchant reference) field. We thus search
# the transaction by its acquirer reference which is the refund id for refund txs.
refund_id = notification_data['object_id'] # The object is a refund.
tx = self.search([('acquirer_reference', '=', refund_id), ('provider', '=', 'stripe')])
else:
raise ValidationError("Stripe: " + _("Received data with missing merchant reference"))
tx = self.search([('reference', '=', reference), ('provider', '=', 'stripe')])
if not tx:
raise ValidationError(
"Stripe: " + _("No transaction found matching reference %s.", reference)
@@ -339,41 +387,52 @@ class PaymentTransaction(models.Model):
if self.provider != 'stripe':
return
if 'charge' in notification_data:
self.acquirer_reference = notification_data['charge']['id']
# Handle the intent status
# Handle the acquirer reference and the status.
if self.operation == 'validation':
intent_status = notification_data.get('setup_intent', {}).get('status')
status = notification_data.get('setup_intent', {}).get('status')
elif self.operation == 'refund':
self.acquirer_reference = notification_data['refund']['id']
status = notification_data['refund']['status']
else: # 'online_redirect', 'online_token', 'offline'
intent_status = notification_data.get('payment_intent', {}).get('status')
if not intent_status:
if 'charge' in notification_data: # The online_redirect operation may include a charge.
self.acquirer_reference = notification_data['charge']['id']
status = notification_data.get('payment_intent', {}).get('status')
if not status:
raise ValidationError(
"Stripe: " + _("Received data with missing intent status.")
)
if intent_status in INTENT_STATUS_MAPPING['draft']:
if status in STATUS_MAPPING['draft']:
pass
elif intent_status in INTENT_STATUS_MAPPING['pending']:
elif status in STATUS_MAPPING['pending']:
self._set_pending()
elif intent_status in INTENT_STATUS_MAPPING['authorized']:
elif status in STATUS_MAPPING['authorized']:
if self.tokenize:
self._stripe_tokenize_from_notification_data(notification_data)
self._set_authorized()
elif intent_status in INTENT_STATUS_MAPPING['done']:
elif status in STATUS_MAPPING['done']:
if self.tokenize:
self._stripe_tokenize_from_notification_data(notification_data)
self._set_done()
elif intent_status in INTENT_STATUS_MAPPING['cancel']:
# Immediately post-process the transaction if it is a refund, as the post-processing
# will not be triggered by a customer browsing the transaction from the portal.
if self.operation == 'refund':
self.env.ref('payment.cron_post_process_payment_tx')._trigger()
elif status in STATUS_MAPPING['cancel']:
self._set_canceled()
elif status in STATUS_MAPPING['error'] and self.operation == 'refund':
self._set_error("Stripe: " + _(
"The refund did not go through. Please log into your Stripe Dashboard to get more "
"information on that matter, and address any accounting discrepancies."
))
else: # Classify unknown intent statuses as `error` tx state
_logger.warning(
"received invalid payment status (%s) for transaction with reference %s",
intent_status, self.reference
)
self._set_error(
"Stripe: " + _("Received data with invalid intent status: %s", intent_status)
status, self.reference
)
self._set_error("Stripe: " + _("Received data with invalid intent status: %s", status))
def _stripe_tokenize_from_notification_data(self, notification_data):
""" Create a new token based on the notification data.
+1
View File
@@ -1,4 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_refund_flows
from . import test_stripe
+34
View File
@@ -1,4 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.payment.tests.common import PaymentCommon
@@ -30,3 +31,36 @@ class StripeCommon(PaymentCommon):
},
'type': 'payment_intent.succeeded'
}
cls.refund_object = {
'amount': cls.amount,
'charge': 'ch_000000000000000000000000',
'currency': 'eur',
'id': 're_000000000000000000000000',
'object': 'refund',
'payment_intent': 'pi_000000000000000000000000',
'status': 'succeeded',
}
cls.refund_notification_data = {
'data': {
'object': {
'id': 'ch_000000000000000000000000',
'object': 'charge',
'amount': cls.amount,
'description': cls.reference,
'refunds': {
'object': 'list',
'data': [cls.refund_object],
'has_more': False,
},
'status': 'succeeded',
}
},
'type': 'charge.refunded'
}
cls.canceled_refund_notification_data = {
'data': {
'object': dict(cls.refund_object, status='failed'),
},
'type': 'charge.refund.updated',
}
@@ -0,0 +1,51 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
from odoo.addons.payment_stripe.controllers.main import StripeController
from odoo.addons.payment_stripe.tests.common import StripeCommon
@tagged('post_install', '-at_install')
class TestRefundFlows(StripeCommon, PaymentHttpCommon):
@mute_logger('odoo.addons.payment_stripe.models.payment_transaction')
def test_refund_id_is_set_as_acquirer_reference(self):
""" Test that the id of the refund object is set as the acquirer reference of the refund
transaction. """
source_tx = self.create_transaction('redirect', state='done')
with patch(
'odoo.addons.payment_stripe.models.payment_acquirer.PaymentAcquirer'
'._stripe_make_request', return_value=self.refund_object
):
source_tx._send_refund_request()
refund_tx = self.env['payment.transaction'].search(
[('source_transaction_id', '=', source_tx.id)]
)
self.assertEqual(refund_tx.acquirer_reference, self.refund_object['id'])
@mute_logger(
'odoo.addons.payment_stripe.controllers.main',
'odoo.addons.payment_stripe.models.payment_transaction',
)
def test_canceled_refund_webhook_notification_triggers_processing(self):
""" Test that receiving a webhook notification for a refund cancellation
(`charge.refund.updated` event) triggers the processing of the notification data. """
source_tx = self.create_transaction('redirect', state='done')
source_tx._create_refund_transaction(
amount_to_refund=source_tx.amount, acquirer_reference=self.refund_object['id']
)
url = self._build_url(StripeController._webhook_url)
with patch(
'odoo.addons.payment_stripe.controllers.main.StripeController'
'._verify_notification_signature'
), patch(
'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
'._handle_notification_data'
) as handle_notification_data_mock:
self._make_json_request(url, data=self.canceled_refund_notification_data)
self.assertEqual(handle_notification_data_mock.call_count, 1)