Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 133 additions & 30 deletions src/viur/shop/modules/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import time
import typing as t # noqa

import datetime

from viur import toolkit
from viur.core import current, db, errors as core_errors, exposed, force_post
from viur.core import current, db, errors as core_errors, exposed, force_post, utils
from viur.core.prototypes import List
from viur.shop.types import *
from viur.shop.types.results import PaymentProviderResult
Expand Down Expand Up @@ -300,7 +302,6 @@ def checkout_start(
return JsonResponse({
"errors": errors,
}, status_code=400)
raise e.InvalidStateError(", ".join(errors))

if order_skel["cart"]["dest"]["key"] == self.shop.cart.current_session_cart_key:
# This is now an order basket and should no longer be modified
Expand Down Expand Up @@ -401,18 +402,79 @@ def checkout_order(
return JsonResponse({
"errors": errors,
}, status_code=400)
raise e.InvalidStateError(", ".join(error_))

order_skel = HOOK_SERVICE.dispatch(Hook.ORDER_ASSIGN_UID, self._default_assign_uid)(order_skel)
# TODO: charge order if it should directly be charged
pp_res = self.get_payment_provider_by_name(order_skel["payment_provider"]).checkout(order_skel)
# Claim the order before talking to the payment provider: a repeated
# checkout_order call (double click, crash between the provider call
# and set_ordered, replayed request) must not trigger a second charge.
try:
order_skel = self._claim_checkout_order(order_skel)
except e.InvalidStateError as exc:
logging.warning(f"Rejecting checkout_order for {order_key!r}: {exc}")
return JsonResponse({
"errors": [ClientError("checkout_order is already in progress")],
}, status_code=409)

try:
order_skel = HOOK_SERVICE.dispatch(Hook.ORDER_ASSIGN_UID, self._default_assign_uid)(order_skel)
# TODO: charge order if it should directly be charged
pp_res = self.get_payment_provider_by_name(order_skel["payment_provider"]).checkout(order_skel)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff8ac20: UID assignment is now guarded with if not order_skel["order_uid"]: so a retry after an overtaken claim no longer reassigns it.

except Exception:
# Release the claim, the user may retry immediately
toolkit.set_status(
key=order_skel["key"],
skel=order_skel,
values={"checkout_order_started": None},
)
raise
Comment on lines +432 to +451

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff8ac20: the release now uses a precondition comparing checkout_order_started against the timestamp this request itself claimed, so it only clears its own claim and no longer clobbers a newer one from a concurrent retry.

order_skel = self.set_ordered(order_skel, pp_res)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff8ac20: removed the extra EVENT_SERVICE.call(Event.ORDER_CHANGED, ...); set_ordered() already fires it on the actual transition.

return JsonResponse({
"skel": order_skel,
"payment": pp_res,
})

CHECKOUT_ORDER_CLAIM_TIMEOUT: t.Final[datetime.timedelta] = datetime.timedelta(minutes=15)
"""How long a `checkout_order` claim blocks further attempts.

If a claimed checkout neither completed (``is_ordered``) nor released its
claim (crash between the payment provider call and :meth:`set_ordered`),
a new attempt is allowed after this period."""

def _claim_checkout_order(
self,
order_skel: SkeletonInstance_T[OrderSkel],
) -> SkeletonInstance_T[OrderSkel]:
"""
Mark the order as "checkout_order in progress".

Sets ``checkout_order_started`` to now, guarded by a precondition:
already ordered orders and orders with a non-expired claim are
rejected with :exc:`InvalidStateError`. Together with
`toolkit.set_status` this makes the claim a check-and-set --
the payment provider gets called at most once per claim period,
preventing double charges.

:param order_skel: Skeleton of the order to claim.
:return: The updated order skeleton.
:raises InvalidStateError: If the order is already ordered or claimed.
"""

def precondition(skel: "SkeletonInstance") -> None:
if skel["is_ordered"]:
raise e.InvalidStateError("Order already is_ordered")
if (started := skel["checkout_order_started"]) is not None:
if utils.utcNow() - started < self.CHECKOUT_ORDER_CLAIM_TIMEOUT:
raise e.InvalidStateError(f"checkout_order already started at {started.isoformat()}")
logging.warning(f'Overtaking expired checkout_order claim from {started.isoformat()} '
f'of order {skel["key"]!r}')

return toolkit.set_status(
key=order_skel["key"],
skel=order_skel,
precondition=precondition,
values={"checkout_order_started": utils.utcNow()},
)

def can_order(
self,
order_skel: "SkeletonInstance",
Expand Down Expand Up @@ -457,37 +519,78 @@ def set_checkout_in_progress(self, order_skel: "SkeletonInstance") -> "SkeletonI
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)
return order_skel

def _set_state_once(
self,
order_skel: "SkeletonInstance",
state_bone: str,
) -> tuple["SkeletonInstance", bool]:
"""
Set a boolean state bone of an order to ``True`` exactly once.

Uses a `set_status` precondition, so the check and the write happen
in the same read-modify-write cycle: if the state is already set
(e.g. by a concurrently processed payment webhook), nothing is
written and the caller gets ``False`` -- it then must not fire the
state's events again, keeping side effects like mails or discount
accounting from running twice.

:param order_skel: Skeleton of the order to modify.
:param state_bone: Name of the boolean state bone (e.g. ``is_paid``).
:return: Tuple of the (possibly updated) skeleton and whether this
call actually performed the transition.
"""

def precondition(skel: "SkeletonInstance") -> None:
if skel[state_bone]:
raise e.InvalidStateError(f"Order already has {state_bone} set")

try:
order_skel = toolkit.set_status(
key=order_skel["key"],
skel=order_skel,
precondition=precondition,
values={state_bone: True},
)
except e.InvalidStateError:
logger.info(f'Order {order_skel["key"]!r} already has {state_bone} set; skipping transition')
return order_skel, False
return order_skel, True

def set_ordered(self, order_skel: "SkeletonInstance", payment: t.Any) -> "SkeletonInstance":
"""Set an order to the state *ordered*"""
order_skel = toolkit.set_status(
key=order_skel["key"],
skel=order_skel,
values={"is_ordered": True},
)
EVENT_SERVICE.call(Event.ORDER_ORDERED, order_skel=order_skel, payment=payment)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)
"""Set an order to the state *ordered*.

Idempotent: the ``ORDER_ORDERED`` event fires only on the actual
transition, a repeated call changes and triggers nothing.
"""
order_skel, changed = self._set_state_once(order_skel, "is_ordered")
if changed:
EVENT_SERVICE.call(Event.ORDER_ORDERED, order_skel=order_skel, payment=payment)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)
return order_skel

