[ADD] website_sale_aplicoop: online payment per group order

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,35 @@
/*
* Copyright 2026 Criptomart
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
*
* Frees the localStorage cart once the order has been placed and paid.
*/
(function () {
"use strict";
document.addEventListener("DOMContentLoaded", function () {
var page = document.querySelector("[data-clear-cart-order-id]");
if (!page) {
return;
}
var orderId = page.getAttribute("data-clear-cart-order-id");
if (!orderId) {
return;
}
// Client side only. /eskaera/clear-cart would also cancel the sale
// order, which is exactly the wrong thing to do to an order that was
// just paid for.
try {
localStorage.removeItem("eskaera_" + orderId + "_cart");
localStorage.removeItem("eskaera_" + orderId + "_cart_cycle");
} catch (e) {
// localStorage unavailable (private mode, quota). The cart is
// server-side irrelevant at this point; the duplicate-order guard
// is what actually protects the member.
console.warn("[ESKAERA PAYMENT] Could not clear the local cart:", e);
}
});
})();

View file

@ -592,6 +592,38 @@
}
},
// The member already has a placed order for this cycle (409). Their
// local cart is stale, so drop it and send them to that order rather
// than let them build — and pay for — a duplicate.
_handleAlreadyPlacedResponse: function (xhr) {
if (!xhr || xhr.status !== 409) {
return false;
}
var data;
try {
data = JSON.parse(xhr.responseText || "{}");
} catch (e) {
return false;
}
if (!data.already_placed) {
return false;
}
var labels = this._getLabels();
this._clearCurrentOrderCartSilently();
this._updateCartDisplay();
this._showNotification(
data.error || labels.already_placed || "You already placed an order.",
"warning",
6000
);
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
return true;
},
_checkGroupOrderStatus: function (callback) {
var self = this;
var done = function () {
@ -783,8 +815,18 @@
var tooltipText = null;
var labelKey = null;
// An explicit key on the element wins over the static map: the
// checkout button carries a different label depending on
// whether the group order takes online payments, and the
// server is the one that knows.
var declaredKey = element.getAttribute("data-tooltip-key");
if (declaredKey && labels[declaredKey]) {
labelKey = declaredKey;
tooltipText = labels[declaredKey];
}
// Check ID-based mapping
if (element.id && tooltipMap[element.id]) {
if (!tooltipText && element.id && tooltipMap[element.id]) {
labelKey = tooltipMap[element.id];
tooltipText = labels[labelKey];
}
@ -1790,6 +1832,9 @@
self._updateCartDisplay();
return;
}
if (self._handleAlreadyPlacedResponse(xhr)) {
return;
}
try {
var errorData = JSON.parse(xhr.responseText);
self._showNotification(
@ -1914,6 +1959,9 @@
self._updateCartDisplay();
return;
}
if (self._handleAlreadyPlacedResponse(xhr)) {
return;
}
try {
var errorData = JSON.parse(xhr.responseText);
self._showNotification(
@ -2036,10 +2084,17 @@
if (data.success) {
var successMsg =
data.message ||
labels.draft_saved_success ||
labels.draft_saved ||
"Order saved as draft successfully";
self._showNotification("\u2713 " + successMsg, "success", 5000);
// With online payment on, the server answers with the
// payment step URL: saving the cart is only half of
// placing the order.
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} else {
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
@ -2056,6 +2111,9 @@
self._updateCartDisplay();
return;
}
if (self._handleAlreadyPlacedResponse(xhr)) {
return;
}
try {
var errorData = JSON.parse(xhr.responseText);
console.error("HTTP error:", xhr.status, errorData);