From a90122ff23a4eaf1840b4808eb731b726cc4529e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Dec 2025 07:47:30 +0000 Subject: [PATCH 1/2] Prevent changing billing address country/state after invoice is generated VAT is calculated based on the billing address at order creation time. Changing the country/state after an order has been paid would result in an inconsistent invoice where the address doesn't match the VAT charged. Changes: - Backend: Add InvoiceBillingAddressUpdateError that is raised when attempting to change country or state on a paid order - Frontend: Disable country and state picker fields in the Edit Invoice modal when the order has been paid - Add disabled prop support to CountryPicker and CountryStatePicker components --- .../src/components/Orders/DownloadInvoice.tsx | 2 ++ .../ui/src/components/atoms/CountryPicker.tsx | 6 +++-- .../components/atoms/CountryStatePicker.tsx | 6 ++++- .../polar/customer_portal/endpoints/order.py | 9 ++++++- server/polar/order/endpoints.py | 14 +++++++++-- server/polar/order/service.py | 25 +++++++++++++++++++ 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/clients/apps/web/src/components/Orders/DownloadInvoice.tsx b/clients/apps/web/src/components/Orders/DownloadInvoice.tsx index d6c9d0ab3d..840fb77a30 100644 --- a/clients/apps/web/src/components/Orders/DownloadInvoice.tsx +++ b/clients/apps/web/src/components/Orders/DownloadInvoice.tsx @@ -341,6 +341,7 @@ const DownloadInvoice = ({ country={country} value={field.value || ''} onChange={field.onChange} + disabled={order.paid} /> @@ -361,6 +362,7 @@ const DownloadInvoice = ({ value={field.value || undefined} onChange={field.onChange} allowedCountries={enums.addressInputCountryValues} + disabled={order.paid} /> diff --git a/clients/packages/ui/src/components/atoms/CountryPicker.tsx b/clients/packages/ui/src/components/atoms/CountryPicker.tsx index dc2d87866e..c57c1fc17e 100644 --- a/clients/packages/ui/src/components/atoms/CountryPicker.tsx +++ b/clients/packages/ui/src/components/atoms/CountryPicker.tsx @@ -28,6 +28,7 @@ const CountryPicker = ({ className, itemClassName, contentClassName, + disabled, }: { allowedCountries: readonly string[] value?: string @@ -36,11 +37,12 @@ const CountryPicker = ({ className?: string itemClassName?: string contentClassName?: string + disabled?: boolean }) => { const countryMap = getCountryList(allowedCountries as TCountryCode[]) return ( - + void country?: string autoComplete?: string + disabled?: boolean }) => { if (country === 'US' || country === 'CA') { const states = country === 'US' ? US_STATES : CA_PROVINCES @@ -100,8 +102,9 @@ const CountryStatePicker = ({ onValueChange={onChange} value={value} autoComplete={autoComplete} + disabled={disabled} > - + onChange(e.target.value)} + disabled={disabled} /> ) } diff --git a/server/polar/customer_portal/endpoints/order.py b/server/polar/customer_portal/endpoints/order.py index cf99ed95a9..f8d0547970 100644 --- a/server/polar/customer_portal/endpoints/order.py +++ b/server/polar/customer_portal/endpoints/order.py @@ -12,6 +12,7 @@ from polar.openapi import APITag from polar.order.schemas import OrderID from polar.order.service import ( + InvoiceBillingAddressUpdateError, MissingInvoiceBillingDetails, NotPaidOrder, PaymentAlreadyInProgress, @@ -113,7 +114,13 @@ async def get( "/{id}", summary="Update Order", response_model=CustomerOrder, - responses={404: OrderNotFound}, + responses={ + 404: OrderNotFound, + 422: { + "description": "Cannot update billing address country/state after order is paid.", + "model": InvoiceBillingAddressUpdateError.schema(), + }, + }, ) async def update( id: OrderID, diff --git a/server/polar/order/endpoints.py b/server/polar/order/endpoints.py index 280bc12820..9b96d9e338 100644 --- a/server/polar/order/endpoints.py +++ b/server/polar/order/endpoints.py @@ -26,7 +26,11 @@ from . import auth, sorting from .schemas import Order as OrderSchema from .schemas import OrderID, OrderInvoice, OrderNotFound, OrderUpdate -from .service import MissingInvoiceBillingDetails, NotPaidOrder +from .service import ( + InvoiceBillingAddressUpdateError, + MissingInvoiceBillingDetails, + NotPaidOrder, +) from .service import order as order_service router = APIRouter(prefix="/orders", tags=["orders", APITag.public, APITag.mcp]) @@ -173,7 +177,13 @@ async def get( "/{id}", summary="Update Order", response_model=OrderSchema, - responses={404: OrderNotFound}, + responses={ + 404: OrderNotFound, + 422: { + "description": "Cannot update billing address country/state after order is paid.", + "model": InvoiceBillingAddressUpdateError.schema(), + }, + }, ) async def update( id: OrderID, diff --git a/server/polar/order/service.py b/server/polar/order/service.py index 66ce65da32..243c220811 100644 --- a/server/polar/order/service.py +++ b/server/polar/order/service.py @@ -223,6 +223,16 @@ def __init__(self, subscription: Subscription) -> None: super().__init__(message) +class InvoiceBillingAddressUpdateError(OrderError): + def __init__(self, order: Order) -> None: + self.order = order + message = ( + "Cannot update billing address country or state after order is paid, " + "as VAT was calculated based on the original address." + ) + super().__init__(message, 422) + + def _is_empty_customer_address(customer_address: dict[str, Any] | None) -> bool: return customer_address is None or customer_address["country"] is None @@ -339,6 +349,21 @@ async def update( order: Order, order_update: OrderUpdate | CustomerOrderUpdate, ) -> Order: + # Validate that country/state cannot be changed after order is paid + # because VAT was calculated based on the original address + if order.paid and order_update.billing_address is not None: + new_address = order_update.billing_address + existing_address = order.billing_address + + # Check if country or state is being changed + new_country = new_address.country if new_address else None + new_state = new_address.state if new_address else None + existing_country = existing_address.get("country") if existing_address else None + existing_state = existing_address.get("state") if existing_address else None + + if new_country != existing_country or new_state != existing_state: + raise InvoiceBillingAddressUpdateError(order) + repository = OrderRepository.from_session(session) order = await repository.update( order, update_dict=order_update.model_dump(exclude_unset=True) From 355c6ad2bed0d442b0b6107b3e924da17a138e91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Dec 2025 14:04:55 +0000 Subject: [PATCH 2/2] Use PolarRequestValidationError for billing address validation Replace custom InvoiceBillingAddressUpdateError with standard PolarRequestValidationError for better consistency with the codebase. --- .../ui/src/components/atoms/CountryPicker.tsx | 7 ++- .../polar/customer_portal/endpoints/order.py | 9 +--- server/polar/order/endpoints.py | 14 +----- server/polar/order/service.py | 47 ++++++++++++------- 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/clients/packages/ui/src/components/atoms/CountryPicker.tsx b/clients/packages/ui/src/components/atoms/CountryPicker.tsx index c57c1fc17e..f1190ea744 100644 --- a/clients/packages/ui/src/components/atoms/CountryPicker.tsx +++ b/clients/packages/ui/src/components/atoms/CountryPicker.tsx @@ -41,7 +41,12 @@ const CountryPicker = ({ }) => { const countryMap = getCountryList(allowedCountries as TCountryCode[]) return ( - None: super().__init__(message) -class InvoiceBillingAddressUpdateError(OrderError): - def __init__(self, order: Order) -> None: - self.order = order - message = ( - "Cannot update billing address country or state after order is paid, " - "as VAT was calculated based on the original address." - ) - super().__init__(message, 422) - - def _is_empty_customer_address(customer_address: dict[str, Any] | None) -> bool: return customer_address is None or customer_address["country"] is None @@ -355,14 +345,35 @@ async def update( new_address = order_update.billing_address existing_address = order.billing_address - # Check if country or state is being changed - new_country = new_address.country if new_address else None + new_country = str(new_address.country) if new_address else None new_state = new_address.state if new_address else None - existing_country = existing_address.get("country") if existing_address else None - existing_state = existing_address.get("state") if existing_address else None - - if new_country != existing_country or new_state != existing_state: - raise InvoiceBillingAddressUpdateError(order) + existing_country = ( + str(existing_address.country) if existing_address else None + ) + existing_state = existing_address.state if existing_address else None + + if new_country != existing_country: + raise PolarRequestValidationError( + [ + { + "type": "value_error", + "loc": ("body", "billing_address", "country"), + "msg": "Cannot change country after order is paid.", + "input": new_country, + } + ] + ) + if new_state != existing_state: + raise PolarRequestValidationError( + [ + { + "type": "value_error", + "loc": ("body", "billing_address", "state"), + "msg": "Cannot change state after order is paid.", + "input": new_state, + } + ] + ) repository = OrderRepository.from_session(session) order = await repository.update(