From 6ba554c91bd21341c0d9e7d69f0a7b6b4538695f Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sun, 16 Aug 2026 21:59:35 +0200 Subject: [PATCH] [ADD] website_sale_aplicoop: online payment per group order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//payment step that renders `payment.form` from `sale`'s `_get_payment_values`, and the standard /my/orders//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 --- website_sale_aplicoop/__manifest__.py | 4 +- .../controllers/website_sale.py | 346 ++++++++++- .../controllers/website_sale_i18n.py | 7 + .../controllers/website_sale_validators.py | 62 +- website_sale_aplicoop/i18n/ca.po | 166 +++++ website_sale_aplicoop/i18n/es.po | 162 +++++ website_sale_aplicoop/i18n/eu.po | 165 +++++ website_sale_aplicoop/models/__init__.py | 1 + website_sale_aplicoop/models/group_order.py | 130 +++- .../models/js_translations.py | 9 + .../models/payment_transaction.py | 50 ++ .../models/sale_order_extension.py | 27 + website_sale_aplicoop/readme/CONFIGURE.rst | 21 + website_sale_aplicoop/readme/DESCRIPTION.rst | 1 + website_sale_aplicoop/readme/USAGE.rst | 18 + .../static/src/js/eskaera_payment.js | 35 ++ .../static/src/js/website_sale.js | 60 +- website_sale_aplicoop/tests/__init__.py | 1 + .../tests/test_online_payment.py | 568 ++++++++++++++++++ .../views/group_order_views.xml | 10 + .../views/website_templates.xml | 187 +++++- 21 files changed, 1993 insertions(+), 37 deletions(-) create mode 100644 website_sale_aplicoop/models/payment_transaction.py create mode 100644 website_sale_aplicoop/static/src/js/eskaera_payment.js create mode 100644 website_sale_aplicoop/tests/test_online_payment.py diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index d46ed47..42c9d8c 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.13.0", + "version": "18.0.1.14.0", "category": "Website/Sale", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "author": "Odoo Community Association (OCA), Criptomart", @@ -13,6 +13,7 @@ "depends": [ "website_sale", "website_sale_stock", + "payment", "product", "sale", "stock", @@ -77,6 +78,7 @@ "website_sale_aplicoop/static/src/js/checkout_labels.js", "website_sale_aplicoop/static/src/js/home_delivery.js", "website_sale_aplicoop/static/src/js/checkout_summary.js", + "website_sale_aplicoop/static/src/js/eskaera_payment.js", # Search and pagination "website_sale_aplicoop/static/src/js/infinite_scroll.js", "website_sale_aplicoop/static/src/js/realtime_search.js", diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index ce2fde2..e77ca45 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -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//transaction`, + because that route does not run `_check_cart_is_ready_to_be_paid()` + (which would demand a carrier); `/shop/payment/transaction/` 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//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//payment/confirmation/"], + 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")], ) diff --git a/website_sale_aplicoop/controllers/website_sale_i18n.py b/website_sale_aplicoop/controllers/website_sale_i18n.py index 21eeb05..b58cfc0 100644 --- a/website_sale_aplicoop/controllers/website_sale_i18n.py +++ b/website_sale_aplicoop/controllers/website_sale_i18n.py @@ -91,6 +91,13 @@ def _get_translated_labels(self, lang=None, request_obj=None): "save_draft": tr("Save Draft"), "save_order_as_draft": tr("Save order as draft"), "order_saved_as_draft": tr("Order saved as draft"), + "confirm_and_pay": tr("Confirm and pay"), + "confirm_and_pay_hint": tr("Confirm the order and go to payment"), + "order_ready_for_payment": tr("Order ready for payment"), + "already_placed": tr("You already placed an order for this cycle."), + "no_payment_method": tr( + "Online payment is not available right now. Please contact your group." + ), "save_cart": tr("Save Cart"), "reload_cart": tr("Reload Cart"), "proceed_to_checkout": tr("Proceed to Checkout"), diff --git a/website_sale_aplicoop/controllers/website_sale_validators.py b/website_sale_aplicoop/controllers/website_sale_validators.py index 7e6e7ff..65d68f6 100644 --- a/website_sale_aplicoop/controllers/website_sale_validators.py +++ b/website_sale_aplicoop/controllers/website_sale_validators.py @@ -62,26 +62,32 @@ def _get_salesperson_for_order(self, partner): return False -def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): - """Return the active-cycle draft sale.order for the partner, or empty. +def _find_cycle_sale_order( + self, partner_id, group_order, states=("draft",), request_obj=None +): + """Return the partner's sale.order for the active cycle, or empty. - A draft only counts as "current cycle" when it satisfies BOTH of these + An order only counts as "current cycle" when it satisfies BOTH of these (neither is sufficient on its own — see the regression each one guards): 1) create_date falls within the active window — derived from group_order.cutoff_date and the order period (7 days weekly, 14 biweekly, one month monthly; one-time orders use a single cycle - starting at start_date). Without this, a draft whose pickup_date + starting at start_date). Without this, an order whose pickup_date happens to match the current one only because pickup_date froze - across cycles (observed in production) would be wrongly reused. - 2) When group_order.pickup_date is set, the draft's pickup_date matches - it exactly. Without this, a draft created "now" for a stale/previous + across cycles (observed in production) would be wrongly matched. + 2) When group_order.pickup_date is set, the order's pickup_date matches + it exactly. Without this, an order created "now" for a stale/previous pickup_date — but still inside the current create_date window — - would be wrongly reused instead of starting a fresh cart. + would be wrongly matched instead of starting a fresh cart. - Drafts failing either check belong to a previous cycle and must not be - reused — otherwise stale carts come back when the user re-enters the - order page. + Orders failing either check belong to a previous cycle. + + The upper bound of the create_date window only applies to drafts. Nothing + stops a member from ordering between the cutoff date and the cron run + that closes the cycle, so a *placed* order created in that gap is still + part of this cycle — dropping the bound is what keeps the duplicate-order + guard from letting them order twice. """ req = request_obj or request @@ -102,12 +108,14 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): else: # once: single cycle, bounded by start_date when set period_start = group_order.start_date + states = tuple(states) domain = [ ("partner_id", "=", partner_id), ("group_order_id", "=", group_order.id), - ("state", "=", "draft"), - ("create_date", "<=", f"{period_end} 23:59:59"), + ("state", "in", list(states)), ] + if states == ("draft",): + domain.append(("create_date", "<=", f"{period_end} 23:59:59")) if period_start: domain.append(("create_date", ">=", f"{period_start} 00:00:00")) if group_order.pickup_date: @@ -118,6 +126,34 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): ) +def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): + """Return the active-cycle draft sale.order for the partner, or empty. + + Draft-only wrapper over `_find_cycle_sale_order`. Callers rely on this + never returning a placed order — `/eskaera/clear-cart` cancels whatever + it gets back, so widening it would cancel paid orders. + """ + return _find_cycle_sale_order( + self, partner_id, group_order, states=("draft",), request_obj=request_obj + ) + + +def _find_placed_cycle_order(self, partner_id, group_order, request_obj=None): + """Return the partner's already-placed order for the active cycle. + + Used by the duplicate-order guard: once a member has paid, their order is + confirmed, so no draft remains and nothing else would stop them from + building — and paying for — a second order in the same cycle. + """ + return _find_cycle_sale_order( + self, + partner_id, + group_order, + states=("sale", "done"), + request_obj=request_obj, + ) + + def _validate_confirm_request(self, data, request_obj=None): req = request_obj or request order_id = data.get("order_id") diff --git a/website_sale_aplicoop/i18n/ca.po b/website_sale_aplicoop/i18n/ca.po index f3f9655..a2f62bd 100644 --- a/website_sale_aplicoop/i18n/ca.po +++ b/website_sale_aplicoop/i18n/ca.po @@ -2362,3 +2362,169 @@ msgid "" msgstr "" " Lliurament a domicili" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary +msgid "Total:" +msgstr "Total:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Back to Checkout" +msgstr "Torna al checkout" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Back to Orders" +msgstr "Torna a les comandes" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order" +msgstr "Comanda" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment in progress" +msgstr "Pagament en curs" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Reference:" +msgstr "Referència:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Thank you, your order is confirmed" +msgstr "Gràcies, la teva comanda està confirmada" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "View my order" +msgstr "Veure la meva comanda" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Your payment is being processed" +msgstr "El teu pagament s'està processant" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "You already placed an order for this cycle." +msgstr "Ja has fet una comanda per a aquest cicle." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm and pay" +msgstr "Confirma i paga" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm the order and go to payment" +msgstr "Confirma la comanda i ves al pagament" + +#. module: website_sale_aplicoop +#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment +msgid "" +"Let members pay their order online when they place it. Payment providers are" +" configured globally (Settings > Payment Providers); this only decides " +"whether this group order offers them. When enabled, paying is the only way " +"to place an order from the checkout page." +msgstr "" +"Permet que els socis paguin la seva comanda en línia en fer-la. Els " +"proveïdors de pagament es configuren globalment (Configuració > Proveïdors " +"de pagament); això només decideix si aquesta comanda de grup els ofereix. " +"Amb l'opció activa, pagar és l'única manera de fer la comanda des de la " +"pàgina de checkout." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "" +"Members must pay online to place their order in this cycle. The\n" +" available methods come from the payment providers published on the\n" +" website (Settings > Payment Providers); this order does not\n" +" configure any of them." +msgstr "" +"Els socis han de pagar en línia per fer la seva comanda en aquest cicle. Les" +" formes de pagament disponibles surten dels proveïdors publicats al lloc web" +" (Configuració > Proveïdors de pagament); aquesta comanda no en configura " +"cap." + +#. module: website_sale_aplicoop +#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "Online Payment" +msgstr "Pagament en línia" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Online payment is not available right now. Please contact your group." +msgstr "" +"El pagament en línia no està disponible ara mateix. Contacta amb el teu " +"grup." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order Confirmed:" +msgstr "Comanda confirmada:" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Order ready for payment" +msgstr "Comanda a punt per pagar" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Pay Order:" +msgstr "Paga la comanda:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment Method" +msgstr "Forma de pagament" + +#. module: website_sale_aplicoop +#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction +msgid "Payment Transaction" +msgstr "Transacció de pagament" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Pickup" +msgstr "Recollida" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "View my order" +msgstr "Veure la meva comanda" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "" +"We are still waiting for your payment to be confirmed. Please do not pay " +"again." +msgstr "" +"Encara estem esperant la confirmació del teu pagament. No tornis a pagar." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "You already placed an order for this cycle." +msgstr "Ja has fet una comanda per a aquest cicle." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "" +"Your order will be confirmed as soon as we receive the payment. You can " +"follow it from your orders page." +msgstr "" +"La teva comanda es confirmarà tan bon punt rebem el pagament. La pots seguir" +" des de la pàgina de comandes." diff --git a/website_sale_aplicoop/i18n/es.po b/website_sale_aplicoop/i18n/es.po index 440f04d..fef81f4 100644 --- a/website_sale_aplicoop/i18n/es.po +++ b/website_sale_aplicoop/i18n/es.po @@ -2358,3 +2358,165 @@ msgid "" msgstr "" " Entrega a Casa" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary +msgid "Total:" +msgstr "Total:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Back to Checkout" +msgstr "Volver al checkout" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Back to Orders" +msgstr "Volver a los pedidos" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order" +msgstr "Pedido" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment in progress" +msgstr "Pago en curso" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Reference:" +msgstr "Referencia:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Thank you, your order is confirmed" +msgstr "Gracias, tu pedido está confirmado" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "View my order" +msgstr "Ver mi pedido" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Your payment is being processed" +msgstr "Tu pago se está procesando" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "You already placed an order for this cycle." +msgstr "Ya has hecho un pedido para este ciclo." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm and pay" +msgstr "Confirmar y pagar" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm the order and go to payment" +msgstr "Confirmar el pedido e ir al pago" + +#. module: website_sale_aplicoop +#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment +msgid "" +"Let members pay their order online when they place it. Payment providers are" +" configured globally (Settings > Payment Providers); this only decides " +"whether this group order offers them. When enabled, paying is the only way " +"to place an order from the checkout page." +msgstr "" +"Permite que los socios paguen su pedido online al hacerlo. Los proveedores " +"de pago se configuran de forma global (Ajustes > Proveedores de pago); esto " +"solo decide si este pedido de grupo los ofrece. Con la opción activa, pagar " +"es la única forma de hacer el pedido desde la página de checkout." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "" +"Members must pay online to place their order in this cycle. The\n" +" available methods come from the payment providers published on the\n" +" website (Settings > Payment Providers); this order does not\n" +" configure any of them." +msgstr "" +"Los socios deben pagar online para hacer su pedido en este ciclo. Las formas" +" de pago disponibles salen de los proveedores publicados en el sitio web " +"(Ajustes > Proveedores de pago); este pedido no configura ninguno." + +#. module: website_sale_aplicoop +#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "Online Payment" +msgstr "Pago online" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Online payment is not available right now. Please contact your group." +msgstr "El pago online no está disponible ahora mismo. Contacta con tu grupo." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order Confirmed:" +msgstr "Pedido confirmado:" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Order ready for payment" +msgstr "Pedido listo para pagar" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Pay Order:" +msgstr "Pagar pedido:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment Method" +msgstr "Forma de pago" + +#. module: website_sale_aplicoop +#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction +msgid "Payment Transaction" +msgstr "Transacción de pago" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Pickup" +msgstr "Recogida" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "View my order" +msgstr "Ver mi pedido" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "" +"We are still waiting for your payment to be confirmed. Please do not pay " +"again." +msgstr "" +"Todavía estamos esperando la confirmación de tu pago. No vuelvas a pagar." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "You already placed an order for this cycle." +msgstr "Ya has hecho un pedido para este ciclo." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "" +"Your order will be confirmed as soon as we receive the payment. You can " +"follow it from your orders page." +msgstr "" +"Tu pedido se confirmará en cuanto recibamos el pago. Puedes seguirlo desde " +"tu página de pedidos." diff --git a/website_sale_aplicoop/i18n/eu.po b/website_sale_aplicoop/i18n/eu.po index e0cec95..81159df 100644 --- a/website_sale_aplicoop/i18n/eu.po +++ b/website_sale_aplicoop/i18n/eu.po @@ -2358,3 +2358,168 @@ msgid "" msgstr "" " Etxerako Entrega" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary +msgid "Total:" +msgstr "Guztira:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Back to Checkout" +msgstr "Itzuli checkout-era" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Back to Orders" +msgstr "Itzuli eskaeretara" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order" +msgstr "Eskaera" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment in progress" +msgstr "Ordainketa abian" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Reference:" +msgstr "Erreferentzia:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Thank you, your order is confirmed" +msgstr "Eskerrik asko, zure eskaera berretsita dago" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "View my order" +msgstr "Ikusi nire eskaera" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Your payment is being processed" +msgstr "Zure ordainketa prozesatzen ari da" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "You already placed an order for this cycle." +msgstr "Dagoeneko eskaera bat egin duzu ziklo honetarako." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm and pay" +msgstr "Berretsi eta ordaindu" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Confirm the order and go to payment" +msgstr "Eskaera berretsi eta ordainketara joan" + +#. module: website_sale_aplicoop +#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment +msgid "" +"Let members pay their order online when they place it. Payment providers are" +" configured globally (Settings > Payment Providers); this only decides " +"whether this group order offers them. When enabled, paying is the only way " +"to place an order from the checkout page." +msgstr "" +"Utzi bazkideei beren eskaera linean ordaintzen egiten dutenean. Ordainketa " +"hornitzaileak orokorrean konfiguratzen dira (Ezarpenak > Ordainketa " +"hornitzaileak); honek talde eskaera honek eskaintzen dituen ala ez " +"erabakitzen du soilik. Aktibatuta dagoenean, ordaintzea da checkout orritik " +"eskaera egiteko modu bakarra." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "" +"Members must pay online to place their order in this cycle. The\n" +" available methods come from the payment providers published on the\n" +" website (Settings > Payment Providers); this order does not\n" +" configure any of them." +msgstr "" +"Bazkideek linean ordaindu behar dute ziklo honetan eskaera egiteko. " +"Erabilgarri dauden moduak webgunean argitaratutako ordainketa " +"hornitzaileetatik datoz (Ezarpenak > Ordainketa hornitzaileak); eskaera " +"honek ez du bat ere konfiguratzen." + +#. module: website_sale_aplicoop +#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form +msgid "Online Payment" +msgstr "Lineako ordainketa" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Online payment is not available right now. Please contact your group." +msgstr "" +"Lineako ordainketa ez dago erabilgarri orain. Jarri harremanetan zure " +"taldearekin." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Order Confirmed:" +msgstr "Eskaera berretsita:" + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "Order ready for payment" +msgstr "Eskaera ordaintzeko prest" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Pay Order:" +msgstr "Ordaindu eskaera:" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "Payment Method" +msgstr "Ordainketa modua" + +#. module: website_sale_aplicoop +#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction +msgid "Payment Transaction" +msgstr "Ordainketa transakzioa" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "Pickup" +msgstr "Jasotzea" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop +msgid "View my order" +msgstr "Ikusi nire eskaera" + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment +msgid "" +"We are still waiting for your payment to be confirmed. Please do not pay " +"again." +msgstr "Zure ordainketa berresteko zain gaude oraindik. Ez ordaindu berriro." + +#. module: website_sale_aplicoop +#. odoo-python +#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0 +#: code:addons/website_sale_aplicoop/models/js_translations.py:0 +msgid "You already placed an order for this cycle." +msgstr "Dagoeneko eskaera bat egin duzu ziklo honetarako." + +#. module: website_sale_aplicoop +#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation +msgid "" +"Your order will be confirmed as soon as we receive the payment. You can " +"follow it from your orders page." +msgstr "" +"Zure eskaera ordainketa jaso bezain laster berretsiko da. Zure eskaeren " +"orritik jarrai dezakezu." diff --git a/website_sale_aplicoop/models/__init__.py b/website_sale_aplicoop/models/__init__.py index 0a00ead..02cec21 100644 --- a/website_sale_aplicoop/models/__init__.py +++ b/website_sale_aplicoop/models/__init__.py @@ -1,5 +1,6 @@ from . import group_order # noqa: F401 from . import group_order_slot # noqa: F401 +from . import payment_transaction # noqa: F401 from . import product_category_extension # noqa: F401 from . import product_extension # noqa: F401 from . import res_config_settings # noqa: F401 diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index 95ae054..a390094 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -6,7 +6,6 @@ import re from datetime import timedelta from dateutil.relativedelta import relativedelta - from odoo import api from odoo import fields from odoo import models @@ -188,6 +187,16 @@ class GroupOrder(models.Model): help="Calculated delivery date (pickup date + 1 day)", ) + # === Online payment === + online_payment = fields.Boolean( + tracking=True, + help="Let members pay their order online when they place it. Payment " + "providers are configured globally (Settings > Payment Providers); " + "this only decides whether this group order offers them. When " + "enabled, paying is the only way to place an order from the " + "checkout page.", + ) + # === Computed date fields === pickup_date = fields.Date( compute="_compute_pickup_date", @@ -770,8 +779,7 @@ class GroupOrder(models.Model): - If no slots are configured, leave fields empty (fallback handled by existing pickup_day logic). """ - from datetime import datetime - from datetime import time + from datetime import datetime, time for record in self: record.next_pickup_slot_id = False @@ -1106,6 +1114,69 @@ class GroupOrder(models.Model): failed_orders, ) + self._cron_batch_paid_orders_of_closed_cycles() + + @api.model + def _cron_batch_paid_orders_of_closed_cycles(self): + """Batch paid orders of group orders that were closed by hand. + + The loop above only walks draft/open group orders. Closing an order + manually after a member has paid would otherwise leave that member's + picking out of every batch, because the confirmation already happened + at payment time and the cron never looks at closed cycles. + """ + closed_orders = self.search( + [("state", "=", "closed"), ("online_payment", "=", True)] + ) + for order in closed_orders: + try: + order._batch_paid_sale_orders() + except Exception: + _logger.exception( + "Cron: Error batching paid sale orders of closed group order " + "%s (%s)", + order.id, + order.name, + ) + + def _batch_paid_sale_orders(self): + """Create the picking batches of orders already confirmed by payment. + + The same sweep `_confirm_linked_sale_orders` does, minus the + confirmation step. Drafts are deliberately left alone: closing a group + order by hand is how a co-op calls a cycle off, and this must not + resurrect the orders it meant to drop. + """ + self.ensure_one() + + batches = self.env["stock.picking.batch"] + if not self.pickup_date: + return batches + + paid_sale_orders = ( + self.env["sale.order"] + .sudo() + .search( + [ + ("group_order_id", "=", self.id), + ("state", "in", ["sale", "done"]), + ("pickup_date", "=", self.pickup_date), + ] + ) + ) + if not paid_sale_orders: + return batches + + batches = self._create_picking_batches_for_sale_orders(paid_sale_orders) + if batches: + _logger.info( + "Cron: Batched %d paid sale order(s) of closed group order %s (%s)", + len(paid_sale_orders), + self.id, + self.name, + ) + return batches + def _close_one_time_order_if_ended(self): """Close one-time orders once their end_date has passed. @@ -1179,7 +1250,25 @@ class GroupOrder(models.Model): ] ) - if not sale_orders: + # Orders paid online are confirmed the moment their transaction is + # done, long before this runs, so they are not in the search above — + # but their pickings still have to end up in this cycle's batch. + # + # pickup_date is what scopes them to this cycle: the same group.order + # record is reused every cycle, and _cron_update_dates() calls this + # method BEFORE recomputing the dates, so self.pickup_date is still + # the closing cycle's, exactly the value stamped on the order when it + # was saved. Backorders from previous cycles hang off orders with an + # older pickup_date, so they are not swept in either. + already_confirmed = SaleOrder.search( + [ + ("group_order_id", "=", self.id), + ("state", "in", ["sale", "done"]), + ("pickup_date", "=", self.pickup_date), + ] + ) + + if not sale_orders and not already_confirmed: _logger.info( "Cron: No sale orders to confirm for group order %s (%s)", self.id, @@ -1188,10 +1277,12 @@ class GroupOrder(models.Model): return _logger.info( - "Cron: Confirming %d sale orders for group order %s (%s)", + "Cron: Confirming %d sale orders for group order %s (%s); " + "%d already confirmed by online payment", len(sale_orders), self.id, self.name, + len(already_confirmed), ) try: @@ -1264,13 +1355,33 @@ class GroupOrder(models.Model): ) batches = self.env["stock.picking.batch"] - if confirmed_sale_orders: - # Create picking batches only for confirmed sale orders + # One call with both sets: _create_picking_batches_for_sale_orders + # groups by picking type and skips pickings that already have a + # batch, so this yields one batch per type for the whole cycle. + batchable_sale_orders = confirmed_sale_orders | already_confirmed + if batchable_sale_orders: batches = self._create_picking_batches_for_sale_orders( - confirmed_sale_orders + batchable_sale_orders ) + # Only the orders confirmed right now: re-reporting the ones + # confirmed in an earlier run would repeat the same warnings + # on every cron pass. self._log_missing_procurement_warnings(confirmed_sale_orders) + if already_confirmed and not batches: + # Paid orders that never made it into a batch are an + # operational hole, and everything here runs inside a + # try/except that only logs. Say so loudly. + _logger.warning( + "Cron: %d already confirmed sale order(s) of group order %s (%s) " + "produced no picking batch. Their pickings may already be " + "batched, done or cancelled — check manually. ids=%s", + len(already_confirmed), + self.id, + self.name, + already_confirmed.ids, + ) + if failed_sale_orders: _logger.warning( "Cron: %d/%d sale orders failed during confirmation for group order %s (%s). " @@ -1309,8 +1420,7 @@ class GroupOrder(models.Model): return failure_reasons = failure_reasons or {} - from markupsafe import Markup - from markupsafe import escape + from markupsafe import Markup, escape items = Markup() for sale_order in failed_sale_orders: diff --git a/website_sale_aplicoop/models/js_translations.py b/website_sale_aplicoop/models/js_translations.py index 681f96b..c3b665b 100644 --- a/website_sale_aplicoop/models/js_translations.py +++ b/website_sale_aplicoop/models/js_translations.py @@ -40,6 +40,15 @@ def _register_translations(): _("Load Draft") _("Browse Product Categories") + # ======================== + # Online Payment Labels + # ======================== + _("Confirm and pay") + _("Confirm the order and go to payment") + _("Order ready for payment") + _("You already placed an order for this cycle.") + _("Online payment is not available right now. Please contact your group.") + # ======================== # Draft Modal Labels # ======================== diff --git a/website_sale_aplicoop/models/payment_transaction.py b/website_sale_aplicoop/models/payment_transaction.py new file mode 100644 index 0000000..474681f --- /dev/null +++ b/website_sale_aplicoop/models/payment_transaction.py @@ -0,0 +1,50 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + def _check_amount_and_confirm_order(self): + """Confirm group order sales the way the cutoff cron already does. + + ``group.order._confirm_linked_sale_orders`` confirms with + ``from_orderpoint=True`` on purpose: ``stock.move._action_confirm`` + forwards ``raise_user_error=not from_orderpoint`` to + ``procurement.group.run``, so a product with a broken replenishment + route does not block the sale, and the missing moves are reported + operationally instead. + + The standard payment post-processing confirms without that context, + and ``/payment/status/poll`` rolls back and re-raises anything + ``_post_process`` throws. Without this override, one misconfigured + product turns a successful payment into an error page while the + transaction is already ``done``, and the retry cron keeps failing on + it. Orders born from a group order follow the cron's operational + rules, so they get the cron's context. + """ + eskaera_txs = self.filtered(lambda tx: tx.sale_order_ids.group_order_id) + if not eskaera_txs: + return super()._check_amount_and_confirm_order() + + confirmed_orders = super( + PaymentTransaction, self - eskaera_txs + )._check_amount_and_confirm_order() + + _logger.info( + "[PAYMENT] Confirming %d group order transaction(s) with " + "from_orderpoint=True: %s", + len(eskaera_txs), + eskaera_txs.ids, + ) + confirmed_orders |= super( + PaymentTransaction, eskaera_txs.with_context(from_orderpoint=True) + )._check_amount_and_confirm_order() + + return confirmed_orders diff --git a/website_sale_aplicoop/models/sale_order_extension.py b/website_sale_aplicoop/models/sale_order_extension.py index 739b300..c1576a6 100644 --- a/website_sale_aplicoop/models/sale_order_extension.py +++ b/website_sale_aplicoop/models/sale_order_extension.py @@ -62,6 +62,33 @@ class SaleOrder(models.Model): help="Whether this order includes home delivery", ) + @api.depends("company_id", "group_order_id", "group_order_id.online_payment") + def _compute_require_payment(self): # pylint: disable=missing-return + """Let the group order decide whether its members pay online. + + Orders outside a group order keep the company default. + + No return: compute methods assign fields, and pylint-odoo's + `missing-return` does not know that about a `super()` call. + """ + super()._compute_require_payment() + for order in self: + if order.group_order_id: + order.require_payment = order.group_order_id.online_payment + + @api.depends("require_payment", "group_order_id") + def _compute_prepayment_percent(self): # pylint: disable=missing-return + """Group orders are paid in full, never with a down payment. + + Written together with `require_payment` on purpose: the core compute + would otherwise pull `company_id.prepayment_percent`, which + `_check_prepayment_percent` rejects unless it is in (0, 1]. + """ + super()._compute_prepayment_percent() + for order in self: + if order.group_order_id and order.require_payment: + order.prepayment_percent = 1.0 + @api.depends( "group_order_id", "group_order_id.next_pickup_slot_id", diff --git a/website_sale_aplicoop/readme/CONFIGURE.rst b/website_sale_aplicoop/readme/CONFIGURE.rst index 77f0e33..e39e649 100644 --- a/website_sale_aplicoop/readme/CONFIGURE.rst +++ b/website_sale_aplicoop/readme/CONFIGURE.rst @@ -58,3 +58,24 @@ Odoo then serves the pages on the new prefix, rewrites the links in the templates and redirects the old URLs. The AJAX endpoints (``/eskaera/labels``, ``/eskaera/save-order``…) are never shown in the address bar and do not need a rule. + +**Online payment (v18.0.1.14.0+):** + +Payment is enabled per group order, and off by default: an order without it +behaves exactly as before, with members saving a draft that the cutoff cron +confirms in bulk. + +#. Configure the providers first, where Odoo always keeps them: + Settings → Payment Providers. Enable and publish at least one (bank + transfer, Redsys, Stripe...). This module ships no provider of its own and + configures none; it only shows whichever ones are compatible. +#. On a multi-website database, leave a provider's *Website* field empty to + 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 +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/DESCRIPTION.rst b/website_sale_aplicoop/readme/DESCRIPTION.rst index eb3eaca..bd4d793 100644 --- a/website_sale_aplicoop/readme/DESCRIPTION.rst +++ b/website_sale_aplicoop/readme/DESCRIPTION.rst @@ -10,5 +10,6 @@ This module replaces the legacy Aplicoop application with a modern, scalable sol * **Multi-language Support**: Full internationalization with translations for 7 languages (ES, EU, CA, GL, PT, FR, IT) * **Email Notifications**: Automatic notifications on order state changes * **Financial Tracking**: Track orders and payments per group member +* **Online Payment**: Optional per group order — members pay at checkout through the standard Odoo payment providers, and their order is confirmed as soon as the transaction completes (v18.0.1.14.0+) * **Product Integration**: Compatible with product ribbons, pricing, and margin modules * **OCA Compliant**: AGPL-3.0 licensed, follows OCA standards for documentation, testing, and code structure diff --git a/website_sale_aplicoop/readme/USAGE.rst b/website_sale_aplicoop/readme/USAGE.rst index f6af101..74edb5e 100644 --- a/website_sale_aplicoop/readme/USAGE.rst +++ b/website_sale_aplicoop/readme/USAGE.rst @@ -43,3 +43,21 @@ Order States * **Confirmed**: Order open for shopping * **Collected**: Orders received from supplier * **Completed**: All members have picked up their orders + +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 +#. 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 +longer be edited. Coming back to the shop shows a notice with a link to the +order instead of an empty cart, so nobody pays twice for the same cycle. + +If the payment is still being processed when you come back, the page says so +and the order is confirmed as soon as the provider settles it. diff --git a/website_sale_aplicoop/static/src/js/eskaera_payment.js b/website_sale_aplicoop/static/src/js/eskaera_payment.js new file mode 100644 index 0000000..0ec59bd --- /dev/null +++ b/website_sale_aplicoop/static/src/js/eskaera_payment.js @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Criptomart + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + * + * Frees the localStorage cart once the order has been placed and paid. + */ + +(function () { + "use strict"; + + document.addEventListener("DOMContentLoaded", function () { + var page = document.querySelector("[data-clear-cart-order-id]"); + if (!page) { + return; + } + + var orderId = page.getAttribute("data-clear-cart-order-id"); + if (!orderId) { + return; + } + + // Client side only. /eskaera/clear-cart would also cancel the sale + // order, which is exactly the wrong thing to do to an order that was + // just paid for. + try { + localStorage.removeItem("eskaera_" + orderId + "_cart"); + localStorage.removeItem("eskaera_" + orderId + "_cart_cycle"); + } catch (e) { + // localStorage unavailable (private mode, quota). The cart is + // server-side irrelevant at this point; the duplicate-order guard + // is what actually protects the member. + console.warn("[ESKAERA PAYMENT] Could not clear the local cart:", e); + } + }); +})(); diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index 4edee32..b011773 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -592,6 +592,38 @@ } }, + // The member already has a placed order for this cycle (409). Their + // local cart is stale, so drop it and send them to that order rather + // than let them build — and pay for — a duplicate. + _handleAlreadyPlacedResponse: function (xhr) { + if (!xhr || xhr.status !== 409) { + return false; + } + + var data; + try { + data = JSON.parse(xhr.responseText || "{}"); + } catch (e) { + return false; + } + if (!data.already_placed) { + return false; + } + + var labels = this._getLabels(); + this._clearCurrentOrderCartSilently(); + this._updateCartDisplay(); + this._showNotification( + data.error || labels.already_placed || "You already placed an order.", + "warning", + 6000 + ); + if (data.redirect_url) { + window.location.href = data.redirect_url; + } + return true; + }, + _checkGroupOrderStatus: function (callback) { var self = this; var done = function () { @@ -783,8 +815,18 @@ var tooltipText = null; var labelKey = null; + // An explicit key on the element wins over the static map: the + // checkout button carries a different label depending on + // whether the group order takes online payments, and the + // server is the one that knows. + var declaredKey = element.getAttribute("data-tooltip-key"); + if (declaredKey && labels[declaredKey]) { + labelKey = declaredKey; + tooltipText = labels[declaredKey]; + } + // Check ID-based mapping - if (element.id && tooltipMap[element.id]) { + if (!tooltipText && element.id && tooltipMap[element.id]) { labelKey = tooltipMap[element.id]; tooltipText = labels[labelKey]; } @@ -1790,6 +1832,9 @@ self._updateCartDisplay(); return; } + if (self._handleAlreadyPlacedResponse(xhr)) { + return; + } try { var errorData = JSON.parse(xhr.responseText); self._showNotification( @@ -1914,6 +1959,9 @@ self._updateCartDisplay(); return; } + if (self._handleAlreadyPlacedResponse(xhr)) { + return; + } try { var errorData = JSON.parse(xhr.responseText); self._showNotification( @@ -2036,10 +2084,17 @@ if (data.success) { var successMsg = + data.message || labels.draft_saved_success || labels.draft_saved || "Order saved as draft successfully"; 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"), @@ -2056,6 +2111,9 @@ self._updateCartDisplay(); return; } + if (self._handleAlreadyPlacedResponse(xhr)) { + return; + } try { var errorData = JSON.parse(xhr.responseText); console.error("HTTP error:", xhr.status, errorData); diff --git a/website_sale_aplicoop/tests/__init__.py b/website_sale_aplicoop/tests/__init__.py index 6ea162f..0938e6d 100644 --- a/website_sale_aplicoop/tests/__init__.py +++ b/website_sale_aplicoop/tests/__init__.py @@ -18,3 +18,4 @@ from . import test_cron_picking_batch # noqa: F401 from . import test_group_order_status_endpoint # noqa: F401 from . import test_home_delivery # noqa: F401 from . import test_forecasted_stock # noqa: F401 +from . import test_online_payment # noqa: F401 diff --git a/website_sale_aplicoop/tests/test_online_payment.py b/website_sale_aplicoop/tests/test_online_payment.py new file mode 100644 index 0000000..d1c53d8 --- /dev/null +++ b/website_sale_aplicoop/tests/test_online_payment.py @@ -0,0 +1,568 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch + +from odoo import fields +from odoo.tests.common import HttpCase +from odoo.tests.common import TransactionCase +from odoo.tests.common import tagged + +from odoo.addons.website_sale_aplicoop.controllers import ( + website_sale_validators as validators, +) + + +@tagged("post_install", "-at_install", "eskaera_online_payment") +class TestOnlinePayment(TransactionCase): + """Online payment for group orders: policy, guards and batching.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.consumer_group = cls.env["res.partner"].create( + { + "name": "Payment Consumer Group", + "is_company": True, + "is_group": True, + } + ) + cls.member = cls.env["res.partner"].create( + { + "name": "Paying Member", + "email": "paying.member@test.com", + "parent_id": cls.consumer_group.id, + } + ) + cls.other_member = cls.env["res.partner"].create( + { + "name": "Other Member", + "email": "other.member@test.com", + "parent_id": cls.consumer_group.id, + } + ) + cls.product = cls.env["product.product"].create( + { + "name": "Payable Product", + "is_storable": True, + "list_price": 10.0, + } + ) + # The validator helpers only ever reach for `request.env`, so a + # namespace stands in for the HTTP request outside a web context. + cls.fake_request = SimpleNamespace(env=cls.env) + + # === Helpers === + + def _create_group_order(self, online_payment=True, cutoff_in_past=False): + """One-time group order whose cycle ends in the past or the future. + + One-time orders derive `cutoff_date` from `end_date`, so the cycle is + steered here through `end_date`. + """ + today = fields.Date.today() + end_date = ( + today - timedelta(days=1) if cutoff_in_past else today + timedelta(days=2) + ) + return self.env["group.order"].create( + { + "name": "Payment Group Order", + "group_ids": [(6, 0, [self.consumer_group.id])], + "period": "once", + "pickup_day": "2", # Wednesday + "state": "open", + "end_date": end_date, + "online_payment": online_payment, + } + ) + + def _create_sale_order(self, group_order, partner=None, pickup_date=None): + return self.env["sale.order"].create( + { + "partner_id": (partner or self.member).id, + "group_order_id": group_order.id, + "consumer_group_id": self.consumer_group.id, + "pickup_date": pickup_date or group_order.pickup_date, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 10.0, + }, + ) + ], + } + ) + + def _create_done_transaction(self, sale_order): + """A `done` transaction covering the order's full amount. + + `payment.method` records ship archived until a provider module is + installed, so the lookup has to ignore the active flag: the test only + needs a well-formed transaction, not a usable payment route. + """ + provider = self.env["payment.provider"].search([], limit=1) + method = ( + self.env["payment.method"] + .with_context(active_test=False) + .search([("primary_payment_method_id", "=", False)], limit=1) + ) + transaction = self.env["payment.transaction"].create( + { + "provider_id": provider.id, + "payment_method_id": method.id, + "reference": f"TEST-{sale_order.id}", + "amount": sale_order.amount_total, + "currency_id": sale_order.currency_id.id, + "partner_id": sale_order.partner_id.id, + "sale_order_ids": [(6, 0, sale_order.ids)], + } + ) + transaction.write({"state": "done"}) + return transaction + + # === Payment policy on the sale order === + + def test_require_payment_follows_group_order(self): + """A group order with online payment makes its orders payable.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + + self.assertTrue(sale_order.require_payment) + self.assertEqual(sale_order.prepayment_percent, 1.0) + self.assertTrue( + sale_order._has_to_be_paid(), + "A draft order of a paying cycle must be payable", + ) + + def test_require_payment_off_without_online_payment(self): + """Without the flag nothing changes: no payment is required.""" + group_order = self._create_group_order(online_payment=False) + sale_order = self._create_sale_order(group_order) + + self.assertFalse(sale_order.require_payment) + self.assertFalse(sale_order._has_to_be_paid()) + + def test_toggling_group_order_clears_require_payment(self): + """Turning the flag off mid-cycle must free the existing drafts.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + self.assertTrue(sale_order.require_payment) + + group_order.online_payment = False + sale_order.invalidate_recordset() + + self.assertFalse( + sale_order.require_payment, + "An existing draft must stop requiring payment when the group " + "order stops offering it", + ) + + def test_non_group_orders_keep_company_default(self): + """Orders outside a group order are left alone.""" + plain_order = self.env["sale.order"].create({"partner_id": self.member.id}) + self.assertEqual( + plain_order.require_payment, + plain_order.company_id.portal_confirmation_pay, + ) + + # === Confirmation through payment === + + def test_payment_confirms_with_from_orderpoint(self): + """Paying must confirm the way the cutoff cron does. + + Without `from_orderpoint`, a product with a broken replenishment route + raises during post-processing, `/payment/status/poll` rolls the whole + thing back and the member sees an error over a `done` transaction. + """ + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + transaction = self._create_done_transaction(sale_order) + + captured = {} + + def _fake_confirm(order_self): + captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint") + return True + + with patch.object( + type(self.env["sale.order"]), "action_confirm", _fake_confirm + ): + transaction._check_amount_and_confirm_order() + + self.assertTrue( + captured.get("from_orderpoint"), + "Group order confirmations triggered by payment must carry " + "from_orderpoint=True", + ) + + def test_payment_confirms_the_order(self): + """The standard machinery confirms the order once paid.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + transaction = self._create_done_transaction(sale_order) + + transaction._check_amount_and_confirm_order() + sale_order.invalidate_recordset() + + self.assertEqual(sale_order.state, "sale") + + def test_plain_orders_confirm_without_from_orderpoint(self): + """Orders unrelated to a group order keep the core behaviour.""" + plain_order = self.env["sale.order"].create( + { + "partner_id": self.member.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 10.0, + }, + ) + ], + } + ) + transaction = self._create_done_transaction(plain_order) + + captured = {} + + def _fake_confirm(order_self): + captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint") + return True + + with patch.object( + type(self.env["sale.order"]), "action_confirm", _fake_confirm + ): + transaction._check_amount_and_confirm_order() + + self.assertFalse(captured.get("from_orderpoint")) + + # === Cycle lookup helpers === + + def test_draft_lookup_ignores_placed_orders(self): + """`_find_recent_draft_order` must never return a placed order. + + `/eskaera/clear-cart` cancels whatever this returns, so widening it + would cancel orders that are already paid for. + """ + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + found = validators._find_recent_draft_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertFalse(found) + + def test_placed_lookup_finds_confirmed_order(self): + """The duplicate guard sees the member's confirmed order.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + found = validators._find_placed_cycle_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertEqual(found, sale_order) + + def test_placed_lookup_survives_orders_created_after_cutoff(self): + """Nothing blocks ordering between the cutoff and the cron run. + + The draft window caps `create_date` at the cutoff date; the placed + lookup must not, or an order paid in that gap would slip past the + guard and let the member order twice. + """ + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + found = validators._find_placed_cycle_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertEqual(found, sale_order) + + def test_placed_lookup_ignores_other_cycles(self): + """An order frozen on another pickup date belongs to another cycle.""" + group_order = self._create_group_order(online_payment=True) + sale_order = self._create_sale_order( + group_order, pickup_date=group_order.pickup_date - timedelta(days=7) + ) + sale_order.action_confirm() + + found = validators._find_placed_cycle_order( + None, self.member.id, group_order, request_obj=self.fake_request + ) + self.assertFalse(found) + + # === Batching at cutoff === + + def test_cron_batches_a_fully_prepaid_cycle(self): + """A cycle where everybody paid early still gets its batch. + + The confirmation loop used to bail out when it found no draft, which + with online payment is the normal case: every order is confirmed the + moment its transaction completes. + """ + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + + self.assertFalse( + self.env["sale.order"].search( + [("group_order_id", "=", group_order.id), ("state", "=", "draft")] + ), + "This cycle must have no drafts left for the test to mean anything", + ) + + group_order._confirm_linked_sale_orders() + + self.assertTrue( + sale_order.picking_ids.batch_id, + "The picking of an order paid before the cutoff must still be " + "batched by the cron", + ) + + def test_cron_batches_paid_and_draft_orders_together(self): + """Paid and cron-confirmed orders share one batch per picking type.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + paid_order = self._create_sale_order(group_order, partner=self.member) + paid_order.action_confirm() + draft_order = self._create_sale_order(group_order, partner=self.other_member) + + group_order._confirm_linked_sale_orders() + draft_order.invalidate_recordset() + + self.assertEqual(draft_order.state, "sale") + batches = paid_order.picking_ids.batch_id | draft_order.picking_ids.batch_id + self.assertEqual( + len(batches), + 1, + "Both orders belong to the same cycle and picking type, so they " + "must land in a single batch", + ) + + def test_cron_ignores_paid_orders_of_previous_cycles(self): + """A previous cycle's order must not be swept into this batch.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + stale_order = self._create_sale_order( + group_order, pickup_date=group_order.pickup_date - timedelta(days=7) + ) + stale_order.action_confirm() + stale_order.picking_ids.batch_id = False + + group_order._confirm_linked_sale_orders() + + self.assertFalse( + stale_order.picking_ids.batch_id, + "An order frozen on a previous pickup date is not part of this " + "cycle and must be left out of its batch", + ) + + def test_closed_cycle_still_batches_paid_orders(self): + """Closing a group order by hand must not strand a paid order.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + sale_order = self._create_sale_order(group_order) + sale_order.action_confirm() + group_order.action_close() + + self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles() + + self.assertTrue( + sale_order.picking_ids.batch_id, + "A paid order of a manually closed cycle must still be batched", + ) + + def test_closed_cycle_leaves_drafts_alone(self): + """Closing a cycle by hand is how a co-op calls it off.""" + group_order = self._create_group_order(online_payment=True, cutoff_in_past=True) + draft_order = self._create_sale_order(group_order) + group_order.action_close() + + self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles() + draft_order.invalidate_recordset() + + self.assertEqual( + draft_order.state, + "draft", + "The closed-cycle sweep must only batch, never confirm", + ) + + +@tagged("post_install", "-at_install", "eskaera_online_payment") +class TestOnlinePaymentRoutes(HttpCase): + """The payment step and its landing page, over HTTP.""" + + def setUp(self): + super().setUp() + + self.group = self.env["res.partner"].create( + { + "name": "Payment Routes Group", + "is_company": True, + "is_group": True, + "email": "payment-routes-group@test.com", + } + ) + self.member_partner = self.env["res.partner"].create( + {"name": "Payment Routes Member", "email": "payment-routes@test.com"} + ) + self.group.member_ids = [(4, self.member_partner.id)] + + login = "portal.payment@test.com" + self.portal_user = self.env["res.users"].create( + { + "name": "Portal Payment User", + "login": login, + "password": login, + "partner_id": self.member_partner.id, + "groups_id": [(4, self.env.ref("base.group_portal").id)], + } + ) + + self.product = self.env["product.product"].create( + {"name": "Route Product", "is_storable": True, "list_price": 10.0} + ) + + start_date = fields.Date.today() + self.group_order = self.env["group.order"].create( + { + "name": "Payment Routes Order", + "group_ids": [(6, 0, [self.group.id])], + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + "online_payment": True, + } + ) + self.group_order.action_open() + + def _create_draft(self): + return self.env["sale.order"].create( + { + "partner_id": self.member_partner.id, + "group_order_id": self.group_order.id, + "consumer_group_id": self.group.id, + "pickup_date": self.group_order.pickup_date, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 10.0, + }, + ) + ], + } + ) + + def _slug_url(self, suffix=""): + return f"/eskaera/{self.group_order.slug}{suffix}" + + def test_payment_page_renders(self): + """The payment step renders for a member with a draft in the cycle.""" + self._create_draft() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/payment"), allow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn( + 'data-name="Eskaera Payment"', + response.text, + "The payment step should render its own page, not redirect away", + ) + + def test_payment_page_needs_a_draft(self): + """With nothing in the cart there is nothing to pay for.""" + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/payment"), allow_redirects=False) + + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/checkout")) + + def test_payment_page_off_without_online_payment(self): + """The step does not exist for a group order that takes no payments.""" + self.group_order.online_payment = False + self._create_draft() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/payment"), allow_redirects=False) + + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/checkout")) + + def test_checkout_offers_payment(self): + """The checkout button turns into the 'confirm and pay' variant. + + Asserted on `data-tooltip-key` rather than the label: the website runs + in whatever language the visitor picked, and the label is translated. + """ + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/checkout"), allow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn('data-tooltip-key="confirm_and_pay"', response.text) + + def test_checkout_keeps_save_draft_without_online_payment(self): + """With the flag off the checkout is exactly what it was.""" + self.group_order.online_payment = False + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/checkout"), allow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn('data-tooltip-key="save_draft"', response.text) + self.assertNotIn('data-tooltip-key="confirm_and_pay"', response.text) + + def test_confirmation_page_renders_for_the_owner(self): + """The landing page reports the order back to the member.""" + order = self._create_draft() + order.action_confirm() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open( + self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=True + ) + + self.assertEqual(response.status_code, 200) + self.assertIn(order.name, response.text) + + def test_confirmation_page_rejects_other_partners(self): + """Nobody gets to read someone else's order through this page.""" + other_partner = self.env["res.partner"].create({"name": "Somebody Else"}) + order = self._create_draft() + order.partner_id = other_partner + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open( + self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=False + ) + + self.assertEqual(response.status_code, 303) + self.assertTrue(response.headers["Location"].endswith("/eskaera")) + + def test_checkout_redirects_once_the_order_is_placed(self): + """A member who already paid cannot build a second order.""" + order = self._create_draft() + order.action_confirm() + self.authenticate(self.portal_user.login, self.portal_user.login) + + response = self.url_open(self._slug_url("/checkout"), allow_redirects=False) + + self.assertEqual(response.status_code, 303) + self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"]) diff --git a/website_sale_aplicoop/views/group_order_views.xml b/website_sale_aplicoop/views/group_order_views.xml index e3d1a05..ad12c40 100644 --- a/website_sale_aplicoop/views/group_order_views.xml +++ b/website_sale_aplicoop/views/group_order_views.xml @@ -18,6 +18,7 @@ + @@ -84,6 +85,15 @@ + + +
+ Members must pay online to place their order in this cycle. The + available methods come from the payment providers published on the + website (Settings > Payment Providers); this order does not + configure any of them. +
+
diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml index 08cc8e0..d832ffe 100644 --- a/website_sale_aplicoop/views/website_templates.xml +++ b/website_sale_aplicoop/views/website_templates.xml @@ -214,6 +214,18 @@ + + + +
+
+ + + + + + + + + + + + + + +
Products in this order, with quantity, price and subtotal
ProductQuantitySubtotal
+ + + +
+
+
+ Total: + + + +
+
+
+ + +