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
@@ -0,0 +1,55 @@
"""Unescape checkout_id placeholder in success URLs

Revision ID: b797366de1fb
Revises: bf0f120ca9a9
Create Date: 2025-09-15 21:12:39.367225

"""

import sqlalchemy as sa
from alembic import op

# Polar Custom Imports

# revision identifiers, used by Alembic.
revision = "b797366de1fb"
down_revision = "bf0f120ca9a9"
branch_labels: tuple[str] | None = None
depends_on: tuple[str] | None = None

ENCODED_PLACEHOLDER = "%7BCHECKOUT_ID%7D"
DECODED_PLACEHOLDER = "{CHECKOUT_ID}"


def upgrade() -> None:
op.execute(
f"""
UPDATE checkout_links
SET success_url = REPLACE(success_url, '{ENCODED_PLACEHOLDER}', '{DECODED_PLACEHOLDER}')
WHERE success_url LIKE '%{ENCODED_PLACEHOLDER}%'
"""
)
op.execute(
f"""
UPDATE checkouts
SET success_url = REPLACE(success_url, '{ENCODED_PLACEHOLDER}', '{DECODED_PLACEHOLDER}')
WHERE success_url LIKE '%{ENCODED_PLACEHOLDER}%'
"""
)


def downgrade() -> None:
op.execute(
f"""
UPDATE checkout_links
SET success_url = REPLACE(success_url, '{DECODED_PLACEHOLDER}', '{ENCODED_PLACEHOLDER}')
WHERE success_url LIKE '%{DECODED_PLACEHOLDER}%'
"""
)
op.execute(
f"""
UPDATE checkouts
SET success_url = REPLACE(success_url, '{DECODED_PLACEHOLDER}', '{ENCODED_PLACEHOLDER}')
WHERE success_url LIKE '%{DECODED_PLACEHOLDER}%'
"""
)
15 changes: 13 additions & 2 deletions server/polar/checkout/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
AliasChoices,
Discriminator,
Field,
HttpUrl,
IPvAnyAddress,
Tag,
computed_field,
field_validator,
)
from pydantic.json_schema import SkipJsonSchema

