[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
|
{ # noqa: B018
|
||||||
"name": "Website Sale - Aplicoop",
|
"name": "Website Sale - Aplicoop",
|
||||||
"version": "18.0.1.13.0",
|
"version": "18.0.1.14.0",
|
||||||
"category": "Website/Sale",
|
"category": "Website/Sale",
|
||||||
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
|
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
|
||||||
"author": "Odoo Community Association (OCA), Criptomart",
|
"author": "Odoo Community Association (OCA), Criptomart",
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"depends": [
|
"depends": [
|
||||||
"website_sale",
|
"website_sale",
|
||||||
"website_sale_stock",
|
"website_sale_stock",
|
||||||
|
"payment",
|
||||||
"product",
|
"product",
|
||||||
"sale",
|
"sale",
|
||||||
"stock",
|
"stock",
|
||||||
|
|
@ -77,6 +78,7 @@
|
||||||
"website_sale_aplicoop/static/src/js/checkout_labels.js",
|
"website_sale_aplicoop/static/src/js/checkout_labels.js",
|
||||||
"website_sale_aplicoop/static/src/js/home_delivery.js",
|
"website_sale_aplicoop/static/src/js/home_delivery.js",
|
||||||
"website_sale_aplicoop/static/src/js/checkout_summary.js",
|
"website_sale_aplicoop/static/src/js/checkout_summary.js",
|
||||||
|
"website_sale_aplicoop/static/src/js/eskaera_payment.js",
|
||||||
# Search and pagination
|
# Search and pagination
|
||||||
"website_sale_aplicoop/static/src/js/infinite_scroll.js",
|
"website_sale_aplicoop/static/src/js/infinite_scroll.js",
|
||||||
"website_sale_aplicoop/static/src/js/realtime_search.js",
|
"website_sale_aplicoop/static/src/js/realtime_search.js",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from odoo import fields
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request
|
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 odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||||
|
|
||||||
from . import website_sale_i18n as _i18n
|
from . import website_sale_i18n as _i18n
|
||||||
|
|
@ -649,13 +650,27 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
"home_delivery": effective_home_delivery,
|
"home_delivery": effective_home_delivery,
|
||||||
"consumer_group_id": consumer_group_id,
|
"consumer_group_id": consumer_group_id,
|
||||||
"commitment_date": commitment_date,
|
"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)
|
# Get salesperson for order creation (portal users need this)
|
||||||
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
||||||
if salesperson:
|
if salesperson:
|
||||||
order_vals["user_id"] = salesperson.id
|
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
|
return sale_order
|
||||||
|
|
||||||
def _decode_json_body(self):
|
def _decode_json_body(self):
|
||||||
|
|
@ -678,6 +693,44 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
self, group_order, status=status
|
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):
|
def _validate_items_for_group_order(self, items, group_order):
|
||||||
"""Delegate availability validation to validators helper."""
|
"""Delegate availability validation to validators helper."""
|
||||||
return _validators._validate_items_for_group_order(
|
return _validators._validate_items_for_group_order(
|
||||||
|
|
@ -704,6 +757,20 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
request,
|
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):
|
def _get_group_order_by_slug(self, group_order_slug):
|
||||||
"""Return the consumer group order published under `group_order_slug`."""
|
"""Return the consumer group order published under `group_order_slug`."""
|
||||||
return (
|
return (
|
||||||
|
|
@ -876,10 +943,26 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
# Get translated labels for JavaScript (same as checkout)
|
# Get translated labels for JavaScript (same as checkout)
|
||||||
labels = self.get_checkout_labels()
|
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(
|
return request.render(
|
||||||
"website_sale_aplicoop.eskaera_shop",
|
"website_sale_aplicoop.eskaera_shop",
|
||||||
{
|
{
|
||||||
"group_order": group_order,
|
"group_order": group_order,
|
||||||
|
"placed_order": placed_order,
|
||||||
|
"placed_order_url": placed_order_url,
|
||||||
"products": products,
|
"products": products,
|
||||||
"filtered_product_tags": filtered_products_dict,
|
"filtered_product_tags": filtered_products_dict,
|
||||||
"any_product_has_tags": any_product_has_tags,
|
"any_product_has_tags": any_product_has_tags,
|
||||||
|
|
@ -1209,6 +1292,11 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
[("Content-Type", "application/json")],
|
[("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)
|
# Validate that the product is available in this order (use discovery logic)
|
||||||
available_products = group_order._get_products_for_group_order(
|
available_products = group_order._get_products_for_group_order(
|
||||||
group_order.id
|
group_order.id
|
||||||
|
|
@ -1336,6 +1424,20 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
if group_order.state != "open":
|
if group_order.state != "open":
|
||||||
return request.redirect("/eskaera")
|
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
|
# Los datos del carrito vienen desde localStorage en el frontend
|
||||||
# Esta página solo muestra resumen y botón de confirmación
|
# 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
|
# Convert to JSON string for safe embedding in script tag
|
||||||
labels_json = json.dumps(labels, ensure_ascii=False)
|
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
|
# Prepare template context with explicit debug info
|
||||||
template_context = {
|
template_context = {
|
||||||
"group_order": group_order,
|
"group_order": group_order,
|
||||||
|
|
@ -1397,6 +1529,14 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
),
|
),
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"labels_json": labels_json,
|
"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()))
|
_logger.warning("Template context keys: %s", list(template_context.keys()))
|
||||||
|
|
@ -1405,6 +1545,178 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
"website_sale_aplicoop.eskaera_checkout", template_context
|
"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(
|
@http.route(
|
||||||
["/eskaera/check-status"],
|
["/eskaera/check-status"],
|
||||||
type="http",
|
type="http",
|
||||||
|
|
@ -1682,6 +1994,11 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
status=400,
|
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
|
# Find the most recent draft sale.order for this partner in active period
|
||||||
# The helper _find_recent_draft_order computes the period criteria itself,
|
# The helper _find_recent_draft_order computes the period criteria itself,
|
||||||
# so we only need to call it here.
|
# so we only need to call it here.
|
||||||
|
|
@ -1923,6 +2240,11 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
)
|
)
|
||||||
return self._build_group_order_unavailable_response(group_order)
|
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(
|
existing_drafts = self._find_recent_draft_order(
|
||||||
current_user.partner_id.id, group_order
|
current_user.partner_id.id, group_order
|
||||||
)
|
)
|
||||||
|
|
@ -1972,15 +2294,21 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
except Exception:
|
except Exception:
|
||||||
pickup_slot_label = None
|
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(
|
return request.make_response(
|
||||||
json.dumps(
|
json.dumps(response_data),
|
||||||
{
|
|
||||||
"success": True,
|
|
||||||
"message": request.env._("Order saved as draft"),
|
|
||||||
"sale_order_id": sale_order.id,
|
|
||||||
"pickup_slot_label": pickup_slot_label,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
[("Content-Type", "application/json")],
|
[("Content-Type", "application/json")],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,13 @@ def _get_translated_labels(self, lang=None, request_obj=None):
|
||||||
"save_draft": tr("Save Draft"),
|
"save_draft": tr("Save Draft"),
|
||||||
"save_order_as_draft": tr("Save order as draft"),
|
"save_order_as_draft": tr("Save order as draft"),
|
||||||
"order_saved_as_draft": tr("Order saved 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"),
|
"save_cart": tr("Save Cart"),
|
||||||
"reload_cart": tr("Reload Cart"),
|
"reload_cart": tr("Reload Cart"),
|
||||||
"proceed_to_checkout": tr("Proceed to Checkout"),
|
"proceed_to_checkout": tr("Proceed to Checkout"),
|
||||||
|
|
|
||||||
|
|
@ -62,26 +62,32 @@ def _get_salesperson_for_order(self, partner):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
def _find_cycle_sale_order(
|
||||||
"""Return the active-cycle draft sale.order for the partner, or empty.
|
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):
|
(neither is sufficient on its own — see the regression each one guards):
|
||||||
|
|
||||||
1) create_date falls within the active window — derived from
|
1) create_date falls within the active window — derived from
|
||||||
group_order.cutoff_date and the order period (7 days weekly, 14
|
group_order.cutoff_date and the order period (7 days weekly, 14
|
||||||
biweekly, one month monthly; one-time orders use a single cycle
|
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
|
happens to match the current one only because pickup_date froze
|
||||||
across cycles (observed in production) would be wrongly reused.
|
across cycles (observed in production) would be wrongly matched.
|
||||||
2) When group_order.pickup_date is set, the draft's pickup_date matches
|
2) When group_order.pickup_date is set, the order's pickup_date matches
|
||||||
it exactly. Without this, a draft created "now" for a stale/previous
|
it exactly. Without this, an order created "now" for a stale/previous
|
||||||
pickup_date — but still inside the current create_date window —
|
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
|
Orders failing either check belong to a previous cycle.
|
||||||
reused — otherwise stale carts come back when the user re-enters the
|
|
||||||
order page.
|
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
|
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
|
else: # once: single cycle, bounded by start_date when set
|
||||||
period_start = group_order.start_date
|
period_start = group_order.start_date
|
||||||
|
|
||||||
|
states = tuple(states)
|
||||||
domain = [
|
domain = [
|
||||||
("partner_id", "=", partner_id),
|
("partner_id", "=", partner_id),
|
||||||
("group_order_id", "=", group_order.id),
|
("group_order_id", "=", group_order.id),
|
||||||
("state", "=", "draft"),
|
("state", "in", list(states)),
|
||||||
("create_date", "<=", f"{period_end} 23:59:59"),
|
|
||||||
]
|
]
|
||||||
|
if states == ("draft",):
|
||||||
|
domain.append(("create_date", "<=", f"{period_end} 23:59:59"))
|
||||||
if period_start:
|
if period_start:
|
||||||
domain.append(("create_date", ">=", f"{period_start} 00:00:00"))
|
domain.append(("create_date", ">=", f"{period_start} 00:00:00"))
|
||||||
if group_order.pickup_date:
|
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):
|
def _validate_confirm_request(self, data, request_obj=None):
|
||||||
req = request_obj or request
|
req = request_obj or request
|
||||||
order_id = data.get("order_id")
|
order_id = data.get("order_id")
|
||||||
|
|
|
||||||
|
|
@ -2362,3 +2362,169 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
||||||
"hidden=\"true\"/> Lliurament a domicili</span>"
|
"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 ""
|
msgstr ""
|
||||||
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
||||||
"hidden=\"true\"/> Entrega a Casa</span>"
|
"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 ""
|
msgstr ""
|
||||||
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
||||||
"hidden=\"true\"/> Etxerako Entrega</span>"
|
"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 # noqa: F401
|
||||||
from . import group_order_slot # 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_category_extension # noqa: F401
|
||||||
from . import product_extension # noqa: F401
|
from . import product_extension # noqa: F401
|
||||||
from . import res_config_settings # noqa: F401
|
from . import res_config_settings # noqa: F401
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from odoo import api
|
from odoo import api
|
||||||
from odoo import fields
|
from odoo import fields
|
||||||
from odoo import models
|
from odoo import models
|
||||||
|
|
@ -188,6 +187,16 @@ class GroupOrder(models.Model):
|
||||||
help="Calculated delivery date (pickup date + 1 day)",
|
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 ===
|
# === Computed date fields ===
|
||||||
pickup_date = fields.Date(
|
pickup_date = fields.Date(
|
||||||
compute="_compute_pickup_date",
|
compute="_compute_pickup_date",
|
||||||
|
|
@ -770,8 +779,7 @@ class GroupOrder(models.Model):
|
||||||
- If no slots are configured, leave fields empty (fallback handled
|
- If no slots are configured, leave fields empty (fallback handled
|
||||||
by existing pickup_day logic).
|
by existing pickup_day logic).
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime, time
|
||||||
from datetime import time
|
|
||||||
|
|
||||||
for record in self:
|
for record in self:
|
||||||
record.next_pickup_slot_id = False
|
record.next_pickup_slot_id = False
|
||||||
|
|
@ -1106,6 +1114,69 @@ class GroupOrder(models.Model):
|
||||||
failed_orders,
|
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):
|
def _close_one_time_order_if_ended(self):
|
||||||
"""Close one-time orders once their end_date has passed.
|
"""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(
|
_logger.info(
|
||||||
"Cron: No sale orders to confirm for group order %s (%s)",
|
"Cron: No sale orders to confirm for group order %s (%s)",
|
||||||
self.id,
|
self.id,
|
||||||
|
|
@ -1188,10 +1277,12 @@ class GroupOrder(models.Model):
|
||||||
return
|
return
|
||||||
|
|
||||||
_logger.info(
|
_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),
|
len(sale_orders),
|
||||||
self.id,
|
self.id,
|
||||||
self.name,
|
self.name,
|
||||||
|
len(already_confirmed),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1264,13 +1355,33 @@ class GroupOrder(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
batches = self.env["stock.picking.batch"]
|
batches = self.env["stock.picking.batch"]
|
||||||
if confirmed_sale_orders:
|
# One call with both sets: _create_picking_batches_for_sale_orders
|
||||||
# Create picking batches only for confirmed 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(
|
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)
|
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:
|
if failed_sale_orders:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "
|
"Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "
|
||||||
|
|
@ -1309,8 +1420,7 @@ class GroupOrder(models.Model):
|
||||||
return
|
return
|
||||||
|
|
||||||
failure_reasons = failure_reasons or {}
|
failure_reasons = failure_reasons or {}
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup, escape
|
||||||
from markupsafe import escape
|
|
||||||
|
|
||||||
items = Markup()
|
items = Markup()
|
||||||
for sale_order in failed_sale_orders:
|
for sale_order in failed_sale_orders:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,15 @@ def _register_translations():
|
||||||
_("Load Draft")
|
_("Load Draft")
|
||||||
_("Browse Product Categories")
|
_("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
|
# 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",
|
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(
|
@api.depends(
|
||||||
"group_order_id",
|
"group_order_id",
|
||||||
"group_order_id.next_pickup_slot_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
|
templates and redirects the old URLs. The AJAX endpoints
|
||||||
(``/eskaera/labels``, ``/eskaera/save-order``…) are never shown in the
|
(``/eskaera/labels``, ``/eskaera/save-order``…) are never shown in the
|
||||||
address bar and do not need a rule.
|
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)
|
* **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
|
* **Email Notifications**: Automatic notifications on order state changes
|
||||||
* **Financial Tracking**: Track orders and payments per group member
|
* **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
|
* **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
|
* **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
|
* **Confirmed**: Order open for shopping
|
||||||
* **Collected**: Orders received from supplier
|
* **Collected**: Orders received from supplier
|
||||||
* **Completed**: All members have picked up their orders
|
* **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) {
|
_checkGroupOrderStatus: function (callback) {
|
||||||
var self = this;
|
var self = this;
|
||||||
var done = function () {
|
var done = function () {
|
||||||
|
|
@ -783,8 +815,18 @@
|
||||||
var tooltipText = null;
|
var tooltipText = null;
|
||||||
var labelKey = 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
|
// Check ID-based mapping
|
||||||
if (element.id && tooltipMap[element.id]) {
|
if (!tooltipText && element.id && tooltipMap[element.id]) {
|
||||||
labelKey = tooltipMap[element.id];
|
labelKey = tooltipMap[element.id];
|
||||||
tooltipText = labels[labelKey];
|
tooltipText = labels[labelKey];
|
||||||
}
|
}
|
||||||
|
|
@ -1790,6 +1832,9 @@
|
||||||
self._updateCartDisplay();
|
self._updateCartDisplay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (self._handleAlreadyPlacedResponse(xhr)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
var errorData = JSON.parse(xhr.responseText);
|
var errorData = JSON.parse(xhr.responseText);
|
||||||
self._showNotification(
|
self._showNotification(
|
||||||
|
|
@ -1914,6 +1959,9 @@
|
||||||
self._updateCartDisplay();
|
self._updateCartDisplay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (self._handleAlreadyPlacedResponse(xhr)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
var errorData = JSON.parse(xhr.responseText);
|
var errorData = JSON.parse(xhr.responseText);
|
||||||
self._showNotification(
|
self._showNotification(
|
||||||
|
|
@ -2036,10 +2084,17 @@
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
var successMsg =
|
var successMsg =
|
||||||
|
data.message ||
|
||||||
labels.draft_saved_success ||
|
labels.draft_saved_success ||
|
||||||
labels.draft_saved ||
|
labels.draft_saved ||
|
||||||
"Order saved as draft successfully";
|
"Order saved as draft successfully";
|
||||||
self._showNotification("\u2713 " + successMsg, "success", 5000);
|
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 {
|
} else {
|
||||||
self._showNotification(
|
self._showNotification(
|
||||||
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
|
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
|
||||||
|
|
@ -2056,6 +2111,9 @@
|
||||||
self._updateCartDisplay();
|
self._updateCartDisplay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (self._handleAlreadyPlacedResponse(xhr)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
var errorData = JSON.parse(xhr.responseText);
|
var errorData = JSON.parse(xhr.responseText);
|
||||||
console.error("HTTP error:", xhr.status, errorData);
|
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_group_order_status_endpoint # noqa: F401
|
||||||
from . import test_home_delivery # noqa: F401
|
from . import test_home_delivery # noqa: F401
|
||||||
from . import test_forecasted_stock # 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="end_date" optional="show"/>
|
||||||
<field name="home_delivery" optional="hide"/>
|
<field name="home_delivery" optional="hide"/>
|
||||||
<field name="delivery_product_id" optional="hide"/>
|
<field name="delivery_product_id" optional="hide"/>
|
||||||
|
<field name="online_payment" optional="hide"/>
|
||||||
<field name="state" optional="show"/>
|
<field name="state" optional="show"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</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_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"/>
|
<field name="delivery_notice" placeholder="Information about home delivery..." nolabel="1"/>
|
||||||
</page>
|
</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">
|
<page string="Product Catalog">
|
||||||
<group string="Included Products" col="2">
|
<group string="Included Products" col="2">
|
||||||
<field name="supplier_ids" widget="many2many_tags" help="All products from these suppliers will be included"/>
|
<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-call="website_sale_aplicoop.order_header">
|
||||||
<t t-set="header_class" t-value="'eskaera-order-header'" />
|
<t t-set="header_class" t-value="'eskaera-order-header'" />
|
||||||
</t>
|
</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">
|
<div class="eskaera-order-header">
|
||||||
<!-- Name/value pairs: a description list, so assistive tech
|
<!-- Name/value pairs: a description list, so assistive tech
|
||||||
announces each value with its label. The 4 → 2 → 1 column
|
announces each value with its label. The 4 → 2 → 1 column
|
||||||
|
|
@ -560,10 +572,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</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">
|
<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">
|
<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 class="fa fa-save" aria-hidden="true" t-translation="off" />
|
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
|
||||||
<span t-esc="labels.get('save_draft', 'Save Draft')" />
|
<span t-esc="checkout_button['label']" />
|
||||||
</button>
|
</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">
|
<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" />
|
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||||
|
|
@ -618,6 +633,172 @@
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</template>
|
</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">
|
<template id="category_hierarchy_options" name="Category Hierarchy Options">
|
||||||
<t t-foreach="categories" t-as="cat">
|
<t t-foreach="categories" t-as="cat">
|
||||||
<t t-set="padding_px" t-value="depth * 20" />
|
<t t-set="padding_px" t-value="depth * 20" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue