# Copyright 2026 Criptomart # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) from datetime import timedelta from types import SimpleNamespace from unittest.mock import patch from odoo import fields from odoo.tests.common import HttpCase from odoo.tests.common import TransactionCase from odoo.tests.common import tagged from odoo.addons.website_sale_aplicoop.controllers import ( website_sale_validators as validators, ) @tagged("post_install", "-at_install", "eskaera_online_payment") class TestOnlinePayment(TransactionCase): """Online payment for group orders: policy, guards and batching.""" @classmethod def setUpClass(cls): super().setUpClass() cls.consumer_group = cls.env["res.partner"].create( { "name": "Payment Consumer Group", "is_company": True, "is_group": True, } ) cls.member = cls.env["res.partner"].create( { "name": "Paying Member", "email": "paying.member@test.com", "parent_id": cls.consumer_group.id, } ) cls.other_member = cls.env["res.partner"].create( { "name": "Other Member", "email": "other.member@test.com", "parent_id": cls.consumer_group.id, } ) cls.product = cls.env["product.product"].create( { "name": "Payable Product", "is_storable": True, "list_price": 10.0, } ) # The validator helpers only ever reach for `request.env`, so a # namespace stands in for the HTTP request outside a web context. cls.fake_request = SimpleNamespace(env=cls.env) # === Helpers === def _create_group_order(self, online_payment=True, cutoff_in_past=False): """One-time group order whose cycle ends in the past or the future. One-time orders derive `cutoff_date` from `end_date`, so the cycle is steered here through `end_date`. """ today = fields.Date.today() end_date = ( today - timedelta(days=1) if cutoff_in_past else today + timedelta(days=2) ) return self.env["group.order"].create( { "name": "Payment Group Order", "group_ids": [(6, 0, [self.consumer_group.id])], "period": "once", "pickup_day": "2", # Wednesday "state": "open", "end_date": end_date, "online_payment": online_payment, } ) def _create_sale_order(self, group_order, partner=None, pickup_date=None): return self.env["sale.order"].create( { "partner_id": (partner or self.member).id, "group_order_id": group_order.id, "consumer_group_id": self.consumer_group.id, "pickup_date": pickup_date or group_order.pickup_date, "order_line": [ ( 0, 0, { "product_id": self.product.id, "product_uom_qty": 1, "price_unit": 10.0, }, ) ], } ) def _create_done_transaction(self, sale_order): """A `done` transaction covering the order's full amount. `payment.method` records ship archived until a provider module is installed, so the lookup has to ignore the active flag: the test only needs a well-formed transaction, not a usable payment route. """ provider = self.env["payment.provider"].search([], limit=1) method = ( self.env["payment.method"] .with_context(active_test=False) .search([("primary_payment_method_id", "=", False)], limit=1) ) transaction = self.env["payment.transaction"].create( { "provider_id": provider.id, "payment_method_id": method.id, "reference": f"TEST-{sale_order.id}", "amount": sale_order.amount_total, "currency_id": sale_order.currency_id.id, "partner_id": sale_order.partner_id.id, "sale_order_ids": [(6, 0, sale_order.ids)], } ) transaction.write({"state": "done"}) return transaction # === Payment policy on the sale order === def test_require_payment_follows_group_order(self): """A group order with online payment makes its orders payable.""" group_order = self._create_group_order(online_payment=True) sale_order = self._create_sale_order(group_order) self.assertTrue(sale_order.require_payment) self.assertEqual(sale_order.prepayment_percent, 1.0) self.assertTrue( sale_order._has_to_be_paid(), "A draft order of a paying cycle must be payable", ) def test_require_payment_off_without_online_payment(self): """Without the flag nothing changes: no payment is required.""" group_order = self._create_group_order(online_payment=False) sale_order = self._create_sale_order(group_order) self.assertFalse(sale_order.require_payment) self.assertFalse(sale_order._has_to_be_paid()) def test_toggling_group_order_clears_require_payment(self): """Turning the flag off mid-cycle must free the existing drafts.""" group_order = self._create_group_order(online_payment=True) sale_order = self._create_sale_order(group_order) self.assertTrue(sale_order.require_payment) group_order.online_payment = False sale_order.invalidate_recordset() self.assertFalse( sale_order.require_payment, "An existing draft must stop requiring payment when the group " "order stops offering it", ) def test_non_group_orders_keep_company_default(self): """Orders outside a group order are left alone.""" plain_order = self.env["sale.order"].create({"partner_id": self.member.id}) self.assertEqual( plain_order.require_payment, plain_order.company_id.portal_confirmation_pay, ) # === Confirmation through payment === def test_payment_confirms_with_from_orderpoint(self): """Paying must confirm the way the cutoff cron does. Without `from_orderpoint`, a product with a broken replenishment route raises during post-processing, `/payment/status/poll` rolls the whole thing back and the member sees an error over a `done` transaction. """ group_order = self._create_group_order(online_payment=True) sale_order = self._create_sale_order(group_order) transaction = self._create_done_transaction(sale_order) captured = {} def _fake_confirm(order_self): captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint") return True with patch.object( type(self.env["sale.order"]), "action_confirm", _fake_confirm ): transaction._check_amount_and_confirm_order() self.assertTrue( captured.get("from_orderpoint"), "Group order confirmations triggered by payment must carry " "from_orderpoint=True", ) def test_payment_confirms_the_order(self): """The standard machinery confirms the order once paid.""" group_order = self._create_group_order(online_payment=True) sale_order = self._create_sale_order(group_order) transaction = self._create_done_transaction(sale_order) transaction._check_amount_and_confirm_order() sale_order.invalidate_recordset() self.assertEqual(sale_order.state, "sale") def test_plain_orders_confirm_without_from_orderpoint(self): """Orders unrelated to a group order keep the core behaviour.""" plain_order = self.env["sale.order"].create( { "partner_id": self.member.id, "order_line": [ ( 0, 0, { "product_id": self.product.id, "product_uom_qty": 1, "price_unit": 10.0, }, ) ], } ) transaction = self._create_done_transaction(plain_order) captured = {} def _fake_confirm(order_self): captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint") return True with patch.object( type(self.env["sale.order"]), "action_confirm", _fake_confirm ): transaction._check_amount_and_confirm_order() self.assertFalse(captured.get("from_orderpoint")) # === Cycle lookup helpers === def test_draft_lookup_ignores_placed_orders(self): """`_find_recent_draft_order` must never return a placed order. `/eskaera/clear-cart` cancels whatever this returns, so widening it would cancel orders that are already paid for. """ group_order = self._create_group_order(online_payment=True) sale_order = self._create_sale_order(group_order) sale_order.action_confirm() found = validators._find_recent_draft_order( None, self.member.id, group_order, request_obj=self.fake_request ) self.assertFalse(found) def test_placed_lookup_finds_confirmed_order(self): """The duplicate guard sees the member's confirmed order.""" group_order = self._create_group_order(online_payment=True) sale_order = self._create_sale_order(group_order) sale_order.action_confirm() found = validators._find_placed_cycle_order( None, self.member.id, group_order, request_obj=self.fake_request ) self.assertEqual(found, sale_order) def test_placed_lookup_survives_orders_created_after_cutoff(self): """Nothing blocks ordering between the cutoff and the cron run. The draft window caps `create_date` at the cutoff date; the placed lookup must not, or an order paid in that gap would slip past the guard and let the member order twice. """ group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) sale_order = self._create_sale_order(group_order) sale_order.action_confirm() found = validators._find_placed_cycle_order( None, self.member.id, group_order, request_obj=self.fake_request ) self.assertEqual(found, sale_order) def test_placed_lookup_ignores_other_cycles(self): """An order frozen on another pickup date belongs to another cycle.""" group_order = self._create_group_order(online_payment=True) sale_order = self._create_sale_order( group_order, pickup_date=group_order.pickup_date - timedelta(days=7) ) sale_order.action_confirm() found = validators._find_placed_cycle_order( None, self.member.id, group_order, request_obj=self.fake_request ) self.assertFalse(found) # === Batching at cutoff === def test_cron_batches_a_fully_prepaid_cycle(self): """A cycle where everybody paid early still gets its batch. The confirmation loop used to bail out when it found no draft, which with online payment is the normal case: every order is confirmed the moment its transaction completes. """ group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) sale_order = self._create_sale_order(group_order) sale_order.action_confirm() self.assertFalse( self.env["sale.order"].search( [("group_order_id", "=", group_order.id), ("state", "=", "draft")] ), "This cycle must have no drafts left for the test to mean anything", ) group_order._confirm_linked_sale_orders() self.assertTrue( sale_order.picking_ids.batch_id, "The picking of an order paid before the cutoff must still be " "batched by the cron", ) def test_cron_batches_paid_and_draft_orders_together(self): """Paid and cron-confirmed orders share one batch per picking type.""" group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) paid_order = self._create_sale_order(group_order, partner=self.member) paid_order.action_confirm() draft_order = self._create_sale_order(group_order, partner=self.other_member) group_order._confirm_linked_sale_orders() draft_order.invalidate_recordset() self.assertEqual(draft_order.state, "sale") batches = paid_order.picking_ids.batch_id | draft_order.picking_ids.batch_id self.assertEqual( len(batches), 1, "Both orders belong to the same cycle and picking type, so they " "must land in a single batch", ) def test_cron_ignores_paid_orders_of_previous_cycles(self): """A previous cycle's order must not be swept into this batch.""" group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) stale_order = self._create_sale_order( group_order, pickup_date=group_order.pickup_date - timedelta(days=7) ) stale_order.action_confirm() stale_order.picking_ids.batch_id = False group_order._confirm_linked_sale_orders() self.assertFalse( stale_order.picking_ids.batch_id, "An order frozen on a previous pickup date is not part of this " "cycle and must be left out of its batch", ) def test_closed_cycle_still_batches_paid_orders(self): """Closing a group order by hand must not strand a paid order.""" group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) sale_order = self._create_sale_order(group_order) sale_order.action_confirm() group_order.action_close() self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles() self.assertTrue( sale_order.picking_ids.batch_id, "A paid order of a manually closed cycle must still be batched", ) def test_closed_cycle_leaves_drafts_alone(self): """Closing a cycle by hand is how a co-op calls it off.""" group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) draft_order = self._create_sale_order(group_order) group_order.action_close() self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles() draft_order.invalidate_recordset() self.assertEqual( draft_order.state, "draft", "The closed-cycle sweep must only batch, never confirm", ) @tagged("post_install", "-at_install", "eskaera_online_payment") class TestOnlinePaymentRoutes(HttpCase): """The payment step and its landing page, over HTTP.""" def setUp(self): super().setUp() self.group = self.env["res.partner"].create( { "name": "Payment Routes Group", "is_company": True, "is_group": True, "email": "payment-routes-group@test.com", } ) self.member_partner = self.env["res.partner"].create( {"name": "Payment Routes Member", "email": "payment-routes@test.com"} ) self.group.member_ids = [(4, self.member_partner.id)] login = "portal.payment@test.com" self.portal_user = self.env["res.users"].create( { "name": "Portal Payment User", "login": login, "password": login, "partner_id": self.member_partner.id, "groups_id": [(4, self.env.ref("base.group_portal").id)], } ) self.product = self.env["product.product"].create( {"name": "Route Product", "is_storable": True, "list_price": 10.0} ) start_date = fields.Date.today() self.group_order = self.env["group.order"].create( { "name": "Payment Routes Order", "group_ids": [(6, 0, [self.group.id])], "type": "regular", "start_date": start_date, "end_date": start_date + timedelta(days=7), "period": "weekly", "pickup_day": "3", "cutoff_day": "0", "online_payment": True, } ) self.group_order.action_open() def _create_draft(self): return self.env["sale.order"].create( { "partner_id": self.member_partner.id, "group_order_id": self.group_order.id, "consumer_group_id": self.group.id, "pickup_date": self.group_order.pickup_date, "order_line": [ ( 0, 0, { "product_id": self.product.id, "product_uom_qty": 1, "price_unit": 10.0, }, ) ], } ) def _slug_url(self, suffix=""): return f"/eskaera/{self.group_order.slug}{suffix}" def test_payment_page_renders(self): """The payment step renders for a member with a draft in the cycle.""" self._create_draft() self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open(self._slug_url("/payment"), allow_redirects=True) self.assertEqual(response.status_code, 200) self.assertIn( 'data-name="Eskaera Payment"', response.text, "The payment step should render its own page, not redirect away", ) def test_payment_page_needs_a_draft(self): """With nothing in the cart there is nothing to pay for.""" self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open(self._slug_url("/payment"), allow_redirects=False) self.assertEqual(response.status_code, 303) self.assertTrue(response.headers["Location"].endswith("/checkout")) def test_payment_page_off_without_online_payment(self): """The step does not exist for a group order that takes no payments.""" self.group_order.online_payment = False self._create_draft() self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open(self._slug_url("/payment"), allow_redirects=False) self.assertEqual(response.status_code, 303) self.assertTrue(response.headers["Location"].endswith("/checkout")) def test_checkout_offers_payment(self): """The checkout button turns into the 'confirm and pay' variant. Asserted on `data-tooltip-key` rather than the label: the website runs in whatever language the visitor picked, and the label is translated. """ self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open(self._slug_url("/checkout"), allow_redirects=True) self.assertEqual(response.status_code, 200) self.assertIn('data-tooltip-key="confirm_and_pay"', response.text) def test_checkout_keeps_save_draft_without_online_payment(self): """With the flag off the checkout is exactly what it was.""" self.group_order.online_payment = False self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open(self._slug_url("/checkout"), allow_redirects=True) self.assertEqual(response.status_code, 200) self.assertIn('data-tooltip-key="save_draft"', response.text) self.assertNotIn('data-tooltip-key="confirm_and_pay"', response.text) def test_confirmation_page_renders_for_the_owner(self): """The landing page reports the order back to the member.""" order = self._create_draft() order.action_confirm() self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open( self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=True ) self.assertEqual(response.status_code, 200) self.assertIn(order.name, response.text) def test_confirmation_page_rejects_other_partners(self): """Nobody gets to read someone else's order through this page.""" other_partner = self.env["res.partner"].create({"name": "Somebody Else"}) order = self._create_draft() order.partner_id = other_partner self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open( self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=False ) self.assertEqual(response.status_code, 303) self.assertTrue(response.headers["Location"].endswith("/eskaera")) def test_checkout_redirects_once_the_order_is_placed(self): """A member who already paid cannot build a second order.""" order = self._create_draft() order.action_confirm() self.authenticate(self.portal_user.login, self.portal_user.login) response = self.url_open(self._slug_url("/checkout"), allow_redirects=False) self.assertEqual(response.status_code, 303) self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"])