Skip to content
11 changes: 10 additions & 1 deletion server/polar/order/schemas.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
from typing import Annotated

from babel.numbers import format_currency
Expand Down Expand Up @@ -179,15 +180,23 @@ class Order(CustomFieldDataOutputMixin, MetadataOutputMixin, OrderBase):

class OrderUpdateBase(Schema):
billing_name: str | None = Field(
default=None,
description=(
"The name of the customer that should appear on the invoice. "
"Can't be updated after the invoice is generated."
)
),
)
billing_address: Address | None = Field(
default=None,
description=(
"The address of the customer that should appear on the invoice. "
"Can't be updated after the invoice is generated."
),
)
custom_field_data: dict[str, str | int | bool | datetime.datetime | None] | None = (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have CustomFieldDataInputMixin in polar/custom_field/data.py that should be used to include that field with all its characteristics in the model.

Field(
default=None,
description="Key-value object storing custom field values. Can be updated by merchants to correct errors.",
)
)

Expand Down
20 changes: 17 additions & 3 deletions server/polar/order/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from polar.checkout.eventstream import CheckoutEvent, publish_checkout_event
from polar.checkout.repository import CheckoutRepository
from polar.config import settings
from polar.custom_field.data import validate_custom_field_data
from polar.customer.repository import CustomerRepository
from polar.customer_portal.schemas.order import (
CustomerOrderPaymentConfirmation,
Expand Down Expand Up @@ -425,10 +426,23 @@ async def update(
if errors:
raise PolarRequestValidationError(errors)

# Handle custom field data validation and update
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unneeded comment

update_dict = order_update.model_dump(exclude_unset=True)

if "custom_field_data" in update_dict:
# Validate custom field data against the product's attached custom fields
custom_field_data = validate_custom_field_data(
order.product.attached_custom_fields,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely raise an eager loading error at runtime (this is a common shortcomings of our tests which usually don't catch that). That's also why @rishi-raj-jain asked for a video showing it working.

For simplicity, you should add that eager load instruction above in the get method:

    async def get(
        self,
        session: AsyncSession,
        auth_subject: AuthSubject[User | Organization],
        id: uuid.UUID,
    ) -> Order | None:
        repository = OrderRepository.from_session(session)
        statement = (
            repository.get_readable_statement(auth_subject)
            .options(
                *repository.get_eager_options(
                    customer_load=contains_eager(Order.customer),
                    product_load=joinedload(Order.product).options(
                        joinedload(Product.organization),
                        selectinload(Product.attached_custom_fields),
                    )
                )
            )
            .where(Order.id == id)
        )
        return await repository.get_one_or_none(statement)

Copy link
Contributor Author

@amankitsingh amankitsingh Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frankie567 are you suggesting this because of the performance, so that update function doesnt lazy load the data?

update_dict["custom_field_data"],
validate_required=False, # Allow merchants to update even if required fields are missing
)
update_dict["custom_field_data"] = custom_field_data

repository = OrderRepository.from_session(session)
order = await repository.update(
order, update_dict=order_update.model_dump(exclude_unset=True)
)
order = await repository.update(order, update_dict=update_dict)

# Refresh the order to get the updated data, including the product relationship
await session.refresh(order, {"product"})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That refresh is not needed at all

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frankie567 is this because, in get method we are eagerly loading the product relationship which the modification?


await self.send_webhook(session, order, WebhookEventType.order_updated)

Expand Down
239 changes: 237 additions & 2 deletions server/tests/order/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@
from httpx import AsyncClient

from polar.auth.scope import Scope
from polar.models import Customer, Order, Product, UserOrganization
from polar.enums import SubscriptionRecurringInterval
from polar.models import Customer, Order, Organization, Product, UserOrganization
from polar.models.custom_field import CustomFieldType
from tests.fixtures.auth import AuthSubjectFixture
from tests.fixtures.database import SaveFixture
from tests.fixtures.random_objects import create_order
from tests.fixtures.random_objects import (
create_custom_field,
create_customer,
create_order,
create_product,
)


@pytest_asyncio.fixture
Expand Down Expand Up @@ -166,6 +173,234 @@ async def test_custom_field(
assert json["custom_field_data"] == {"test": None}


@pytest.mark.asyncio
class TestUpdateOrder:
async def test_anonymous(self, client: AsyncClient, orders: list[Order]) -> None:
response = await client.patch(
f"/v1/orders/{orders[0].id}",
json={"custom_field_data": {"test": "updated"}},
)

assert response.status_code == 401

@pytest.mark.auth
async def test_not_existing(self, client: AsyncClient) -> None:
response = await client.patch(
f"/v1/orders/{uuid.uuid4()}",
json={"custom_field_data": {"test": "updated"}},
)

assert response.status_code == 404

@pytest.mark.auth
async def test_user_not_organization_member(
self, client: AsyncClient, orders: list[Order]
) -> None:
response = await client.patch(
f"/v1/orders/{orders[0].id}",
json={"custom_field_data": {"test": "updated"}},
)

assert response.status_code == 404

@pytest.mark.auth(
AuthSubjectFixture(scopes={Scope.web_write}),
AuthSubjectFixture(scopes={Scope.orders_write}),
)
async def test_user_valid(
self,
save_fixture: SaveFixture,
client: AsyncClient,
user_organization: UserOrganization,
organization: Organization,
) -> None:
# Create a product with custom fields
text_field = await create_custom_field(
save_fixture,
type=CustomFieldType.text,
slug="text",
organization=organization,
)
select_field = await create_custom_field(
save_fixture,
type=CustomFieldType.select,
slug="select",
organization=organization,
properties={
"options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
},
)
product = await create_product(
save_fixture,
organization=organization,
recurring_interval=SubscriptionRecurringInterval.month,
attached_custom_fields=[(text_field, False), (select_field, True)],
)

# Create an order with custom field data
order = await create_order(
save_fixture,
product=product,
customer=await create_customer(save_fixture, organization=organization),
custom_field_data={"text": "original", "select": "a"},
)

response = await client.patch(
f"/v1/orders/{order.id}",
json={"custom_field_data": {"text": "updated", "select": "b"}},
)

assert response.status_code == 200

json = response.json()
assert json["custom_field_data"] == {"text": "updated", "select": "b"}

@pytest.mark.auth(
AuthSubjectFixture(subject="organization", scopes={Scope.orders_write}),
)
async def test_organization(
self, save_fixture: SaveFixture, client: AsyncClient, organization: Organization
) -> None:
# Create a product with custom fields
text_field = await create_custom_field(
save_fixture,
type=CustomFieldType.text,
slug="text",
organization=organization,
)
select_field = await create_custom_field(
save_fixture,
type=CustomFieldType.select,
slug="select",
organization=organization,
properties={
"options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
},
)
product = await create_product(
save_fixture,
organization=organization,
recurring_interval=SubscriptionRecurringInterval.month,
attached_custom_fields=[(text_field, False), (select_field, True)],
)

# Create an order with custom field data
order = await create_order(
save_fixture,
product=product,
customer=await create_customer(save_fixture, organization=organization),
custom_field_data={"text": "original", "select": "a"},
)

response = await client.patch(
f"/v1/orders/{order.id}",
json={"custom_field_data": {"text": "updated", "select": "b"}},
)

assert response.status_code == 200

json = response.json()
assert json["custom_field_data"] == {"text": "updated", "select": "b"}

@pytest.mark.auth(
AuthSubjectFixture(scopes={Scope.web_write}),
)
async def test_update_existing_custom_field_data(
self,
save_fixture: SaveFixture,
client: AsyncClient,
user_organization: UserOrganization,
organization: Organization,
) -> None:
# Create a product with custom fields
text_field = await create_custom_field(
save_fixture,
type=CustomFieldType.text,
slug="text",
organization=organization,
)
select_field = await create_custom_field(
save_fixture,
type=CustomFieldType.select,
slug="select",
organization=organization,
properties={
"options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
},
)
product = await create_product(
save_fixture,
organization=organization,
recurring_interval=SubscriptionRecurringInterval.month,
attached_custom_fields=[(text_field, False), (select_field, True)],
)

# Create an order with custom field data
order = await create_order(
save_fixture,
product=product,
customer=await create_customer(save_fixture, organization=organization),
custom_field_data={"text": "original", "select": "a"},
)

response = await client.patch(
f"/v1/orders/{order.id}",
json={"custom_field_data": {"text": "updated", "select": "b"}},
)

assert response.status_code == 200

json = response.json()
assert json["custom_field_data"] == {"text": "updated", "select": "b"}

@pytest.mark.auth(
AuthSubjectFixture(scopes={Scope.web_write}),
)
async def test_update_billing_name(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not relevant for those changes

self,
client: AsyncClient,
user_organization: UserOrganization,
orders: list[Order],
) -> None:
response = await client.patch(
f"/v1/orders/{orders[0].id}",
json={"billing_name": "Updated Billing Name"},
)

assert response.status_code == 200

json = response.json()
assert json["billing_name"] == "Updated Billing Name"

@pytest.mark.auth(
AuthSubjectFixture(scopes={Scope.web_write}),
)
async def test_update_billing_address(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not relevant for those changes

self,
client: AsyncClient,
user_organization: UserOrganization,
orders: list[Order],
) -> None:
new_address = {
"country": "US",
"state": "CA",
"line1": "123 Updated St",
"city": "Updated City",
"postal_code": "12345",
}

response = await client.patch(
f"/v1/orders/{orders[0].id}",
json={"billing_address": new_address},
)

assert response.status_code == 200

json = response.json()
assert json["billing_address"]["line1"] == "123 Updated St"
assert json["billing_address"]["city"] == "Updated City"


@pytest.mark.asyncio
class TesGetOrdersStatistics:
async def test_anonymous(self, client: AsyncClient) -> None:
Expand Down
Loading