[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

@ -6,7 +6,6 @@ import re
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import api
from odoo import fields
from odoo import models
@ -188,6 +187,16 @@ class GroupOrder(models.Model):
help="Calculated delivery date (pickup date + 1 day)",
)
# === Online payment ===
online_payment = fields.Boolean(
tracking=True,
help="Let members pay their order online when they place it. Payment "
"providers are configured globally (Settings > Payment Providers); "
"this only decides whether this group order offers them. When "
"enabled, paying is the only way to place an order from the "
"checkout page.",
)
# === Computed date fields ===
pickup_date = fields.Date(
compute="_compute_pickup_date",
@ -770,8 +779,7 @@ class GroupOrder(models.Model):
- If no slots are configured, leave fields empty (fallback handled
by existing pickup_day logic).
"""
from datetime import datetime
from datetime import time
from datetime import datetime, time
for record in self:
record.next_pickup_slot_id = False
@ -1106,6 +1114,69 @@ class GroupOrder(models.Model):
failed_orders,
)
self._cron_batch_paid_orders_of_closed_cycles()
@api.model
def _cron_batch_paid_orders_of_closed_cycles(self):
"""Batch paid orders of group orders that were closed by hand.
The loop above only walks draft/open group orders. Closing an order
manually after a member has paid would otherwise leave that member's
picking out of every batch, because the confirmation already happened
at payment time and the cron never looks at closed cycles.
"""
closed_orders = self.search(
[("state", "=", "closed"), ("online_payment", "=", True)]
)
for order in closed_orders:
try:
order._batch_paid_sale_orders()
except Exception:
_logger.exception(
"Cron: Error batching paid sale orders of closed group order "
"%s (%s)",
order.id,
order.name,
)
def _batch_paid_sale_orders(self):
"""Create the picking batches of orders already confirmed by payment.
The same sweep `_confirm_linked_sale_orders` does, minus the
confirmation step. Drafts are deliberately left alone: closing a group
order by hand is how a co-op calls a cycle off, and this must not
resurrect the orders it meant to drop.
"""
self.ensure_one()
batches = self.env["stock.picking.batch"]
if not self.pickup_date:
return batches
paid_sale_orders = (
self.env["sale.order"]
.sudo()
.search(
[
("group_order_id", "=", self.id),
("state", "in", ["sale", "done"]),
("pickup_date", "=", self.pickup_date),
]
)
)
if not paid_sale_orders:
return batches
batches = self._create_picking_batches_for_sale_orders(paid_sale_orders)
if batches:
_logger.info(
"Cron: Batched %d paid sale order(s) of closed group order %s (%s)",
len(paid_sale_orders),
self.id,
self.name,
)
return batches
def _close_one_time_order_if_ended(self):
"""Close one-time orders once their end_date has passed.
@ -1179,7 +1250,25 @@ class GroupOrder(models.Model):
]
)
if not sale_orders:
# Orders paid online are confirmed the moment their transaction is
# done, long before this runs, so they are not in the search above —
# but their pickings still have to end up in this cycle's batch.
#
# pickup_date is what scopes them to this cycle: the same group.order
# record is reused every cycle, and _cron_update_dates() calls this
# method BEFORE recomputing the dates, so self.pickup_date is still
# the closing cycle's, exactly the value stamped on the order when it
# was saved. Backorders from previous cycles hang off orders with an
# older pickup_date, so they are not swept in either.
already_confirmed = SaleOrder.search(
[
("group_order_id", "=", self.id),
("state", "in", ["sale", "done"]),
("pickup_date", "=", self.pickup_date),
]
)
if not sale_orders and not already_confirmed:
_logger.info(
"Cron: No sale orders to confirm for group order %s (%s)",
self.id,
@ -1188,10 +1277,12 @@ class GroupOrder(models.Model):
return
_logger.info(
"Cron: Confirming %d sale orders for group order %s (%s)",
"Cron: Confirming %d sale orders for group order %s (%s); "
"%d already confirmed by online payment",
len(sale_orders),
self.id,
self.name,
len(already_confirmed),
)
try:
@ -1264,13 +1355,33 @@ class GroupOrder(models.Model):
)
batches = self.env["stock.picking.batch"]
if confirmed_sale_orders:
# Create picking batches only for confirmed sale orders
# One call with both sets: _create_picking_batches_for_sale_orders
# groups by picking type and skips pickings that already have a
# batch, so this yields one batch per type for the whole cycle.
batchable_sale_orders = confirmed_sale_orders | already_confirmed
if batchable_sale_orders:
batches = self._create_picking_batches_for_sale_orders(
confirmed_sale_orders
batchable_sale_orders
)
# Only the orders confirmed right now: re-reporting the ones
# confirmed in an earlier run would repeat the same warnings
# on every cron pass.
self._log_missing_procurement_warnings(confirmed_sale_orders)
if already_confirmed and not batches:
# Paid orders that never made it into a batch are an
# operational hole, and everything here runs inside a
# try/except that only logs. Say so loudly.
_logger.warning(
"Cron: %d already confirmed sale order(s) of group order %s (%s) "
"produced no picking batch. Their pickings may already be "
"batched, done or cancelled — check manually. ids=%s",
len(already_confirmed),
self.id,
self.name,
already_confirmed.ids,
)
if failed_sale_orders:
_logger.warning(
"Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "
@ -1309,8 +1420,7 @@ class GroupOrder(models.Model):
return
failure_reasons = failure_reasons or {}
from markupsafe import Markup
from markupsafe import escape
from markupsafe import Markup, escape
items = Markup()
for sale_order in failed_sale_orders: