[ADD] website_sale_aplicoop: online payment per group order

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

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

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

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

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

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

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

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

View file

@ -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",

View file

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

View file

@ -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"),

View file

@ -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")

View file

@ -2362,3 +2362,169 @@ msgid ""
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Lliurament a domicili</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
msgid "<span class=\"total-label\">Total</span>:"
msgstr "<span class=\"total-label\">Total</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Back to Checkout</span>"
msgstr "<span>Torna al checkout</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Back to Orders</span>"
msgstr "<span>Torna a les comandes</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Order</span>"
msgstr "<span>Comanda</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Payment in progress</span>"
msgstr "<span>Pagament en curs</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Reference</span>:"
msgstr "<span>Referència</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Thank you, your order is confirmed</span>"
msgstr "<span>Gràcies, la teva comanda està confirmada</span>"
#. 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 "<span>View my order</span>"
msgstr "<span>Veure la meva comanda</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Your payment is being processed</span>"
msgstr "<span>El teu pagament s'està processant</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<strong>You already placed an order for this cycle.</strong>"
msgstr "<strong>Ja has fet una comanda per a aquest cicle.</strong>"
#. 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 &gt; 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."

View file

@ -2358,3 +2358,165 @@ msgid ""
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Entrega a Casa</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
msgid "<span class=\"total-label\">Total</span>:"
msgstr "<span class=\"total-label\">Total</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Back to Checkout</span>"
msgstr "<span>Volver al checkout</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Back to Orders</span>"
msgstr "<span>Volver a los pedidos</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Order</span>"
msgstr "<span>Pedido</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Payment in progress</span>"
msgstr "<span>Pago en curso</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Reference</span>:"
msgstr "<span>Referencia</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Thank you, your order is confirmed</span>"
msgstr "<span>Gracias, tu pedido está confirmado</span>"
#. 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 "<span>View my order</span>"
msgstr "<span>Ver mi pedido</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Your payment is being processed</span>"
msgstr "<span>Tu pago se está procesando</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<strong>You already placed an order for this cycle.</strong>"
msgstr "<strong>Ya has hecho un pedido para este ciclo.</strong>"
#. 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 &gt; 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."

View file

@ -2358,3 +2358,168 @@ msgid ""
msgstr ""
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
"hidden=\"true\"/> Etxerako Entrega</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
msgid "<span class=\"total-label\">Total</span>:"
msgstr "<span class=\"total-label\">Guztira</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Back to Checkout</span>"
msgstr "<span>Itzuli checkout-era</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Back to Orders</span>"
msgstr "<span>Itzuli eskaeretara</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Order</span>"
msgstr "<span>Eskaera</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Payment in progress</span>"
msgstr "<span>Ordainketa abian</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
msgid "<span>Reference</span>:"
msgstr "<span>Erreferentzia</span>:"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Thank you, your order is confirmed</span>"
msgstr "<span>Eskerrik asko, zure eskaera berretsita dago</span>"
#. 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 "<span>View my order</span>"
msgstr "<span>Ikusi nire eskaera</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
msgid "<span>Your payment is being processed</span>"
msgstr "<span>Zure ordainketa prozesatzen ari da</span>"
#. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
msgid "<strong>You already placed an order for this cycle.</strong>"
msgstr "<strong>Dagoeneko eskaera bat egin duzu ziklo honetarako.</strong>"
#. 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 &gt; 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."

View file

@ -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

View file

@ -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:

View file

@ -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
# ========================

View file

@ -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

View file

@ -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",

View file

@ -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/<slug>/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.

View file

@ -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

View file

@ -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/<slug>`` 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.

View file

@ -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);
}
});
})();

View file

@ -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);

View file

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

View file

