[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:
@@ -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>
|
||||
<!--
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
]
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user