payment_transaction.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  2. import logging
  3. import pprint
  4. from odoo import _, api, fields, models
  5. from odoo.exceptions import UserError, ValidationError
  6. from odoo.addons.payment import utils as payment_utils
  7. from odoo.addons.payment_adyen import utils as adyen_utils
  8. from odoo.addons.payment_adyen.const import CURRENCY_DECIMALS, RESULT_CODES_MAPPING
  9. _logger = logging.getLogger(__name__)
  10. class PaymentTransaction(models.Model):
  11. _inherit = 'payment.transaction'
  12. #=== BUSINESS METHODS ===#
  13. def _get_specific_processing_values(self, processing_values):
  14. """ Override of payment to return Adyen-specific processing values.
  15. Note: self.ensure_one() from `_get_processing_values`
  16. :param dict processing_values: The generic processing values of the transaction
  17. :return: The dict of provider-specific processing values
  18. :rtype: dict
  19. """
  20. res = super()._get_specific_processing_values(processing_values)
  21. if self.provider_code != 'adyen':
  22. return res
  23. converted_amount = payment_utils.to_minor_currency_units(
  24. self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name)
  25. )
  26. return {
  27. 'converted_amount': converted_amount,
  28. 'access_token': payment_utils.generate_access_token(
  29. processing_values['reference'],
  30. converted_amount,
  31. processing_values['partner_id']
  32. )
  33. }
  34. def _send_payment_request(self):
  35. """ Override of payment to send a payment request to Adyen.
  36. Note: self.ensure_one()
  37. :return: None
  38. :raise: UserError if the transaction is not linked to a token
  39. """
  40. super()._send_payment_request()
  41. if self.provider_code != 'adyen':
  42. return
  43. # Prepare the payment request to Adyen
  44. if not self.token_id:
  45. raise UserError("Adyen: " + _("The transaction is not linked to a token."))
  46. converted_amount = payment_utils.to_minor_currency_units(
  47. self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name)
  48. )
  49. data = {
  50. 'merchantAccount': self.provider_id.adyen_merchant_account,
  51. 'amount': {
  52. 'value': converted_amount,
  53. 'currency': self.currency_id.name,
  54. },
  55. 'reference': self.reference,
  56. 'paymentMethod': {
  57. 'recurringDetailReference': self.token_id.provider_ref,
  58. },
  59. 'shopperReference': self.token_id.adyen_shopper_reference,
  60. 'recurringProcessingModel': 'Subscription',
  61. 'shopperIP': payment_utils.get_customer_ip_address(),
  62. 'shopperInteraction': 'ContAuth',
  63. 'shopperEmail': self.partner_email,
  64. 'shopperName': adyen_utils.format_partner_name(self.partner_name),
  65. 'telephoneNumber': self.partner_phone,
  66. **adyen_utils.include_partner_addresses(self),
  67. }
  68. # Force the capture delay on Adyen side if the provider is not configured for capturing
  69. # payments manually. This is necessary because it's not possible to distinguish
  70. # 'AUTHORISATION' events sent by Adyen with the merchant account's capture delay set to
  71. # 'manual' from events with the capture delay set to 'immediate' or a number of hours. If
  72. # the merchant account is configured to capture payments with a delay but the provider is
  73. # not, we force the immediate capture to avoid considering authorized transactions as
  74. # captured on Odoo.
  75. if not self.provider_id.capture_manually:
  76. data.update(captureDelayHours=0)
  77. # Make the payment request to Adyen
  78. response_content = self.provider_id._adyen_make_request(
  79. url_field_name='adyen_checkout_api_url',
  80. endpoint='/payments',
  81. payload=data,
  82. method='POST',
  83. )
  84. # Handle the payment request response
  85. _logger.info(
  86. "payment request response for transaction with reference %s:\n%s",
  87. self.reference, pprint.pformat(response_content)
  88. )
  89. self._handle_notification_data('adyen', response_content)
  90. def _send_refund_request(self, amount_to_refund=None):
  91. """ Override of payment to send a refund request to Adyen.
  92. Note: self.ensure_one()
  93. :param float amount_to_refund: The amount to refund
  94. :return: The refund transaction created to process the refund request.
  95. :rtype: recordset of `payment.transaction`
  96. """
  97. refund_tx = super()._send_refund_request(amount_to_refund=amount_to_refund)
  98. if self.provider_code != 'adyen':
  99. return refund_tx
  100. # Make the refund request to Adyen
  101. converted_amount = payment_utils.to_minor_currency_units(
  102. -refund_tx.amount, # The amount is negative for refund transactions
  103. refund_tx.currency_id,
  104. arbitrary_decimal_number=CURRENCY_DECIMALS.get(refund_tx.currency_id.name)
  105. )
  106. data = {
  107. 'merchantAccount': self.provider_id.adyen_merchant_account,
  108. 'amount': {
  109. 'value': converted_amount,
  110. 'currency': refund_tx.currency_id.name,
  111. },
  112. 'reference': refund_tx.reference,
  113. }
  114. response_content = refund_tx.provider_id._adyen_make_request(
  115. url_field_name='adyen_checkout_api_url',
  116. endpoint='/payments/{}/refunds',
  117. endpoint_param=self.provider_reference,
  118. payload=data,
  119. method='POST'
  120. )
  121. _logger.info(
  122. "refund request response for transaction with reference %s:\n%s",
  123. self.reference, pprint.pformat(response_content)
  124. )
  125. # Handle the refund request response
  126. psp_reference = response_content.get('pspReference')
  127. status = response_content.get('status')
  128. if psp_reference and status == 'received':
  129. # The PSP reference associated with this /refunds request is different from the psp
  130. # reference associated with the original payment request.
  131. refund_tx.provider_reference = psp_reference
  132. return refund_tx
  133. def _send_capture_request(self):
  134. """ Override of payment to send a capture request to Adyen.
  135. Note: self.ensure_one()
  136. :return: None
  137. """
  138. super()._send_capture_request()
  139. if self.provider_code != 'adyen':
  140. return
  141. converted_amount = payment_utils.to_minor_currency_units(
  142. self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name)
  143. )
  144. data = {
  145. 'merchantAccount': self.provider_id.adyen_merchant_account,
  146. 'amount': {
  147. 'value': converted_amount,
  148. 'currency': self.currency_id.name,
  149. },
  150. 'reference': self.reference,
  151. }
  152. response_content = self.provider_id._adyen_make_request(
  153. url_field_name='adyen_checkout_api_url',
  154. endpoint='/payments/{}/captures',
  155. endpoint_param=self.provider_reference,
  156. payload=data,
  157. method='POST',
  158. )
  159. _logger.info("capture request response:\n%s", pprint.pformat(response_content))
  160. # Handle the capture request response
  161. status = response_content.get('status')
  162. if status == 'received':
  163. self._log_message_on_linked_documents(_(
  164. "The capture of the transaction with reference %s has been requested (%s).",
  165. self.reference, self.provider_id.name
  166. ))
  167. def _send_void_request(self):
  168. """ Override of payment to send a void request to Adyen.
  169. Note: self.ensure_one()
  170. :return: None
  171. """
  172. super()._send_void_request()
  173. if self.provider_code != 'adyen':
  174. return
  175. data = {
  176. 'merchantAccount': self.provider_id.adyen_merchant_account,
  177. 'reference': self.reference,
  178. }
  179. response_content = self.provider_id._adyen_make_request(
  180. url_field_name='adyen_checkout_api_url',
  181. endpoint='/payments/{}/cancels',
  182. endpoint_param=self.provider_reference,
  183. payload=data,
  184. method='POST',
  185. )
  186. _logger.info("void request response:\n%s", pprint.pformat(response_content))
  187. # Handle the void request response
  188. status = response_content.get('status')
  189. if status == 'received':
  190. self._log_message_on_linked_documents(_(
  191. "A request was sent to void the transaction with reference %s (%s).",
  192. self.reference, self.provider_id.name
  193. ))
  194. def _get_tx_from_notification_data(self, provider_code, notification_data):
  195. """ Override of payment to find the transaction based on Adyen data.
  196. :param str provider_code: The code of the provider that handled the transaction
  197. :param dict notification_data: The notification data sent by the provider
  198. :return: The transaction if found
  199. :rtype: recordset of `payment.transaction`
  200. :raise: ValidationError if inconsistent data were received
  201. :raise: ValidationError if the data match no transaction
  202. """
  203. tx = super()._get_tx_from_notification_data(provider_code, notification_data)
  204. if provider_code != 'adyen' or len(tx) == 1:
  205. return tx
  206. reference = notification_data.get('merchantReference')
  207. if not reference:
  208. raise ValidationError("Adyen: " + _("Received data with missing merchant reference"))
  209. event_code = notification_data.get('eventCode', 'AUTHORISATION') # Fallback on auth if S2S.
  210. provider_reference = notification_data.get('pspReference')
  211. source_reference = notification_data.get('originalReference')
  212. if event_code == 'AUTHORISATION':
  213. tx = self.search([('reference', '=', reference), ('provider_code', '=', 'adyen')])
  214. elif event_code in ['CAPTURE', 'CANCELLATION']:
  215. # The capture/void may be initiated from Adyen, so we can't trust the reference.
  216. # We find the transaction based on the original provider reference since Adyen will have
  217. # two different references: one for the original transaction and one for the capture.
  218. tx = self.search(
  219. [('provider_reference', '=', source_reference), ('provider_code', '=', 'adyen')]
  220. )
  221. else: # 'REFUND'
  222. # The refund may be initiated from Adyen, so we can't trust the reference, which could
  223. # be identical to another existing transaction. We find the transaction based on the
  224. # provider reference.
  225. tx = self.search(
  226. [('provider_reference', '=', provider_reference), ('provider_code', '=', 'adyen')]
  227. )
  228. if not tx: # The refund was initiated from Adyen
  229. # Find the source transaction based on the original reference
  230. source_tx = self.search(
  231. [('provider_reference', '=', source_reference), ('provider_code', '=', 'adyen')]
  232. )
  233. if source_tx:
  234. # Manually create a refund transaction with a new reference. The reference of
  235. # the refund transaction was personalized from Adyen and could be identical to
  236. # that of an existing transaction.
  237. tx = self._adyen_create_refund_tx_from_notification_data(
  238. source_tx, notification_data
  239. )
  240. else: # The refund was initiated for an unknown source transaction
  241. pass # Don't do anything with the refund notification
  242. if not tx:
  243. raise ValidationError(
  244. "Adyen: " + _("No transaction found matching reference %s.", reference)
  245. )
  246. return tx
  247. def _adyen_create_refund_tx_from_notification_data(self, source_tx, notification_data):
  248. """ Create a refund transaction based on Adyen data.
  249. :param recordset source_tx: The source transaction for which a refund is initiated, as a
  250. `payment.transaction` recordset
  251. :param dict notification_data: The notification data sent by the provider
  252. :return: The newly created refund transaction
  253. :rtype: recordset of `payment.transaction`
  254. :raise: ValidationError if inconsistent data were received
  255. """
  256. refund_provider_reference = notification_data.get('pspReference')
  257. amount_to_refund = notification_data.get('amount', {}).get('value')
  258. if not refund_provider_reference or not amount_to_refund:
  259. raise ValidationError(
  260. "Adyen: " + _("Received refund data with missing transaction values")
  261. )
  262. converted_amount = payment_utils.to_major_currency_units(
  263. amount_to_refund, source_tx.currency_id
  264. )
  265. return source_tx._create_refund_transaction(
  266. amount_to_refund=converted_amount, provider_reference=refund_provider_reference
  267. )
  268. def _process_notification_data(self, notification_data):
  269. """ Override of payment to process the transaction based on Adyen data.
  270. Note: self.ensure_one()
  271. :param dict notification_data: The notification data sent by the provider
  272. :return: None
  273. :raise: ValidationError if inconsistent data were received
  274. """
  275. super()._process_notification_data(notification_data)
  276. if self.provider_code != 'adyen':
  277. return
  278. # Extract or assume the event code. If none is provided, the feedback data originate from a
  279. # direct payment request whose feedback data share the same payload as an 'AUTHORISATION'
  280. # webhook notification.
  281. event_code = notification_data.get('eventCode', 'AUTHORISATION')
  282. # Handle the provider reference. If the event code is 'CAPTURE' or 'CANCELLATION', we
  283. # discard the pspReference as it is different from the original pspReference of the tx.
  284. if 'pspReference' in notification_data and event_code in ['AUTHORISATION', 'REFUND']:
  285. self.provider_reference = notification_data.get('pspReference')
  286. # Handle the payment state
  287. payment_state = notification_data.get('resultCode')
  288. refusal_reason = notification_data.get('refusalReason') or notification_data.get('reason')
  289. if not payment_state:
  290. raise ValidationError("Adyen: " + _("Received data with missing payment state."))
  291. if payment_state in RESULT_CODES_MAPPING['pending']:
  292. self._set_pending()
  293. elif payment_state in RESULT_CODES_MAPPING['done']:
  294. additional_data = notification_data.get('additionalData', {})
  295. has_token_data = 'recurring.recurringDetailReference' in additional_data
  296. if self.tokenize and has_token_data:
  297. self._adyen_tokenize_from_notification_data(notification_data)
  298. if not self.provider_id.capture_manually:
  299. self._set_done()
  300. else: # The payment was configured for manual capture.
  301. # Differentiate the state based on the event code.
  302. if event_code == 'AUTHORISATION':
  303. self._set_authorized()
  304. else: # 'CAPTURE'
  305. self._set_done()
  306. # Immediately post-process the transaction if it is a refund, as the post-processing
  307. # will not be triggered by a customer browsing the transaction from the portal.
  308. if self.operation == 'refund':
  309. self.env.ref('payment.cron_post_process_payment_tx')._trigger()
  310. elif payment_state in RESULT_CODES_MAPPING['cancel']:
  311. self._set_canceled()
  312. elif payment_state in RESULT_CODES_MAPPING['error']:
  313. if event_code in ['AUTHORISATION', 'REFUND']:
  314. _logger.warning(
  315. "the transaction with reference %s underwent an error. reason: %s",
  316. self.reference, refusal_reason
  317. )
  318. self._set_error(
  319. _("An error occurred during the processing of your payment. Please try again.")
  320. )
  321. elif event_code == 'CANCELLATION':
  322. _logger.warning(
  323. "the void of the transaction with reference %s failed. reason: %s",
  324. self.reference, refusal_reason
  325. )
  326. self._log_message_on_linked_documents(
  327. _("The void of the transaction with reference %s failed.", self.reference)
  328. )
  329. else: # 'CAPTURE'
  330. _logger.warning(
  331. "the capture of the transaction with reference %s failed. reason: %s",
  332. self.reference, refusal_reason
  333. )
  334. self._log_message_on_linked_documents(
  335. _("The capture of the transaction with reference %s failed.", self.reference)
  336. )
  337. elif payment_state in RESULT_CODES_MAPPING['refused']:
  338. _logger.warning(
  339. "the transaction with reference %s was refused. reason: %s",
  340. self.reference, refusal_reason
  341. )
  342. self._set_error(_("Your payment was refused. Please try again."))
  343. else: # Classify unsupported payment state as `error` tx state
  344. _logger.warning(
  345. "received data for transaction with reference %s with invalid payment state: %s",
  346. self.reference, payment_state
  347. )
  348. self._set_error(
  349. "Adyen: " + _("Received data with invalid payment state: %s", payment_state)
  350. )
  351. def _adyen_tokenize_from_notification_data(self, notification_data):
  352. """ Create a new token based on the notification data.
  353. Note: self.ensure_one()
  354. :param dict notification_data: The notification data sent by the provider
  355. :return: None
  356. """
  357. self.ensure_one()
  358. additional_data = notification_data['additionalData']
  359. token = self.env['payment.token'].create({
  360. 'provider_id': self.provider_id.id,
  361. 'payment_details': additional_data.get('cardSummary'),
  362. 'partner_id': self.partner_id.id,
  363. 'provider_ref': additional_data['recurring.recurringDetailReference'],
  364. 'adyen_shopper_reference': additional_data['recurring.shopperReference'],
  365. 'verified': True, # The payment is authorized, so the payment method is valid
  366. })
  367. self.write({
  368. 'token_id': token,
  369. 'tokenize': False,
  370. })
  371. _logger.info(
  372. "created token with id %(token_id)s for partner with id %(partner_id)s from "
  373. "transaction with reference %(ref)s",
  374. {
  375. 'token_id': token.id,
  376. 'partner_id': self.partner_id.id,
  377. 'ref': self.reference,
  378. },
  379. )