Merge commit '9576d1f01f' into dev
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-05 01:44:39 -07:00
commit b5be9408f7
5 changed files with 461 additions and 46 deletions

View file

@ -26,6 +26,7 @@ from app.schemas.stripe import (
CreateCheckoutSessionResponse, CreateCheckoutSessionResponse,
CreateTokenCheckoutSessionRequest, CreateTokenCheckoutSessionRequest,
CreateTokenCheckoutSessionResponse, CreateTokenCheckoutSessionResponse,
FinalizeCheckoutResponse,
PagePurchaseHistoryResponse, PagePurchaseHistoryResponse,
StripeStatusResponse, StripeStatusResponse,
StripeWebhookResponse, StripeWebhookResponse,
@ -65,7 +66,15 @@ def _get_checkout_urls(search_space_id: int) -> tuple[str, str]:
) )
base_url = config.NEXT_FRONTEND_URL.rstrip("/") 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" cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
return success_url, cancel_url return success_url, cancel_url
@ -88,10 +97,62 @@ def _normalize_optional_string(value: Any) -> str | None:
def _get_metadata(checkout_session: Any) -> dict[str, str]: def _get_metadata(checkout_session: Any) -> dict[str, str]:
metadata = getattr(checkout_session, "metadata", None) or {} """Extract checkout session metadata as a plain ``str -> str`` dict.
In ``stripe>=15.0`` ``StripeObject`` is no longer a ``dict`` subclass
and exposes neither ``items()`` nor ``__iter__`` nor ``keys()``.
``dict(obj)`` falls into the sequence protocol and raises
``KeyError: 0``; ``obj.items()`` raises ``AttributeError``. The
supported way to materialize a ``StripeObject`` as a plain dict is
its ``to_dict()`` method (added in stripe-python 8.x, present in 15.x).
"""
metadata = getattr(checkout_session, "metadata", None)
if metadata is None:
return {}
# 1. Plain dict (older SDKs that subclassed dict, JSON-decoded events
# in tests, etc.).
if isinstance(metadata, dict): if isinstance(metadata, dict):
return {str(key): str(value) for key, value in metadata.items()} return {str(k): str(v) for k, v in metadata.items()}
return dict(metadata)
# 2. Modern Stripe SDK: every ``StripeObject`` has ``to_dict()``.
# ``recursive=False`` is correct because Stripe metadata values
# are always primitive strings.
to_dict = getattr(metadata, "to_dict", None)
if callable(to_dict):
try:
d = to_dict(recursive=False)
if isinstance(d, dict):
return {str(k): str(v) for k, v in d.items()}
except Exception:
logger.exception(
"Stripe metadata.to_dict() failed for session %s",
getattr(checkout_session, "id", "?"),
)
# 3. Last-resort: read the SDK's private ``_data`` backing dict.
# Stable across stripe-python 6.x -> 15.x.
inner = getattr(metadata, "_data", None)
if isinstance(inner, dict):
return {str(k): str(v) for k, v in inner.items()}
logger.warning(
"Could not extract metadata from checkout session %s (metadata type=%s)",
getattr(checkout_session, "id", "?"),
type(metadata).__name__,
)
return {}
# 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( async def _get_or_create_purchase_from_checkout_session(
@ -439,45 +500,217 @@ async def stripe_webhook(
detail="Invalid Stripe webhook signature.", detail="Invalid Stripe webhook signature.",
) from exc ) from exc
if event.type in { try:
"checkout.session.completed", if event.type in {
"checkout.session.async_payment_succeeded", "checkout.session.completed",
}: "checkout.session.async_payment_succeeded",
checkout_session = event.data.object
payment_status = getattr(checkout_session, "payment_status", None)
if event.type == "checkout.session.completed" and payment_status not in {
"paid",
"no_payment_required",
}: }:
logger.info( checkout_session = event.data.object
"Received checkout.session.completed for unpaid session %s; waiting for async success.", payment_status = getattr(checkout_session, "payment_status", None)
checkout_session.id,
)
return StripeWebhookResponse()
metadata = _get_metadata(checkout_session) if event.type == "checkout.session.completed" and payment_status not in {
purchase_type = metadata.get("purchase_type", "page_packs") "paid",
if purchase_type == "premium_tokens": "no_payment_required",
return await _fulfill_completed_token_purchase(db_session, checkout_session) }:
return await _fulfill_completed_purchase(db_session, checkout_session) logger.info(
"Received checkout.session.completed for unpaid session %s; waiting for async success.",
checkout_session.id,
)
return StripeWebhookResponse()
if event.type in { metadata = _get_metadata(checkout_session)
"checkout.session.async_payment_failed", if _is_token_purchase(metadata):
"checkout.session.expired", return await _fulfill_completed_token_purchase(
}: db_session, checkout_session
checkout_session = event.data.object )
metadata = _get_metadata(checkout_session) return await _fulfill_completed_purchase(db_session, checkout_session)
purchase_type = metadata.get("purchase_type", "page_packs")
if purchase_type == "premium_tokens": if event.type in {
return await _mark_token_purchase_failed( "checkout.session.async_payment_failed",
db_session, str(checkout_session.id) "checkout.session.expired",
) }:
return await _mark_purchase_failed(db_session, str(checkout_session.id)) checkout_session = event.data.object
metadata = _get_metadata(checkout_session)
if _is_token_purchase(metadata):
return await _mark_token_purchase_failed(
db_session, str(checkout_session.id)
)
return await _mark_purchase_failed(db_session, str(checkout_session.id))
except Exception:
# Re-raise so FastAPI returns 500 and Stripe retries this delivery.
# Logging here gives us a structured trail with event id + type so
# future webhook bugs surface immediately in the logs without
# having to grep by request_id.
logger.exception(
"Stripe webhook handler failed for event id=%s type=%s — Stripe will retry",
getattr(event, "id", "?"),
getattr(event, "type", "?"),
)
raise
return StripeWebhookResponse() 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)
# Defensive fallback: if metadata can't be read for any reason
# (extraction failure, manually-created session in Stripe dashboard,
# SDK upgrade breaking ``to_dict``, etc.) we'd otherwise route every
# purchase to the page_packs handler and get stuck. Resolve the
# purchase_type by checking which table actually has the row keyed
# by this Stripe session id.
if not metadata:
existing_token_purchase = (
await db_session.execute(
select(PremiumTokenPurchase.id).where(
PremiumTokenPurchase.stripe_checkout_session_id
== str(checkout_session.id)
)
)
).scalar_one_or_none()
if existing_token_purchase is not None:
is_token = True
else:
existing_page_purchase = (
await db_session.execute(
select(PagePurchase.id).where(
PagePurchase.stripe_checkout_session_id
== str(checkout_session.id)
)
)
).scalar_one_or_none()
if existing_page_purchase is None:
logger.error(
"finalize_checkout: no purchase row in either table "
"and metadata is empty for session=%s user=%s",
session_id,
user.id,
)
# Fall through; downstream path will short-circuit on
# missing-row + empty-metadata.
logger.info(
"finalize_checkout: recovered purchase_type=%s for session=%s "
"via DB fallback (metadata was empty)",
"premium_tokens" if is_token else "page_packs",
session_id,
)
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) @router.get("/purchases", response_model=PagePurchaseHistoryResponse)
async def get_page_purchases( async def get_page_purchases(
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
@ -524,7 +757,11 @@ def _get_token_checkout_urls(search_space_id: int) -> tuple[str, str]:
detail="NEXT_FRONTEND_URL is not configured.", detail="NEXT_FRONTEND_URL is not configured.",
) )
base_url = config.NEXT_FRONTEND_URL.rstrip("/") 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" cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel"
return success_url, cancel_url return success_url, cancel_url
@ -575,7 +812,11 @@ async def create_token_checkout_session(
"user_id": str(user.id), "user_id": str(user.id),
"quantity": str(body.quantity), "quantity": str(body.quantity),
"credit_micros_per_unit": str(config.STRIPE_CREDIT_MICROS_PER_UNIT), "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 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): class CreateTokenCheckoutSessionRequest(BaseModel):
"""Request body for creating a premium token purchase checkout session.""" """Request body for creating a premium token purchase checkout session."""

View file

@ -1,8 +1,9 @@
"use client"; "use client";
import { CheckCircle2 } from "lucide-react"; import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@ -12,23 +13,133 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import type { FinalizeCheckoutResponse } from "@/contracts/types/stripe.types";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
type FinalizeState =
| { kind: "loading" }
| { kind: "completed"; data: FinalizeCheckoutResponse }
| { kind: "pending"; data: FinalizeCheckoutResponse }
| { kind: "still_pending"; data: FinalizeCheckoutResponse }
| { kind: "failed"; data: FinalizeCheckoutResponse }
| { kind: "error"; message: string }
| { kind: "no_session" };
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_ATTEMPTS = 15; // ~30s total before falling back to the still_pending state
export default function PurchaseSuccessPage() { export default function PurchaseSuccessPage() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams();
const searchSpaceId = String(params.search_space_id ?? ""); const searchSpaceId = String(params.search_space_id ?? "");
const sessionId = searchParams.get("session_id");
const [state, setState] = useState<FinalizeState>(
sessionId ? { kind: "loading" } : { kind: "no_session" }
);
// Tracks active polling so component unmount cancels it
const cancelledRef = useRef(false);
useEffect(() => {
if (!sessionId) return;
cancelledRef.current = false;
const poll = async (attempt: number): Promise<void> => {
if (cancelledRef.current) return;
try {
const data = await stripeApiService.finalizeCheckout(sessionId);
if (cancelledRef.current) return;
if (data.status === "completed") {
setState({ kind: "completed", data });
return;
}
if (data.status === "failed") {
setState({ kind: "failed", data });
return;
}
// Status is "pending" - either the user paid via async
// payment method (Klarna, ACH) or webhook + finalize both
// raced and lost. Keep polling up to MAX_POLL_ATTEMPTS,
// then fall back to a friendlier message that explains
// fulfilment may complete asynchronously.
if (attempt < MAX_POLL_ATTEMPTS) {
setState({ kind: "pending", data });
setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS);
} else {
setState({ kind: "still_pending", data });
}
} catch (err) {
if (cancelledRef.current) return;
const message = err instanceof Error ? err.message : "Unable to finalize checkout.";
setState({ kind: "error", message });
}
};
void poll(1);
return () => {
cancelledRef.current = true;
};
}, [sessionId]);
return ( return (
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8"> <div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
<Card className="w-full max-w-lg"> <Card className="w-full max-w-lg">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" /> {state.kind === "loading" || state.kind === "pending" ? (
<CardTitle className="text-2xl">Purchase complete</CardTitle> <Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
<CardDescription>Your purchase is being applied to your account now.</CardDescription> ) : state.kind === "completed" ? (
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
) : (
<AlertCircle className="mx-auto h-10 w-10 text-amber-500" />
)}
<CardTitle className="text-2xl">
{state.kind === "loading" && "Confirming payment…"}
{state.kind === "pending" && "Processing your payment…"}
{state.kind === "still_pending" && "Payment still processing"}
{state.kind === "completed" && "Purchase complete"}
{state.kind === "failed" && "Purchase failed"}
{state.kind === "error" && "Couldn't confirm payment"}
{state.kind === "no_session" && "Purchase complete"}
</CardTitle>
<CardDescription>
{state.kind === "loading" && "We're verifying your payment with Stripe."}
{state.kind === "pending" &&
"Your bank is taking a moment to confirm. This usually takes 530 seconds."}
{state.kind === "still_pending" &&
"Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."}
{state.kind === "completed" &&
(state.data.purchase_type === "page_packs"
? `Added ${formatNumber(state.data.pages_granted ?? 0)} pages to your account.`
: `Added ${formatCredit(state.data.premium_credit_micros_granted ?? 0)} of premium credit to your account.`)}
{state.kind === "failed" &&
"Stripe reported the checkout as failed or expired. Your card was not charged."}
{state.kind === "error" &&
"Don't worry — if your card was charged, your purchase will still apply within a minute or two."}
{state.kind === "no_session" &&
"Your purchase is being applied to your account."}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-center"> <CardContent className="space-y-3 text-center">
<p className="text-sm text-muted-foreground"> {state.kind === "completed" && state.data.purchase_type === "page_packs" && (
Your usage meters should refresh automatically in a moment. <p className="text-sm text-muted-foreground">
</p> New balance: {formatNumber(state.data.pages_limit ?? 0)} total pages
{typeof state.data.pages_used === "number"
? ` (${formatNumber((state.data.pages_limit ?? 0) - state.data.pages_used)} remaining)`
: ""}
</p>
)}
{state.kind === "completed" && state.data.purchase_type === "premium_tokens" && (
<p className="text-sm text-muted-foreground">
New premium credit balance: {formatCredit(state.data.premium_credit_micros_limit ?? 0)}
</p>
)}
{state.kind === "error" && (
<p className="text-sm text-muted-foreground">{state.message}</p>
)}
</CardContent> </CardContent>
<CardFooter className="flex flex-col gap-2"> <CardFooter className="flex flex-col gap-2">
<Button asChild className="w-full"> <Button asChild className="w-full">
@ -42,3 +153,16 @@ export default function PurchaseSuccessPage() {
</div> </div>
); );
} }
function formatNumber(n: number): string {
return new Intl.NumberFormat("en-US").format(n);
}
function formatCredit(micros: number): string {
const dollars = micros / 1_000_000;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(dollars);
}

