[ADD] website_sale_aplicoop: online payment per group order

Members can now pay their eskaera at checkout, through the standard Odoo
payment machinery. Enabled per group order with a new `online_payment`
boolean, off by default: an order without it behaves exactly as before,
members save a draft and the cutoff cron confirms them in bulk.

The flow mirrors website_sale's: the checkout button becomes "Confirm and
pay", saving the cart redirects to a new /eskaera/<slug>/payment step that
renders `payment.form` from `sale`'s `_get_payment_values`, and the standard
/my/orders/<id>/transaction route takes it from there. This addon ships no
provider and configures none; the co-op publishes whichever it wants.

`website_sale`'s `_get_shop_payment_values` is deliberately not reused: it
runs `_get_shop_payment_errors`, which blocks on shippable products without a
delivery method — exactly an eskaera order, collected at the co-op with no
carrier. For the same reason the transaction route stays the portal one,
which does not call `_check_cart_is_ready_to_be_paid()`.

Payment confirms the order, which has three consequences handled here:

* `payment.transaction._check_amount_and_confirm_order` now confirms group
  orders with `from_orderpoint=True`, the way the cutoff cron already does.
  Without it a product with a broken replenishment route raises inside
  `_post_process`, and `/payment/status/poll` rolls back and re-raises: the
  member sees a payment error over a `done` transaction and the retry cron
  fails forever.
* `_confirm_linked_sale_orders` also sweeps the cycle's already confirmed
  orders into the picking batch, scoped by `pickup_date`. Its early return on
  "no drafts" ran before any batching, so a fully prepaid cycle produced no
  batch at all. `_cron_batch_paid_orders_of_closed_cycles` covers the same
  hole for cycles closed by hand.
* A duplicate-order guard answers 409 on save-order, add-to-cart and
  load-draft, and shows a notice on the shop, so a member whose order is
  already placed cannot build and pay for a second one.

The payment policy lives in the model rather than the controller: there are
three sale.order creation paths and two are live, so `_compute_require_payment`
and `_compute_prepayment_percent` are extended instead of patching five vals
dicts. Orders are also created under the group order's company, which is what
filters the payment providers.

Along the way: eskaera drafts were invisible in /my/orders. The portal rule is
`message_partner_ids child_of` and sale.order only subscribes the customer on
send or confirm, never on a draft create, so the `_prepare_orders_domain`
override that includes drafts never had any effect. Fixed with an explicit
`message_subscribe`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-16 21:59:35 +02:00
parent a67181ab42
commit 6ba554c91b
21 changed files with 1993 additions and 37 deletions

View file

@ -18,3 +18,4 @@ from . import test_cron_picking_batch # noqa: F401
from . import test_group_order_status_endpoint # noqa: F401
from . import test_home_delivery # noqa: F401
from . import test_forecasted_stock # noqa: F401
from . import test_online_payment # noqa: F401

View file

@ -0,0 +1,568 @@
# 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"])