[IMP] payment_test: support all additional features

After the payment apocalypse, `payment_test` was cleaned and stripped of
all useless parts. It was a very basic testing acquirer that allowed to
enter fake arbitrary payment details, choose whether they should be
tokenized, and then pay. It was intended to test the payment flow of
various applications (Sales, Subscriptions, Invoicing, ...) for a single
scenario: successful payments with immediate capture.

In order to extensively test an app's payment flow (explore other
scenarios), we need `payment_test` to have additional features.

Now, `payment_test` will allow profound testing of these features:
- separate authorization and capture
- customer fees computation
- tokenization
- refunds
- on the /pay page, users will be able to select the status of the
  payment
- on the payment transaction form view, users can change the status of
  a payment 'pending' to either succeed or fail
- on the payment token form view, users can change the state of future
  transactions made with that token

task-2512196

closes odoo/odoo#78083

Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
Co-authored-by: xlu-odoo <xlu@odoo.com>
This commit is contained in:
Valentin Chevalier
2022-07-01 19:37:10 +02:00
committed by Antoine Vandevenne (anv)
co-authored by xlu-odoo
parent 3262adf566
commit 2d81dffd23
18 changed files with 453 additions and 37 deletions
@@ -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',
+23 -5
View File
@@ -85,11 +85,11 @@
<!-- === Acquirer inline form === -->
<div t-attf-id="o_payment_acquirer_inline_form_{{acquirer.id}}"
name="o_payment_inline_form"
class="card-footer d-none">
<!-- === Inline form content (filled by acquirer) === -->
class="card-footer px-3 d-none">
<t t-if="acquirer.sudo()._should_build_inline_form(is_validation=False)">
<t t-set="inline_form_xml_id"
t-value="acquirer.sudo().inline_form_view_id.xml_id"/>
<!-- === Inline form content (filled by acquirer) === -->
<div t-if="inline_form_xml_id" class="clearfix">
<t t-call="{{inline_form_xml_id}}">
<t t-set="acquirer_id" t-value="acquirer.id"/>
@@ -123,7 +123,16 @@
<!-- === Token inline form === -->
<div t-attf-id="o_payment_token_inline_form_{{token.id}}"
name="o_payment_inline_form"
class="card-footer d-none"/>
class="card-footer d-none">
<t t-set="token_inline_form_xml_id"
t-value="token.sudo().acquirer_id.token_inline_form_view_id.xml_id"/>
<!-- === Inline form content (filled by acquirer) === -->
<div t-if="token_inline_form_xml_id" class="clearfix">
<t t-call="{{token_inline_form_xml_id}}">
<t t-set="token" t-value="token"/>
</t>
</div>
</div>
</t>
</div>
<!-- === "Pay" button === -->
@@ -207,9 +216,9 @@
<div t-attf-id="o_payment_acquirer_inline_form_{{acquirer.id}}"
name="o_payment_inline_form"
class="card-footer d-none">
<!-- === Inline form content (filled by acquirer) === -->
<t t-set="inline_form_xml_id"
t-value="acquirer.sudo().inline_form_view_id.xml_id"/>
<!-- === Inline form content (filled by acquirer) === -->
<div t-if="inline_form_xml_id" class="clearfix">
<t t-call="{{inline_form_xml_id}}">
<t t-set="acquirer_id" t-value="acquirer.id"/>
@@ -245,7 +254,16 @@
<!-- === Token inline form === -->
<div t-attf-id="o_payment_token_inline_form_{{token.id}}"
name="o_payment_inline_form"
class="card-footer d-none"/>
class="card-footer d-none">
<t t-set="token_inline_form_xml_id"
t-value="token.sudo().acquirer_id.token_inline_form_view_id.xml_id"/>
<!-- === Inline form content (filled by acquirer) === -->
<div t-if="token_inline_form_xml_id" class="clearfix">
<t t-call="{{token_inline_form_xml_id}}">
<t t-set="token" t-value="token"/>
</t>
</div>
</div>
</t>
</div>
<!-- === "Save Payment Method" button === -->
+2 -2
View File
@@ -15,11 +15,11 @@
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<group>
<group name="general_information">
<field name="name"/>
<field name="partner_id" />
</group>
<group>
<group name="technical_information">
<field name="acquirer_id"/>
<field name="acquirer_ref"/>
<field name="company_id" groups="base.group_multi_company"/>
+2
View File
@@ -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',
+5 -11
View File
@@ -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)
@@ -4,6 +4,7 @@
<record id="payment.payment_acquirer_test" model="payment.acquirer">
<field name="provider">test</field>
<field name="inline_form_view_id" ref="inline_form"/>
<field name="token_inline_form_view_id" ref="token_inline_form"/>
<field name="allow_tokenization">True</field>
</record>
+1
View File
@@ -2,4 +2,5 @@
from . import account_payment_method
from . import payment_acquirer
from . import payment_token
from . import payment_transaction
@@ -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,
})
@@ -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"),
],
)
+161 -13
View File
@@ -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
)
@@ -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';
+5
View File
@@ -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
+18
View File
@@ -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',
}
@@ -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)
@@ -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)
@@ -3,17 +3,61 @@
<template id="inline_form">
<div t-attf-id="test-container-{{acquirer_id}}">
<div class="row mt-8">
<div class="form-group col-lg-12">
<div class="form-row">
<div class="form-group">
<input name="acquirer_id" type="hidden" id="acquirer_id" t-att-value="id"/>
<input name="partner_id" type="hidden" t-att-value="partner_id"/>
</div>
<div class="form-group col-lg-12">
<input type="text" name="customer_input" id="customer_input" class="form-control"
placeholder="Test Card Information"/>
<div class="form-group col mt-0 mb-0">
<label for="customer_input" class="mt-0">
<small><b>Payment Details (test data)</b></small>
</label>
<input type="text"
name="customer_input"
id="customer_input"
class="form-control"
placeholder="XXXX XXXX XXXX XXXX"/>
</div>
<div class="form-group col mb-0">
<label for="simulated_payment_state" class="mt-0">
<small><b>Payment Status</b></small>
</label>
<select id="simulated_payment_state" class="form-control">
<option value="done" title="Successful payment">
Successful
</option>
<option value="pending" title="Payment processing">
Pending
</option>
<option value="cancel" title="Payment canceled by customer">
Canceled
</option>
<option value="error" title="Processing error">
Error
</option>
</select>
</div>
</div>
</div>
</template>
<template id="token_inline_form">
<div t-attf-id="test-token-container-{{token.id}}">
<div class="alert alert-warning m-2">
<span t-if="token.test_simulated_state=='pending'">
Payments made with this payment method will remain <b>pending</b>.
</span>
<span t-elif="token.test_simulated_state=='done'">
Payments made with this payment method will be <b>successful</b>.
</span>
<span t-elif="token.test_simulated_state=='cancel'">
Payments made with this payment method will be automatically <b>canceled</b>.
</span>
<span t-else="">
Payments made with this payment method will simulate a processing <b>error</b>.
</span>
</div>
</div>
</template>
</odoo>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_token_form" model="ir.ui.view">
<field name="name">Test Token Form</field>
<field name="model">payment.token</field>
<field name="inherit_id" ref="payment.payment_token_form"/>
<field name="arch" type="xml">
<group name="general_information" position="inside">
<field name="provider" invisible="1"/>
<field name="test_simulated_state"
attrs="{'invisible': [('provider', '!=', 'test')], 'required': [('provider', '=', 'test')]}"/>
</group>
</field>
</record>
</odoo>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_transaction_form_inherit_payment_test" model="ir.ui.view">
<field name="name">Test Transaction Form</field>
<field name="model">payment.transaction</field>
<field name="inherit_id" ref="payment.payment_transaction_form"/>
<field name="arch" type="xml">
<header position="inside">
<field name="capture_manually" invisible="1"/>
<button string="Authorize"
type="object"
name="action_test_set_done"
class="oe_highlight"
attrs="{'invisible': ['|', '|', ('provider', '!=', 'test'), ('capture_manually', '=', False), ('state', '!=', 'pending')]}"/>
<button string="Confirm"
type="object"
name="action_test_set_done"
class="oe_highlight"
attrs="{'invisible': ['|', '|', ('provider', '!=', 'test'), ('capture_manually', '=', True), ('state', '!=', 'pending')]}"/>
<button string="Cancel"
type="object"
name="action_test_set_canceled"
attrs="{'invisible': ['|', ('provider', '!=', 'test'), ('state', '!=', 'pending')]}"/>
<button string="Set to Error"
type="object"
name="action_test_set_error"
attrs="{'invisible': ['|', ('provider', '!=', 'test'), ('state', '!=', 'pending')]}"/>
</header>
</field>
</record>
</odoo>