123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- # Part of Odoo. See LICENSE file for full copyright and licensing details.
- import logging
- import pprint
- from odoo import _, api, fields, models
- from odoo.exceptions import UserError, ValidationError
- from odoo.addons.payment import utils as payment_utils
- from odoo.addons.payment_adyen import utils as adyen_utils
- from odoo.addons.payment_adyen.const import CURRENCY_DECIMALS, RESULT_CODES_MAPPING
- _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, CURRENCY_DECIMALS.get(self.currency_id.name)
- )
- return {
- 'converted_amount': converted_amount,
- 'access_token': payment_utils.generate_access_token(
- processing_values['reference'],
- converted_amount,
- 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, 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': {
- 'recurringDetailReference': 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
- response_content = self.provider_id._adyen_make_request(
- url_field_name='adyen_checkout_api_url',
- endpoint='/payments',
- payload=data,
- method='POST',
- )
- # 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=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(
- url_field_name='adyen_checkout_api_url',
- 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):
- """ Override of payment to send a capture request to Adyen.
- Note: self.ensure_one()
- :return: None
- """
- super()._send_capture_request()
- if self.provider_code != 'adyen':
- return
- converted_amount = payment_utils.to_minor_currency_units(
- self.amount, self.currency_id, 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(
- url_field_name='adyen_checkout_api_url',
- 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')
- if status == 'received':
- self._log_message_on_linked_documents(_(
- "The capture of the transaction with reference %s has been requested (%s).",
- self.reference, self.provider_id.name
- ))
- def _send_void_request(self):
- """ Override of payment to send a void request to Adyen.
- Note: self.ensure_one()
- :return: None
- """
- super()._send_void_request()
- if self.provider_code != 'adyen':
- return
- data = {
- 'merchantAccount': self.provider_id.adyen_merchant_account,
- 'reference': self.reference,
- }
- response_content = self.provider_id._adyen_make_request(
- url_field_name='adyen_checkout_api_url',
- 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 %s (%s).",
- self.reference, self.provider_id.name
- ))
- 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 ['CAPTURE', 'CANCELLATION']:
- # 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.
- tx = self.search(
- [('provider_reference', '=', source_reference), ('provider_code', '=', 'adyen')]
- )
- 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_refund_tx_from_notification_data(
- source_tx, notification_data
- )
- 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_refund_tx_from_notification_data(self, source_tx, notification_data):
- """ Create a refund transaction based on Adyen data.
- :param recordset source_tx: The source transaction for which a refund is initiated, as a
- `payment.transaction` recordset
- :param dict notification_data: The notification data sent by the provider
- :return: The newly created refund transaction
- :rtype: recordset of `payment.transaction`
- :raise: ValidationError if inconsistent data were received
- """
- refund_provider_reference = notification_data.get('pspReference')
- amount_to_refund = notification_data.get('amount', {}).get('value')
- if not refund_provider_reference or not amount_to_refund:
- raise ValidationError(
- "Adyen: " + _("Received refund data with missing transaction values")
- )
- converted_amount = payment_utils.to_major_currency_units(
- amount_to_refund, source_tx.currency_id
- )
- return source_tx._create_refund_transaction(
- amount_to_refund=converted_amount, provider_reference=refund_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')
- # Handle 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')
- # Handle 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 RESULT_CODES_MAPPING['pending']:
- self._set_pending()
- elif payment_state in 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 RESULT_CODES_MAPPING['cancel']:
- self._set_canceled()
- elif payment_state in 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
- )
- self._log_message_on_linked_documents(
- _("The void of the transaction with reference %s failed.", self.reference)
- )
- else: # 'CAPTURE'
- _logger.warning(
- "the capture of the transaction with reference %s failed. reason: %s",
- self.reference, refusal_reason
- )
- self._log_message_on_linked_documents(
- _("The capture of the transaction with reference %s failed.", self.reference)
- )
- elif payment_state in RESULT_CODES_MAPPING['refused']:
- _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_details': additional_data.get('cardSummary'),
- 'partner_id': self.partner_id.id,
- 'provider_ref': additional_data['recurring.recurringDetailReference'],
- 'adyen_shopper_reference': additional_data['recurring.shopperReference'],
- 'verified': True, # The payment is authorized, so the payment method is valid
- })
- 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,
- },
- )
|