View file

@ -73,6 +73,19 @@ export const getTokenPurchasesResponse = z.object({
purchases: z.array(tokenPurchase), purchases: z.array(tokenPurchase),
}); });
// Response from /stripe/finalize-checkout. Either page or token fields
// are populated depending on purchase_type.
export const finalizeCheckoutResponse = z.object({
purchase_type: z.enum(["page_packs", "premium_tokens"]),
status: pagePurchaseStatusEnum,
pages_limit: z.number().nullable().optional(),
pages_used: z.number().nullable().optional(),
pages_granted: z.number().nullable().optional(),
premium_credit_micros_limit: z.number().nullable().optional(),
premium_credit_micros_used: z.number().nullable().optional(),
premium_credit_micros_granted: z.number().nullable().optional(),
});
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>; export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>; export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>; export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
@ -85,3 +98,4 @@ export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>; export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
export type TokenPurchase = z.infer<typeof tokenPurchase>; export type TokenPurchase = z.infer<typeof tokenPurchase>;
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>; export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
export type FinalizeCheckoutResponse = z.infer<typeof finalizeCheckoutResponse>;

View file

@ -5,6 +5,8 @@ import {
type CreateTokenCheckoutSessionResponse, type CreateTokenCheckoutSessionResponse,
createCheckoutSessionResponse, createCheckoutSessionResponse,
createTokenCheckoutSessionResponse, createTokenCheckoutSessionResponse,
type FinalizeCheckoutResponse,
finalizeCheckoutResponse,
type GetPagePurchasesResponse, type GetPagePurchasesResponse,
type GetTokenPurchasesResponse, type GetTokenPurchasesResponse,
getPagePurchasesResponse, getPagePurchasesResponse,
@ -54,6 +56,20 @@ class StripeApiService {
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => { getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse); return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
}; };
/**
* Synchronously fulfil a checkout session from the success page.
*
* Solves the race where the user lands on /purchase-success before
* Stripe's checkout.session.completed webhook arrives. Idempotent
* safe to call concurrently with the webhook.
*/
finalizeCheckout = async (sessionId: string): Promise<FinalizeCheckoutResponse> => {
return baseApiService.get(
`/api/v1/stripe/finalize-checkout?session_id=${encodeURIComponent(sessionId)}`,
finalizeCheckoutResponse
);
};
} }
export const stripeApiService = new StripeApiService(); export const stripeApiService = new StripeApiService();