Odoo18-Base/addons/payment_adyen/models/payment_transaction.py

509 lines
24 KiB
Python
Raw Permalink Normal View History

2025-01-06 10:57:38 +07:00
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import pprint
from odoo import _, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools import format_amount
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment_adyen import utils as adyen_utils
from odoo.addons.payment_adyen import const
_logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
#=== BUSINESS METHODS ===#
def _get_specific_processing_values(self, processing_values):
""" Override of payment to return Adyen-specific processing values.
Note: self.ensure_one() from `_get_processing_values`
:param dict processing_values: The generic processing values of the transaction
:return: The dict of provider-specific processing values
:rtype: dict
"""
res = super()._get_specific_processing_values(processing_values)
if self.provider_code != 'adyen':
return res
converted_amount = payment_utils.to_minor_currency_units(
self.amount, self.currency_id, const.CURRENCY_DECIMALS.get(self.currency_id.name)
)
return {
'converted_amount': converted_amount,
'access_token': payment_utils.generate_access_token(
processing_values['reference'],
converted_amount,
self.currency_id.id,
processing_values['partner_id']
)
}
def _send_payment_request(self):
""" Override of payment to send a payment request to Adyen.
Note: self.ensure_one()
:return: None
:raise: UserError if the transaction is not linked to a token
"""
super()._send_payment_request()
if self.provider_code != 'adyen':
return
# Prepare the payment request to Adyen
if not self.token_id:
raise UserError("Adyen: " + _("The transaction is not linked to a token."))
converted_amount = payment_utils.to_minor_currency_units(
self.amount, self.currency_id, const.CURRENCY_DECIMALS.get(self.currency_id.name)
)
data = {
'merchantAccount': self.provider_id.adyen_merchant_account,
'amount': {
'value': converted_amount,
'currency': self.currency_id.name,
},
'reference': self.reference,
'paymentMethod': {
'storedPaymentMethodId': self.token_id.provider_ref,
},
'shopperReference': self.token_id.adyen_shopper_reference,
'recurringProcessingModel': 'Subscription',
'shopperIP': payment_utils.get_customer_ip_address(),
'shopperInteraction': 'ContAuth',
'shopperEmail': self.partner_email,
'shopperName': adyen_utils.format_partner_name(self.partner_name),
'telephoneNumber': self.partner_phone,
**adyen_utils.include_partner_addresses(self),
}
# Force the capture delay on Adyen side if the provider is not configured for capturing
# payments manually. This is necessary because it's not possible to distinguish
# 'AUTHORISATION' events sent by Adyen with the merchant account's capture delay set to
# 'manual' from events with the capture delay set to 'immediate' or a number of hours. If
# the merchant account is configured to capture payments with a delay but the provider is
# not, we force the immediate capture to avoid considering authorized transactions as
# captured on Odoo.
if not self.provider_id.capture_manually:
data.update(captureDelayHours=0)
# Make the payment request to Adyen
try:
response_content = self.provider_id._adyen_make_request(
endpoint='/payments',
payload=data,
method='POST',
idempotency_key=payment_utils.generate_idempotency_key(
self, scope='payment_request_token'
)
)
except ValidationError as e:
if self.operation == 'offline':
self._set_error(str(e)) # Log the error message on linked documents' chatter.
return # There is nothing to process.
else:
raise e
# Handle the payment request response
_logger.info(
"payment request response for transaction with reference %s:\n%s",
self.reference, pprint.pformat(response_content)
)
self._handle_notification_data('adyen', response_content)
def _send_refund_request(self, amount_to_refund=None):
""" Override of payment to send a refund request to Adyen.
Note: self.ensure_one()
:param float amount_to_refund: The amount to refund
:return: The refund transaction created to process the refund request.
:rtype: recordset of `payment.transaction`
"""
refund_tx = super()._send_refund_request(amount_to_refund=amount_to_refund)
if self.provider_code != 'adyen':
return refund_tx
# Make the refund request to Adyen
converted_amount = payment_utils.to_minor_currency_units(
-refund_tx.amount, # The amount is negative for refund transactions
refund_tx.currency_id,
arbitrary_decimal_number=const.CURRENCY_DECIMALS.get(refund_tx.currency_id.name)
)
data = {
'merchantAccount': self.provider_id.adyen_merchant_account,
'amount': {
'value': converted_amount,
'currency': refund_tx.currency_id.name,
},
'reference': refund_tx.reference,
}
response_content = refund_tx.provider_id._adyen_make_request(
endpoint='/payments/{}/refunds',
endpoint_param=self.provider_reference,
payload=data,
method='POST'
)
_logger.info(
"refund request response for transaction with reference %s:\n%s",
self.reference, pprint.pformat(response_content)
)
# Handle the refund request response
psp_reference = response_content.get('pspReference')
status = response_content.get('status')
if psp_reference and status == 'received':
# The PSP reference associated with this /refunds request is different from the psp
# reference associated with the original payment request.
refund_tx.provider_reference = psp_reference
return refund_tx
def _send_capture_request(self, amount_to_capture=None):
""" Override of `payment` to send a capture request to Adyen. """
capture_child_tx = super()._send_capture_request(amount_to_capture=amount_to_capture)
if self.provider_code != 'adyen':
return capture_child_tx
amount_to_capture = amount_to_capture or self.amount
converted_amount = payment_utils.to_minor_currency_units(
amount_to_capture, self.currency_id, const.CURRENCY_DECIMALS.get(self.currency_id.name)
)
data = {
'merchantAccount': self.provider_id.adyen_merchant_account,
'amount': {
'value': converted_amount,
'currency': self.currency_id.name,
},
'reference': self.reference,
}
response_content = self.provider_id._adyen_make_request(
endpoint='/payments/{}/captures',
endpoint_param=self.provider_reference,
payload=data,
method='POST',
)
_logger.info("capture request response:\n%s", pprint.pformat(response_content))
# Handle the capture request response
status = response_content.get('status')
formatted_amount = format_amount(self.env, amount_to_capture, self.currency_id)
if status == 'received':
self._log_message_on_linked_documents(_(
"The capture request of %(amount)s for the transaction with reference %(ref)s has "
"been requested (%(provider_name)s).",
amount=formatted_amount, ref=self.reference, provider_name=self.provider_id.name
))
if capture_child_tx:
# The PSP reference associated with this capture request is different from the PSP
# reference associated with the original payment request.
capture_child_tx.provider_reference = response_content.get('pspReference')
return capture_child_tx
def _send_void_request(self, amount_to_void=None):
""" Override of `payment` to send a void request to Adyen. """
child_void_tx = super()._send_void_request(amount_to_void=amount_to_void)
if self.provider_code != 'adyen':
return child_void_tx
data = {
'merchantAccount': self.provider_id.adyen_merchant_account,
'reference': self.reference,
}
response_content = self.provider_id._adyen_make_request(
endpoint='/payments/{}/cancels',
endpoint_param=self.provider_reference,
payload=data,
method='POST',
)
_logger.info("void request response:\n%s", pprint.pformat(response_content))
# Handle the void request response
status = response_content.get('status')
if status == 'received':
self._log_message_on_linked_documents(_(
"A request was sent to void the transaction with reference %(reference)s (%(provider)s).",
reference=self.reference, provider=self.provider_id.name,
))
if child_void_tx:
# The PSP reference associated with this void request is different from the PSP
# reference associated with the original payment request.
child_void_tx.provider_reference = response_content.get('pspReference')
return child_void_tx
def _get_tx_from_notification_data(self, provider_code, notification_data):
""" Override of payment to find the transaction based on Adyen data.
:param str provider_code: The code of the provider that handled the transaction
:param dict notification_data: The notification data sent by the provider
:return: The transaction if found
:rtype: recordset of `payment.transaction`
:raise: ValidationError if inconsistent data were received
:raise: ValidationError if the data match no transaction
"""
tx = super()._get_tx_from_notification_data(provider_code, notification_data)
if provider_code != 'adyen' or len(tx) == 1:
return tx
reference = notification_data.get('merchantReference')
if not reference:
raise ValidationError("Adyen: " + _("Received data with missing merchant reference"))
event_code = notification_data.get('eventCode', 'AUTHORISATION') # Fallback on auth if S2S.
provider_reference = notification_data.get('pspReference')
source_reference = notification_data.get('originalReference')
if event_code == 'AUTHORISATION':
tx = self.search([('reference', '=', reference), ('provider_code', '=', 'adyen')])
elif event_code in ['CANCELLATION', 'CAPTURE', 'CAPTURE_FAILED']:
# The capture/void may be initiated from Adyen, so we can't trust the reference.
# We find the transaction based on the original provider reference since Adyen will have
# two different references: one for the original transaction and one for the capture or
# void. We keep the second one only for child transactions. For full capture/void, no
# child transaction are created. Thus, we first look for the source transaction before
# checking if we need to find/create a child transaction.
source_tx = self.search(
[('provider_reference', '=', source_reference), ('provider_code', '=', 'adyen')]
)
if source_tx:
notification_data_amount = notification_data.get('amount', {}).get('value')
converted_notification_amount = payment_utils.to_major_currency_units(
notification_data_amount, source_tx.currency_id
)
if source_tx.amount == converted_notification_amount: # Full capture/void.
tx = source_tx
else: # Partial capture/void; we search for the child transaction instead.
tx = self.search([
('provider_reference', '=', provider_reference),
('provider_code', '=', 'adyen'),
])
if tx and tx.amount != converted_notification_amount:
# If the void was requested expecting a certain amount but, in the meantime,
# others captures that Odoo was unaware of were done, the amount voided will
# be different from the amount of the existing transaction.
tx._set_error(_(
"The amount processed by Adyen for the transaction %s is different than"
" the one requested. Another transaction is created with the correct"
" amount.", tx.reference
))
tx = self.env['payment.transaction']
if not tx: # Partial capture/void initiated from Adyen or with a wrong amount.
# Manually create a child transaction with a new reference. The reference of
# the child transaction was personalized from Adyen and could be identical
# to that of an existing transaction.
tx = self._adyen_create_child_tx_from_notification_data(
source_tx, notification_data
)
else: # The capture/void was initiated for an unknown source transaction
pass # Don't do anything with the capture/void notification
else: # 'REFUND'
# The refund may be initiated from Adyen, so we can't trust the reference, which could
# be identical to another existing transaction. We find the transaction based on the
# provider reference.
tx = self.search(
[('provider_reference', '=', provider_reference), ('provider_code', '=', 'adyen')]
)
if not tx: # The refund was initiated from Adyen
# Find the source transaction based on the original reference
source_tx = self.search(
[('provider_reference', '=', source_reference), ('provider_code', '=', 'adyen')]
)
if source_tx:
# Manually create a refund transaction with a new reference. The reference of
# the refund transaction was personalized from Adyen and could be identical to
# that of an existing transaction.
tx = self._adyen_create_child_tx_from_notification_data(
source_tx, notification_data, is_refund=True
)
else: # The refund was initiated for an unknown source transaction
pass # Don't do anything with the refund notification
if not tx:
raise ValidationError(
"Adyen: " + _("No transaction found matching reference %s.", reference)
)
return tx
def _adyen_create_child_tx_from_notification_data(
self, source_tx, notification_data, is_refund=False
):
""" Create a child transaction based on Adyen data.
:param payment.transaction source_tx: The source transaction for which a new operation is
initiated.
:param dict notification_data: The notification data sent by the provider
:return: The newly created child transaction.
:rtype: payment.transaction
:raise ValidationError: If inconsistent data were received.
"""
provider_reference = notification_data.get('pspReference')
amount = notification_data.get('amount', {}).get('value')
if not provider_reference or amount is None: # amount == 0 if success == False
raise ValidationError(
"Adyen: " + _("Received data for child transaction with missing transaction values")
)
converted_amount = payment_utils.to_major_currency_units(amount, source_tx.currency_id)
return source_tx._create_child_transaction(
converted_amount, is_refund=is_refund, provider_reference=provider_reference
)
def _process_notification_data(self, notification_data):
""" Override of payment to process the transaction based on Adyen data.
Note: self.ensure_one()
:param dict notification_data: The notification data sent by the provider
:return: None
:raise: ValidationError if inconsistent data were received
"""
super()._process_notification_data(notification_data)
if self.provider_code != 'adyen':
return
# Extract or assume the event code. If none is provided, the feedback data originate from a
# direct payment request whose feedback data share the same payload as an 'AUTHORISATION'
# webhook notification.
event_code = notification_data.get('eventCode', 'AUTHORISATION')
# Update the provider reference. If the event code is 'CAPTURE' or 'CANCELLATION', we
# discard the pspReference as it is different from the original pspReference of the tx.
if 'pspReference' in notification_data and event_code in ['AUTHORISATION', 'REFUND']:
self.provider_reference = notification_data.get('pspReference')
# Update the payment method.
payment_method_data = notification_data.get('paymentMethod', '')
if isinstance(payment_method_data, dict): # Not from webhook: the data contain the PM code.
payment_method_type = payment_method_data['type']
if payment_method_type == 'scheme': # card
payment_method_code = payment_method_data['brand']
else:
payment_method_code = payment_method_type
else: # Sent from the webhook: the PM code is directly received as a string.
payment_method_code = payment_method_data
payment_method = self.env['payment.method']._get_from_code(
payment_method_code, mapping=const.PAYMENT_METHODS_MAPPING
)
self.payment_method_id = payment_method or self.payment_method_id
# Update the payment state.
payment_state = notification_data.get('resultCode')
refusal_reason = notification_data.get('refusalReason') or notification_data.get('reason')
if not payment_state:
raise ValidationError("Adyen: " + _("Received data with missing payment state."))
if payment_state in const.RESULT_CODES_MAPPING['pending']:
self._set_pending()
elif payment_state in const.RESULT_CODES_MAPPING['done']:
additional_data = notification_data.get('additionalData', {})
has_token_data = 'recurring.recurringDetailReference' in additional_data
if self.tokenize and has_token_data:
self._adyen_tokenize_from_notification_data(notification_data)
if not self.provider_id.capture_manually:
self._set_done()
else: # The payment was configured for manual capture.
# Differentiate the state based on the event code.
if event_code == 'AUTHORISATION':
self._set_authorized()
else: # 'CAPTURE'
self._set_done()
# 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 payment_state in const.RESULT_CODES_MAPPING['cancel']:
self._set_canceled()
elif payment_state in const.RESULT_CODES_MAPPING['error']:
if event_code in ['AUTHORISATION', 'REFUND']:
_logger.warning(
"the transaction with reference %s underwent an error. reason: %s",
self.reference, refusal_reason,
)
self._set_error(
_("An error occurred during the processing of your payment. Please try again.")
)
elif event_code == 'CANCELLATION':
_logger.warning(
"The void of the transaction with reference %s failed. reason: %s",
self.reference, refusal_reason,
)
if self.source_transaction_id: # child tx => The event can't be retried.
self._set_error(
_("The void of the transaction with reference %s failed.", self.reference)
)
else: # source tx with failed void stays in its state, could be voided again
self._log_message_on_linked_documents(
_("The void of the transaction with reference %s failed.", self.reference)
)
else: # 'CAPTURE', 'CAPTURE_FAILED'
_logger.warning(
"The capture of the transaction with reference %s failed. reason: %s",
self.reference, refusal_reason,
)
if self.source_transaction_id: # child_tx => The event can't be retried.
self._set_error(_(
"The capture of the transaction with reference %s failed.", self.reference
))
else: # source tx with failed capture stays in its state, could be captured again
self._log_message_on_linked_documents(_(
"The capture of the transaction with reference %s failed.", self.reference
))
_logger.warning(
"the transaction with reference %s was refused. reason: %s",
self.reference, refusal_reason
)
self._set_error(_("Your payment was refused. Please try again."))
else: # Classify unsupported payment state as `error` tx state
_logger.warning(
"received data for transaction with reference %s with invalid payment state: %s",
self.reference, payment_state
)
self._set_error(
"Adyen: " + _("Received data with invalid payment state: %s", payment_state)
)
def _adyen_tokenize_from_notification_data(self, notification_data):
""" Create a new token based on the notification data.
Note: self.ensure_one()
:param dict notification_data: The notification data sent by the provider
:return: None
"""
self.ensure_one()
additional_data = notification_data['additionalData']
token = self.env['payment.token'].create({
'provider_id': self.provider_id.id,
'payment_method_id': self.payment_method_id.id,
'payment_details': additional_data.get('cardSummary'),
'partner_id': self.partner_id.id,
'provider_ref': additional_data['recurring.recurringDetailReference'],
'adyen_shopper_reference': additional_data['recurring.shopperReference'],
})
self.write({
'token_id': token,
'tokenize': False,
})
_logger.info(
"Created token with id %(token_id)s for partner with id %(partner_id)s from "
"transaction with reference %(ref)s",
{
'token_id': token.id,
'partner_id': self.partner_id.id,
'ref': self.reference,
},
)