feat: implement finalize checkout endpoint and update purchase success handling

- Added a new endpoint `/stripe/finalize-checkout` to synchronously fulfill a checkout session, addressing the webhook-vs-redirect race condition.
- Updated the `PurchaseSuccessPage` component to handle various states of the checkout process, including loading, completed, pending, and failed states.
- Introduced a new response model `FinalizeCheckoutResponse` to provide immediate feedback on the purchase status.
- Enhanced the Stripe API service to include the new finalize checkout functionality.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-05 01:01:12 -07:00
parent 5ff6baedb3
commit 6e1dd40597
5 changed files with 333 additions and 15 deletions

View file

@ -26,6 +26,7 @@ from app.schemas.stripe import (
CreateCheckoutSessionResponse,
CreateTokenCheckoutSessionRequest,
CreateTokenCheckoutSessionResponse,
FinalizeCheckoutResponse,
PagePurchaseHistoryResponse,
StripeStatusResponse,
StripeWebhookResponse,
@ -65,7 +66,15 @@ def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
)
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
success_url = f"{base_url}/dashboard/{search_space_id}/purchase-success"
# Stripe substitutes ``{CHECKOUT_SESSION_ID}`` with the actual session id
# at redirect time. The frontend uses it to call /stripe/finalize-checkout
# which fulfils synchronously without waiting for the webhook — fixing the
# webhook-vs-redirect race where users land on /purchase-success before
# checkout.session.completed has been delivered.
success_url = (
f"{base_url}/dashboard/{search_space_id}/purchase-success"
f"?session_id={{CHECKOUT_SESSION_ID}}"
)
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
return success_url, cancel_url
@ -106,6 +115,17 @@ def _get_metadata(checkout_session: Any) -> dict[str, str]:
return {str(key): str(value) for key, value in items}
# Canonical purchase_type metadata values. ``premium_credit`` was emitted
# by an earlier release of ``create_token_checkout_session`` so it's still
# accepted on the read side for backward compat with in-flight sessions.
_PURCHASE_TYPE_TOKEN_VALUES = frozenset({"premium_tokens", "premium_credit"})
def _is_token_purchase(metadata: dict[str, str]) -> bool:
"""Return True for premium-credit (a.k.a. premium_token) purchases."""
return metadata.get("purchase_type", "page_packs") in _PURCHASE_TYPE_TOKEN_VALUES
async def _get_or_create_purchase_from_checkout_session(
db_session: AsyncSession,
checkout_session: Any,
@ -470,8 +490,7 @@ async def stripe_webhook(
return StripeWebhookResponse()
metadata = _get_metadata(checkout_session)
purchase_type = metadata.get("purchase_type", "page_packs")
if purchase_type == "premium_tokens":
if _is_token_purchase(metadata):
return await _fulfill_completed_token_purchase(
db_session, checkout_session
)
@ -483,8 +502,7 @@ async def stripe_webhook(
}:
checkout_session = event.data.object
metadata = _get_metadata(checkout_session)
purchase_type = metadata.get("purchase_type", "page_packs")
if purchase_type == "premium_tokens":
if _is_token_purchase(metadata):
return await _mark_token_purchase_failed(
db_session, str(checkout_session.id)
)
@ -504,6 +522,124 @@ async def stripe_webhook(
return StripeWebhookResponse()
@router.get("/finalize-checkout", response_model=FinalizeCheckoutResponse)
async def finalize_checkout(
session_id: str,
user: User = Depends(current_active_user),
db_session: AsyncSession = Depends(get_async_session),
) -> FinalizeCheckoutResponse:
"""Synchronously fulfil a checkout session from the success page.
Solves the webhook-vs-redirect race: the user lands on
``/dashboard/<id>/purchase-success?session_id=cs_...`` typically a
few hundred ms after paying, but Stripe's
``checkout.session.completed`` webhook can take 5-30s+ to arrive.
Calling this endpoint on success-page mount fulfils the purchase
immediately by retrieving the session from Stripe's API and
invoking the same idempotent helpers the webhook uses.
Idempotency: if the webhook has already fulfilled this purchase
(status=COMPLETED), the helpers short-circuit and we just return
the latest balance. Concurrent webhook + finalize calls are safe
because both acquire ``SELECT ... FOR UPDATE`` on the purchase row.
Authorization: the session's ``client_reference_id`` must match the
authenticated user's id. This prevents a user from finalising
someone else's checkout session if they happen to know the id.
"""
stripe_client = get_stripe_client()
try:
checkout_session = stripe_client.v1.checkout.sessions.retrieve(session_id)
except StripeError as exc:
logger.warning(
"finalize_checkout: stripe lookup failed for session=%s user=%s: %s",
session_id,
user.id,
exc,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Checkout session not found.",
) from exc
# Authorization check: the user finalising must be the user who
# initiated the checkout. ``client_reference_id`` is set in
# ``create_checkout_session`` / ``create_token_checkout_session``.
client_reference_id = getattr(checkout_session, "client_reference_id", None)
if client_reference_id != str(user.id):
logger.warning(
"finalize_checkout: ownership mismatch session=%s client_ref=%s user=%s",
session_id,
client_reference_id,
user.id,
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This checkout session does not belong to you.",
)
metadata = _get_metadata(checkout_session)
is_token = _is_token_purchase(metadata)
payment_status = getattr(checkout_session, "payment_status", None)
session_status = getattr(checkout_session, "status", None)
is_paid = payment_status in {"paid", "no_payment_required"}
is_expired = session_status == "expired"
if is_paid:
if is_token:
await _fulfill_completed_token_purchase(db_session, checkout_session)
else:
await _fulfill_completed_purchase(db_session, checkout_session)
elif is_expired:
if is_token:
await _mark_token_purchase_failed(db_session, str(checkout_session.id))
else:
await _mark_purchase_failed(db_session, str(checkout_session.id))
# Otherwise (e.g. payment_status="unpaid", session_status="open"),
# leave the purchase row alone — frontend will keep polling and the
# webhook will eventually win the race.
# Refresh the user row so the response reflects any update applied
# by the fulfilment helpers in this same session.
await db_session.refresh(user)
if is_token:
purchase = (
await db_session.execute(
select(PremiumTokenPurchase).where(
PremiumTokenPurchase.stripe_checkout_session_id
== str(checkout_session.id)
)
)
).scalar_one_or_none()
return FinalizeCheckoutResponse(
purchase_type="premium_tokens",
status=purchase.status.value if purchase else "pending",
premium_credit_micros_limit=user.premium_credit_micros_limit,
premium_credit_micros_used=user.premium_credit_micros_used,
premium_credit_micros_granted=(
purchase.credit_micros_granted if purchase else None
),
)
purchase = (
await db_session.execute(
select(PagePurchase).where(
PagePurchase.stripe_checkout_session_id == str(checkout_session.id)
)
)
).scalar_one_or_none()
return FinalizeCheckoutResponse(
purchase_type="page_packs",
status=purchase.status.value if purchase else "pending",
pages_limit=user.pages_limit,
pages_used=user.pages_used,
pages_granted=purchase.pages_granted if purchase else None,
)
@router.get("/purchases", response_model=PagePurchaseHistoryResponse)
async def get_page_purchases(
user: User = Depends(current_active_user),
@ -550,7 +686,11 @@ def _get_token_checkout_urls(search_space_id: int) -> tuple[str, str]:
detail="NEXT_FRONTEND_URL is not configured.",
)
base_url = config.NEXT_FRONTEND_URL.rstrip("/")
success_url = f"{base_url}/dashboard/{search_space_id}/purchase-success"
# See ``_get_checkout_urls`` for why session_id is appended.
success_url = (
f"{base_url}/dashboard/{search_space_id}/purchase-success"
f"?session_id={{CHECKOUT_SESSION_ID}}"
)
cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
return success_url, cancel_url
@ -601,7 +741,11 @@ async def create_token_checkout_session(
"user_id": str(user.id),
"quantity": str(body.quantity),
"credit_micros_per_unit": str(config.STRIPE_CREDIT_MICROS_PER_UNIT),
"purchase_type": "premium_credit",
# Canonical value matched by ``_is_token_purchase``.
# The legacy ``"premium_credit"`` is still accepted on
# the read side for any in-flight sessions started
# before this rename.
"purchase_type": "premium_tokens",
},
}
)

View file

@ -56,6 +56,26 @@ class StripeWebhookResponse(BaseModel):
received: bool = True
class FinalizeCheckoutResponse(BaseModel):
"""Response from /stripe/finalize-checkout.
Returned by the success page so the UI can show the post-purchase
balance immediately, even when the Stripe webhook hasn't been
delivered yet. ``status`` mirrors the underlying purchase row
(``pending`` / ``completed`` / ``failed``); the FE polls this
endpoint until it sees ``completed`` or a final ``failed``.
"""
purchase_type: str # "page_packs" | "premium_tokens"
status: str # PagePurchaseStatus / PremiumTokenPurchaseStatus value
pages_limit: int | None = None
pages_used: int | None = None
pages_granted: int | None = None
premium_credit_micros_limit: int | None = None
premium_credit_micros_used: int | None = None
premium_credit_micros_granted: int | None = None
class CreateTokenCheckoutSessionRequest(BaseModel):
"""Request body for creating a premium token purchase checkout session."""