@ -0,0 +1,568 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import patch
from odoo import fields
from odoo.tests.common import HttpCase
from odoo.tests.common import TransactionCase
from odoo.tests.common import tagged
from odoo.addons.website_sale_aplicoop.controllers import (
website_sale_validators as validators,
)
@tagged("post_install", "-at_install", "eskaera_online_payment")
class TestOnlinePayment(TransactionCase):
"""Online payment for group orders: policy, guards and batching."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.consumer_group = cls.env["res.partner"].create(
{
"name": "Payment Consumer Group",
"is_company": True,
"is_group": True,
}
)
cls.member = cls.env["res.partner"].create(
{
"name": "Paying Member",
"email": "paying.member@test.com",
"parent_id": cls.consumer_group.id,
}
)
cls.other_member = cls.env["res.partner"].create(
{
"name": "Other Member",
"email": "other.member@test.com",
"parent_id": cls.consumer_group.id,
}
)
cls.product = cls.env["product.product"].create(
{
"name": "Payable Product",
"is_storable": True,
"list_price": 10.0,
}
)
# The validator helpers only ever reach for `request.env`, so a
# namespace stands in for the HTTP request outside a web context.
cls.fake_request = SimpleNamespace(env=cls.env)
# === Helpers ===
def _create_group_order(self, online_payment=True, cutoff_in_past=False):
"""One-time group order whose cycle ends in the past or the future.
One-time orders derive `cutoff_date` from `end_date`, so the cycle is
steered here through `end_date`.
"""
today = fields.Date.today()
end_date = (
today - timedelta(days=1) if cutoff_in_past else today + timedelta(days=2)
)
return self.env["group.order"].create(
{
"name": "Payment Group Order",
"group_ids": [(6, 0, [self.consumer_group.id])],
"period": "once",
"pickup_day": "2", # Wednesday
"state": "open",
"end_date": end_date,
"online_payment": online_payment,
}
)
def _create_sale_order(self, group_order, partner=None, pickup_date=None):
return self.env["sale.order"].create(
{
"partner_id": (partner or self.member).id,
"group_order_id": group_order.id,
"consumer_group_id": self.consumer_group.id,
"pickup_date": pickup_date or group_order.pickup_date,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom_qty": 1,
"price_unit": 10.0,
},
)
],
}
)
def _create_done_transaction(self, sale_order):
"""A `done` transaction covering the order's full amount.
`payment.method` records ship archived until a provider module is
installed, so the lookup has to ignore the active flag: the test only
needs a well-formed transaction, not a usable payment route.
"""
provider = self.env["payment.provider"].search([], limit=1)
method = (
self.env["payment.method"]
.with_context(active_test=False)
.search([("primary_payment_method_id", "=", False)], limit=1)
)
transaction = self.env["payment.transaction"].create(
{
"provider_id": provider.id,
"payment_method_id": method.id,
"reference": f"TEST-{sale_order.id}",
"amount": sale_order.amount_total,
"currency_id": sale_order.currency_id.id,
"partner_id": sale_order.partner_id.id,
"sale_order_ids": [(6, 0, sale_order.ids)],
}
)
transaction.write({"state": "done"})
return transaction
# === Payment policy on the sale order ===
def test_require_payment_follows_group_order(self):
"""A group order with online payment makes its orders payable."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
self.assertTrue(sale_order.require_payment)
self.assertEqual(sale_order.prepayment_percent, 1.0)
self.assertTrue(
sale_order._has_to_be_paid(),
"A draft order of a paying cycle must be payable",
)
def test_require_payment_off_without_online_payment(self):
"""Without the flag nothing changes: no payment is required."""
group_order = self._create_group_order(online_payment=False)
sale_order = self._create_sale_order(group_order)
self.assertFalse(sale_order.require_payment)
self.assertFalse(sale_order._has_to_be_paid())
def test_toggling_group_order_clears_require_payment(self):
"""Turning the flag off mid-cycle must free the existing drafts."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
self.assertTrue(sale_order.require_payment)
group_order.online_payment = False
sale_order.invalidate_recordset()
self.assertFalse(
sale_order.require_payment,
"An existing draft must stop requiring payment when the group "
"order stops offering it",
)
def test_non_group_orders_keep_company_default(self):
"""Orders outside a group order are left alone."""
plain_order = self.env["sale.order"].create({"partner_id": self.member.id})
self.assertEqual(
plain_order.require_payment,
plain_order.company_id.portal_confirmation_pay,
)
# === Confirmation through payment ===
def test_payment_confirms_with_from_orderpoint(self):
"""Paying must confirm the way the cutoff cron does.
Without `from_orderpoint`, a product with a broken replenishment route
raises during post-processing, `/payment/status/poll` rolls the whole
thing back and the member sees an error over a `done` transaction.
"""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
transaction = self._create_done_transaction(sale_order)
captured = {}
def _fake_confirm(order_self):
captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint")
return True
with patch.object(
type(self.env["sale.order"]), "action_confirm", _fake_confirm
):
transaction._check_amount_and_confirm_order()
self.assertTrue(
captured.get("from_orderpoint"),
"Group order confirmations triggered by payment must carry "
"from_orderpoint=True",
)
def test_payment_confirms_the_order(self):
"""The standard machinery confirms the order once paid."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
transaction = self._create_done_transaction(sale_order)
transaction._check_amount_and_confirm_order()
sale_order.invalidate_recordset()
self.assertEqual(sale_order.state, "sale")
def test_plain_orders_confirm_without_from_orderpoint(self):
"""Orders unrelated to a group order keep the core behaviour."""
plain_order = self.env["sale.order"].create(
{
"partner_id": self.member.id,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom_qty": 1,
"price_unit": 10.0,
},
)
],
}
)
transaction = self._create_done_transaction(plain_order)
captured = {}
def _fake_confirm(order_self):
captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint")
return True
with patch.object(
type(self.env["sale.order"]), "action_confirm", _fake_confirm
):
transaction._check_amount_and_confirm_order()
self.assertFalse(captured.get("from_orderpoint"))
# === Cycle lookup helpers ===
def test_draft_lookup_ignores_placed_orders(self):
"""`_find_recent_draft_order` must never return a placed order.
`/eskaera/clear-cart` cancels whatever this returns, so widening it
would cancel orders that are already paid for.
"""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
found = validators._find_recent_draft_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertFalse(found)
def test_placed_lookup_finds_confirmed_order(self):
"""The duplicate guard sees the member's confirmed order."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
found = validators._find_placed_cycle_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertEqual(found, sale_order)
def test_placed_lookup_survives_orders_created_after_cutoff(self):
"""Nothing blocks ordering between the cutoff and the cron run.
The draft window caps `create_date` at the cutoff date; the placed
lookup must not, or an order paid in that gap would slip past the
guard and let the member order twice.
"""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
found = validators._find_placed_cycle_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertEqual(found, sale_order)
def test_placed_lookup_ignores_other_cycles(self):
"""An order frozen on another pickup date belongs to another cycle."""
group_order = self._create_group_order(online_payment=True)
sale_order = self._create_sale_order(
group_order, pickup_date=group_order.pickup_date - timedelta(days=7)
)
sale_order.action_confirm()
found = validators._find_placed_cycle_order(
None, self.member.id, group_order, request_obj=self.fake_request
)
self.assertFalse(found)
# === Batching at cutoff ===
def test_cron_batches_a_fully_prepaid_cycle(self):
"""A cycle where everybody paid early still gets its batch.
The confirmation loop used to bail out when it found no draft, which
with online payment is the normal case: every order is confirmed the
moment its transaction completes.
"""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
self.assertFalse(
self.env["sale.order"].search(
[("group_order_id", "=", group_order.id), ("state", "=", "draft")]
),
"This cycle must have no drafts left for the test to mean anything",
)
group_order._confirm_linked_sale_orders()
self.assertTrue(
sale_order.picking_ids.batch_id,
"The picking of an order paid before the cutoff must still be "
"batched by the cron",
)
def test_cron_batches_paid_and_draft_orders_together(self):
"""Paid and cron-confirmed orders share one batch per picking type."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
paid_order = self._create_sale_order(group_order, partner=self.member)
paid_order.action_confirm()
draft_order = self._create_sale_order(group_order, partner=self.other_member)
group_order._confirm_linked_sale_orders()
draft_order.invalidate_recordset()
self.assertEqual(draft_order.state, "sale")
batches = paid_order.picking_ids.batch_id | draft_order.picking_ids.batch_id
self.assertEqual(
len(batches),
1,
"Both orders belong to the same cycle and picking type, so they "
"must land in a single batch",
)
def test_cron_ignores_paid_orders_of_previous_cycles(self):
"""A previous cycle's order must not be swept into this batch."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
stale_order = self._create_sale_order(
group_order, pickup_date=group_order.pickup_date - timedelta(days=7)
)
stale_order.action_confirm()
stale_order.picking_ids.batch_id = False
group_order._confirm_linked_sale_orders()
self.assertFalse(
stale_order.picking_ids.batch_id,
"An order frozen on a previous pickup date is not part of this "
"cycle and must be left out of its batch",
)
def test_closed_cycle_still_batches_paid_orders(self):
"""Closing a group order by hand must not strand a paid order."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
sale_order = self._create_sale_order(group_order)
sale_order.action_confirm()
group_order.action_close()
self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles()
self.assertTrue(
sale_order.picking_ids.batch_id,
"A paid order of a manually closed cycle must still be batched",
)
def test_closed_cycle_leaves_drafts_alone(self):
"""Closing a cycle by hand is how a co-op calls it off."""
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
draft_order = self._create_sale_order(group_order)
group_order.action_close()
self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles()
draft_order.invalidate_recordset()
self.assertEqual(
draft_order.state,
"draft",
"The closed-cycle sweep must only batch, never confirm",
)
@tagged("post_install", "-at_install", "eskaera_online_payment")
class TestOnlinePaymentRoutes(HttpCase):
"""The payment step and its landing page, over HTTP."""
def setUp(self):
super().setUp()
self.group = self.env["res.partner"].create(
{
"name": "Payment Routes Group",
"is_company": True,
"is_group": True,
"email": "payment-routes-group@test.com",
}
)
self.member_partner = self.env["res.partner"].create(
{"name": "Payment Routes Member", "email": "payment-routes@test.com"}
)
self.group.member_ids = [(4, self.member_partner.id)]
login = "portal.payment@test.com"
self.portal_user = self.env["res.users"].create(
{
"name": "Portal Payment User",
"login": login,
"password": login,
"partner_id": self.member_partner.id,
"groups_id": [(4, self.env.ref("base.group_portal").id)],
}
)
self.product = self.env["product.product"].create(
{"name": "Route Product", "is_storable": True, "list_price": 10.0}
)
start_date = fields.Date.today()
self.group_order = self.env["group.order"].create(
{
"name": "Payment Routes Order",
"group_ids": [(6, 0, [self.group.id])],
"type": "regular",
"start_date": start_date,
"end_date": start_date + timedelta(days=7),
"period": "weekly",
"pickup_day": "3",
"cutoff_day": "0",
"online_payment": True,
}
)
self.group_order.action_open()
def _create_draft(self):
return self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"consumer_group_id": self.group.id,
"pickup_date": self.group_order.pickup_date,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom_qty": 1,
"price_unit": 10.0,
},
)
],
}
)
def _slug_url(self, suffix=""):
return f"/eskaera/{self.group_order.slug}{suffix}"
def test_payment_page_renders(self):
"""The payment step renders for a member with a draft in the cycle."""
self._create_draft()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/payment"), allow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(
'data-name="Eskaera Payment"',
response.text,
"The payment step should render its own page, not redirect away",
)
def test_payment_page_needs_a_draft(self):
"""With nothing in the cart there is nothing to pay for."""
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["Location"].endswith("/checkout"))
def test_payment_page_off_without_online_payment(self):
"""The step does not exist for a group order that takes no payments."""
self.group_order.online_payment = False
self._create_draft()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["Location"].endswith("/checkout"))
def test_checkout_offers_payment(self):
"""The checkout button turns into the 'confirm and pay' variant.
Asserted on `data-tooltip-key` rather than the label: the website runs
in whatever language the visitor picked, and the label is translated.
"""
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn('data-tooltip-key="confirm_and_pay"', response.text)
def test_checkout_keeps_save_draft_without_online_payment(self):
"""With the flag off the checkout is exactly what it was."""
self.group_order.online_payment = False
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn('data-tooltip-key="save_draft"', response.text)
self.assertNotIn('data-tooltip-key="confirm_and_pay"', response.text)
def test_confirmation_page_renders_for_the_owner(self):
"""The landing page reports the order back to the member."""
order = self._create_draft()
order.action_confirm()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(
self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=True
)
self.assertEqual(response.status_code, 200)
self.assertIn(order.name, response.text)
def test_confirmation_page_rejects_other_partners(self):
"""Nobody gets to read someone else's order through this page."""
other_partner = self.env["res.partner"].create({"name": "Somebody Else"})
order = self._create_draft()
order.partner_id = other_partner
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(
self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=False
)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["Location"].endswith("/eskaera"))
def test_checkout_redirects_once_the_order_is_placed(self):
"""A member who already paid cannot build a second order."""
order = self._create_draft()
order.action_confirm()
self.authenticate(self.portal_user.login, self.portal_user.login)
response = self.url_open(self._slug_url("/checkout"), allow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"])

View file

@ -18,6 +18,7 @@
<field name="end_date" optional="show"/>
<field name="home_delivery" optional="hide"/>
<field name="delivery_product_id" optional="hide"/>
<field name="online_payment" optional="hide"/>
<field name="state" optional="show"/>
</list>
</field>
@ -84,6 +85,15 @@
<field name="delivery_product_id" help="Product to use for home delivery. Setting this enables home delivery."/>
<field name="delivery_notice" placeholder="Information about home delivery..." nolabel="1"/>
</page>
<page string="Online Payment" name="online_payment">
<field name="online_payment"/>
<div class="text-muted" invisible="not online_payment">
Members must pay online to place their order in this cycle. The
available methods come from the payment providers published on the
website (Settings &gt; Payment Providers); this order does not
configure any of them.
</div>
</page>
<page string="Product Catalog">
<group string="Included Products" col="2">
<field name="supplier_ids" widget="many2many_tags" help="All products from these suppliers will be included"/>

View file

@ -214,6 +214,18 @@
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'eskaera-order-header'" />
</t>
<!-- Paying confirms the order, so no draft is left to
reuse and nothing else would stop a second, also
payable, order for the same cycle. -->
<t t-if="placed_order">
<div class="alert alert-info d-flex flex-column flex-md-row gap-2 align-items-md-center" role="alert">
<div class="flex-grow-1">
<strong>You already placed an order for this cycle.</strong>
<span t-esc="placed_order.name" />
</div>
<a t-att-href="placed_order_url" class="btn btn-sm btn-outline-primary">View my order</a>
</div>
</t>
<div class="eskaera-order-header">
<!-- Name/value pairs: a description list, so assistive tech
announces each value with its label. The 4 → 2 → 1 column
@ -560,10 +572,13 @@
</div>
</div>
</t>
<t t-if="online_payment and not payment_available">
<div class="alert alert-warning" role="alert" t-esc="no_payment_method_message" />
</t>
<div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="labels.get('order_saved_as_draft', 'Order saved as draft')" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="labels.get('save_order_as_draft', 'Save order as draft')" t-att-data-bs-title="labels.get('save_draft', 'Save Draft')" data-bs-toggle="tooltip">
<i class="fa fa-save" aria-hidden="true" t-translation="off" />
<span t-esc="labels.get('save_draft', 'Save Draft')" />
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="checkout_button['done_label']" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="checkout_button['hint']" t-att-data-bs-title="checkout_button['label']" t-att-data-tooltip-key="checkout_button['tooltip_key']" t-att-disabled="online_payment and not payment_available" data-bs-toggle="tooltip">
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
<span t-esc="checkout_button['label']" />
</button>
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
@ -618,6 +633,172 @@
</div>
</t>
</template>
<template id="eskaera_order_lines_summary" name="Placed Order Summary">
<!-- Server-side twin of eskaera_checkout_summary: from the payment
step on, the amounts shown must be the ones that will be
charged, so they come from the sale.order and not from the
localStorage cart. -->
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
<table class="table table-hover checkout-summary-table">
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
<thead class="table-dark">
<tr>
<th scope="col" class="col-name">Product</th>
<th scope="col" class="col-qty text-center">Quantity</th>
<th scope="col" class="col-subtotal text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="sale_order.order_line" t-as="line">
<td class="col-name" t-esc="line.name" />
<td class="col-qty text-center" t-esc="line.product_uom_qty" />
<td class="col-subtotal text-end">
<span t-field="line.price_total" t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}" />
</td>
</tr>
</tbody>
</table>
<div class="checkout-total-section">
<div class="total-row">
<span class="total-label">Total</span>:
<span class="total-amount">
<span t-field="sale_order.amount_total" t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}" />
</span>
</div>
</div>
</div>
</template>
<template id="eskaera_payment" name="Eskaera Payment">
<t t-call="website.layout">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment">
<div class="container mt-5">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<div class="mb-4">
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'checkout-header'" />
<t t-set="header_title">Pay Order: <t t-esc="group_order.name" />
</t>
</t>
</div>
<h4 class="summary-heading mb-3">Order Summary</h4>
<div class="mb-5">
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
</div>
<t t-if="pending_transaction">
<div class="alert alert-info" role="alert">
<h5 class="alert-heading">
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
<span>Payment in progress</span>
</h5>
<p class="mb-1">We are still waiting for your payment to be confirmed. Please do not pay again.</p>
<p class="mb-0">
<span>Reference</span>:
<span t-esc="pending_transaction.reference" />
</p>
</div>
<div class="checkout-actions d-grid gap-3">
<a t-att-href="sale_order.get_portal_url()" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
<span>View my order</span>
</a>
</div>
</t>
<t t-else="">
<h4 class="summary-heading mb-3">Payment Method</h4>
<!-- The whole provider UI is payment.form; this addon
configures no provider of its own. -->
<div id="payment_method" class="o_not_editable mb-4">
<t t-call="payment.form" />
</div>
<!-- Deliberately outside #o_payment_form: website_sale's
payment_form.js binds every
[name="o_payment_submit_button"] in the document, on
top of payment_form.js's own delegated handler inside
the form. A button inside would get both listeners and
one click would open two transactions. -->
<div class="checkout-actions d-grid gap-3">
<t t-call="payment.submit_button" />
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Checkout</span>
</a>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="eskaera_payment_confirmation" name="Eskaera Payment Confirmation">
<t t-call="website.layout">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment Confirmation" t-attf-data-clear-cart-order-id="{{ group_order.id }}">
<div class="container mt-5">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<div class="mb-4">
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'checkout-header'" />
<t t-set="header_title">Order Confirmed: <t t-esc="group_order.name" />
</t>
</t>
</div>
<!-- The browser lands here from /payment/status once
post-processing has run, but that is asynchronous, so
the page reports the order's actual state instead of
assuming it was confirmed. -->
<t t-if="is_paid">
<div class="alert alert-success" role="alert">
<h5 class="alert-heading">
<i class="fa fa-check-circle me-2" aria-hidden="true" t-translation="off" />
<span>Thank you, your order is confirmed</span>
</h5>
<p class="mb-0">
<span>Order</span>
<span t-esc="sale_order.name" />
</p>
</div>
</t>
<t t-else="">
<div class="alert alert-info" role="alert">
<h5 class="alert-heading">
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
<span>Your payment is being processed</span>
</h5>
<p class="mb-0">Your order will be confirmed as soon as we receive the payment. You can follow it from your orders page.</p>
</div>
</t>
<t t-if="sale_order.pickup_slot_label">
<div class="order-info-card card border-0 shadow-sm mb-4">
<div class="card-body">
<dl class="info-pair mb-0">
<dt class="info-label">Pickup</dt>
<dd class="info-value" t-esc="sale_order.pickup_slot_label" />
</dl>
</div>
</div>
</t>
<h4 class="summary-heading mb-3">Order Summary</h4>
<div class="mb-5">
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
</div>
<div class="checkout-actions d-grid gap-3">
<a t-att-href="sale_order.get_portal_url()" class="btn btn-primary btn-lg">
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
<span>View my order</span>
</a>
<a href="/eskaera" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Orders</span>
</a>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="category_hierarchy_options" name="Category Hierarchy Options">
<t t-foreach="categories" t-as="cat">
<t t-set="padding_px" t-value="depth * 20" />