376 lines
18 KiB
Python
376 lines
18 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from datetime import timedelta
|
|
from unittest.mock import patch
|
|
|
|
from odoo import Command, fields
|
|
from odoo.exceptions import AccessError
|
|
from odoo.tests import tagged, JsonRpcException, freeze_time
|
|
from odoo.tools import mute_logger
|
|
|
|
from odoo.addons.mail.tests.common import MockEmail
|
|
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
|
|
from odoo.addons.http_routing.tests.common import MockRequest
|
|
from odoo.addons.sale_subscription.tests.test_sale_subscription import TestSubscriptionCommon
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestSubscriptionPaymentFlows(TestSubscriptionCommon, PaymentHttpCommon, MockEmail):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.order = cls.env['sale.order'].create({
|
|
'partner_id': cls.partner.id,
|
|
})
|
|
cls.subscription.partner_id = cls.partner
|
|
cls.user_with_so_access = cls.env['res.users'].create({
|
|
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
|
'login': 'user_a_pouet',
|
|
'password': 'user_a_pouet', # may the min password length burn in hell
|
|
'name': 'User A',
|
|
})
|
|
cls.user_without_so_access = cls.env['res.users'].create({
|
|
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
|
'login': 'user_b_pouet',
|
|
'password': 'user_b_pouet',
|
|
'name': 'User B',
|
|
})
|
|
# Portal access rule currently relies on customer of the order -> put in same company
|
|
cls.user_with_so_access.partner_id.parent_id = cls.partner.id
|
|
|
|
cls.inbound_payment_method_line.payment_provider_id = cls.provider
|
|
cls.enable_post_process_patcher = False
|
|
|
|
def _my_sub_assign_token(self, **values):
|
|
url = self._build_url(f"/my/subscriptions/assign_token/{self.order.id}")
|
|
with mute_logger('odoo.addons.base.models.ir_rule', 'odoo.http'):
|
|
return self.make_jsonrpc_request(url, params=values)
|
|
|
|
def test_assign_token_route_with_so_access(self):
|
|
"""Test Assign Token Route with a user allowed to access the SO."""
|
|
self.authenticate(self.user_with_so_access.login, self.user_with_so_access.login)
|
|
dumb_token_so_user = self._create_token(
|
|
partner_id=self.user_with_so_access.partner_id.id
|
|
)
|
|
_response = self._my_sub_assign_token(token_id=dumb_token_so_user.id)
|
|
self.assertEqual(
|
|
self.order.payment_token_id, dumb_token_so_user,
|
|
"Logged Customer wasn't able to assign their token to their subscription."
|
|
)
|
|
|
|
def test_assign_token_without_so_access(self):
|
|
"""Test Assign Token Route with a user without access to the SO."""
|
|
self.authenticate(
|
|
self.user_without_so_access.login,
|
|
self.user_without_so_access.login,
|
|
)
|
|
|
|
# 1) with access token
|
|
own_token = self._create_token(
|
|
partner_id=self.user_without_so_access.partner_id.id
|
|
)
|
|
response = self._my_sub_assign_token(
|
|
token_id=own_token.id,
|
|
access_token=self.order._portal_ensure_token(),
|
|
)
|
|
self.assertEqual(
|
|
self.order.payment_token_id, own_token,
|
|
"User wasn't able to assign their token to the subscription of another customer,"
|
|
" even with the right access token.",
|
|
)
|
|
|
|
# 2) Without access token
|
|
with self._assertNotFound():
|
|
self._my_sub_assign_token(token_id=own_token.id)
|
|
|
|
# 3) With wrong access token
|
|
with self._assertNotFound():
|
|
self._my_sub_assign_token(
|
|
token_id=own_token.id,
|
|
access_token="hohoho",
|
|
)
|
|
|
|
def test_assign_token_payment_token_access(self):
|
|
self.authenticate(self.user_with_so_access.login, self.user_with_so_access.login)
|
|
|
|
# correct token
|
|
dumb_token_so_user = self._create_token(
|
|
payment_details=f'token {self.user_with_so_access.name}',
|
|
partner_id=self.user_with_so_access.partner_id.id,
|
|
)
|
|
_response = self._my_sub_assign_token(token_id=dumb_token_so_user.id)
|
|
self.assertEqual(
|
|
self.order.payment_token_id, dumb_token_so_user,
|
|
"Logged Customer wasn't able to assign their token to their subscription."
|
|
)
|
|
|
|
# token of other user --> forbidden
|
|
other_user_token = self._create_token(
|
|
payment_details=f'token {self.user_without_so_access.name}',
|
|
partner_id=self.user_without_so_access.partner_id.id,
|
|
)
|
|
with self.assertRaises(AccessError):
|
|
# Make sure the test correctly tests what it should be testing
|
|
# i.e. assigning a token not belonging to the user of the request
|
|
other_user_token.with_user(self.user_with_so_access).read()
|
|
|
|
with self._assertNotFound():
|
|
self._my_sub_assign_token(token_id=other_user_token.id)
|
|
|
|
# archived token --> forbidden
|
|
archived_token = self._create_token(
|
|
payment_details=f"archived token {self.user_with_so_access.name}",
|
|
partner_id=self.user_with_so_access.partner_id.id,
|
|
)
|
|
archived_token.action_archive()
|
|
with self._assertNotFound():
|
|
self._my_sub_assign_token(token_id=archived_token.id)
|
|
|
|
other_user_token.unlink()
|
|
deleted_token_id = other_user_token.id
|
|
|
|
with self._assertNotFound():
|
|
self._my_sub_assign_token(token_id=deleted_token_id)
|
|
|
|
self.assertEqual(
|
|
self.order.payment_token_id, dumb_token_so_user,
|
|
"Previous forbidden operations shouldn't have modified the SO token"
|
|
)
|
|
|
|
def test_assign_token_maximum_amount(self):
|
|
self.authenticate(self.user_with_so_access.login, self.user_with_so_access.login)
|
|
token = self._create_token(
|
|
partner_id=self.user_with_so_access.partner_id.id
|
|
)
|
|
token.provider_id.maximum_amount = 1
|
|
self.env['sale.order.line'].create({
|
|
'order_id': self.order.id,
|
|
'product_id': self.product.id,
|
|
'price_unit': 100
|
|
})
|
|
|
|
self.assertGreater(
|
|
self.order.amount_total,
|
|
self.payment_token.provider_id.maximum_amount,
|
|
'The subscription amount should be greater than the maximum amount on the provider for this test.'
|
|
)
|
|
|
|
with self.assertRaisesRegex(JsonRpcException, "odoo.exceptions.UserError"):
|
|
self._my_sub_assign_token(token_id=token.id)
|
|
|
|
self.assertFalse(self.order.payment_token_id, 'The payment token should not have been assigned.')
|
|
|
|
@mute_logger('odoo.http')
|
|
def test_transaction_route_rejects_unexpected_kwarg(self):
|
|
url = self._build_url(f'/my/subscriptions/{self.order.id}/transaction')
|
|
route_kwargs = {
|
|
'access_token': self.order._portal_ensure_token(),
|
|
'partner_id': self.partner.id, # This should be rejected.
|
|
}
|
|
with mute_logger("odoo.http"), self.assertRaises(
|
|
JsonRpcException, msg='odoo.exceptions.ValidationError'
|
|
):
|
|
self.make_jsonrpc_request(url, route_kwargs)
|
|
|
|
@mute_logger('odoo.http')
|
|
def test_transaction_route_from_email(self):
|
|
url = self._build_url(f'/my/subscriptions/{self.subscription.id}/transaction')
|
|
self.subscription.write({
|
|
'partner_id': self.partner.id,
|
|
'company_id': self.company.id,
|
|
'sale_order_template_id': self.subscription_tmpl.id,
|
|
'payment_token_id': self.payment_token.id,
|
|
})
|
|
self.subscription._onchange_sale_order_template_id()
|
|
self.subscription.action_confirm()
|
|
invoice = self.subscription._create_invoices()
|
|
invoice._post()
|
|
# A "send email" action triggered on invoice, sends an email with the invoice access token
|
|
# but the subscription transaction url expects the order access_token.
|
|
# Make sure that if the wrong access_token is sent throw validation error.
|
|
route_kwargs = {
|
|
'access_token': invoice._portal_ensure_token(),
|
|
}
|
|
with mute_logger("odoo.http"), self.assertRaises(
|
|
JsonRpcException, msg='odoo.exceptions.ValidationError'
|
|
):
|
|
self.make_jsonrpc_request(url, route_kwargs)
|
|
|
|
def test_subscription_online_payment_with_token(self):
|
|
with freeze_time("2024-05-01"):
|
|
self.subscription.require_payment = True
|
|
self.subscription.payment_token_id = self.payment_token.id
|
|
self.subscription.end_date = '2024-08-01'
|
|
self.subscription.action_confirm()
|
|
with patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._do_payment', wraps=self._mock_subscription_do_payment):
|
|
self.env['sale.order']._cron_recurring_create_invoice()
|
|
self.subscription.transaction_ids._get_last()._post_process()
|
|
# it should create an invoice
|
|
self.assertEqual(self.subscription.invoice_count, 1)
|
|
with freeze_time("2024-06-01"):
|
|
with patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._do_payment', wraps=self._mock_subscription_do_payment):
|
|
self.env['sale.order']._cron_recurring_create_invoice()
|
|
self.subscription.transaction_ids._get_last()._post_process()
|
|
# it should create an invoice
|
|
self.assertEqual(self.subscription.invoice_count, 2)
|
|
with freeze_time("2024-07-01"):
|
|
with patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._do_payment', wraps=self._mock_subscription_do_payment):
|
|
self.env['sale.order']._cron_recurring_create_invoice()
|
|
self.subscription.transaction_ids._get_last()._post_process()
|
|
# it should create an invoice
|
|
self.assertEqual(self.subscription.invoice_count, 3)
|
|
with freeze_time("2024-08-01"):
|
|
with patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._do_payment', wraps=self._mock_subscription_do_payment):
|
|
self.env['sale.order']._cron_recurring_create_invoice()
|
|
# subscription should be closed on end date
|
|
self.assertEqual(self.subscription.subscription_state, '6_churn')
|
|
|
|
def test_portal_pay_subscription(self):
|
|
# When portal pays a subscription, a success mail is sent.
|
|
# This calls AccountMove.amount_by_group, which triggers _compute_invoice_taxes_by_group().
|
|
# As this method writes on this field and also reads tax_ids, which portal has no rights to,
|
|
# it might cause some access rights issues. This test checks that no error is raised.
|
|
portal_partner = self.user_portal.partner_id
|
|
portal_partner.country_id = self.env['res.country'].search([('code', '=', 'US')])
|
|
self.env['account.move'].create({
|
|
'move_type': 'out_invoice',
|
|
})
|
|
provider = self.env['payment.provider'].create({
|
|
'name': 'Test',
|
|
})
|
|
self.env['payment.transaction'].create({
|
|
'amount': 100,
|
|
'provider_id': provider.id,
|
|
'payment_method_id': self.payment_method_id,
|
|
'currency_id': self.env.company.currency_id.id,
|
|
'partner_id': portal_partner.id,
|
|
})
|
|
|
|
def test_check_mandate_start_date(self):
|
|
now = fields.Datetime.now()
|
|
self.subscription.write({'start_date': now - timedelta(days=10)})
|
|
tx = self._create_transaction(
|
|
'direct', tokenize=True, sale_order_ids=[self.subscription.id]
|
|
)
|
|
tx_mandate_values = tx._get_mandate_values()
|
|
self.assertGreaterEqual(
|
|
tx_mandate_values['start_datetime'].timestamp(), (now - timedelta(days=1)).timestamp(),
|
|
f"Subscription mandate should start at least {now}",
|
|
)
|
|
|
|
def test_payment_link_renewed(self):
|
|
self.subscription.write({
|
|
'partner_id': self.partner.id,
|
|
'company_id': self.company.id,
|
|
'sale_order_template_id': self.subscription_tmpl.id,
|
|
'payment_token_id': self.payment_token.id,
|
|
})
|
|
self.subscription._onchange_sale_order_template_id()
|
|
self.subscription.action_confirm()
|
|
invoice = self.subscription._create_invoices()
|
|
invoice._post()
|
|
payment_context = {'active_model': 'sale.order', 'active_id': self.subscription.id}
|
|
# payment.link.wizard needs to access request.env when it generates the acces_token.
|
|
# This pirouette is needed to avoid a
|
|
with MockRequest(self.subscription.env):
|
|
wiz = self.env['payment.link.wizard'].with_context(payment_context).create({})
|
|
pay_url = wiz.link
|
|
self.assertFalse(wiz.warning_message, "The wizard has no warning message")
|
|
action = self.subscription.with_context(tracking_disable=False).prepare_renewal_order()
|
|
renewal_so = self.env['sale.order'].browse(action['res_id'])
|
|
renewal_so = renewal_so.with_context(tracking_disable=False)
|
|
renewal_so.order_line.product_uom_qty = 3
|
|
renewal_so.name = "Renewal"
|
|
renewal_so.action_confirm()
|
|
with MockRequest(self.subscription.env):
|
|
wiz = self.env['payment.link.wizard'].with_context(payment_context).create({})
|
|
self.assertEqual(wiz.warning_message, "You cannot generate a payment link for a renewed subscription")
|
|
res = self.url_open(pay_url)
|
|
self.assertEqual(res.status_code, 200, "Response should = OK")
|
|
content = res.content.decode("utf-8")
|
|
self.assertFalse("o_sale_portal_paynow" in content, "There is nothing to pay for payment link of renewed order")
|
|
|
|
def test_check_mandate_no_start_date(self):
|
|
now = fields.Datetime.now()
|
|
self.subscription.write({'start_date': None})
|
|
tx = self._create_transaction(
|
|
'direct', tokenize=True, sale_order_ids=[self.subscription.id]
|
|
)
|
|
tx_mandate_values = tx._get_mandate_values()
|
|
self.assertGreaterEqual(
|
|
tx_mandate_values['start_datetime'].timestamp(), (now - timedelta(days=1)).timestamp(),
|
|
f"Subscription mandate should start at least {now}",
|
|
)
|
|
|
|
@mute_logger('odoo.http')
|
|
def test_invoice_document_generation(self):
|
|
"""Check that invoice documents get generated when posting subscription invoices."""
|
|
self.env['ir.config_parameter'].set_param(
|
|
'sale.automatic_invoice', self.env.context.get('automatic_invoice', False),
|
|
)
|
|
self.subscription.action_confirm()
|
|
|
|
AccountMoveSend = self.env.registry['account.move.send']
|
|
with (
|
|
patch.object(AccountMoveSend, '_get_default_extra_edis', lambda self, move: {'dummy'}),
|
|
patch.object(AccountMoveSend, '_call_web_service_before_invoice_pdf_render') as mock,
|
|
):
|
|
response = self._make_http_get_request(
|
|
f'/my/subscriptions/{self.subscription.id}',
|
|
params={'access_token': self.subscription.access_token},
|
|
)
|
|
tx_context = self._get_payment_context(response)
|
|
tx_sudo = self._create_transaction(
|
|
flow='direct',
|
|
amount=tx_context['amount'],
|
|
landing_route=tx_context['landing_route'],
|
|
currency_id=self.subscription.currency_id.id,
|
|
sale_order_ids=[Command.set(self.subscription.ids)],
|
|
subscription_action='assign_token',
|
|
)
|
|
tx_sudo._set_done()
|
|
tx_sudo._post_process()
|
|
self.assertEqual(
|
|
mock.call_count, 1,
|
|
"Web services should have been called for document generation",
|
|
)
|
|
|
|
def test_invoice_document_generation_auto_invoice(self):
|
|
"""Check that invoice documents get generated only once with automatic invoice enabled."""
|
|
self.env = self.env['base'].with_context(automatic_invoice=True).env
|
|
self.test_invoice_document_generation()
|
|
|
|
def test_downpayment_upsell_automatic_invoice(self):
|
|
""" Confirm the original subscription and trigger recurring invoices.
|
|
Prepare an upsell order from the subscription and get its sale order.
|
|
Set the quantity of actual product lines in the upsell order to 1 (for downpayment).
|
|
Confirm the upsell order and configure it to require payment with a 20% prepayment.
|
|
Calculate the downpayment amount and enable automatic invoice creation.
|
|
Create a direct payment transaction for the downpayment and reconcile it.
|
|
Verify that exactly one invoice is created for the downpayment and its amount matches the paid amount."""
|
|
|
|
self.subscription.action_confirm()
|
|
self.env['sale.order']._cron_recurring_create_invoice()
|
|
action = self.subscription.prepare_upsell_order()
|
|
upsell_so = self.env['sale.order'].browse(action['res_id'])
|
|
upsell_order_line = upsell_so.order_line.filtered(lambda line: not line.display_type)
|
|
for sol in upsell_order_line:
|
|
sol.product_uom_qty = 1.0
|
|
upsell_so.action_confirm()
|
|
upsell_so.require_payment = True
|
|
upsell_so.prepayment_percent = 0.2
|
|
amt = upsell_so.amount_total * upsell_so.prepayment_percent
|
|
self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True')
|
|
tx = self._create_transaction(
|
|
flow='direct',
|
|
amount=amt,
|
|
sale_order_ids=[upsell_so.id],
|
|
state='done')
|
|
with mute_logger('odoo.addons.sale.models.payment_transaction'):
|
|
tx._post_process()
|
|
invoice = upsell_so.invoice_ids
|
|
self.assertTrue(len(invoice))
|
|
self.assertTrue(invoice.line_ids[0].is_downpayment)
|
|
self.assertAlmostEqual(invoice.amount_total, amt, "The amount paid and the amount of which invoice made should be same")
|