[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

@ -62,26 +62,32 @@ def _get_salesperson_for_order(self, partner):
return False
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
"""Return the active-cycle draft sale.order for the partner, or empty.
def _find_cycle_sale_order(
self, partner_id, group_order, states=("draft",), request_obj=None
):
"""Return the partner's sale.order for the active cycle, or empty.
A draft only counts as "current cycle" when it satisfies BOTH of these
An order only counts as "current cycle" when it satisfies BOTH of these
(neither is sufficient on its own see the regression each one guards):
1) create_date falls within the active window derived from
group_order.cutoff_date and the order period (7 days weekly, 14
biweekly, one month monthly; one-time orders use a single cycle
starting at start_date). Without this, a draft whose pickup_date
starting at start_date). Without this, an order whose pickup_date
happens to match the current one only because pickup_date froze
across cycles (observed in production) would be wrongly reused.
2) When group_order.pickup_date is set, the draft's pickup_date matches
it exactly. Without this, a draft created "now" for a stale/previous
across cycles (observed in production) would be wrongly matched.
2) When group_order.pickup_date is set, the order's pickup_date matches
it exactly. Without this, an order created "now" for a stale/previous
pickup_date but still inside the current create_date window
would be wrongly reused instead of starting a fresh cart.
would be wrongly matched instead of starting a fresh cart.
Drafts failing either check belong to a previous cycle and must not be
reused otherwise stale carts come back when the user re-enters the
order page.
Orders failing either check belong to a previous cycle.
The upper bound of the create_date window only applies to drafts. Nothing
stops a member from ordering between the cutoff date and the cron run
that closes the cycle, so a *placed* order created in that gap is still
part of this cycle dropping the bound is what keeps the duplicate-order
guard from letting them order twice.
"""
req = request_obj or request
@ -102,12 +108,14 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
else: # once: single cycle, bounded by start_date when set
period_start = group_order.start_date
states = tuple(states)
domain = [
("partner_id", "=", partner_id),
("group_order_id", "=", group_order.id),
("state", "=", "draft"),
("create_date", "<=", f"{period_end} 23:59:59"),
("state", "in", list(states)),
]
if states == ("draft",):
domain.append(("create_date", "<=", f"{period_end} 23:59:59"))
if period_start:
domain.append(("create_date", ">=", f"{period_start} 00:00:00"))
if group_order.pickup_date:
@ -118,6 +126,34 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
)
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
"""Return the active-cycle draft sale.order for the partner, or empty.
Draft-only wrapper over `_find_cycle_sale_order`. Callers rely on this
never returning a placed order `/eskaera/clear-cart` cancels whatever
it gets back, so widening it would cancel paid orders.
"""
return _find_cycle_sale_order(
self, partner_id, group_order, states=("draft",), request_obj=request_obj
)
def _find_placed_cycle_order(self, partner_id, group_order, request_obj=None):
"""Return the partner's already-placed order for the active cycle.
Used by the duplicate-order guard: once a member has paid, their order is
confirmed, so no draft remains and nothing else would stop them from
building and paying for a second order in the same cycle.
"""
return _find_cycle_sale_order(
self,
partner_id,
group_order,
states=("sale", "done"),
request_obj=request_obj,
)
def _validate_confirm_request(self, data, request_obj=None):
req = request_obj or request
order_id = data.get("order_id")