# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import datetime from datetime import date from freezegun import freeze_time from unittest.mock import patch from odoo.addons.sale.models.sale_order import SaleOrder from odoo.addons.sale_subscription.tests.common_sale_subscription import TestSubscriptionCommon from odoo.addons.payment.tests.common import PaymentCommon from odoo.addons.payment.tests.http_common import PaymentHttpCommon from odoo.tests.common import new_test_user, tagged from odoo.tools import mute_logger from odoo import Command from odoo import http @tagged("post_install", "-at_install", "subscription_controller") class TestSubscriptionController(PaymentHttpCommon, PaymentCommon, TestSubscriptionCommon): def setUp(self): super().setUp() context_no_mail = {'no_reset_password': True, 'mail_create_nosubscribe': True, 'mail_create_nolog': True,} SaleOrder = self.env['sale.order'].with_context(context_no_mail) ProductTmpl = self.env['product.template'].with_context(context_no_mail) self.user = new_test_user(self.env, "test_user_1", email="test_user_1@nowhere.com", tz="UTC") self.other_user = new_test_user(self.env, "test_user_2", email="test_user_2@nowhere.com", password="P@ssw0rd!", tz="UTC") self.partner = self.user.partner_id # Test products self.sub_product_tmpl = ProductTmpl.sudo().create({ 'name': 'TestProduct', 'type': 'service', 'recurring_invoice': True, 'uom_id': self.env.ref('uom.product_uom_unit').id, 'product_subscription_pricing_ids': [Command.set((self.pricing_month + self.pricing_year).ids)], }) self.subscription_tmpl = self.env['sale.order.template'].create({ 'name': 'Subscription template without discount', 'duration_unit': 'year', 'is_unlimited': False, 'duration_value': 2, 'note': "This is the template description", 'plan_id': self.plan_month.id, 'sale_order_template_line_ids': [Command.create({ 'name': "monthly", 'product_id': self.sub_product_tmpl.product_variant_ids.id, 'product_uom_qty': 1, 'product_uom_id': self.sub_product_tmpl.uom_id.id }), Command.create({ 'name': "yearly", 'product_id': self.sub_product_tmpl.product_variant_ids.id, 'product_uom_qty': 1, 'product_uom_id': self.sub_product_tmpl.uom_id.id, }) ] }) # Test Subscription self.subscription = SaleOrder.create({ 'name': 'TestSubscription', 'is_subscription': True, 'note': "original subscription description", 'partner_id': self.other_user.partner_id.id, 'pricelist_id': self.other_user.property_product_pricelist.id, 'sale_order_template_id': self.subscription_tmpl.id, }) self.subscription._onchange_sale_order_template_id() self.subscription.end_date = False # reset the end_date self.subscription_tmpl.flush_recordset() self.subscription.flush_recordset() def test_close_contract(self): """ Test subscription close """ with freeze_time("2021-11-18"): self.authenticate(None, None) self.subscription.plan_id.user_closable = True self.subscription.action_confirm() close_reason_id = self.env.ref('sale_subscription.close_reason_1') data = {'access_token': self.subscription.access_token, 'csrf_token': http.Request.csrf_token(self), 'close_reason_id': close_reason_id.id, 'closing_text': "I am broke"} url = "/my/subscriptions/%s/close" % self.subscription.id res = self.url_open(url, allow_redirects=False, data=data) self.assertEqual(res.status_code, 303) self.env.invalidate_all() self.assertEqual(self.subscription.subscription_state, '6_churn', 'The subscription should be closed.') self.assertEqual(self.subscription.end_date, date(2021, 11, 18), 'The end date of the subscription should be updated.') def test_prevents_assigning_not_owned_payment_tokens_to_subscriptions(self): malicious_user_subscription = self.env['sale.order'].create({ 'name': 'Free Subscription', 'partner_id': self.malicious_user.partner_id.id, 'sale_order_template_id': self.subscription_tmpl.id, }) malicious_user_subscription._onchange_sale_order_template_id() legit_user_subscription = self.env['sale.order'].create({ 'name': 'Free Subscription', 'partner_id': self.legit_user.partner_id.id, 'sale_order_template_id': self.subscription_tmpl.id, }) stolen_payment_method = self.env['payment.token'].create( {'payment_details': 'Jimmy McNulty', 'partner_id': self.malicious_user.partner_id.id, 'provider_id': self.dummy_provider.id, 'payment_method_id': self.payment_method_id, 'provider_ref': 'Omar Little'}) legit_payment_method = self.env['payment.token'].create( {'payment_details': 'Jimmy McNulty', 'partner_id': self.legit_user.partner_id.id, 'provider_id': self.dummy_provider.id, 'payment_method_id': self.payment_method_id, 'provider_ref': 'Legit ref'}) legit_user_subscription._portal_ensure_token() malicious_user_subscription._portal_ensure_token() # Payment Token exists/does not exists # Payment Token is accessible to user/not accessible # SO accessible to User/Accessible thanks to the token/Not Accessible (wrong token, no token) # First we check a legit token assignation for a legit subscription. self.authenticate('ness', 'nessnessness') data = {'token_id': legit_payment_method.id, 'order_id': legit_user_subscription.id, 'access_token': legit_user_subscription.access_token } url = self._build_url("/my/subscriptions/assign_token/%s" % legit_user_subscription.id) self.make_jsonrpc_request(url, data) legit_user_subscription.invalidate_recordset() self.assertEqual(legit_user_subscription.payment_token_id, legit_payment_method) data = {'token_id': 9999999999999999, 'order_id': legit_user_subscription.id} with self._assertNotFound(): self.make_jsonrpc_request(url, data) legit_user_subscription.invalidate_recordset() self.assertEqual(legit_user_subscription.payment_token_id, legit_payment_method, "The new token should be saved on the order.") # Payment token is inacessible to user but the SO is OK self.authenticate('al', 'alalalal') data = {'token_id': legit_payment_method.id, 'order_id': malicious_user_subscription.id} url = self._build_url("/my/subscriptions/assign_token/%s" % malicious_user_subscription.id) with self._assertNotFound(): self.make_jsonrpc_request(url, data) malicious_user_subscription.invalidate_recordset() self.assertFalse(malicious_user_subscription.payment_token_id, "No token should be saved on the order.") # The SO is not accessible but the token is mine data = {'token_id': stolen_payment_method.id, 'order_id': legit_user_subscription.id} self._build_url("/my/subscriptions/assign_token/%s" % legit_user_subscription.id) with self._assertNotFound(): self.make_jsonrpc_request(url, data) legit_user_subscription.invalidate_recordset() self.assertEqual(legit_user_subscription.payment_token_id, legit_payment_method, "The token should not be updated") def test_automatic_invoice_token(self): self.original_prepare_invoice = self.subscription._prepare_invoice self.mock_send_success_count = 0 with patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._do_payment', wraps=self._mock_subscription_do_payment),\ patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._send_success_mail', wraps=self._mock_subscription_send_success_mail): self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'False') subscription = self._portal_payment_controller_flow() subscription.transaction_ids.unlink() # set automatic invoice and restart self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True') self._portal_payment_controller_flow() def _portal_payment_controller_flow(self): subscription = self.subscription.create({ 'partner_id': self.partner.id, 'company_id': self.company.id, 'payment_token_id': self.payment_token.id, 'sale_order_template_id': self.subscription_tmpl.id, }) subscription._onchange_sale_order_template_id() subscription.state = 'sent' subscription._portal_ensure_token() signature = "R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" # BASE64 of a simple image data = {'order_id': subscription.id, 'access_token': subscription.access_token, 'signature': signature} url = self._build_url("/my/orders/%s/accept" % subscription.id) self.make_jsonrpc_request(url, data) data = { 'provider_id': self.dummy_provider.id, 'payment_method_id': self.payment_method_id, 'token_id': None, 'order_id': subscription.id, 'access_token': subscription.access_token, 'amount': subscription.amount_total, 'flow': 'direct', 'tokenization_requested': True, 'landing_route': subscription.get_portal_url(), } url = self._build_url("/my/orders/%s/transaction" % subscription.id) self.make_jsonrpc_request(url, data) subscription.transaction_ids.provider_id.support_manual_capture = 'full_only' subscription.transaction_ids._set_authorized() subscription.invoice_ids.filtered(lambda am: am.state == 'draft')._post() subscription.transaction_ids.token_id = self.payment_token.id self.assertEqual(subscription.next_invoice_date, datetime.date.today()) self.assertEqual(subscription.state, 'sale') subscription.transaction_ids._reconcile_after_done() # Create the payment self.assertEqual(subscription.invoice_count, 1, "One invoice should be created") # subscription has a payment_token_id, the invoice is created by the flow. subscription.invoice_ids.invoice_line_ids.account_id.account_type = 'income' subscription.invoice_ids.auto_post = 'at_date' self.env.ref('account.ir_cron_auto_post_draft_entry').method_direct_trigger() self.assertTrue(subscription.next_invoice_date > datetime.date.today(), "the next invoice date should be updated") self.env['account.payment.register'] \ .with_context(active_model='account.move', active_ids=subscription.invoice_ids.ids) \ .create({ 'currency_id': subscription.currency_id.id, 'amount': subscription.amount_total, })._create_payments() self.assertEqual(subscription.invoice_ids.mapped('state'), ['posted']) self.assertTrue(subscription.invoice_ids.payment_state in ['paid', 'in_payment']) subscription._cron_recurring_create_invoice() invoices = subscription.invoice_ids.filtered(lambda am: am.state in ['draft', 'posted']) # avoid counting canceled invoices self.assertEqual(len(invoices), 1, "Only one invoice should be created") # test transaction flow when paying from the portal self.assertEqual(len(subscription.transaction_ids), 1, "Only one transaction should be created") first_transaction_id = subscription.transaction_ids url = self._build_url("/my/subscriptions/%s/transaction" % subscription.id) data = {'access_token': subscription.access_token, 'landing_route': subscription.get_portal_url(), 'provider_id': self.dummy_provider.id, 'payment_method_id': self.payment_method_id, 'token_id': False, 'flow': 'direct', } self.make_jsonrpc_request(url, data) # the transaction is associated to the invoice in tx._reconcile_after_done() invoice_transactions = subscription.invoice_ids.transaction_ids self.assertEqual(len(invoice_transactions), 2, "Two transactions should be created. Calling /my/subscriptions/transaction/ creates a new one") last_transaction_id = subscription.transaction_ids - first_transaction_id self.assertEqual(len(subscription.transaction_ids), 2) self.assertEqual(last_transaction_id.sale_order_ids, subscription) last_transaction_id._set_done() self.assertEqual(subscription.invoice_ids.sorted('id').mapped('state'), ['posted', 'draft']) subscription.invoice_ids.filtered(lambda am: am.state == 'draft')._post() subscription.transaction_ids._reconcile_after_done() # Create the payment # subscription has a payment_token_id, the invoice is created by the flow. subscription.invoice_ids.invoice_line_ids.account_id.account_type = 'asset_cash' subscription.invoice_ids.auto_post = 'at_date' subscription.invoice_ids.filtered(lambda am: am.state == 'draft')._post() self.env['account.payment.register'] \ .with_context(active_model='account.move', active_ids=subscription.invoice_ids.ids) \ .create({ 'currency_id': subscription.currency_id.id, 'amount': subscription.amount_total, })._create_payments() self.assertFalse(set(subscription.invoice_ids.mapped('payment_state')) & {'not_paid', 'partial'}, "All invoices should be in paid or in_payment status") return subscription def test_controller_transaction_refund(self): self.original_prepare_invoice = self.subscription._prepare_invoice self.mock_send_success_count = 0 self.pricing_month.price = 10 subscription = self.subscription.create({ 'partner_id': self.partner.id, 'company_id': self.company.id, 'payment_token_id': self.payment_token.id, 'sale_order_template_id': self.subscription_tmpl.id, }) subscription._onchange_sale_order_template_id() subscription.order_line.product_uom_qty = 2 subscription.action_confirm() invoice = subscription._create_invoices() invoice._post() self.assertEqual(invoice.amount_total, 46) # partial refund refund_wizard = self.env['account.move.reversal'].with_context( active_model="account.move", active_ids=invoice.ids).create({ 'reason': 'Test refund', 'journal_id': invoice.journal_id.id, }) res = refund_wizard.reverse_moves() refund_move = self.env['account.move'].browse(res['res_id']) refund_move.invoice_line_ids.quantity = 1 refund_move._post() self.assertEqual(refund_move.amount_total, 23, "The refund is half the invoice") url = self._build_url("/my/subscriptions/%s/transaction/" % subscription.id) data = {'access_token': subscription.access_token, 'landing_route': subscription.get_portal_url(), 'provider_id': self.dummy_provider.id, 'payment_method_id': self.payment_method_id, 'token_id': False, 'flow': 'direct', } self.make_jsonrpc_request(url, data) invoice_transactions = subscription.invoice_ids.transaction_ids # the amount should be equal to the last self.assertEqual(invoice_transactions.amount, subscription.amount_total, "The last transaction should be equal to the total") def test_portal_partial_payment(self): with patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._do_payment', wraps=self._mock_subscription_do_payment), \ patch('odoo.addons.sale_subscription.models.sale_order.SaleOrder._send_success_mail', wraps=self._mock_subscription_send_success_mail): self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'False') with freeze_time("2024-01-22"): subscription = self.subscription.create({ 'partner_id': self.partner.id, 'company_id': self.company.id, 'sale_order_template_id': self.subscription_tmpl.id, }) self.pricing_month.price = 100 subscription._onchange_sale_order_template_id() subscription.state = 'sent' subscription._portal_ensure_token() # test customized /payment/pay route with sale_order_id param # partial amount specified self.amount = subscription.amount_total / 2.0 # self.amount is used to create the right transaction pay_route_values = self._prepare_pay_values( amount=self.amount, currency=subscription.currency_id, partner=subscription.partner_id, ) pay_route_values['sale_order_id'] = subscription.id tx_context = self._get_portal_pay_context(**pay_route_values) tx_route_values = { 'provider_id': self.provider.id, 'payment_method_id': self.payment_method_id, 'token_id': None, 'amount': tx_context['amount'], 'flow': 'direct', 'tokenization_requested': False, 'landing_route': '/my/subscriptions', 'access_token': tx_context['access_token'], } with mute_logger('odoo.addons.payment.models.payment_transaction'): processing_values = self._get_processing_values( tx_route=tx_context['transaction_route'], **tx_route_values ) tx_sudo = self._get_tx(processing_values['reference']) # make sure to have a token on the transaction. it is needed to test the confirmation flow tx_sudo.token_id = self.payment_token.id self.assertEqual(tx_sudo.sale_order_ids, subscription) # self.assertEqual(tx_sudo.amount, amount) self.assertEqual(tx_sudo.sale_order_ids.transaction_ids, tx_sudo) tx_sudo._set_done() with mute_logger('odoo.addons.sale.models.payment_transaction'): tx_sudo._finalize_post_processing() self.assertEqual(subscription.state, 'sent') # Only a partial amount was paid subscription.action_confirm() self.assertEqual(subscription.next_invoice_date, datetime.date.today()) self.assertEqual(subscription.state, 'sale') self.assertEqual(subscription.invoice_count, 0, "No invoice should be created") self.assertFalse(subscription.payment_token_id, "No token should be saved") # Renew subscription and set payment amount as half of the total amount (partial). subscription._create_recurring_invoice() action = subscription.prepare_renewal_order() renewal_so = self.env['sale.order'].browse(action['res_id']) self.amount = renewal_so.amount_total / 2.0 # Prepare renewal subscription's payment values. pay_route_values = self._prepare_pay_values( amount=self.amount, currency=renewal_so.currency_id, partner=renewal_so.partner_id, ) pay_route_values['sale_order_id'] = renewal_so.id tx_context = self._get_portal_pay_context(**pay_route_values) tx_route_values = { 'provider_id': self.provider.id, 'payment_method_id': self.payment_method_id, 'token_id': None, 'amount': tx_context['amount'], 'flow': 'direct', 'tokenization_requested': False, 'landing_route': '/my/subscriptions', 'access_token': tx_context['access_token'], } with mute_logger('odoo.addons.payment.models.payment_transaction'): processing_values = self._get_processing_values(tx_route=tx_context['transaction_route'], **tx_route_values) # Make sure to have a token on the transaction, it is needed to test the confirmation flow. tx_sudo = self._get_tx(processing_values['reference']) tx_sudo.token_id = self.payment_token.id self.assertEqual(tx_sudo.sale_order_ids, renewal_so) self.assertEqual(tx_sudo.sale_order_ids.transaction_ids, tx_sudo) tx_sudo._set_done() with mute_logger('odoo.addons.sale.models.payment_transaction'): tx_sudo._finalize_post_processing() # Confirm renewal. Assert that no token was saved, renewal was sent and only one invoice was registered. renewal_so.action_confirm() self.assertFalse(renewal_so.payment_token_id, "No token should be saved") self.assertEqual(renewal_so.state, 'sale') self.assertEqual(renewal_so.invoice_count, 1, "Only one invoice from previous subscription should be registered") self.assertEqual(renewal_so.next_invoice_date, datetime.date.today() + datetime.timedelta(days=31)) def test_portal_quote_document(self): product_document = self.env['product.document'].create({ 'name': 'doc.txt', 'active': True, 'datas': 'TXkgYXR0YWNobWVudA==', 'res_model': 'product.product', 'res_id': self.sub_product_tmpl.product_variant_ids.id, 'attached_on': 'sale_order', }) self.subscription.action_confirm() response = self.url_open( self.subscription.get_portal_url('/document/' + str(product_document.id)) ) self.assertEqual(response.status_code, 200) self.assertEqual("My attachment", response.text) def test_payment_confirmation_email(self): """Check that payment confirmation emails aren't sent for validation transactions.""" def process_notification_data(data): tx = self.env['payment.transaction'].search( [('reference', '=', data['reference'])], limit=1, ) tx.token_id = tx.tokenize and self.env['payment.token'].create({ 'provider_id': self.provider.id, 'payment_method_id': self.payment_method_id, 'partner_id': self.subscription.partner_id.id, 'provider_ref': 'test123', }) tx._set_done() self.portal_user.email = 'chell@aperture.com' self.subscription.partner_id = self.portal_partner self.subscription.action_confirm() self.subscription._create_invoices() with patch.object(SaleOrder, '_send_order_notification_mail') as notification_mail_mock: subscription_tx_url = f'/my/subscriptions/{self.subscription.id}/transaction' base_tx_route_values = { 'access_token': None, 'order_id': self.subscription.id, 'amount': 0.0, 'provider_id': self.provider.id, 'payment_method_id': self.payment_method_id, 'token_id': None, 'flow': 'direct', 'is_validation': False, 'tokenization_requested': False, 'landing_route': self.subscription.get_portal_url(), } # Log in and save a payment method for a subscription self.authenticate(self.portal_user.login, self.portal_user.login) tx_response = self.make_jsonrpc_request(subscription_tx_url, { **base_tx_route_values, 'is_validation': True, 'tokenization_requested': True, }) process_notification_data(tx_response) self.make_jsonrpc_request('/payment/status/poll', {}) self.assertFalse( notification_mail_mock.call_count, "Simply setting a payment token shouldn't send a payment succeeded email", ) # Use saved payment method to pay subscription tx_response = self.make_jsonrpc_request(subscription_tx_url, { **base_tx_route_values, 'amount': self.subscription.amount_total, 'token_id': self.subscription.payment_token_id.id, 'flow': 'token', }) process_notification_data(tx_response) self.make_jsonrpc_request('/payment/status/poll', {}) self.assertEqual( notification_mail_mock.call_count, 1, "Paying a subscription should send one payment succeeded email", )