def set_paid(self, order_skel: "SkeletonInstance") -> "SkeletonInstance":
"""Set an order to the state *paid*"""
order_skel = toolkit.set_status(
key=order_skel["key"],
skel=order_skel,
values={"is_paid": True},
)
EVENT_SERVICE.call(Event.ORDER_PAID, order_skel=order_skel)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)
"""Set an order to the state *paid*.

Idempotent: the ``ORDER_PAID`` event fires only on the actual
transition, a repeated call (e.g. return handler and payment
webhook processing the same payment) changes and triggers nothing.
"""
order_skel, changed = self._set_state_once(order_skel, "is_paid")
if changed:
EVENT_SERVICE.call(Event.ORDER_PAID, order_skel=order_skel)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)
return order_skel

def set_rts(self, order_skel: "SkeletonInstance") -> "SkeletonInstance":
"""Set an order to the state *Ready to ship*"""
order_skel = toolkit.set_status(
key=order_skel["key"],
skel=order_skel,
values={"is_rts": True},
)
EVENT_SERVICE.call(Event.ORDER_RTS, order_skel=order_skel)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)
"""Set an order to the state *Ready to ship*.

Idempotent: the ``ORDER_RTS`` event fires only on the actual
transition, a repeated call changes and triggers nothing.
"""
order_skel, changed = self._set_state_once(order_skel, "is_rts")
if changed:
EVENT_SERVICE.call(Event.ORDER_RTS, order_skel=order_skel)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)
return order_skel

# --- Hooks ---------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions src/viur/shop/skeletons/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ class OrderSkel(Skeleton):
is_checkout_in_progress = BooleanBone(
)

checkout_order_started = DateBone(
)
"""When the final ``checkout_order`` step claimed this order.

Set before the payment provider is called and acts as a guard against
concurrent or repeated ``checkout_order`` calls charging twice.
Cleared again if the payment provider call fails; a stale claim (crash
after the provider call) expires after
:attr:`Order.CHECKOUT_ORDER_CLAIM_TIMEOUT`."""

state = SelectBone(
values=OrderState,
multiple=True,
Expand Down
Loading