Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,30 @@ export default async function Page({
if (error instanceof ResourceNotFound) {
notFound()
} else if (error instanceof ExpiredCheckoutError) {
notFound() // TODO: show expired checkout page
// This should rarely happen now since the backend recreates expired sessions,
// but if it does, show a not found page
notFound()
} else {
throw error
}
}

// Check if the returned checkout has a different client secret
// This means the backend recreated an expired session
if (checkout.clientSecret !== clientSecret) {
const searchParamsString = new URLSearchParams({
...(embed && { embed }),
...(theme && { theme }),
...(customer_session_token && { customer_session_token }),
}).toString()

const redirectUrl = `/checkout/${checkout.clientSecret}/confirmation${
searchParamsString ? `?${searchParamsString}` : ''
}`

redirect(redirectUrl)
}

if (checkout.status === 'open') {
redirect(checkout.url)
}
Expand Down
20 changes: 19 additions & 1 deletion clients/apps/web/src/app/checkout/[clientSecret]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,30 @@ export default async function Page({
if (error instanceof ResourceNotFound) {
notFound()
} else if (error instanceof ExpiredCheckoutError) {
notFound() // TODO: show expired checkout page
// This should rarely happen now since the backend recreates expired sessions,
// but if it does, show a not found page
notFound()
} else {
throw error
}
}

// Check if the returned checkout has a different client secret
// This means the backend recreated an expired session
if (checkout.clientSecret !== clientSecret) {
const searchParamsString = new URLSearchParams({
...(_embed && { embed: _embed }),
...(theme && { theme }),
...prefilledParameters,
}).toString()

const redirectUrl = `/checkout/${checkout.clientSecret}${
searchParamsString ? `?${searchParamsString}` : ''
}`

redirect(redirectUrl)
}

if (checkout.status === 'succeeded') {
redirect(checkout.successUrl)
}
Expand Down
39 changes: 39 additions & 0 deletions clients/packages/checkout/src/providers/CheckoutProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ interface CheckoutProviderProps {
serverURL?: string
}

/**
* Redirects to a new checkout session when the client secret changes.
* This happens when the backend recreates an expired session.
*/
const redirectToNewSession = (newClientSecret: string) => {
const currentPath = window.location.pathname
const searchParams = window.location.search

// Replace the old client secret in the URL with the new one
const newPath = currentPath.replace(
/\/checkout\/[^\/]+/,
`/checkout/${newClientSecret}`
)

// Perform the redirect
window.location.href = `${newPath}${searchParams}`
}

export const CheckoutProvider = ({
clientSecret,
serverURL,
Expand All @@ -121,6 +139,12 @@ export const CheckoutProvider = ({
checkoutsClientGet(client, { clientSecret }).then(
({ ok, value, error }) => {
if (ok) {
// Check if the returned checkout has a different client secret
// This indicates the backend recreated an expired session
if (value.clientSecret !== clientSecret) {
redirectToNewSession(value.clientSecret)
return
}
setCheckout(value)
} else {
throw error
Expand All @@ -132,6 +156,11 @@ export const CheckoutProvider = ({
const refresh = useCallback(async () => {
const result = await checkoutsClientGet(client, { clientSecret })
if (result.ok) {
// Check for client secret change on refresh
if (result.value.clientSecret !== clientSecret) {
redirectToNewSession(result.value.clientSecret)
return result
}
setCheckout(result.value)
}
return result
Expand All @@ -144,6 +173,11 @@ export const CheckoutProvider = ({
checkoutUpdatePublic: data,
})
if (result.ok) {
// Check for client secret change on update
if (result.value.clientSecret !== clientSecret) {
redirectToNewSession(result.value.clientSecret)
return result
}
setCheckout(result.value)
}
return result
Expand All @@ -158,6 +192,11 @@ export const CheckoutProvider = ({
checkoutConfirmStripe: data,
})
if (result.ok) {
// Check for client secret change on confirm
if (result.value.clientSecret !== clientSecret) {
redirectToNewSession(result.value.clientSecret)
return result
}
setCheckout(
result.value as CheckoutPublicConfirmed & { status: 'confirmed' },
)
Expand Down
25 changes: 21 additions & 4 deletions server/polar/checkout/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ async def client_get(
session: AsyncSession = Depends(get_db_session),
) -> Checkout:
"""Get a checkout session by client secret."""
return await checkout_service.get_by_client_secret(session, client_secret)
return await checkout_service.get_or_recreate_by_client_secret(session, client_secret)


@inner_router.post(
Expand Down Expand Up @@ -238,8 +238,18 @@ async def client_update(
locker: Locker = Depends(get_locker),
) -> Checkout:
"""Update a checkout session by client secret."""
checkout = await checkout_service.get_by_client_secret(session, client_secret)

checkout = await checkout_service.get_or_recreate_by_client_secret(session, client_secret)

# If we got a new checkout (different client_secret), we need to handle it specially
if checkout.client_secret != client_secret:
# For a new checkout, we can apply the update immediately without going through the full update flow
# since it's a fresh session
checkout = await checkout_service.update(
session, locker, checkout, checkout_update, ip_geolocation_client
)
# The frontend should detect the new client_secret and redirect
return checkout

return await checkout_service.update(
session, locker, checkout, checkout_update, ip_geolocation_client
)
Expand Down Expand Up @@ -269,7 +279,14 @@ async def client_confirm(

Orders and subscriptions will be processed.
"""
checkout = await checkout_service.get_by_client_secret(session, client_secret)
checkout = await checkout_service.get_or_recreate_by_client_secret(session, client_secret)

# If we got a new checkout (different client_secret), the frontend should handle the redirect
# We cannot complete the confirmation with a new session immediately as it may need
# additional customer input/validation
if checkout.client_secret != client_secret:
# Return the new checkout so frontend can redirect to the new session
return checkout

return await checkout_service.confirm(
session, locker, auth_subject, checkout, checkout_confirm
Expand Down
128 changes: 127 additions & 1 deletion server/polar/checkout/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import typing
import uuid
from collections.abc import AsyncGenerator, AsyncIterator, Sequence

from datetime import timedelta
import stripe as stripe_lib
import structlog
from pydantic import ValidationError as PydanticValidationError
Expand Down Expand Up @@ -82,6 +82,7 @@
from polar.subscription.service import subscription as subscription_service
from polar.webhook.service import webhook as webhook_service
from polar.worker import enqueue_job
from polar.kit.utils import utc_now

from ..kit.tax import InvalidTaxID, TaxCalculationError, calculate_tax
from . import ip_geolocation
Expand Down Expand Up @@ -1045,6 +1046,131 @@ async def get_by_client_secret(
raise ExpiredCheckoutError()
return checkout

async def get_or_recreate_by_client_secret(
self, session: AsyncSession, client_secret: str
) -> Checkout:
"""
Get checkout by client secret, or create a new one if expired.

This method handles the case where a customer idles at checkout
and comes back to an expired session. Instead of returning an error,
we create a new checkout session with the same parameters.

Security boundaries:
- Only recreates sessions expired for less than 24 hours
- Never recreates confirmed, succeeded, or failed sessions
- Validates product/price are still active and available
- Checks organization is not blocked
"""
repository = CheckoutRepository.from_session(session)
checkout = await repository.get_by_client_secret(
client_secret, options=repository.get_eager_options()
)

if checkout is None:
raise ResourceNotFound()

if checkout.is_expired:
# Security check: Don't recreate sessions that are too old (24 hours)
max_recreation_age = timedelta(hours=24)
if checkout.expires_at < utc_now() - max_recreation_age:
raise ExpiredCheckoutError()

# Security check: Never recreate non-open sessions
if checkout.status != CheckoutStatus.open:
raise ExpiredCheckoutError()

# Security check: Validate product and organization are still active
if checkout.product.is_archived:
raise ExpiredCheckoutError()

if checkout.product.organization.is_blocked():
raise ExpiredCheckoutError()

# Security check: Validate price is still active
if checkout.product_price.is_archived:
raise ExpiredCheckoutError()

# Create a new checkout session with the same parameters
new_checkout = Checkout(
payment_processor=checkout.payment_processor,
client_secret=generate_token(prefix=CHECKOUT_CLIENT_SECRET_PREFIX),
amount=checkout.amount,
currency=checkout.currency,
checkout_products=[
CheckoutProduct(product=cp.product, order=cp.order)
for cp in checkout.checkout_products
],
product=checkout.product,
product_price=checkout.product_price,
customer=checkout.customer,
subscription=checkout.subscription,
customer_email=checkout.customer_email,
customer_name=checkout.customer_name,
customer_billing_name=checkout.customer_billing_name,
customer_billing_address=checkout.customer_billing_address,
customer_tax_id=checkout.customer_tax_id,
customer_metadata=checkout.customer_metadata,
discount=checkout.discount,
external_customer_id=checkout.external_customer_id,
is_business_customer=checkout.is_business_customer,
allow_discount_codes=checkout.allow_discount_codes,
require_billing_address=checkout.require_billing_address,
embed_origin=checkout.embed_origin,
_success_url=checkout._success_url,
custom_field_data=checkout.custom_field_data,
metadata=checkout.metadata,
)

# Additional security check: Validate discount is still active (if present)
if new_checkout.discount:
discount = new_checkout.discount
discount_invalid = (
discount.deleted_at is not None or # Soft deleted
(discount.starts_at and discount.starts_at > utc_now()) or # Not started
(discount.ends_at and discount.ends_at < utc_now()) or # Expired
(discount.max_redemptions and discount.redemptions_count >= discount.max_redemptions) or # Max redemptions reached
not discount.is_applicable(new_checkout.product) # Not applicable to product
)
if discount_invalid:
new_checkout.discount = None

# Copy payment processor metadata
if checkout.payment_processor == PaymentProcessor.stripe:
new_checkout.payment_processor_metadata = {
"publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
}
if new_checkout.customer and new_checkout.customer.stripe_customer_id is not None:
stripe_customer_session = await stripe_service.create_customer_session(
new_checkout.customer.stripe_customer_id
)
new_checkout.payment_processor_metadata = {
**new_checkout.payment_processor_metadata,
"customer_session_client_secret": stripe_customer_session.client_secret,
}

# Copy IP address if available
new_checkout.customer_ip_address = checkout.customer_ip_address

# Update IP geolocation (this will be a no-op if IP address is the same)
new_checkout = await self._update_checkout_ip_geolocation(
session, new_checkout, None
)

# Try to calculate tax (swallow errors like in create methods)
try:
new_checkout = await self._update_checkout_tax(session, new_checkout)
except TaxCalculationError:
pass

session.add(new_checkout)
await session.flush()
await self._after_checkout_created(session, new_checkout)

return new_checkout

return checkout

async def _get_validated_price(
self,
session: AsyncSession,
Expand Down
Loading
Loading