[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:
parent
a67181ab42
commit
6ba554c91b
21 changed files with 1993 additions and 37 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 > 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."
|
||||
|
|
|
|||
|
|
@ -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 > 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."
|
||||
|
|
|
|||
|
|
@ -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 > 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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ========================
|
||||
|
|
|
|||
50
website_sale_aplicoop/models/payment_transaction.py
Normal file
50
website_sale_aplicoop/models/payment_transaction.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
35
website_sale_aplicoop/static/src/js/eskaera_payment.js
Normal file
35
website_sale_aplicoop/static/src/js/eskaera_payment.js
Normal 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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
568
website_sale_aplicoop/tests/test_online_payment.py
Normal file
568
website_sale_aplicoop/tests/test_online_payment.py
Normal 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"])
|
||||
|
|
@ -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 > 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"/>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue