# 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, }, )