addons-cm/website_sale_aplicoop/tests/test_online_payment.py
GitHub Copilot e625b0c2f3 [IMP] website_sale_aplicoop: pay on the checkout page, not a step later
The separate /eskaera/<slug>/payment step is gone. Members review the
summary, choose home delivery and pick a payment method on the checkout,
in one screen; the old URL redirects there so bookmarks and sessions that
were mid-flow do not hit a 404.

The checkout now renders the member's draft sale.order instead of the
localStorage cart. That is what fixes the products appearing "out of
nowhere" between the two pages: the summary was a snapshot of localStorage
taken at page load, and `_autoLoadDraftOnInit` then pulled the draft back
into localStorage without re-rendering. Deleting a product in the shop
removed it from the cart but left the line on the draft, so the autoload
resurrected it, the confirm button sent it back, and it only became
visible one page later. The checkout no longer auto-loads the draft — it
renders it, and what it shows is what the payment form charges.

"Proceed to Checkout" pushes the cart to that draft before navigating.
Saving is idempotent: `_merge_or_replace_draft` reuses the cycle's draft
and, through the new `_draft_matches_lines`, rewrites `order_line` only
when the lines actually differ — replacing them unlinks and recreates
every one of them, which is pure churn when nothing changed.

The home delivery checkbox goes through the new /eskaera/set-home-delivery
so the delivery line moves on the order itself. Writing only to
localStorage would have changed the summary and left the amount alone,
which with online payment on is the amount being charged.

Also fixes the confirmation notice nobody ever saw: saving answered with
the payment step URL and the frontend followed it immediately, destroying
the toast in the same tick. Saving no longer navigates; the caller decides
whether it is staying or moving on.

Along the way: checkout_labels.js and the eskaera_checkout_summary /
eskaera_payment templates are removed, superseded by the server-rendered
summary and checkout, and the stale sessionStorage delivery preference no
longer overrides the checkbox the order just rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:14:49 +02:00

736 lines
28 KiB
Python

# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import json
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 _publish_a_provider(self):
"""Make one provider usable, so the checkout renders the payment form."""
provider = self.env.ref("payment.payment_provider_transfer")
provider.write(
{
"state": "test",
"is_published": True,
"company_id": self.group_order.company_id.id,
}
)
return provider
def test_legacy_payment_step_redirects_to_checkout(self):
"""The separate payment step is gone; its URL lands on the checkout.
Kept as a redirect rather than dropped so bookmarks, and sessions
that were mid-flow when the step disappeared, do not hit a 404.
"""
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_legacy_payment_step_redirects_without_online_payment(self):
"""Same for a group order that takes no payments at all."""
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_renders_the_payment_form(self):
"""Picking a payment method happens on the checkout, in one screen."""
self._publish_a_provider()
self._create_draft()
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(
'id="payment_method"',
response.text,
"The payment form belongs on the checkout page",
)
self.assertIn(
'data-name="Eskaera Checkout"',
response.text,
"There must be no separate payment step to redirect to",
)
def test_checkout_summary_comes_from_the_order(self):
"""The summary is the order, so it cannot drift from what is charged.
The cart lives in localStorage while the member shops, and the old
client-rendered summary was a snapshot of it taken at page load: a
draft reloaded afterwards silently added its own lines back, which
only became visible one page later, at payment time.
"""
self._publish_a_provider()
self._create_draft()
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(self.product.name, response.text)
self.assertNotIn(
'id="checkout-summary-tbody"',
response.text,
"The summary must not be the client-rendered table any more",
)
def test_checkout_without_a_draft_shows_the_empty_state(self):
"""Nothing saved for the cycle: nothing to summarise and nothing to pay."""
self._publish_a_provider()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
# Asserted on markup rather than the empty-state wording: the website
# runs in whatever language the visitor picked.
self.assertEqual(response.status_code, 200)
self.assertNotIn(
"checkout-summary-table",
response.text,
"With no order there is nothing to summarise",
)
self.assertNotIn('id="payment_method"', 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._create_draft()
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"])
def _post_json(self, route, payload):
return self.url_open(
route,
data=json.dumps(payload),
headers={"Content-Type": "application/json"},
)
def _save_cart(self, quantity, is_delivery=None):
payload = {
"order_id": self.group_order.id,
"items": [
{
"product_id": self.product.id,
"product_name": self.product.name,
"quantity": quantity,
"product_price": 10.0,
}
],
}
if is_delivery is not None:
payload["is_delivery"] = is_delivery
return self._post_json("/eskaera/save-order", payload)
def _cycle_drafts(self):
return self.env["sale.order"].search(
[
("partner_id", "=", self.member_partner.id),
("group_order_id", "=", self.group_order.id),
("state", "=", "draft"),
]
)
def test_saving_the_cart_again_updates_the_same_draft(self):
"""Pushing the cart reuses the cycle draft rather than adding another.
The cart reaches the server from the shop's save button and again on
the way into the checkout, so this runs on every ordinary flow.
"""
self.authenticate(self.portal_user.login, self.portal_user.login)
first = self._save_cart(1)
self.assertEqual(first.status_code, 200)
order_id = first.json()["sale_order_id"]
second = self._save_cart(3)
self.assertEqual(second.status_code, 200)
self.assertEqual(
second.json()["sale_order_id"],
order_id,
"A second save must update the draft, never create a new one",
)
drafts = self._cycle_drafts()
self.assertEqual(len(drafts), 1)
self.assertEqual(drafts.order_line.product_uom_qty, 3)
def test_saving_an_unchanged_cart_leaves_the_lines_alone(self):
"""Nothing changed, nothing rewritten.
Replacing `order_line` unlinks and recreates every line, so an
idempotent save would churn their ids for no reason.
"""
self.authenticate(self.portal_user.login, self.portal_user.login)
self._save_cart(2)
line_ids = self._cycle_drafts().order_line.ids
self._save_cart(2)
self.assertEqual(
self._cycle_drafts().order_line.ids,
line_ids,
"An unchanged cart must not rewrite the order lines",
)
def test_home_delivery_toggle_moves_the_line_on_the_order(self):
"""The checkout toggle writes to the order, which is what gets charged."""
delivery_product = self.env["product.product"].create(
{"name": "Home Delivery", "type": "service", "list_price": 5.0}
)
self.group_order.delivery_product_id = delivery_product
self.authenticate(self.portal_user.login, self.portal_user.login)
self._save_cart(1)
response = self._post_json(
"/eskaera/set-home-delivery",
{"order_id": self.group_order.id, "is_delivery": True},
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["is_delivery"])
draft = self._cycle_drafts()
self.assertTrue(draft.home_delivery)
self.assertIn(delivery_product, draft.order_line.product_id)
response = self._post_json(
"/eskaera/set-home-delivery",
{"order_id": self.group_order.id, "is_delivery": False},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["is_delivery"])
draft = self._cycle_drafts()
self.assertFalse(draft.home_delivery)
self.assertNotIn(delivery_product, draft.order_line.product_id)
def test_home_delivery_toggle_needs_a_draft(self):
"""With nothing saved there is no order to put the delivery line on."""
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self._post_json(
"/eskaera/set-home-delivery",
{"order_id": self.group_order.id, "is_delivery": True},
)
self.assertEqual(response.status_code, 404)