diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py
index b00ae9db7be..53cf377eed7 100644
--- a/addons/payment/models/payment_acquirer.py
+++ b/addons/payment/models/payment_acquirer.py
@@ -54,6 +54,12 @@ class PaymentAcquirer(models.Model):
string="Inline Form Template", comodel_name='ir.ui.view',
help="The template rendering the inline payment form when making a direct payment",
domain=[('type', '=', 'qweb')])
+ token_inline_form_view_id = fields.Many2one(
+ string="Token Inline Form Template",
+ comodel_name='ir.ui.view',
+ help="The template rendering the inline payment form when making a payment by token.",
+ domain=[('type', '=', 'qweb')],
+ )
country_ids = fields.Many2many(
string="Countries", comodel_name='res.country', relation='payment_country_rel',
column1='payment_id', column2='country_id',
diff --git a/addons/payment/views/payment_templates.xml b/addons/payment/views/payment_templates.xml
index f5cbcf29261..5267066238e 100644
--- a/addons/payment/views/payment_templates.xml
+++ b/addons/payment/views/payment_templates.xml
@@ -85,11 +85,11 @@
@@ -207,9 +216,9 @@
diff --git a/addons/payment/views/payment_token_views.xml b/addons/payment/views/payment_token_views.xml
index 1684fef0bed..3bd5eb043a7 100644
--- a/addons/payment/views/payment_token_views.xml
+++ b/addons/payment/views/payment_token_views.xml
@@ -15,11 +15,11 @@
-
+
-
+
diff --git a/addons/payment_test/__manifest__.py b/addons/payment_test/__manifest__.py
index 6ae9cf646c4..a003363df6a 100644
--- a/addons/payment_test/__manifest__.py
+++ b/addons/payment_test/__manifest__.py
@@ -12,6 +12,8 @@ It should never be used in production environment. Make sure to disable it befor
'data': [
'views/payment_templates.xml',
'views/payment_test_templates.xml',
+ 'views/payment_token_views.xml',
+ 'views/payment_transaction_views.xml',
'data/payment_acquirer_data.xml',
],
'uninstall_hook': 'uninstall_hook',
diff --git a/addons/payment_test/controllers/main.py b/addons/payment_test/controllers/main.py
index a187e97a33d..ed17ae4c80d 100644
--- a/addons/payment_test/controllers/main.py
+++ b/addons/payment_test/controllers/main.py
@@ -5,19 +5,13 @@ from odoo.http import request
class PaymentTestController(http.Controller):
+ _simulation_url = '/payment/test/simulate_payment'
- @http.route('/payment/test/simulate_payment', type='json', auth='public')
- def test_simulate_payment(self, reference, customer_input):
+ @http.route(_simulation_url, type='json', auth='public')
+ def test_simulate_payment(self, **data):
""" Simulate the response of a payment request.
- :param str reference: The reference of the transaction
- :param str customer_input: The payment method details
+ :param dict data: The simulated notification data.
:return: None
"""
- fake_api_response = {
- 'reference': reference,
- 'cc_summary': customer_input[-4:],
- }
- request.env['payment.transaction'].sudo()._handle_notification_data(
- 'test', fake_api_response
- )
+ request.env['payment.transaction'].sudo()._handle_notification_data('test', data)
diff --git a/addons/payment_test/data/payment_acquirer_data.xml b/addons/payment_test/data/payment_acquirer_data.xml
index 1f4c22fccdf..b8e6f512914 100644
--- a/addons/payment_test/data/payment_acquirer_data.xml
+++ b/addons/payment_test/data/payment_acquirer_data.xml
@@ -4,6 +4,7 @@
test
+
True
diff --git a/addons/payment_test/models/__init__.py b/addons/payment_test/models/__init__.py
index 28c8703fcc1..2303108a7c9 100644
--- a/addons/payment_test/models/__init__.py
+++ b/addons/payment_test/models/__init__.py
@@ -2,4 +2,5 @@
from . import account_payment_method
from . import payment_acquirer
+from . import payment_token
from . import payment_transaction
diff --git a/addons/payment_test/models/payment_acquirer.py b/addons/payment_test/models/payment_acquirer.py
index 580b80359a1..67cd1788b61 100644
--- a/addons/payment_test/models/payment_acquirer.py
+++ b/addons/payment_test/models/payment_acquirer.py
@@ -24,6 +24,9 @@ class PaymentAcquirer(models.Model):
""" Override of `payment` to enable additional features. """
super()._compute_feature_support_fields()
self.filtered(lambda acq: acq.provider == 'test').update({
+ 'support_fees': True,
+ 'support_manual_capture': True,
+ 'support_refund': 'partial',
'support_tokenization': True,
})
diff --git a/addons/payment_test/models/payment_token.py b/addons/payment_test/models/payment_token.py
new file mode 100644
index 00000000000..7bddb51285a
--- /dev/null
+++ b/addons/payment_test/models/payment_token.py
@@ -0,0 +1,18 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details
+
+from odoo import fields, models
+
+
+class PaymentToken(models.Model):
+ _inherit = 'payment.token'
+
+ test_simulated_state = fields.Selection(
+ string="Simulated State",
+ help="The state in which transactions created from this token should be set.",
+ selection=[
+ ('pending', "Pending"),
+ ('done', "Confirmed"),
+ ('cancel', "Canceled"),
+ ('error', "Error"),
+ ],
+ )
diff --git a/addons/payment_test/models/payment_transaction.py b/addons/payment_test/models/payment_transaction.py
index c591a1c096b..109f7a0cbee 100644
--- a/addons/payment_test/models/payment_transaction.py
+++ b/addons/payment_test/models/payment_transaction.py
@@ -2,8 +2,8 @@
import logging
-from odoo import _, api, models
-from odoo.exceptions import ValidationError
+from odoo import _, fields, models
+from odoo.exceptions import UserError, ValidationError
from odoo.addons.payment import utils as payment_utils
@@ -13,6 +13,54 @@ _logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
+ capture_manually = fields.Boolean(related='acquirer_id.capture_manually')
+
+ #=== ACTION METHODS ===#
+
+ def action_test_set_done(self):
+ """ Set the state of the test transaction to 'done'.
+
+ Note: self.ensure_one()
+
+ :return: None
+ """
+ self.ensure_one()
+ if self.provider != 'test':
+ return
+
+ notification_data = {'reference': self.reference, 'simulated_state': 'done'}
+ self._handle_notification_data('test', notification_data)
+
+ def action_test_set_canceled(self):
+ """ Set the state of the test transaction to 'cancel'.
+
+ Note: self.ensure_one()
+
+ :return: None
+ """
+ self.ensure_one()
+ if self.provider != 'test':
+ return
+
+ notification_data = {'reference': self.reference, 'simulated_state': 'cancel'}
+ self._handle_notification_data('test', notification_data)
+
+ def action_test_set_error(self):
+ """ Set the state of the test transaction to 'error'.
+
+ Note: self.ensure_one()
+
+ :return: None
+ """
+ self.ensure_one()
+ if self.provider != 'test':
+ return
+
+ notification_data = {'reference': self.reference, 'simulated_state': 'error'}
+ self._handle_notification_data('test', notification_data)
+
+ #=== BUSINESS METHODS ===#
+
def _send_payment_request(self):
""" Override of payment to simulate a payment request.
@@ -24,7 +72,65 @@ class PaymentTransaction(models.Model):
if self.provider != 'test':
return
- self._handle_notification_data('test', None)
+ if not self.token_id:
+ raise UserError("Test: " + _("The transaction is not linked to a token."))
+
+ simulated_state = self.token_id.test_simulated_state
+ notification_data = {'reference': self.reference, 'simulated_state': simulated_state}
+ self._handle_notification_data('test', notification_data)
+
+ def _send_refund_request(self, create_refund_transaction=True, **kwargs):
+ """ Override of payment to simulate a refund.
+
+ Note: self.ensure_one()
+
+ :param bool create_refund_transaction: Whether a refund transaction should be created.
+ :param dict kwargs: The keyword arguments.
+ :return: The refund transaction if any
+ :rtype: recordset of `payment.transaction`
+ """
+ if self.provider != 'test':
+ return super()._send_refund_request(
+ create_refund_transaction=create_refund_transaction, **kwargs
+ )
+ refund_tx = super()._send_refund_request(create_refund_transaction=True, **kwargs)
+
+ notification_data = {'reference': refund_tx.reference, 'simulated_state': 'done'}
+ refund_tx._handle_notification_data('test', notification_data)
+
+ return refund_tx
+
+ def _send_capture_request(self):
+ """ Override of payment to simulate a capture request.
+
+ Note: self.ensure_one()
+
+ :return: None
+ """
+ super()._send_capture_request()
+ if self.provider != 'test':
+ return
+
+ notification_data = {
+ 'reference': self.reference,
+ 'simulated_state': 'done',
+ 'manual_capture': True, # Distinguish manual captures from regular one-step captures.
+ }
+ self._handle_notification_data('test', notification_data)
+
+ def _send_void_request(self):
+ """ Override of payment to simulate a void request.
+
+ Note: self.ensure_one()
+
+ :return: None
+ """
+ super()._send_void_request()
+ if self.provider != 'test':
+ return
+
+ notification_data = {'reference': self.reference, 'simulated_state': 'cancel'}
+ self._handle_notification_data('test', notification_data)
def _get_tx_from_notification_data(self, provider, notification_data):
""" Override of payment to find the transaction based on dummy data.
@@ -60,14 +166,56 @@ class PaymentTransaction(models.Model):
if self.provider != "test":
return
- self._set_done() # Dummy transactions are always successful
if self.tokenize:
- payment_details_short = notification_data['cc_summary']
- token = self.env['payment.token'].create({
- 'acquirer_id': self.acquirer_id.id,
- 'name': payment_utils.build_token_name(payment_details_short=payment_details_short),
- 'partner_id': self.partner_id.id,
- 'acquirer_ref': 'fake acquirer reference',
- 'verified': True,
- })
- self.token_id = token.id
+ # The reasons why we immediately tokenize the transaction regardless of the state rather
+ # than waiting for the payment method to be validated ('authorized' or 'done') like the
+ # other payment acquirers do are:
+ # - To save the simulated state and payment details on the token while we have them.
+ # - To allow customers to create tokens whose transactions will always end up in the
+ # said simulated state.
+ self._test_tokenize_from_notification_data(notification_data)
+
+ state = notification_data['simulated_state']
+ if state == 'pending':
+ self._set_pending()
+ elif state == 'done':
+ if self.capture_manually and not notification_data.get('manual_capture'):
+ self._set_authorized()
+ else:
+ 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 state == 'cancel':
+ self._set_canceled()
+ else: # Simulate an error state.
+ self._set_error(_("You selected the following test payment status: %s", state))
+
+ def _test_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 fake notification data to tokenize from.
+ :return: None
+ """
+ self.ensure_one()
+
+ payment_details_short = notification_data['cc_summary']
+ state = notification_data['simulated_state']
+ token = self.env['payment.token'].create({
+ 'acquirer_id': self.acquirer_id.id,
+ 'name': payment_utils.build_token_name(payment_details_short=payment_details_short),
+ 'partner_id': self.partner_id.id,
+ 'acquirer_ref': 'fake acquirer reference',
+ 'verified': True,
+ 'test_simulated_state': state,
+ })
+ self.write({
+ 'token_id': token,
+ 'tokenize': False,
+ })
+ _logger.info(
+ "Created token with id %s for partner with id %s.", token.id, self.partner_id.id
+ )
diff --git a/addons/payment_test/static/src/js/payment_form.js b/addons/payment_test/static/src/js/payment_form.js
index 49a3f3bf698..13ae6f9e6d6 100644
--- a/addons/payment_test/static/src/js/payment_form.js
+++ b/addons/payment_test/static/src/js/payment_form.js
@@ -26,11 +26,13 @@ odoo.define('payment_test.payment_form', require => {
}
const customerInput = document.getElementById('customer_input').value;
+ const simulatedPaymentState = document.getElementById('simulated_payment_state').value;
return this._rpc({
route: '/payment/test/simulate_payment',
params: {
'reference': processingValues.reference,
- 'customer_input': customerInput,
+ 'cc_summary': customerInput.slice(-4),
+ 'simulated_state': simulatedPaymentState,
},
}).then(() => {
window.location = '/payment/status';
diff --git a/addons/payment_test/tests/__init__.py b/addons/payment_test/tests/__init__.py
new file mode 100644
index 00000000000..9783393de31
--- /dev/null
+++ b/addons/payment_test/tests/__init__.py
@@ -0,0 +1,5 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import common
+from . import test_payment_transaction
+from . import test_processing_flows
diff --git a/addons/payment_test/tests/common.py b/addons/payment_test/tests/common.py
new file mode 100644
index 00000000000..1ed165a5201
--- /dev/null
+++ b/addons/payment_test/tests/common.py
@@ -0,0 +1,18 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.payment.tests.common import PaymentCommon
+
+
+class PaymentTestCommon(PaymentCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ cls.acquirer = cls._prepare_acquirer(provider='test')
+
+ cls.notification_data = {
+ 'reference': cls.reference,
+ 'cc_summary': '1234',
+ 'simulated_state': 'done',
+ }
diff --git a/addons/payment_test/tests/test_payment_transaction.py b/addons/payment_test/tests/test_payment_transaction.py
new file mode 100644
index 00000000000..9587555b759
--- /dev/null
+++ b/addons/payment_test/tests/test_payment_transaction.py
@@ -0,0 +1,82 @@
+# 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_test.tests.common import PaymentTestCommon
+from odoo.addons.payment.tests.http_common import PaymentHttpCommon
+
+
+@tagged('-at_install', 'post_install')
+class TestPaymentTransaction(PaymentTestCommon, PaymentHttpCommon):
+
+ def test_processing_notification_data_sets_transaction_pending(self):
+ """ Test that the transaction state is set to 'pending' when the notification data indicate
+ a pending payment. """
+ tx = self._create_transaction('direct')
+ tx._process_notification_data(dict(self.notification_data, simulated_state='pending'))
+ self.assertEqual(tx.state, 'pending')
+
+ def test_processing_notification_data_authorizes_transaction(self):
+ """ Test that the transaction state is set to 'authorize' when the notification data
+ indicate a successful payment and manual capture is enabled. """
+ self.acquirer.capture_manually = True
+ tx = self._create_transaction('direct')
+ tx._process_notification_data(self.notification_data)
+ self.assertEqual(tx.state, 'authorized')
+
+ def test_processing_notification_data_confirms_transaction(self):
+ """ Test that the transaction state is set to 'done' when the notification data indicate a
+ successful payment. """
+ tx = self._create_transaction('direct')
+ tx._process_notification_data(self.notification_data)
+ self.assertEqual(tx.state, 'done')
+
+ def test_processing_notification_data_cancels_transaction(self):
+ """ Test that the transaction state is set to 'cancel' when the notification data indicate
+ an unsuccessful payment. """
+ tx = self._create_transaction('direct')
+ tx._process_notification_data(dict(self.notification_data, simulated_state='cancel'))
+ self.assertEqual(tx.state, 'cancel')
+
+ def test_processing_notification_data_sets_transaction_in_error(self):
+ """ Test that the transaction state is set to 'error' when the notification data indicate
+ an error during the payment. """
+ tx = self._create_transaction('direct')
+ tx._process_notification_data(dict(self.notification_data, simulated_state='error'))
+ self.assertEqual(tx.state, 'error')
+
+ def test_processing_notification_data_tokenizes_transaction(self):
+ """ Test that the transaction is tokenized when it was requested and the notification data
+ include token data. """
+ tx = self._create_transaction('direct', tokenize=True)
+ with patch(
+ 'odoo.addons.payment_test.models.payment_transaction.PaymentTransaction'
+ '._test_tokenize_from_notification_data'
+ ) as tokenize_mock:
+ tx._process_notification_data(self.notification_data)
+ self.assertEqual(tokenize_mock.call_count, 1)
+
+ @mute_logger('odoo.addons.payment_test.models.payment_transaction')
+ def test_processing_notification_data_propagates_simulated_state_to_token(self):
+ """ Test that the simulated state of the notification data is set on the token when
+ processing notification data. """
+ for counter, state in enumerate(['pending', 'done', 'cancel', 'error']):
+ tx = self._create_transaction(
+ 'direct', reference=f'{self.reference}-{counter}', tokenize=True
+ )
+ tx._process_notification_data(dict(self.notification_data, simulated_state=state))
+ self.assertEqual(tx.token_id.test_simulated_state, state)
+
+ def test_making_a_payment_request_propagates_token_simulated_state_to_transaction(self):
+ """ Test that the simulated state of the token is set on the transaction when making a
+ payment request. """
+ for counter, state in enumerate(['pending', 'done', 'cancel', 'error']):
+ tx = self._create_transaction(
+ 'direct', reference=f'{self.reference}-{counter}'
+ )
+ tx.token_id = self._create_token(test_simulated_state=state)
+ tx._send_payment_request()
+ self.assertEqual(tx.state, tx.token_id.test_simulated_state)
diff --git a/addons/payment_test/tests/test_processing_flows.py b/addons/payment_test/tests/test_processing_flows.py
new file mode 100644
index 00000000000..bea64a2ce83
--- /dev/null
+++ b/addons/payment_test/tests/test_processing_flows.py
@@ -0,0 +1,24 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from unittest.mock import patch
+
+from odoo.tests import tagged
+
+from odoo.addons.payment_test.controllers.main import PaymentTestController
+from odoo.addons.payment_test.tests.common import PaymentTestCommon
+from odoo.addons.payment.tests.http_common import PaymentHttpCommon
+
+
+@tagged('-at_install', 'post_install')
+class TestProcessingFlows(PaymentTestCommon, PaymentHttpCommon):
+
+ def test_portal_payment_triggers_processing(self):
+ """ Test that paying from the frontend triggers the processing of the notification data. """
+ self._create_transaction(flow='direct')
+ url = self._build_url(PaymentTestController._simulation_url)
+ with patch(
+ 'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
+ '._handle_notification_data'
+ ) as handle_notification_data_mock:
+ self._make_json_rpc_request(url, data=self.notification_data)
+ self.assertEqual(handle_notification_data_mock.call_count, 1)
diff --git a/addons/payment_test/views/payment_test_templates.xml b/addons/payment_test/views/payment_test_templates.xml
index 9a777198214..e4acfe8e867 100644
--- a/addons/payment_test/views/payment_test_templates.xml
+++ b/addons/payment_test/views/payment_test_templates.xml
@@ -3,17 +3,61 @@