Expand Down Expand Up @@ -46,6 +46,7 @@
TrialConfigurationOutputMixin,
TrialInterval,
)
from polar.kit.validators import validate_http_url
from polar.models.checkout import (
CheckoutBillingAddressFields,
CheckoutCustomerBillingAddressFields,
Expand Down Expand Up @@ -97,7 +98,7 @@
Address, Field(description="Billing address of the customer.")
]
SuccessURL = Annotated[
HttpUrl | None,
str | None,
Field(
description=(
"URL where the customer will be redirected after a successful payment."
Expand Down Expand Up @@ -204,6 +205,11 @@ class CheckoutCreateBase(
success_url: SuccessURL = None
embed_origin: EmbedOrigin = None

@field_validator("success_url")
@classmethod
def validate_is_http_url(cls, url: str | None) -> str | None:
return validate_http_url(url)


class CheckoutPriceCreate(CheckoutCreateBase):
"""
Expand Down Expand Up @@ -325,6 +331,11 @@ class CheckoutUpdate(
success_url: SuccessURL = None
embed_origin: EmbedOrigin = None

@field_validator("success_url")
@classmethod
def validate_is_http_url(cls, url: str | None) -> str | None:
return validate_http_url(url)


class CheckoutUpdatePublic(CheckoutUpdateBase):
"""Update an existing checkout session using the client secret."""
Expand Down
21 changes: 19 additions & 2 deletions server/polar/checkout_link/schemas.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from typing import Annotated, Literal

from pydantic import UUID4, AliasPath, Field, HttpUrl, computed_field
from pydantic import (
UUID4,
AliasPath,
Field,
computed_field,
field_validator,
)
from pydantic.json_schema import SkipJsonSchema

from polar.config import settings
Expand All @@ -18,6 +24,7 @@
TimestampedSchema,
)
from polar.kit.trial import TrialConfigurationInputMixin, TrialConfigurationOutputMixin
from polar.kit.validators import validate_http_url
from polar.organization.schemas import OrganizationID
from polar.product.schemas import (
BenefitPublicList,
Expand All @@ -28,7 +35,7 @@
)

SuccessURL = Annotated[
HttpUrl | None,
str | None,
Field(
description=(
"URL where the customer will be redirected after a successful payment."
Expand Down Expand Up @@ -74,6 +81,11 @@ class CheckoutLinkCreateBase(TrialConfigurationInputMixin, MetadataInputMixin, S
)
success_url: SuccessURL = None

@field_validator("success_url")
@classmethod
def validate_is_http_url(cls, url: str | None) -> str | None:
return validate_http_url(url)


class CheckoutLinkCreateProductPrice(CheckoutLinkCreateBase):
"""
Expand Down Expand Up @@ -132,6 +144,11 @@ class CheckoutLinkUpdate(MetadataInputMixin, TrialConfigurationInputMixin):
)
success_url: SuccessURL = None

@field_validator("success_url")
@classmethod
def validate_is_http_url(cls, url: str | None) -> str | None:
return validate_http_url(url)


class CheckoutLinkBase(
MetadataOutputMixin, TrialConfigurationOutputMixin, TimestampedSchema, IDSchema
Expand Down
8 changes: 8 additions & 0 deletions server/polar/kit/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from pydantic import HttpUrl, TypeAdapter


def validate_http_url(url: str | None) -> str | None:
if not url:
return None
TypeAdapter(HttpUrl).validate_python(url)
return url
10 changes: 3 additions & 7 deletions server/tests/checkout/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest
import pytest_asyncio
import stripe as stripe_lib
from pydantic import HttpUrl, ValidationError
from pydantic import ValidationError
from pytest_mock import MockerFixture
from sqlalchemy.orm import joinedload

Expand Down Expand Up @@ -739,9 +739,7 @@ async def test_valid_success_url_with_interpolation(
session,
CheckoutPriceCreate(
product_price_id=price.id,
success_url=HttpUrl(
"https://example.com/success?checkout_id={CHECKOUT_ID}"
),
success_url="https://example.com/success?checkout_id={CHECKOUT_ID}",
),
auth_subject,
)
Expand All @@ -765,9 +763,7 @@ async def test_valid_success_url_with_invalid_interpolation_variable(
session,
CheckoutPriceCreate(
product_price_id=price.id,
success_url=HttpUrl(
"https://example.com/success?checkout_id={CHECKOUT_SESSION_ID}"
),
success_url="https://example.com/success?checkout_id={CHECKOUT_SESSION_ID}",
),
auth_subject,
)
Expand Down
9 changes: 2 additions & 7 deletions server/tests/checkout_link/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import pytest
import pytest_asyncio
from pydantic import HttpUrl

from polar.auth.models import AuthSubject
from polar.checkout_link.schemas import (
Expand Down Expand Up @@ -159,9 +158,7 @@ async def test_valid(
CheckoutLinkCreateProducts(
payment_processor=PaymentProcessor.stripe,
products=[product_one_time.id],
success_url=HttpUrl(
"https://example.com/success?checkout_id={CHECKOUT_ID}"
),
success_url="https://example.com/success?checkout_id={CHECKOUT_ID}",
metadata={"key": "value"},
),
auth_subject,
Expand Down Expand Up @@ -192,9 +189,7 @@ async def test_valid_discount(
payment_processor=PaymentProcessor.stripe,
products=[product_one_time.id],
discount_id=discount_fixed_once.id,
success_url=HttpUrl(
"https://example.com/success?checkout_id={CHECKOUT_ID}"
),
success_url="https://example.com/success?checkout_id={CHECKOUT_ID}",
metadata={"key": "value"},
),
auth_subject,
Expand Down
Loading