[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>
This commit is contained in:
parent
f81c1ca8e7
commit
e625b0c2f3
11 changed files with 873 additions and 746 deletions
|
|
@ -526,6 +526,39 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
product_max_qty[p.id] = net
|
||||
return products_ctx, product_max_qty
|
||||
|
||||
def _draft_matches_lines(self, draft, sale_order_lines, home_delivery):
|
||||
"""Whether `draft` already holds exactly `sale_order_lines`.
|
||||
|
||||
The cart is pushed to the draft on every step of the flow (saving from
|
||||
the shop, entering the checkout), so most of those writes have nothing
|
||||
to change. Rewriting `order_line` anyway would unlink and recreate
|
||||
every line for no reason — it moves their ids, bumps `write_date` and
|
||||
drops anything attached to them.
|
||||
"""
|
||||
if draft.home_delivery != home_delivery:
|
||||
return False
|
||||
if len(draft.order_line) != len(sale_order_lines):
|
||||
return False
|
||||
|
||||
currency = draft.currency_id
|
||||
|
||||
def _key(product_id, qty, price):
|
||||
return (product_id, round(qty, 3), currency.round(price))
|
||||
|
||||
current = sorted(
|
||||
_key(line.product_id.id, line.product_uom_qty, line.price_unit)
|
||||
for line in draft.order_line
|
||||
)
|
||||
incoming = sorted(
|
||||
_key(
|
||||
vals["product_id"],
|
||||
vals["product_uom_qty"],
|
||||
vals["price_unit"],
|
||||
)
|
||||
for _command, _id, vals in sale_order_lines
|
||||
)
|
||||
return current == incoming
|
||||
|
||||
def _merge_or_replace_draft(
|
||||
self,
|
||||
group_order,
|
||||
|
|
@ -555,6 +588,14 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
|
||||
if existing_drafts:
|
||||
draft = existing_drafts[0].sudo()
|
||||
if self._draft_matches_lines(
|
||||
draft, sale_order_lines, effective_home_delivery
|
||||
):
|
||||
_logger.info(
|
||||
"Draft order %s already matches the cart, left untouched",
|
||||
draft.id,
|
||||
)
|
||||
return draft
|
||||
_logger.info(
|
||||
"Replacing existing draft order %s for partner %s",
|
||||
draft.id,
|
||||
|
|
@ -1227,36 +1268,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
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
|
||||
|
||||
# DEBUG: Log ALL delivery fields
|
||||
_logger.warning("=== ESKAERA_CHECKOUT DELIVERY DEBUG ===")
|
||||
_logger.warning("group_order.id: %s", group_order.id)
|
||||
_logger.warning("group_order.name: %s", group_order.name)
|
||||
_logger.warning(
|
||||
"group_order.pickup_day: %s (type: %s)",
|
||||
group_order.pickup_day,
|
||||
type(group_order.pickup_day),
|
||||
# The cart itself still lives in localStorage while the member shops,
|
||||
# but from here on everything is read off their draft sale.order: the
|
||||
# checkout shows the very lines and amounts that are about to be paid.
|
||||
# The shop pushes the cart to that draft before sending them here.
|
||||
order_sudo = self._find_recent_draft_order(
|
||||
request.env.user.partner_id.id, group_order
|
||||
)
|
||||
_logger.warning(
|
||||
"group_order.pickup_date: %s (type: %s)",
|
||||
group_order.pickup_date,
|
||||
type(group_order.pickup_date),
|
||||
)
|
||||
_logger.warning(
|
||||
"group_order.delivery_date: %s (type: %s)",
|
||||
group_order.delivery_date,
|
||||
type(group_order.delivery_date),
|
||||
)
|
||||
_logger.warning("group_order.home_delivery: %s", group_order.home_delivery)
|
||||
_logger.warning("group_order.delivery_notice: %s", group_order.delivery_notice)
|
||||
if group_order.pickup_date:
|
||||
_logger.warning(
|
||||
"pickup_date formatted: %s",
|
||||
group_order.pickup_date.strftime("%d/%m/%Y"),
|
||||
)
|
||||
_logger.warning("========================================")
|
||||
|
||||
# Get delivery product from group_order (configured per group order)
|
||||
delivery_product = group_order.delivery_product_id
|
||||
|
|
@ -1278,38 +1296,24 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
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.
|
||||
# the payment form itself. 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",
|
||||
}
|
||||
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,
|
||||
"sale_order": order_sudo,
|
||||
"day_names": self._get_day_names(env=request.env),
|
||||
"delivery_product_id": delivery_product_id,
|
||||
"delivery_product_name": delivery_product_name, # Auto-translated to user's language
|
||||
|
|
@ -1328,7 +1332,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
),
|
||||
}
|
||||
|
||||
_logger.warning("Template context keys: %s", list(template_context.keys()))
|
||||
if online_payment:
|
||||
redirect_url, payment_values = self._prepare_checkout_payment_context(
|
||||
group_order, order_sudo
|
||||
)
|
||||
if redirect_url:
|
||||
return request.redirect(redirect_url)
|
||||
template_context.update(payment_values)
|
||||
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_checkout", template_context
|
||||
|
|
@ -1336,9 +1346,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
|
||||
# === 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_checkout_url(self, group_order):
|
||||
"""Return the checkout URL of `group_order`.
|
||||
|
||||
Payment happens on the checkout page: there is no separate step, so
|
||||
the member picks a method next to the very summary they just read.
|
||||
"""
|
||||
return self._eskaera_url(group_order, suffix="/checkout")
|
||||
|
||||
def _eskaera_payment_confirmation_url(self, group_order, sale_order):
|
||||
"""Return the landing URL shown once `sale_order` has been paid."""
|
||||
|
|
@ -1401,6 +1415,44 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
)
|
||||
return values
|
||||
|
||||
def _prepare_checkout_payment_context(self, group_order, order_sudo):
|
||||
"""Resolve the payment half of the checkout page.
|
||||
|
||||
Returns `(redirect_url, values)`: a non-empty `redirect_url` means the
|
||||
member has nothing left to pay here and the caller must send them
|
||||
away instead of rendering. Otherwise `values` completes the checkout
|
||||
context — either the payment form, or the notice of a transaction
|
||||
already under way.
|
||||
"""
|
||||
if not order_sudo:
|
||||
# Nothing saved for this cycle yet: the page renders its empty
|
||||
# state and there is no amount to build a payment form around.
|
||||
return None, {"pending_transaction": False, "payment_ready": False}
|
||||
|
||||
# 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 (
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
||||
{},
|
||||
)
|
||||
return None, {"pending_transaction": last_tx, "payment_ready": False}
|
||||
|
||||
# `_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 (
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
||||
{},
|
||||
)
|
||||
|
||||
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
||||
values.update({"pending_transaction": False, "payment_ready": True})
|
||||
return None, values
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment"],
|
||||
type="http",
|
||||
|
|
@ -1409,68 +1461,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
)
|
||||
@eskaera_route
|
||||
def eskaera_payment(self, group_order_slug, **post):
|
||||
"""Payment step: pick a method and pay the order placed at checkout."""
|
||||
"""Legacy payment step, folded into the checkout page.
|
||||
|
||||
Kept so bookmarks and sessions in flight when the step disappeared
|
||||
land somewhere sensible rather than on a 404.
|
||||
"""
|
||||
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)
|
||||
return request.redirect(self._eskaera_checkout_url(group_order))
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment/confirmation/<int:order_id>"],
|
||||
|
|
@ -1969,12 +1966,12 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
"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.
|
||||
# No redirect here. Saving used to bounce the member to a separate
|
||||
# payment step, which also killed the confirmation notice on the
|
||||
# way out; payment now lives on the checkout page, and the caller
|
||||
# decides whether it is staying or moving on.
|
||||
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(response_data),
|
||||
|
|
@ -1992,6 +1989,150 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
status=500,
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/set-home-delivery"],
|
||||
type="http",
|
||||
auth="user",
|
||||
website=True,
|
||||
methods=["POST"],
|
||||
csrf=False,
|
||||
)
|
||||
@eskaera_route
|
||||
def set_eskaera_home_delivery(self, **post):
|
||||
"""Move the delivery line on and off the cycle draft.
|
||||
|
||||
On the shop the delivery product is just another cart entry, but the
|
||||
checkout renders the sale.order, and its total is what the payment
|
||||
form charges. So the toggle has to reach the order itself; the client
|
||||
only says yes or no.
|
||||
"""
|
||||
try:
|
||||
data = self._decode_json_body()
|
||||
|
||||
order_id = data.get("order_id")
|
||||
try:
|
||||
order_id = int(order_id)
|
||||
except (TypeError, ValueError):
|
||||
return request.make_response(
|
||||
json.dumps({"error": "order_id is required"}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=400,
|
||||
)
|
||||
|
||||
group_order = request.env["group.order"].sudo().browse(order_id).exists()
|
||||
if not group_order:
|
||||
return request.make_response(
|
||||
json.dumps({"error": f"Order {order_id} not found"}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=400,
|
||||
)
|
||||
if group_order.state != "open":
|
||||
return self._build_group_order_unavailable_response(group_order)
|
||||
|
||||
current_user = request.env.user
|
||||
try:
|
||||
self._validate_user_group_access(group_order, current_user)
|
||||
except ValueError as e:
|
||||
return request.make_response(
|
||||
json.dumps({"error": str(e)}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=403,
|
||||
)
|
||||
|
||||
if placed_response := self._build_already_placed_response(
|
||||
current_user.partner_id.id, group_order
|
||||
):
|
||||
return placed_response
|
||||
|
||||
draft = self._find_recent_draft_order(
|
||||
current_user.partner_id.id, group_order
|
||||
)
|
||||
if not draft:
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"error": request.env._(
|
||||
"No draft orders found for the current order period"
|
||||
)
|
||||
}
|
||||
),
|
||||
[("Content-Type", "application/json")],
|
||||
status=404,
|
||||
)
|
||||
draft = draft[0].sudo()
|
||||
|
||||
is_delivery = self._to_bool(data.get("is_delivery", False))
|
||||
effective_delivery, commitment_date = self._get_effective_delivery_context(
|
||||
group_order, is_delivery
|
||||
)
|
||||
delivery_product = group_order.delivery_product_id
|
||||
|
||||
existing_lines = draft.order_line.filtered(
|
||||
lambda line: line.product_id == delivery_product
|
||||
)
|
||||
if effective_delivery and delivery_product:
|
||||
if not existing_lines:
|
||||
pricing = self._get_pricing_info(
|
||||
delivery_product, self._resolve_pricelist(), quantity=1.0
|
||||
)
|
||||
draft.write(
|
||||
{
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": delivery_product.id,
|
||||
"product_uom_qty": 1.0,
|
||||
"price_unit": pricing.get(
|
||||
"price_unit", delivery_product.list_price
|
||||
),
|
||||
"name": delivery_product.with_context(
|
||||
lang=request.env.lang
|
||||
).name,
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
elif existing_lines:
|
||||
existing_lines.unlink()
|
||||
|
||||
draft.write(
|
||||
{
|
||||
"home_delivery": effective_delivery,
|
||||
"commitment_date": commitment_date,
|
||||
}
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"set_eskaera_home_delivery: order %s home_delivery=%s",
|
||||
draft.id,
|
||||
effective_delivery,
|
||||
)
|
||||
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"is_delivery": effective_delivery,
|
||||
"sale_order_id": draft.id,
|
||||
"delivery_product_id": (
|
||||
delivery_product.id if delivery_product else None
|
||||
),
|
||||
}
|
||||
),
|
||||
[("Content-Type", "application/json")],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception("set_eskaera_home_delivery: Unexpected error")
|
||||
return request.make_response(
|
||||
json.dumps({"error": str(e)}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=500,
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/confirm"],
|
||||
type="http",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue