Checkout
Buy a cart of à la carte products for this organization.
One Subscription row and one gateway subscription per product, each
line independent (§12) — so this answers 201 as soon as ANY line was
bought, even alongside a line that was refused. Partial success is not an
error to report as one: the successful lines have already created real
resources at the gateway, and telling the caller “409” because a second
product was already subscribed would misdescribe what happened and invite
a retry that can only fail the same way. When nothing at all was bought,
the first line’s reason picks the status (_failed_cart_status). Either
way the body is the full per-line result list.
Retry safety is entirely the service’s (§10 / checkout.py): a resubmit
of a cart whose products are already live comes back as per-line
already_subscribed rather than a second charge, backstopped by
uq_subscriptions_one_live_per_product and a row lock on the buyer.
Nothing here may add a second guard on top — a duplicate check at this
layer would have to duplicate that lock to be correct, and a
non-serialized version of it would only widen the window it is trying to
close. checkout_cart is therefore called exactly once per request, with
no background task or retry wrapper around it.
Coupons are equally the service’s: one applied redemption per (coupon,
user) is a partial unique index, not an in-request check, so submitting
the same code again on a separate request — for a different product,
even a different organization — is refused coupon_already_redeemed on
its line. See coupons.redeem_coupon.
Authorizations
Parameters
Query Parameters
Request Body required
POST /api/billing/checkout — an à la carte cart.
product_codes are PLAN codes (base_ota, addon_monitoring, …), not
organization_entitlements.product values — §12 turned §2’s three fixed
bundles into one plan per product, so a cart is a list of plan codes and
the request-level name from §10 (plan_code) became plural. Unknown codes
are deliberately NOT rejected here: checkout_cart reports each one as its
own unknown_plan line, which keeps a cart with one bad code from
discarding the good ones. min_length=1 is the one shape rule enforced up
front, so that a NoPurchasableProductsError from the service
unambiguously means “non-empty cart, nothing sellable in it” (404) rather
than “empty cart” (422, raised right here by pydantic).
payment_method_id is the gateway’s own tokenized-card handle from
POST /billing/stripe/setup-intent (Stripe only; omitted for
razorpay/paypal per §10). It is forwarded verbatim to the gateway, so it
is constrained to an opaque-token character set — this is a request field
that becomes an argument to an external API call, and no legitimate token
contains anything else.
Responses
201
Successful Response
POST /api/billing/checkout — one result per requested product code.
Returned at EVERY status, not only 201: a cart is per-product independent
(§12), so “monitoring bought, crash-reporting already subscribed” is a real
outcome that a bare {"detail": …} error body could not express. The
status code summarizes (201 when anything was bought, else the failing
line’s own code) and this body is where the client reads what actually
happened.
object
One cart line’s outcome — mirrors checkout.CheckoutLineResult.
ok decides which half of the fields is populated: a refused line carries
error/detail, a bought one carries the subscription and its client
payload. error is a wire-level string the console maps to copy —
CheckoutErrorCode values (already_subscribed, …) or, for a refused
coupon, a CouponRejectionReason value (coupon_already_redeemed, …).
object
422
Validation Error