[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

@ -9,6 +9,7 @@ from odoo import fields
from odoo import http
from odoo.http import request
from odoo.addons.sale.controllers import portal as sale_portal
from odoo.addons.website_sale.controllers.main import WebsiteSale
from . import website_sale_i18n as _i18n
@ -649,13 +650,27 @@ class AplicoopWebsiteSale(WebsiteSale):
"home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id,
"commitment_date": commitment_date,
# Follow the group order's company rather than the website's:
# payment providers are filtered by the order's company, and the
# picking batch is created under the group order's.
"company_id": group_order.company_id.id,
}
# Get salesperson for order creation (portal users need this)
salesperson = self._get_salesperson_for_order(current_user.partner_id)
if salesperson:
order_vals["user_id"] = salesperson.id
sale_order = request.env["sale.order"].sudo().create(order_vals)
sale_order = (
request.env["sale.order"]
.sudo()
.with_company(group_order.company_id)
.create(order_vals)
)
# sale.order only subscribes the customer in action_quotation_sent and
# _action_confirm, so a draft has no followers — and the portal rule is
# `message_partner_ids child_of ...`. Without this the member cannot
# see their own draft in /my/orders, nor open its payment page there.
sale_order.message_subscribe(partner_ids=current_user.partner_id.ids)
return sale_order
def _decode_json_body(self):
@ -678,6 +693,44 @@ class AplicoopWebsiteSale(WebsiteSale):
self, group_order, status=status
)
def _build_already_placed_response(self, partner_id, group_order):
"""Return a 409 payload when the member already placed this cycle.
Only relevant with online payment on: paying confirms the order, so no
draft is left behind and nothing else would stop the member from
building and paying for a second one. Returns None when there is
nothing to block, so callers can use it as a guard.
"""
if not group_order or not group_order.online_payment:
return None
placed_order = self._find_placed_cycle_order(partner_id, group_order)
if not placed_order:
return None
_logger.info(
"[PAYMENT] Blocking a second order for partner %s in group order %s: "
"%s is already placed",
partner_id,
group_order.id,
placed_order.name,
)
return request.make_response(
json.dumps(
{
"error": request.env._(
"You already placed an order for this cycle."
),
"already_placed": True,
"sale_order_id": placed_order.id,
"redirect_url": self._eskaera_payment_confirmation_url(
group_order, placed_order
),
}
),
[("Content-Type", "application/json")],
status=409,
)
def _validate_items_for_group_order(self, items, group_order):
"""Delegate availability validation to validators helper."""
return _validators._validate_items_for_group_order(
@ -704,6 +757,20 @@ class AplicoopWebsiteSale(WebsiteSale):
request,
)
def _find_placed_cycle_order(self, partner_id, group_order):
"""Find the partner's already-placed sale.order for the active cycle.
Returns the recordset (limit=1) or an empty recordset. Used to stop a
member who already paid from building a second order for the same
cycle: their first one is confirmed, so no draft is left to reuse.
"""
return _validators._find_placed_cycle_order(
self,
partner_id,
group_order,
request,
)
def _get_group_order_by_slug(self, group_order_slug):
"""Return the consumer group order published under `group_order_slug`."""
return (
@ -876,10 +943,26 @@ class AplicoopWebsiteSale(WebsiteSale):
# Get translated labels for JavaScript (same as checkout)
labels = self.get_checkout_labels()
# With online payment on, paying confirms the order, so a member who
# already paid has no draft left to reuse. Look their placed order up
# here so the template only has to render the banner.
placed_order = (
self._find_placed_cycle_order(request.env.user.partner_id.id, group_order)
if group_order.online_payment
else request.env["sale.order"]
)
placed_order_url = (
self._eskaera_payment_confirmation_url(group_order, placed_order)
if placed_order
else ""
)
return request.render(
"website_sale_aplicoop.eskaera_shop",
{
"group_order": group_order,
"placed_order": placed_order,
"placed_order_url": placed_order_url,
"products": products,
"filtered_product_tags": filtered_products_dict,
"any_product_has_tags": any_product_has_tags,
@ -1209,6 +1292,11 @@ class AplicoopWebsiteSale(WebsiteSale):
[("Content-Type", "application/json")],
)
if placed_response := self._build_already_placed_response(
request.env.user.partner_id.id, group_order
):
return placed_response
# Validate that the product is available in this order (use discovery logic)
available_products = group_order._get_products_for_group_order(
group_order.id
@ -1336,6 +1424,20 @@ class AplicoopWebsiteSale(WebsiteSale):
if group_order.state != "open":
return request.redirect("/eskaera")
# A member who already placed (and paid) an order for this cycle has
# nothing to check out: send them to the confirmation of that order
# instead of letting them build a duplicate. Only applies with online
# payment on — otherwise orders are only confirmed by the cutoff cron,
# well after the checkout page stops being reachable.
if group_order.online_payment:
placed_order = self._find_placed_cycle_order(
request.env.user.partner_id.id, group_order
)
if placed_order:
return request.redirect(
self._eskaera_payment_confirmation_url(group_order, placed_order)
)
# Los datos del carrito vienen desde localStorage en el frontend
# Esta página solo muestra resumen y botón de confirmación
@ -1386,6 +1488,36 @@ class AplicoopWebsiteSale(WebsiteSale):
# Convert to JSON string for safe embedding in script tag
labels_json = json.dumps(labels, ensure_ascii=False)
# Online payment turns the primary button from "save a draft" into
# "confirm and pay". Everything the template needs is resolved here so
# it only reads attributes.
online_payment = bool(group_order.online_payment)
payment_available = online_payment and self._has_available_payment_method(
group_order
)
if online_payment:
checkout_button = {
"label": labels.get("confirm_and_pay", "Confirm and pay"),
"hint": labels.get(
"confirm_and_pay_hint", "Confirm the order and go to payment"
),
"done_label": labels.get(
"order_ready_for_payment", "Order ready for payment"
),
"icon": "fa-credit-card",
"tooltip_key": "confirm_and_pay",
}
else:
checkout_button = {
"label": labels.get("save_draft", "Save Draft"),
"hint": labels.get("save_order_as_draft", "Save order as draft"),
"done_label": labels.get(
"order_saved_as_draft", "Order saved as draft"
),
"icon": "fa-save",
"tooltip_key": "save_draft",
}
# Prepare template context with explicit debug info
template_context = {
"group_order": group_order,
@ -1397,6 +1529,14 @@ class AplicoopWebsiteSale(WebsiteSale):
),
"labels": labels,
"labels_json": labels_json,
"online_payment": online_payment,
"payment_available": payment_available,
"checkout_button": checkout_button,
"no_payment_method_message": labels.get(
"no_payment_method",
"Online payment is not available right now. "
"Please contact your group.",
),
}
_logger.warning("Template context keys: %s", list(template_context.keys()))
@ -1405,6 +1545,178 @@ class AplicoopWebsiteSale(WebsiteSale):
"website_sale_aplicoop.eskaera_checkout", template_context
)
# === Online payment ===
def _eskaera_payment_url(self, group_order):
"""Return the payment step URL of `group_order`."""
return self._eskaera_url(group_order, suffix="/payment")
def _eskaera_payment_confirmation_url(self, group_order, sale_order):
"""Return the landing URL shown once `sale_order` has been paid."""
return self._eskaera_url(
group_order, suffix=f"/payment/confirmation/{sale_order.id}"
)
def _has_available_payment_method(self, group_order):
"""Whether any payment provider could serve this group order.
A cheap pre-check for the checkout button: with payment mandatory, an
order with no usable provider would send the member to a dead end. The
authoritative filtering (country, currency, amount, tokenization) still
happens on the payment page through `_get_compatible_providers`.
"""
Provider = request.env["payment.provider"].sudo()
providers = Provider.search(
[
*Provider._check_company_domain(group_order.company_id.id),
("state", "in", ["enabled", "test"]),
("is_published", "=", True),
]
)
website_id = request.website.id
return any(
not provider.website_id or provider.website_id.id == website_id
for provider in providers
)
def _get_eskaera_payment_values(self, group_order, order_sudo):
"""Build the payment form context for an eskaera order.
Reuses `sale`'s portal helper rather than `website_sale`'s
`_get_shop_payment_values`: the latter also runs
`_get_shop_payment_errors`, which blocks whenever the order has
shippable products and no delivery method exactly an eskaera order,
which is collected at the co-op and never carries a `carrier_id`.
`transaction_route` is left at its default, `/my/orders/<id>/transaction`,
because that route does not run `_check_cart_is_ready_to_be_paid()`
(which would demand a carrier); `/shop/payment/transaction/<id>` does.
"""
values = sale_portal.CustomerPortal._get_payment_values(
self, order_sudo, website_id=request.website.id
)
values.update(
{
# The submit button is rendered outside the form on purpose:
# website_sale's payment_form.js binds every
# [name="o_payment_submit_button"] in the document on top of
# payment_form.js's own handler, which is delegated inside
# #o_payment_form. A button inside the form gets both, and one
# click would start two transactions.
"display_submit_button": False,
"submit_button_label": request.env._("Confirm and pay"),
"landing_route": self._eskaera_payment_confirmation_url(
group_order, order_sudo
),
}
)
return values
@http.route(
["/eskaera/<string:group_order_slug>/payment"],
type="http",
auth="user",
website=True,
)
def eskaera_payment(self, group_order_slug, **post):
"""Payment step: pick a method and pay the order placed at checkout."""
group_order = self._get_group_order_by_slug(group_order_slug)
if not group_order or not group_order.online_payment:
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
# Entry gate only. Once an order exists the transaction route and the
# landing page stay reachable even if the cycle closes meanwhile,
# otherwise a member who is already at the provider would come back to
# a redirect and their payment would be orphaned.
if group_order.state != "open":
return request.redirect("/eskaera")
partner = request.env.user.partner_id
try:
self._validate_user_group_access(group_order, request.env.user)
except ValueError:
return request.redirect("/eskaera")
placed_order = self._find_placed_cycle_order(partner.id, group_order)
if placed_order:
return request.redirect(
self._eskaera_payment_confirmation_url(group_order, placed_order)
)
order_sudo = self._find_recent_draft_order(partner.id, group_order)
if not order_sudo:
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
# A transaction already under way must not be duplicated: the first one
# to reach `done` confirms the order, so a second would be an overpay.
last_tx = order_sudo.get_portal_last_transaction()
if last_tx and last_tx.state in ("pending", "authorized", "done"):
if last_tx.state == "done":
return request.redirect(
self._eskaera_payment_confirmation_url(group_order, order_sudo)
)
return request.render(
"website_sale_aplicoop.eskaera_payment",
{
"group_order": group_order,
"sale_order": order_sudo,
"pending_transaction": last_tx,
},
)
# `_get_payment_values` prices the form as amount_total - amount_paid;
# a non-positive amount has nothing left to charge and would silently
# render the "no payment method" warning.
if order_sudo.currency_id.compare_amounts(order_sudo.amount_total, 0) <= 0:
return request.redirect(
self._eskaera_payment_confirmation_url(group_order, order_sudo)
)
values = self._get_eskaera_payment_values(group_order, order_sudo)
values.update(
{
"group_order": group_order,
"sale_order": order_sudo,
"pending_transaction": False,
}
)
return request.render("website_sale_aplicoop.eskaera_payment", values)
@http.route(
["/eskaera/<string:group_order_slug>/payment/confirmation/<int:order_id>"],
type="http",
auth="user",
website=True,
)
def eskaera_payment_confirmation(self, group_order_slug, order_id, **post):
"""Landing page after paying: show the outcome and free the cart.
Deliberately tolerant about the order state. The browser is sent here
by `/payment/status` once post-processing has run, but that is
asynchronous and may still be pending, so the page reports whatever
the order says instead of asserting it was confirmed. The group order
may also have closed while the member was at the provider, which must
not hide their own order from them.
"""
group_order = self._get_group_order_by_slug(group_order_slug)
if not group_order:
return request.redirect("/eskaera")
order_sudo = request.env["sale.order"].sudo().browse(order_id).exists()
if not order_sudo or order_sudo.partner_id != request.env.user.partner_id:
return request.redirect("/eskaera")
last_tx = order_sudo.get_portal_last_transaction()
return request.render(
"website_sale_aplicoop.eskaera_payment_confirmation",
{
"group_order": group_order,
"sale_order": order_sudo,
"transaction": last_tx,
"is_paid": order_sudo.state in ("sale", "done"),
},
)
@http.route(
["/eskaera/check-status"],
type="http",
@ -1682,6 +1994,11 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400,
)
if placed_response := self._build_already_placed_response(
current_user.partner_id.id, group_order
):
return placed_response
# Find the most recent draft sale.order for this partner in active period
# The helper _find_recent_draft_order computes the period criteria itself,
# so we only need to call it here.
@ -1923,6 +2240,11 @@ class AplicoopWebsiteSale(WebsiteSale):
)
return self._build_group_order_unavailable_response(group_order)
if placed_response := self._build_already_placed_response(
current_user.partner_id.id, group_order
):
return placed_response
existing_drafts = self._find_recent_draft_order(
current_user.partner_id.id, group_order
)
@ -1972,15 +2294,21 @@ class AplicoopWebsiteSale(WebsiteSale):
except Exception:
pickup_slot_label = None
response_data = {
"success": True,
"message": request.env._("Order saved as draft"),
"sale_order_id": sale_order.id,
"pickup_slot_label": pickup_slot_label,
}
# With online payment on, saving the cart is only the first half of
# placing the order: the frontend follows this URL to the payment
# step. The server builds it so the client never assembles routes.
if group_order.online_payment:
response_data["message"] = request.env._("Order ready for payment")
response_data["redirect_url"] = self._eskaera_payment_url(group_order)
return request.make_response(
json.dumps(
{
"success": True,
"message": request.env._("Order saved as draft"),
"sale_order_id": sale_order.id,
"pickup_slot_label": pickup_slot_label,
}
),
json.dumps(response_data),
[("Content-Type", "application/json")],
)