diff --git a/website_sale_aplicoop/CHANGELOG.md b/website_sale_aplicoop/CHANGELOG.md index cba0818..7a3f011 100644 --- a/website_sale_aplicoop/CHANGELOG.md +++ b/website_sale_aplicoop/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog - Website Sale Aplicoop +## [18.0.1.16.0] - 2026-08-17 + +### Changed + +- **Payment moved into the checkout**: the separate `/eskaera//payment` + step is gone. Members review the summary, pick home delivery and choose a + payment method on `/eskaera//checkout`, in one screen. The old URL + redirects there so bookmarks and in-flight sessions keep working. +- **The checkout summary is server-side**: it renders the draft `sale.order` + instead of the localStorage cart, so what is shown is what gets charged. + "Proceed to Checkout" pushes the cart to that draft before navigating, + reusing the cycle's draft and rewriting its lines only when they differ. +- The home delivery checkbox on the checkout writes to the order through + `/eskaera/set-home-delivery`, so the total and the payment amount follow it. + +### Fixed + +- The confirmation notice after saving a draft was destroyed by the immediate + redirect to the payment step, so members saw nothing at all. Saving no longer + navigates. +- Products the member had deleted reappeared between the checkout and the + payment page. The checkout auto-loaded the draft into localStorage after the + summary had already rendered, resurrecting deleted lines, which were then + sent back on confirm. The checkout no longer auto-loads the draft — it + renders it. + +### Removed + +- `checkout_labels.js` and the `eskaera_checkout_summary` / `eskaera_payment` + templates, superseded by the server-rendered summary and checkout. + ## [18.0.1.13.0] - 2026-08-11 ### Added diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index 97c2a0e..e908de8 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -3,7 +3,7 @@ { # noqa: B018 "name": "Website Sale - Aplicoop", - "version": "18.0.1.15.0", + "version": "18.0.1.16.0", "category": "Website/Sale", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "author": "Odoo Community Association (OCA), Criptomart", @@ -71,7 +71,6 @@ "website_sale_aplicoop/static/src/js/i18n_helpers.js", # Core shop functionality "website_sale_aplicoop/static/src/js/website_sale.js", - "website_sale_aplicoop/static/src/js/checkout_labels.js", "website_sale_aplicoop/static/src/js/home_delivery.js", "website_sale_aplicoop/static/src/js/eskaera_payment.js", # Search and pagination diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index 8423255..5254423 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -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//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//payment/confirmation/"], @@ -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", diff --git a/website_sale_aplicoop/readme/CONFIGURE.rst b/website_sale_aplicoop/readme/CONFIGURE.rst index e39e649..79a60cb 100644 --- a/website_sale_aplicoop/readme/CONFIGURE.rst +++ b/website_sale_aplicoop/readme/CONFIGURE.rst @@ -73,9 +73,10 @@ confirms in bulk. offer it everywhere, or set it to restrict the provider to one site. #. Open the group order form → *Online Payment* tab → tick **Online payment**. -With the flag on, the checkout button becomes "Confirm and pay" and paying is -the only way to place an order: the member goes through -``/eskaera//payment``, picks a method, and their sale order is confirmed -as soon as the transaction completes. Members who have not paid by the cutoff +With the flag on, paying is the only way to place an order: the payment +methods appear on ``/eskaera//checkout``, next to the order summary, and +the member's sale order is confirmed as soon as the transaction completes. +There is no separate payment step — the old ``/eskaera//payment`` URL +redirects to the checkout. Members who have not paid by the cutoff date still get their draft confirmed by the cron, exactly as they do today — the flag decides how orders are placed, not who gets served. diff --git a/website_sale_aplicoop/readme/USAGE.rst b/website_sale_aplicoop/readme/USAGE.rst index 74edb5e..8283ea0 100644 --- a/website_sale_aplicoop/readme/USAGE.rst +++ b/website_sale_aplicoop/readme/USAGE.rst @@ -50,9 +50,11 @@ Paying an order online When the group order has online payment enabled: #. Build the cart in ``/eskaera/`` as usual -#. Go to the checkout, review the summary and choose home delivery if offered -#. Press **Confirm and pay**: the order is saved and the payment step opens -#. Pick a payment method and pay; you are sent to the provider and back +#. Press **Proceed to Checkout**: the cart is saved to your order first, so + the checkout shows the very lines and amounts that will be charged +#. Review the summary, choose home delivery if offered, and pick a payment + method — all on the same page +#. Press **Confirm and pay**; you are sent to the provider and back #. The confirmation page shows the outcome and empties the local cart The order is confirmed the moment the payment goes through, so it can no diff --git a/website_sale_aplicoop/static/src/js/checkout_labels.js b/website_sale_aplicoop/static/src/js/checkout_labels.js deleted file mode 100644 index 3971e74..0000000 --- a/website_sale_aplicoop/static/src/js/checkout_labels.js +++ /dev/null @@ -1,340 +0,0 @@ -/** - * Checkout Labels Loading - * Fetches translated labels for checkout table summary - * IMPORTANT: This script waits for the cart to be loaded by website_sale.js - * before rendering the checkout summary. - */ - -(function () { - "use strict"; - - console.log("[CHECKOUT] Script loaded"); - - // Get order ID from button - var confirmBtn = document.getElementById("confirm-order-btn"); - if (!confirmBtn) { - console.log("[CHECKOUT] No confirm button found"); - return; - } - - var orderId = confirmBtn.getAttribute("data-order-id"); - if (!orderId) { - console.log("[CHECKOUT] No order ID found"); - return; - } - - console.log("[CHECKOUT] Order ID:", orderId); - - // Get summary div - var summaryDiv = document.getElementById("checkout-summary"); - if (!summaryDiv) { - console.log("[CHECKOUT] No summary div found"); - return; - } - - // Function to fetch labels and render checkout - var fetchLabelsAndRender = function () { - console.log("[CHECKOUT] Fetching labels..."); - - // Wait for window.groupOrderShop.labels to be initialized (contains hardcoded labels) - var waitForLabels = function (callback, maxWait = 3000, checkInterval = 50) { - var startTime = Date.now(); - var checkLabels = function () { - if ( - window.groupOrderShop && - window.groupOrderShop.labels && - Object.keys(window.groupOrderShop.labels).length > 0 - ) { - console.log("[CHECKOUT] ✅ Hardcoded labels found, proceeding"); - callback(); - } else if (Date.now() - startTime < maxWait) { - setTimeout(checkLabels, checkInterval); - } else { - console.log("[CHECKOUT] ⚠️ Timeout waiting for labels, proceeding anyway"); - callback(); - } - }; - checkLabels(); - }; - - waitForLabels(function () { - // Now fetch additional labels from server - // Detect current language from document or navigator - var currentLang = - document.documentElement.lang || - document.documentElement.getAttribute("lang") || - navigator.language || - "es_ES"; - console.log("[CHECKOUT] Detected language:", currentLang); - - fetch("/eskaera/labels", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - lang: currentLang, - }), - }) - .then(function (response) { - console.log("[CHECKOUT] Response status:", response.status); - return response.json(); - }) - .then(function (data) { - console.log("[CHECKOUT] Response data:", data); - var serverLabels = data.result || data; - console.log( - "[CHECKOUT] Server labels count:", - Object.keys(serverLabels).length - ); - console.log("[CHECKOUT] Sample server labels:", { - draft_merged_success: serverLabels.draft_merged_success, - home_delivery: serverLabels.home_delivery, - }); - - // CRITICAL: Merge server labels with existing hardcoded labels - // Hardcoded labels MUST take precedence over server labels - if (window.groupOrderShop && window.groupOrderShop.labels) { - var existingLabels = window.groupOrderShop.labels; - console.log( - "[CHECKOUT] Existing hardcoded labels count:", - Object.keys(existingLabels).length - ); - console.log("[CHECKOUT] Sample existing labels:", { - draft_merged_success: existingLabels.draft_merged_success, - home_delivery: existingLabels.home_delivery, - }); - - // Start with server labels, then overwrite with hardcoded ones - var mergedLabels = Object.assign({}, serverLabels); - Object.assign(mergedLabels, existingLabels); - - window.groupOrderShop.labels = mergedLabels; - console.log( - "[CHECKOUT] ✅ Merged labels - final count:", - Object.keys(mergedLabels).length - ); - console.log("[CHECKOUT] Verification:", { - draft_merged_success: mergedLabels.draft_merged_success, - home_delivery: mergedLabels.home_delivery, - }); - } else { - // If no existing labels, use server labels as fallback - if (window.groupOrderShop) { - window.groupOrderShop.labels = serverLabels; - } - console.log("[CHECKOUT] ⚠️ No existing labels, using server labels"); - } - - window.renderCheckoutSummary(window.groupOrderShop.labels); - }) - .catch(function (error) { - console.error("[CHECKOUT] Error:", error); - // Fallback to translated labels - window.renderCheckoutSummary(window.getCheckoutLabels()); - }); - }); - }; - - // Listen for cart ready event instead of polling - if (window.groupOrderShop && window.groupOrderShop.orderId) { - // Cart already initialized, render immediately - console.log("[CHECKOUT] Cart already ready"); - fetchLabelsAndRender(); - } else { - // Wait for cart initialization event - console.log("[CHECKOUT] Waiting for cart ready event..."); - document.addEventListener( - "groupOrderCartReady", - function () { - console.log("[CHECKOUT] Cart ready event received"); - fetchLabelsAndRender(); - }, - { once: true } - ); - - // Fallback timeout in case event never fires - setTimeout(function () { - if (window.groupOrderShop && window.groupOrderShop.orderId) { - console.log("[CHECKOUT] Fallback timeout triggered"); - fetchLabelsAndRender(); - } - }, 500); - } - - /** - * Render order summary table or empty message - * Exposed globally so other scripts can call it - */ - window.renderCheckoutSummary = function (labels) { - labels = labels || window.getCheckoutLabels(); - - var summaryDiv = document.getElementById("checkout-summary"); - if (!summaryDiv) return; - - var cartKey = - "eskaera_" + - (document.getElementById("confirm-order-btn") - ? document.getElementById("confirm-order-btn").getAttribute("data-order-id") - : "1") + - "_cart"; - var cart = JSON.parse(localStorage.getItem(cartKey) || "{}"); - - var summaryTable = summaryDiv.querySelector(".checkout-summary-table"); - var tbody = summaryDiv.querySelector("#checkout-summary-tbody"); - var totalSection = summaryDiv.querySelector(".checkout-total-section"); - - // If no table found, create it with headers (shouldn't happen, but fallback) - if (!summaryTable) { - var html = - '' + - '" + - '" + - '" + - '" + - '
' + - escapeHtml(labels.product) + - "' + - escapeHtml(labels.quantity) + - "' + - escapeHtml(labels.price) + - "' + - escapeHtml(labels.subtotal) + - "
' + - '
' + - '' + - escapeHtml(labels.total) + - "" + - '€0.00' + - "
"; - summaryDiv.innerHTML = html; - summaryTable = summaryDiv.querySelector(".checkout-summary-table"); - tbody = summaryDiv.querySelector("#checkout-summary-tbody"); - totalSection = summaryDiv.querySelector(".checkout-total-section"); - } - - // Clear only tbody, preserve headers - tbody.innerHTML = ""; - - if (Object.keys(cart).length === 0) { - // Show empty message if cart is empty - var emptyRow = document.createElement("tr"); - emptyRow.id = "checkout-empty-row"; - emptyRow.className = "empty-message"; - emptyRow.innerHTML = - '' + - '' + - "

" + - escapeHtml(labels.empty) + - "

" + - ""; - tbody.appendChild(emptyRow); - - // Hide total section - totalSection.style.display = "none"; - } else { - // Hide empty row if visible - var emptyRow = tbody.querySelector("#checkout-empty-row"); - if (emptyRow) emptyRow.remove(); - - // Get delivery product ID from page data - var checkoutPage = document.querySelector(".eskaera-checkout-page"); - var deliveryProductId = checkoutPage - ? checkoutPage.getAttribute("data-delivery-product-id") - : null; - - // Separate normal products from delivery product - var normalProducts = []; - var deliveryProduct = null; - - Object.keys(cart).forEach(function (productId) { - if (productId === deliveryProductId) { - deliveryProduct = { id: productId, item: cart[productId] }; - } else { - normalProducts.push({ id: productId, item: cart[productId] }); - } - }); - - // Sort normal products numerically - normalProducts.sort(function (a, b) { - return parseInt(a.id) - parseInt(b.id); - }); - - var total = 0; - - // Render normal products first - normalProducts.forEach(function (product) { - var item = product.item; - var qty = parseFloat(item.quantity || item.qty || 1); - if (isNaN(qty)) qty = 1; - var price = parseFloat(item.price || 0); - if (isNaN(price)) price = 0; - var subtotal = qty * price; - total += subtotal; - - var row = document.createElement("tr"); - row.innerHTML = - "" + - escapeHtml(item.name) + - "" + - '' + - qty.toFixed(2).replace(/\.?0+$/, "") + - "" + - '€' + - price.toFixed(2) + - "" + - '€' + - subtotal.toFixed(2) + - ""; - tbody.appendChild(row); - }); - - // Render delivery product last if present - if (deliveryProduct) { - var item = deliveryProduct.item; - var qty = parseFloat(item.quantity || item.qty || 1); - if (isNaN(qty)) qty = 1; - var price = parseFloat(item.price || 0); - if (isNaN(price)) price = 0; - var subtotal = qty * price; - total += subtotal; - - var row = document.createElement("tr"); - row.innerHTML = - "" + - escapeHtml(item.name) + - "" + - '' + - qty.toFixed(2).replace(/\.?0+$/, "") + - "" + - '€' + - price.toFixed(2) + - "" + - '€' + - subtotal.toFixed(2) + - ""; - tbody.appendChild(row); - } - - // Update total - var totalAmount = summaryDiv.querySelector("#checkout-total-amount"); - if (totalAmount) { - totalAmount.textContent = "€" + total.toFixed(2); - } - - // Show total section - totalSection.style.display = "block"; - } - - console.log("[CHECKOUT] Summary rendered"); - }; - - /** - * Escape HTML to prevent XSS - */ - function escapeHtml(text) { - var div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; - } -})(); diff --git a/website_sale_aplicoop/static/src/js/home_delivery.js b/website_sale_aplicoop/static/src/js/home_delivery.js index 795dc2c..ccfa8dd 100644 --- a/website_sale_aplicoop/static/src/js/home_delivery.js +++ b/website_sale_aplicoop/static/src/js/home_delivery.js @@ -68,10 +68,15 @@ } } - // Get order ID from multiple possible sources + // Get order ID from multiple possible sources. The checkout page + // carries it on its wrapper: with online payment on there is no + // confirm button there, the payment form takes its place. var confirmBtn = document.getElementById("confirm-order-btn"); var cartContainer = document.getElementById("cart-items-container"); - var orderIdElement = confirmBtn || cartContainer; + var orderIdElement = + confirmBtn || + cartContainer || + (checkoutPage && checkoutPage.getAttribute("data-order-id") ? checkoutPage : null); // The URL is not a fallback here: it carries the slug of the order, // not its id. @@ -81,22 +86,16 @@ console.log("[HomeDelivery] orderId resolved:", this.orderId); - // Handle checkbox (only exists on checkout page) + // Handle checkbox (only exists on checkout page). Its state is + // rendered from `sale_order.home_delivery`, so it is not read back + // from localStorage here: the order is what the payment form + // charges, and the two must not disagree. var checkbox = document.getElementById("home-delivery-checkbox"); if (checkbox) { var self = this; checkbox.addEventListener("change", function () { - if (this.checked) { - self.addDeliveryProduct(); - self.showDeliveryInfo(); - } else { - self.removeDeliveryProduct(); - self.hideDeliveryInfo(); - } + self.setDeliveryOnOrder(this.checked, this); }); - - // Check if delivery product is already in cart on page load - this.checkDeliveryInCart(); } // Vincular botón Home Delivery en el shop SOLO si hay un producto de delivery válido @@ -133,11 +132,6 @@ homeDeliveryBtn.classList.remove("btn-outline-warning"); homeDeliveryBtn.classList.add("active", "btn-warning"); } - - // Trigger cart reload to update UI - if (typeof window.renderCheckoutSummary === "function") { - window.renderCheckoutSummary(); - } }); // Set initial button state @@ -184,17 +178,92 @@ } }, - checkDeliveryInCart: function () { - if (!this.deliveryProductId) return; + /** + * Move the delivery line on and off the draft order (checkout page). + * + * The checkout renders the sale.order and its total is what gets + * charged, so the toggle has to reach the order — writing only to + * localStorage would change the summary and leave the amount alone. + * localStorage is kept in step so the shop cart agrees, and the page + * is reloaded to pick up the new total and payment amount. + */ + setDeliveryOnOrder: function (isDelivery, checkbox) { + var self = this; - var cart = this.getCart(); - if (cart[this.deliveryProductId]) { - var checkbox = document.getElementById("home-delivery-checkbox"); - if (checkbox) { - checkbox.checked = true; - this.showDeliveryInfo(); - } + if (!this.orderId) { + console.warn("[HomeDelivery] No order id, cannot set delivery"); + return; } + + if (checkbox) { + checkbox.disabled = true; + } + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "/eskaera/set-home-delivery", true); + xhr.setRequestHeader("Content-Type", "application/json"); + + xhr.onload = function () { + if (xhr.status !== 200) { + console.error("[HomeDelivery] set-home-delivery failed:", xhr.status); + self.revertCheckbox(checkbox, !isDelivery); + return; + } + var data = {}; + try { + data = JSON.parse(xhr.responseText); + } catch (e) { + data = {}; + } + if (!data.success) { + console.error("[HomeDelivery] set-home-delivery rejected:", data.error); + self.revertCheckbox(checkbox, !isDelivery); + return; + } + + // Keep the shop cart in step with the order before reloading. + var cart = self.getCart(); + if (self.deliveryProductId) { + if (data.is_delivery) { + cart[self.deliveryProductId] = { + id: self.deliveryProductId, + name: self.deliveryProductName, + price: self.deliveryProductPrice, + qty: 1, + }; + } else { + delete cart[self.deliveryProductId]; + } + try { + localStorage.setItem( + "eskaera_" + self.orderId + "_cart", + JSON.stringify(cart) + ); + } catch (e) { + console.warn("[HomeDelivery] Could not update the local cart:", e); + } + } + + window.location.reload(); + }; + + xhr.onerror = function () { + console.error("[HomeDelivery] set-home-delivery connection error"); + self.revertCheckbox(checkbox, !isDelivery); + }; + + xhr.send( + JSON.stringify({ + order_id: self.orderId, + is_delivery: isDelivery, + }) + ); + }, + + revertCheckbox: function (checkbox, previousState) { + if (!checkbox) return; + checkbox.checked = previousState; + checkbox.disabled = false; }, getCart: function () { @@ -216,14 +285,6 @@ window.groupOrderShop._updateCartDisplay(); } } - - // Re-render checkout summary without reloading - setTimeout(function () { - // Use the global function from checkout_labels.js - if (typeof window.renderCheckoutSummary === "function") { - window.renderCheckoutSummary(); - } - }, 50); }, addDeliveryProduct: function () { diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index be8970e..6926f18 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -20,8 +20,12 @@ // Get order ID first (needed by i18nManager and other functions) var confirmBtn = document.getElementById("confirm-order-btn"); var cartContainer = document.getElementById("cart-items-container"); + // The checkout page carries the id on its wrapper: with online + // payment on there is no confirm button there, the payment form + // takes its place. + this._checkoutPage = document.querySelector(".eskaera-checkout-page[data-order-id]"); - var orderIdElement = confirmBtn || cartContainer; + var orderIdElement = confirmBtn || cartContainer || this._checkoutPage; if (!orderIdElement) { console.log("No elements found to get order ID"); return false; @@ -126,6 +130,15 @@ return; } + // Never on the checkout page. It renders the draft server-side, so + // pulling the same lines back into localStorage would only put the + // two out of step — and it used to resurrect lines the member had + // just deleted in the shop, which then reappeared at payment time. + if (this._checkoutPage) { + console.log("Auto-load draft skipped (checkout renders the order itself)"); + return; + } + // Only auto-load if cart is empty var cartItemsCount = Object.keys(this.cart).length; if (cartItemsCount > 0) { @@ -442,8 +455,7 @@ // Storage layout: // eskaera__cart → items as plain {productId: {...}} // (keeps the contract used by - // checkout_labels.js, home_delivery.js - // and _saveOrderDraft). + // home_delivery.js and _saveOrderDraft). // eskaera__cart_cycle → "YYYY-MM-DD" the cart was saved at. // Eviction only fires when we KNOW the current cycle and it // disagrees with the stored one. If either side is missing @@ -472,8 +484,8 @@ return; } // Migration: v18.0.1.10.0 wrapped the cart as {cutoff_date, items} - // in the same key, which broke checkout_labels.js / home_delivery.js - // / _saveOrderDraft (they iterated Object.keys treating them as + // in the same key, which broke home_delivery.js and + // _saveOrderDraft (they iterated Object.keys treating them as // productIds). Unwrap once; subsequent saves use the canonical // split-key layout below. var items; @@ -747,7 +759,8 @@ }, _getLabels: function () { - // Get current labels from window.groupOrderShop which is updated by checkout_labels.js + // Get current labels from window.groupOrderShop, seeded by the + // labels the page renders inline. console.log("[_getLabels] Starting label resolution..."); console.log("[_getLabels] window.groupOrderShop exists:", !!window.groupOrderShop); @@ -880,7 +893,7 @@ _showConfirmation: function (message, onConfirm, onCancel) { var self = this; - // Get current labels - may be updated by checkout_labels.js endpoint + // Get current labels - seeded by the labels the page renders inline var labels = this._getLabels(); console.log("[_showConfirmation] Using labels:", labels); @@ -998,29 +1011,15 @@ }); } - // On checkout page: apply sessionStorage delivery preference to checkbox. - // The shop-page toggle may have stored a "false" preference even though - // the checkbox is checked by default in the template. - var checkoutCheckbox = document.getElementById("home-delivery-checkbox"); - if (checkoutCheckbox) { - var storedDeliveryPref = sessionStorage.getItem( - "eskaera_is_delivery_" + self.orderId - ); - if (storedDeliveryPref !== null) { - checkoutCheckbox.checked = storedDeliveryPref === "true"; - console.log( - "[CHECKOUT] Restored delivery checkbox from sessionStorage:", - checkoutCheckbox.checked - ); - } - // Sync sessionStorage when user manually changes the checkbox - checkoutCheckbox.addEventListener("change", function () { - sessionStorage.setItem( - "eskaera_is_delivery_" + self.orderId, - this.checked ? "true" : "false" - ); - }); - } + // The checkout delivery checkbox is no longer restored from + // sessionStorage: it renders from `sale_order.home_delivery`, + // and home_delivery.js writes any change straight back to the + // order. A stale session preference could only contradict it. + + // Send the cart to the server before opening the checkout, so + // the page renders the member's current cart rather than + // whatever the draft happened to hold. + this._attachCheckoutLinkListeners(); // Button to reload from draft (in My Cart header - cart pages) var reloadCartBtn = document.getElementById("reload-cart-btn"); @@ -1621,6 +1620,103 @@ self._executeSaveCartAsDraft(items); }, + // Read the cart the way the server wants it. localStorage is the + // source of truth while shopping: home_delivery.js writes to it + // directly, so this.cart can lag behind by a tick. + _collectCartItems: function () { + var cartKey = "eskaera_" + this.orderId + "_cart"; + var storedCart = localStorage.getItem(cartKey); + var cart; + try { + cart = storedCart ? JSON.parse(storedCart) : this.cart; + } catch (e) { + cart = this.cart; + } + return Object.keys(cart || {}).map(function (productId) { + var item = cart[productId]; + return { + product_id: productId, + product_name: item.name, + quantity: item.qty, + product_price: item.price, + }; + }); + }, + + // The checkout renders the draft sale.order, so the cart has to reach + // the server before we navigate. Saving is idempotent: the endpoint + // reuses the cycle's draft and only rewrites its lines when they + // actually differ, so a member who already saved from the shop just + // gets their changes applied. + _attachCheckoutLinkListeners: function () { + var self = this; + var links = document.querySelectorAll(".js-eskaera-checkout"); + console.log("[_attachEventListeners] checkout links found:", links.length); + + links.forEach(function (link) { + link.addEventListener("click", function (e) { + e.preventDefault(); + self._saveCartAndGoToCheckout(link.getAttribute("href")); + }); + }); + }, + + _saveCartAndGoToCheckout: function (checkoutUrl) { + var self = this; + var labels = this._getLabels(); + var items = this._collectCartItems(); + + if (items.length === 0) { + this._showNotification(labels.empty_cart || "Your cart is empty", "warning"); + return; + } + + var orderData = { + order_id: this.orderId, + items: items, + merge_action: "replace", + }; + + // Delivery preference: the shop toggle owns it on this page. + var deliveryBtn = document.getElementById("home-delivery-btn"); + if (deliveryBtn) { + orderData.is_delivery = deliveryBtn.classList.contains("active"); + } + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "/eskaera/save-order", true); + xhr.setRequestHeader("Content-Type", "application/json"); + + xhr.onload = function () { + if (xhr.status === 200) { + window.location.href = checkoutUrl; + return; + } + if (self._isClosedOrderResponse(xhr)) { + self._clearCurrentOrderCartSilently(); + self._updateCartDisplay(); + return; + } + if (self._handleAlreadyPlacedResponse(xhr)) { + return; + } + var message = labels.error_saving_draft || "Error saving cart"; + try { + var errorData = JSON.parse(xhr.responseText); + message = errorData.error || message; + } catch (e) { + message = message + " (HTTP " + xhr.status + ")"; + } + self._showNotification(message, "danger"); + }; + + xhr.onerror = function () { + self._showNotification(labels.connection_error || "Connection error", "danger"); + }; + + xhr.send(JSON.stringify(orderData)); + }, + _executeSaveCartAsDraft: function (items) { var self = this; @@ -1934,13 +2030,10 @@ labels.draft_saved_success || labels.draft_saved || "Order saved as draft successfully"; + // No navigation here: the confirmation notice used + // to be wiped out by an immediate redirect to the + // payment step, so the member saw nothing at all. self._showNotification("\u2713 " + successMsg, "success", 5000); - // With online payment on, the server answers with the - // payment step URL: saving the cart is only half of - // placing the order. - if (data.redirect_url) { - window.location.href = data.redirect_url; - } } else { self._showNotification( "Error: " + (data.error || labels.error_unknown || "Unknown error"), diff --git a/website_sale_aplicoop/tests/test_online_payment.py b/website_sale_aplicoop/tests/test_online_payment.py index d1c53d8..9e4403d 100644 --- a/website_sale_aplicoop/tests/test_online_payment.py +++ b/website_sale_aplicoop/tests/test_online_payment.py @@ -1,6 +1,7 @@ # 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 @@ -471,22 +472,25 @@ class TestOnlinePaymentRoutes(HttpCase): 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 _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_payment_page_needs_a_draft(self): - """With nothing in the cart there is nothing to pay for.""" + 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) @@ -494,8 +498,8 @@ class TestOnlinePaymentRoutes(HttpCase): 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.""" + 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) @@ -505,22 +509,69 @@ class TestOnlinePaymentRoutes(HttpCase): 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. - """ + 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('data-tooltip-key="confirm_and_pay"', response.text) + 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) @@ -566,3 +617,120 @@ class TestOnlinePaymentRoutes(HttpCase): 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) diff --git a/website_sale_aplicoop/tests/test_templates_rendering.py b/website_sale_aplicoop/tests/test_templates_rendering.py index 12dfe06..6cc9cc8 100644 --- a/website_sale_aplicoop/tests/test_templates_rendering.py +++ b/website_sale_aplicoop/tests/test_templates_rendering.py @@ -116,9 +116,9 @@ class TestTemplatesRendering(TransactionCase): # The fix ensures no exists # which was causing the NoneType error - def test_eskaera_checkout_summary_template_exists(self): - """Test that eskaera_checkout_summary sub-template exists.""" - template = self.env.ref("website_sale_aplicoop.eskaera_checkout_summary") + def test_order_lines_summary_template_exists(self): + """The checkout summary sub-template exists and reads the sale.order.""" + template = self.env.ref("website_sale_aplicoop.eskaera_order_lines_summary") self.assertIsNotNone(template) self.assertEqual(template.type, "qweb") # Verify it has the expected structure @@ -127,6 +127,14 @@ class TestTemplatesRendering(TransactionCase): template.arch_db, "Template must have checkout-summary-table id", ) + # The summary is server-side on purpose: what it shows is what the + # payment form charges, so it can never drift from the localStorage + # cart the way the old client-rendered table did. + self.assertIn( + "sale_order.order_line", + template.arch_db, + "Template must read the order lines, not a client-side cart", + ) self.assertIn( "Product", template.arch_db, @@ -137,23 +145,36 @@ class TestTemplatesRendering(TransactionCase): template.arch_db, "Template must have Quantity label for translation", ) - self.assertIn( - "Price", template.arch_db, "Template must have Price label for translation" - ) self.assertIn( "Subtotal", template.arch_db, "Template must have Subtotal label for translation", ) - def test_eskaera_checkout_summary_renders(self): - """Test that eskaera_checkout_summary renders without errors.""" - template = self.env.ref("website_sale_aplicoop.eskaera_checkout_summary") - # Render the template with empty context - html = template._render_template(template.xml_id, {}) - # Should contain the basic table structure + def test_order_lines_summary_renders(self): + """The summary renders the lines and total of a real order.""" + order = self.env["sale.order"].create( + { + "partner_id": self.supplier.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 2, + "price_unit": 5.0, + }, + ) + ], + } + ) + template = self.env.ref("website_sale_aplicoop.eskaera_order_lines_summary") + + html = template._render_template(template.xml_id, {"sale_order": order}) + self.assertIn(" - + +