From 9cacda28c6edf5c6e551ca841d9c1e06d2e4abe2 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 16:49:53 +0530 Subject: [PATCH 01/15] feat: implement organization-level notifications - Add OrganizationNotification model for tracking read status per organization - Add organization notification endpoints: GET/POST /organizations/{id}/notifications - Add send_to_organization() method to notification service - Add database migrations for organization_notifications table and index - Maintain backward compatibility with existing user-level notifications Closes #6598 --- ...22_add_organization_notifications_table.py | 38 ++++++++++ ...8-29-1624_add_organization_id_index_to_.py | 35 +++++++++ server/polar/models/__init__.py | 2 + server/polar/models/notification.py | 4 ++ .../polar/models/organization_notification.py | 22 ++++++ server/polar/notifications/endpoints.py | 42 ++++++++++- server/polar/notifications/schemas.py | 9 +++ server/polar/notifications/service.py | 59 +++++++++++++++ server/tests/notifications/test_endpoints.py | 37 ++++++++++ server/tests/notifications/test_service.py | 71 +++++++++++++++++++ 10 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py create mode 100644 server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py create mode 100644 server/polar/models/organization_notification.py create mode 100644 server/tests/notifications/test_service.py diff --git a/server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py b/server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py new file mode 100644 index 0000000000..5c531af46b --- /dev/null +++ b/server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py @@ -0,0 +1,38 @@ +"""add_organization_notifications_table + +Revision ID: e679ea1bb2c5 +Revises: b5ffc01faa80 +Create Date: 2025-08-29 16:22:00.271280 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "e679ea1bb2c5" +down_revision = "b5ffc01faa80" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "organization_notifications", + sa.Column("organization_id", sa.UUID(), nullable=False), + sa.Column("last_read_notification_id", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint( + ["organization_id"], ["organizations.id"], name=op.f("organization_notifications_organization_id_fkey") + ), + sa.PrimaryKeyConstraint("organization_id", name=op.f("organization_notifications_pkey")), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("organization_notifications") + # ### end Alembic commands ### diff --git a/server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py b/server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py new file mode 100644 index 0000000000..0a1d8fbd95 --- /dev/null +++ b/server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py @@ -0,0 +1,35 @@ +"""add_organization_id_index_to_notifications + +Revision ID: d0a1dc3e9a90 +Revises: e679ea1bb2c5 +Create Date: 2025-08-29 16:24:54.991724 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "d0a1dc3e9a90" +down_revision = "e679ea1bb2c5" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index( + "idx_notifications_organization_id", + "notifications", + ["organization_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("idx_notifications_organization_id", table_name="notifications") + # ### end Alembic commands ### diff --git a/server/polar/models/__init__.py b/server/polar/models/__init__.py index ff35ae6114..eb3d91c9dc 100644 --- a/server/polar/models/__init__.py +++ b/server/polar/models/__init__.py @@ -30,6 +30,7 @@ from .meter import Meter from .notification import Notification from .notification_recipient import NotificationRecipient +from .organization_notification import OrganizationNotification from .oauth2_authorization_code import OAuth2AuthorizationCode from .oauth2_client import OAuth2Client from .oauth2_grant import OAuth2Grant @@ -106,6 +107,7 @@ "Meter", "Notification", "NotificationRecipient", + "OrganizationNotification", "OAuth2AuthorizationCode", "OAuth2Client", "OAuth2Grant", diff --git a/server/polar/models/notification.py b/server/polar/models/notification.py index cec149d055..fe1abe4949 100644 --- a/server/polar/models/notification.py +++ b/server/polar/models/notification.py @@ -15,6 +15,10 @@ class Notification(RecordModel): "idx_notifications_user_id", "user_id", ), + Index( + "idx_notifications_organization_id", + "organization_id", + ), ) user_id: Mapped[UUID] = mapped_column(Uuid, nullable=True) diff --git a/server/polar/models/organization_notification.py b/server/polar/models/organization_notification.py new file mode 100644 index 0000000000..e71d06a8b7 --- /dev/null +++ b/server/polar/models/organization_notification.py @@ -0,0 +1,22 @@ +from uuid import UUID + +from sqlalchemy import ForeignKey, Uuid +from sqlalchemy.orm import Mapped, mapped_column + +from polar.kit.db.models import Model + + +class OrganizationNotification(Model): + __tablename__ = "organization_notifications" + + organization_id: Mapped[UUID] = mapped_column( + Uuid, + ForeignKey("organizations.id"), + nullable=False, + primary_key=True, + ) + + last_read_notification_id: Mapped[UUID] = mapped_column( + Uuid, + nullable=True, + ) diff --git a/server/polar/notifications/endpoints.py b/server/polar/notifications/endpoints.py index 356cae0cea..4409ed41f8 100644 --- a/server/polar/notifications/endpoints.py +++ b/server/polar/notifications/endpoints.py @@ -19,10 +19,17 @@ auth as notifications_auth, ) from polar.openapi import APITag +from polar.organization import auth as organization_auth +from polar.organization.schemas import OrganizationID from polar.postgres import AsyncSession, get_db_session from polar.routing import APIRouter -from .schemas import NotificationsList, NotificationsMarkRead +from .schemas import ( + NotificationsList, + NotificationsMarkRead, + OrganizationNotificationsList, + OrganizationNotificationsMarkRead, +) from .service import notifications NotificationRecipientID = Annotated[ @@ -124,3 +131,36 @@ async def delete( ) -> None: """Delete a notification recipient.""" await notification_recipient_service.delete(session, auth_subject, id) + + +# Organization notification endpoints +@router.get("/organizations/{organization_id}/notifications", response_model=OrganizationNotificationsList) +async def get_organization_notifications( + organization_id: OrganizationID, + auth_subject: organization_auth.OrganizationsRead, + session: AsyncSession = Depends(get_db_session), +) -> OrganizationNotificationsList: + """Get notifications for an organization.""" + notifs = await notifications.get_for_organization(session, organization_id) + last_read_notification_id = await notifications.get_organization_last_read( + session, organization_id + ) + + return OrganizationNotificationsList( + notifications=notifs, # type: ignore + last_read_notification_id=last_read_notification_id, + ) + + +@router.post("/organizations/{organization_id}/notifications/read") +async def mark_organization_notifications_read( + organization_id: OrganizationID, + read: OrganizationNotificationsMarkRead, + auth_subject: organization_auth.OrganizationsWrite, + session: AsyncSession = Depends(get_db_session), +) -> None: + """Mark organization notifications as read.""" + await notifications.set_organization_last_read( + session, organization_id, read.notification_id + ) + return None diff --git a/server/polar/notifications/schemas.py b/server/polar/notifications/schemas.py index 9c7f80cf29..ca21d9e056 100644 --- a/server/polar/notifications/schemas.py +++ b/server/polar/notifications/schemas.py @@ -11,3 +11,12 @@ class NotificationsList(Schema): class NotificationsMarkRead(Schema): notification_id: UUID4 + + +class OrganizationNotificationsList(Schema): + notifications: list[Notification] + last_read_notification_id: UUID4 | None + + +class OrganizationNotificationsMarkRead(Schema): + notification_id: UUID4 diff --git a/server/polar/notifications/service.py b/server/polar/notifications/service.py index df24bb8464..fc5572f250 100644 --- a/server/polar/notifications/service.py +++ b/server/polar/notifications/service.py @@ -6,6 +6,7 @@ from polar.kit.extensions.sqlalchemy import sql from polar.models.notification import Notification +from polar.models.organization_notification import OrganizationNotification from polar.models.pledge import Pledge from polar.models.user_notification import UserNotification from polar.notifications.notification import Notification as NotificationSchema @@ -44,6 +45,20 @@ async def get_for_user( res = await session.execute(stmt) return res.scalars().unique().all() + async def get_for_organization( + self, session: AsyncSession, organization_id: UUID + ) -> Sequence[Notification]: + stmt = ( + sql.select(Notification) + .join(Pledge, Pledge.id == Notification.pledge_id, isouter=True) + .where(Notification.organization_id == organization_id) + .order_by(desc(Notification.created_at)) + .limit(100) + ) + + res = await session.execute(stmt) + return res.scalars().unique().all() + async def send_to_user( self, session: AsyncSession, @@ -77,6 +92,25 @@ async def send_to_org_members( notif=notif, ) + async def send_to_organization( + self, + session: AsyncSession, + organization_id: UUID, + notif: PartialNotification, + ) -> bool: + notification = Notification( + organization_id=organization_id, + type=notif.type, + pledge_id=notif.pledge_id, + payload=notif.payload.model_dump(mode="json"), + ) + + session.add(notification) + await session.flush() + enqueue_job("notifications.send", notification_id=notification.id) + enqueue_job("notifications.push", notification_id=notification.id) + return True + async def send_to_anonymous_email( self, session: AsyncSession, @@ -152,5 +186,30 @@ async def set_user_last_read( ) await session.execute(stmt) + async def get_organization_last_read( + self, session: AsyncSession, organization_id: UUID + ) -> UUID | None: + stmt = sql.select(OrganizationNotification).where( + OrganizationNotification.organization_id == organization_id + ) + res = await session.execute(stmt) + org_notif = res.scalar_one_or_none() + return org_notif.last_read_notification_id if org_notif else None + + async def set_organization_last_read( + self, session: AsyncSession, organization_id: UUID, notification_id: UUID + ) -> None: + stmt = ( + sql.insert(OrganizationNotification) + .values( + organization_id=organization_id, last_read_notification_id=notification_id + ) + .on_conflict_do_update( + index_elements=[OrganizationNotification.organization_id], + set_={"last_read_notification_id": notification_id}, + ) + ) + await session.execute(stmt) + notifications = NotificationsService() diff --git a/server/tests/notifications/test_endpoints.py b/server/tests/notifications/test_endpoints.py index a180730d55..b61a07c492 100644 --- a/server/tests/notifications/test_endpoints.py +++ b/server/tests/notifications/test_endpoints.py @@ -1,5 +1,10 @@ import pytest from httpx import AsyncClient +from uuid import uuid4 + +from polar.auth.scope import Scope +from polar.models import Organization, UserOrganization +from tests.fixtures.auth import AuthSubjectFixture @pytest.mark.asyncio @@ -8,3 +13,35 @@ async def test_get(client: AsyncClient) -> None: response = await client.get("/v1/notifications") assert response.status_code == 200 + + +@pytest.mark.asyncio +class TestOrganizationNotifications: + @pytest.mark.auth(AuthSubjectFixture(scopes={Scope.web_read, Scope.organizations_read})) + async def test_get_organization_notifications( + self, client: AsyncClient, organization: Organization + ) -> None: + response = await client.get(f"/v1/organizations/{organization.id}/notifications") + + assert response.status_code == 200 + data = response.json() + assert "notifications" in data + assert "last_read_notification_id" in data + + @pytest.mark.auth(AuthSubjectFixture(scopes={Scope.web_write, Scope.organizations_write})) + async def test_mark_organization_notifications_read( + self, client: AsyncClient, organization: Organization + ) -> None: + # First get notifications to get a notification ID + response = await client.get(f"/v1/organizations/{organization.id}/notifications") + assert response.status_code == 200 + + # Mark as read (this will work even if there are no notifications) + response = await client.post( + f"/v1/organizations/{organization.id}/notifications/read", + json={"notification_id": str(uuid4())} + ) + if response.status_code != 200: + print(f"Response status: {response.status_code}") + print(f"Response body: {response.text}") + assert response.status_code == 200 diff --git a/server/tests/notifications/test_service.py b/server/tests/notifications/test_service.py new file mode 100644 index 0000000000..9515fb223e --- /dev/null +++ b/server/tests/notifications/test_service.py @@ -0,0 +1,71 @@ +import pytest +from uuid import uuid4 + +from polar.models import Organization, OrganizationNotification, User +from polar.notifications.service import notifications +from tests.fixtures.database import SaveFixture + + +@pytest.mark.asyncio +class TestNotificationsService: + async def test_get_for_organization( + self, session, organization: Organization + ) -> None: + # Test getting notifications for an organization + notifs = await notifications.get_for_organization(session, organization.id) + assert isinstance(notifs, list) + + async def test_get_organization_last_read( + self, session, organization: Organization + ) -> None: + # Test getting last read notification for an organization + last_read = await notifications.get_organization_last_read( + session, organization.id + ) + assert last_read is None # Should be None initially + + async def test_set_organization_last_read( + self, session, organization: Organization, save_fixture: SaveFixture + ) -> None: + # Test setting last read notification for an organization + notification_id = uuid4() + await notifications.set_organization_last_read( + session, organization.id, notification_id + ) + await session.commit() + + # Verify it was set + last_read = await notifications.get_organization_last_read( + session, organization.id + ) + assert last_read == notification_id + + async def test_send_to_organization( + self, session, organization: Organization + ) -> None: + # Test sending a notification to an organization + from polar.notifications.notification import NotificationPayload, NotificationType + + from polar.notifications.service import PartialNotification + + from polar.notifications.notification import MaintainerNewProductSaleNotificationPayload + + notif = PartialNotification( + type=NotificationType.maintainer_new_product_sale, + payload=MaintainerNewProductSaleNotificationPayload( + customer_name="Test Customer", + product_name="Test Product", + product_price_amount=1000, + organization_name="Test Org", + ), + ) + + result = await notifications.send_to_organization( + session, organization.id, notif + ) + assert result is True + + # Verify the notification was created + notifs = await notifications.get_for_organization(session, organization.id) + assert len(notifs) == 1 + assert notifs[0].organization_id == organization.id From 5b081d7b1a75a3dd3ea2fc6549250b421890b373 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 16:53:57 +0530 Subject: [PATCH 02/15] Revert "fix: backoffice link in plain (#6609)" This reverts commit c65acb228d0fe81563dd2da317cd7af2ca5d283d. --- server/polar/integrations/plain/service.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/server/polar/integrations/plain/service.py b/server/polar/integrations/plain/service.py index 59c488977b..f6ff0803ec 100644 --- a/server/polar/integrations/plain/service.py +++ b/server/polar/integrations/plain/service.py @@ -581,13 +581,11 @@ async def create_appeal_review_thread( ], ) ), - # Backoffice Link + # Admin Dashboard Link ComponentContainerContentInput( component_link_button=ComponentLinkButtonInput( - link_button_url=settings.generate_external_url( - f"/backoffice/organizations/{organization.id}" - ), - link_button_label="View in Backoffice", + link_button_url=f"{settings.FRONTEND_BASE_URL}/backoffice/organizations/{organization.id}", + link_button_label="View in Admin Dashboard", ) ), ] From 92315f1154e8c019537bda063bf2cb1e30a0d001 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:18:44 +0530 Subject: [PATCH 03/15] update custom field data --- server/polar/order/schemas.py | 9 ++++++++- server/polar/order/service.py | 20 +++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/server/polar/order/schemas.py b/server/polar/order/schemas.py index 83bb063252..3b804436bd 100644 --- a/server/polar/order/schemas.py +++ b/server/polar/order/schemas.py @@ -1,3 +1,4 @@ +import datetime from typing import Annotated from babel.numbers import format_currency @@ -5,7 +6,7 @@ from pydantic import UUID4, AliasChoices, AliasPath, Field, computed_field from pydantic.json_schema import SkipJsonSchema -from polar.custom_field.data import CustomFieldDataOutputMixin +from polar.custom_field.data import CustomFieldDataInputMixin, CustomFieldDataOutputMixin from polar.customer.schemas.customer import CustomerBase from polar.discount.schemas import DiscountMinimal from polar.exceptions import ResourceNotFound @@ -179,17 +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 = Field( + default=None, + description="Key-value object storing custom field values. Can be updated by merchants to correct errors.", + ) class OrderUpdate(OrderUpdateBase): diff --git a/server/polar/order/service.py b/server/polar/order/service.py index 890d7744d8..c446791b1d 100644 --- a/server/polar/order/service.py +++ b/server/polar/order/service.py @@ -27,6 +27,7 @@ from polar.enums import PaymentProcessor from polar.eventstream.service import publish as eventstream_publish from polar.exceptions import PolarError, PolarRequestValidationError, ValidationError +from polar.custom_field.data import validate_custom_field_data from polar.held_balance.service import held_balance as held_balance_service from polar.integrations.stripe.schemas import ProductType from polar.integrations.stripe.service import stripe as stripe_service @@ -425,10 +426,23 @@ async def update( if errors: raise PolarRequestValidationError(errors) + # Handle custom field data validation and update + 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, + 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"}) await self.send_webhook(session, order, WebhookEventType.order_updated) From e8d12076cd1538f8c05a5f59a349118b87e24dd6 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:18:53 +0530 Subject: [PATCH 04/15] test case for custom field --- server/tests/order/test_endpoints.py | 230 ++++++++++++++++++++++++++- server/tests/order/test_service.py | 195 ++++++++++++++++++++++- 2 files changed, 422 insertions(+), 3 deletions(-) diff --git a/server/tests/order/test_endpoints.py b/server/tests/order/test_endpoints.py index 35ce0fd84c..cf21a0032b 100644 --- a/server/tests/order/test_endpoints.py +++ b/server/tests/order/test_endpoints.py @@ -5,10 +5,17 @@ from httpx import AsyncClient from polar.auth.scope import Scope -from polar.models import Customer, Order, Product, UserOrganization +from polar.models import Customer, Order, Organization, Product, UserOrganization +from polar.models.custom_field import CustomFieldType +from polar.models.subscription import SubscriptionRecurringInterval 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 @@ -166,6 +173,225 @@ 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( + 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( + 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: diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index e61bed96a6..0d54ccf435 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -4,6 +4,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, call import pytest +import pytest_asyncio import stripe as stripe_lib from freezegun import freeze_time from pydantic_extra_types.country import CountryAlpha2 @@ -38,8 +39,9 @@ from polar.models.organization import Organization from polar.models.payment import PaymentStatus from polar.models.product import ProductBillingType -from polar.models.subscription import SubscriptionStatus +from polar.models.subscription import SubscriptionRecurringInterval, SubscriptionStatus from polar.models.transaction import PlatformFeeType, TransactionType +from polar.models.custom_field import CustomFieldType from polar.order.service import ( MissingCheckoutCustomer, NoPendingBillingEntries, @@ -70,10 +72,12 @@ create_canceled_subscription, create_checkout, create_customer, + create_custom_field, create_discount, create_order, create_payment, create_payment_method, + create_product, create_subscription, ) from tests.fixtures.stripe import construct_stripe_invoice @@ -2328,3 +2332,192 @@ async def test_process_retry_payment_already_in_progress( await order_service.process_retry_payment( session, order, "ctoken_test", PaymentProcessor.stripe ) + + +@pytest_asyncio.fixture +async def product_custom_fields( + save_fixture: SaveFixture, organization: Organization +) -> Product: + 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"}], + }, + ) + return await create_product( + save_fixture, + organization=organization, + recurring_interval=SubscriptionRecurringInterval.month, + attached_custom_fields=[(text_field, False), (select_field, True)], + ) + + +@pytest.mark.asyncio +class TestUpdateOrder: + async def test_update_custom_field_data( + self, + session: AsyncSession, + save_fixture: SaveFixture, + product_custom_fields: Product, + customer: Customer, + ) -> None: + """Test updating custom field data for an order.""" + order = await create_order( + save_fixture, + product=product_custom_fields, + customer=customer, + custom_field_data={"text": "original", "select": "a"}, + ) + await save_fixture(order) + + from polar.order.schemas import OrderUpdate + + updated_order = await order_service.update( + session, + order, + OrderUpdate(custom_field_data={"text": "updated", "select": "b"}), + ) + + assert updated_order.custom_field_data == {"text": "updated", "select": "b"} + + async def test_update_billing_name( + self, + session: AsyncSession, + save_fixture: SaveFixture, + product: Product, + customer: Customer, + ) -> None: + """Test updating billing name for an order.""" + order = await create_order( + save_fixture, + product=product, + customer=customer, + billing_name="Original Name", + ) + await save_fixture(order) + + from polar.order.schemas import OrderUpdate + + updated_order = await order_service.update( + session, + order, + OrderUpdate(billing_name="Updated Name"), + ) + + assert updated_order.billing_name == "Updated Name" + + async def test_update_billing_address( + self, + session: AsyncSession, + save_fixture: SaveFixture, + product: Product, + customer: Customer, + ) -> None: + """Test updating billing address for an order.""" + original_address = Address( + country="US", + state="NY", + line1="123 Original St", + city="Original City", + postal_code="10001", + ) + order = await create_order( + save_fixture, + product=product, + customer=customer, + billing_address=original_address, + ) + await save_fixture(order) + + new_address = Address( + country="US", + state="CA", + line1="456 Updated St", + city="Updated City", + postal_code="90210", + ) + + from polar.order.schemas import OrderUpdate + + updated_order = await order_service.update( + session, + order, + OrderUpdate(billing_address=new_address), + ) + + assert updated_order.billing_address["country"] == "US" + assert updated_order.billing_address["state"] == "US-CA" + assert updated_order.billing_address["line1"] == "456 Updated St" + assert updated_order.billing_address["city"] == "Updated City" + assert updated_order.billing_address["postal_code"] == "90210" + + async def test_update_with_invoice_generated( + self, + session: AsyncSession, + save_fixture: SaveFixture, + product: Product, + customer: Customer, + ) -> None: + """Test that billing fields cannot be updated after invoice is generated.""" + order = await create_order( + save_fixture, + product=product, + customer=customer, + billing_name="Original Name", + ) + await save_fixture(order) + + # Set invoice_path after creation + order.invoice_path = "/path/to/invoice.pdf" # Invoice already generated + await save_fixture(order) + + from polar.order.schemas import OrderUpdate + from polar.exceptions import PolarRequestValidationError + + with pytest.raises(PolarRequestValidationError) as e: + await order_service.update( + session, + order, + OrderUpdate(billing_name="Updated Name"), + ) + + errors = e.value.errors() + assert len(errors) == 1 + assert errors[0]["loc"] == ("body", "billing_name") + assert "cannot be updated after the invoice is generated" in errors[0]["msg"] + + async def test_update_custom_field_data_with_invoice_generated( + self, + session: AsyncSession, + save_fixture: SaveFixture, + product_custom_fields: Product, + customer: Customer, + ) -> None: + """Test that custom field data can still be updated after invoice is generated.""" + order = await create_order( + save_fixture, + product=product_custom_fields, + customer=customer, + custom_field_data={"text": "original", "select": "a"}, + ) + await save_fixture(order) + + # Set invoice_path after creation + order.invoice_path = "/path/to/invoice.pdf" # Invoice already generated + await save_fixture(order) + + from polar.order.schemas import OrderUpdate + + updated_order = await order_service.update( + session, + order, + OrderUpdate(custom_field_data={"text": "updated", "select": "b"}), + ) + + assert updated_order.custom_field_data == {"text": "updated", "select": "b"} From cecd05bf5ae10470ceaf6dea7ef215bf0a3541fb Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:27:03 +0530 Subject: [PATCH 05/15] Revert "feat: implement organization-level notifications" This reverts commit 9cacda28c6edf5c6e551ca841d9c1e06d2e4abe2. --- ...22_add_organization_notifications_table.py | 38 ---------- ...8-29-1624_add_organization_id_index_to_.py | 35 --------- server/polar/models/__init__.py | 2 - server/polar/models/notification.py | 4 -- .../polar/models/organization_notification.py | 22 ------ server/polar/notifications/endpoints.py | 42 +---------- server/polar/notifications/schemas.py | 9 --- server/polar/notifications/service.py | 59 --------------- server/tests/notifications/test_endpoints.py | 37 ---------- server/tests/notifications/test_service.py | 71 ------------------- 10 files changed, 1 insertion(+), 318 deletions(-) delete mode 100644 server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py delete mode 100644 server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py delete mode 100644 server/polar/models/organization_notification.py delete mode 100644 server/tests/notifications/test_service.py diff --git a/server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py b/server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py deleted file mode 100644 index 5c531af46b..0000000000 --- a/server/migrations/versions/2025-08-29-1622_add_organization_notifications_table.py +++ /dev/null @@ -1,38 +0,0 @@ -"""add_organization_notifications_table - -Revision ID: e679ea1bb2c5 -Revises: b5ffc01faa80 -Create Date: 2025-08-29 16:22:00.271280 - -""" - -import sqlalchemy as sa -from alembic import op - -# Polar Custom Imports - -# revision identifiers, used by Alembic. -revision = "e679ea1bb2c5" -down_revision = "b5ffc01faa80" -branch_labels: tuple[str] | None = None -depends_on: tuple[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "organization_notifications", - sa.Column("organization_id", sa.UUID(), nullable=False), - sa.Column("last_read_notification_id", sa.UUID(), nullable=True), - sa.ForeignKeyConstraint( - ["organization_id"], ["organizations.id"], name=op.f("organization_notifications_organization_id_fkey") - ), - sa.PrimaryKeyConstraint("organization_id", name=op.f("organization_notifications_pkey")), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("organization_notifications") - # ### end Alembic commands ### diff --git a/server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py b/server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py deleted file mode 100644 index 0a1d8fbd95..0000000000 --- a/server/migrations/versions/2025-08-29-1624_add_organization_id_index_to_.py +++ /dev/null @@ -1,35 +0,0 @@ -"""add_organization_id_index_to_notifications - -Revision ID: d0a1dc3e9a90 -Revises: e679ea1bb2c5 -Create Date: 2025-08-29 16:24:54.991724 - -""" - -import sqlalchemy as sa -from alembic import op - -# Polar Custom Imports - -# revision identifiers, used by Alembic. -revision = "d0a1dc3e9a90" -down_revision = "e679ea1bb2c5" -branch_labels: tuple[str] | None = None -depends_on: tuple[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_index( - "idx_notifications_organization_id", - "notifications", - ["organization_id"], - unique=False, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index("idx_notifications_organization_id", table_name="notifications") - # ### end Alembic commands ### diff --git a/server/polar/models/__init__.py b/server/polar/models/__init__.py index eb3d91c9dc..ff35ae6114 100644 --- a/server/polar/models/__init__.py +++ b/server/polar/models/__init__.py @@ -30,7 +30,6 @@ from .meter import Meter from .notification import Notification from .notification_recipient import NotificationRecipient -from .organization_notification import OrganizationNotification from .oauth2_authorization_code import OAuth2AuthorizationCode from .oauth2_client import OAuth2Client from .oauth2_grant import OAuth2Grant @@ -107,7 +106,6 @@ "Meter", "Notification", "NotificationRecipient", - "OrganizationNotification", "OAuth2AuthorizationCode", "OAuth2Client", "OAuth2Grant", diff --git a/server/polar/models/notification.py b/server/polar/models/notification.py index fe1abe4949..cec149d055 100644 --- a/server/polar/models/notification.py +++ b/server/polar/models/notification.py @@ -15,10 +15,6 @@ class Notification(RecordModel): "idx_notifications_user_id", "user_id", ), - Index( - "idx_notifications_organization_id", - "organization_id", - ), ) user_id: Mapped[UUID] = mapped_column(Uuid, nullable=True) diff --git a/server/polar/models/organization_notification.py b/server/polar/models/organization_notification.py deleted file mode 100644 index e71d06a8b7..0000000000 --- a/server/polar/models/organization_notification.py +++ /dev/null @@ -1,22 +0,0 @@ -from uuid import UUID - -from sqlalchemy import ForeignKey, Uuid -from sqlalchemy.orm import Mapped, mapped_column - -from polar.kit.db.models import Model - - -class OrganizationNotification(Model): - __tablename__ = "organization_notifications" - - organization_id: Mapped[UUID] = mapped_column( - Uuid, - ForeignKey("organizations.id"), - nullable=False, - primary_key=True, - ) - - last_read_notification_id: Mapped[UUID] = mapped_column( - Uuid, - nullable=True, - ) diff --git a/server/polar/notifications/endpoints.py b/server/polar/notifications/endpoints.py index 4409ed41f8..356cae0cea 100644 --- a/server/polar/notifications/endpoints.py +++ b/server/polar/notifications/endpoints.py @@ -19,17 +19,10 @@ auth as notifications_auth, ) from polar.openapi import APITag -from polar.organization import auth as organization_auth -from polar.organization.schemas import OrganizationID from polar.postgres import AsyncSession, get_db_session from polar.routing import APIRouter -from .schemas import ( - NotificationsList, - NotificationsMarkRead, - OrganizationNotificationsList, - OrganizationNotificationsMarkRead, -) +from .schemas import NotificationsList, NotificationsMarkRead from .service import notifications NotificationRecipientID = Annotated[ @@ -131,36 +124,3 @@ async def delete( ) -> None: """Delete a notification recipient.""" await notification_recipient_service.delete(session, auth_subject, id) - - -# Organization notification endpoints -@router.get("/organizations/{organization_id}/notifications", response_model=OrganizationNotificationsList) -async def get_organization_notifications( - organization_id: OrganizationID, - auth_subject: organization_auth.OrganizationsRead, - session: AsyncSession = Depends(get_db_session), -) -> OrganizationNotificationsList: - """Get notifications for an organization.""" - notifs = await notifications.get_for_organization(session, organization_id) - last_read_notification_id = await notifications.get_organization_last_read( - session, organization_id - ) - - return OrganizationNotificationsList( - notifications=notifs, # type: ignore - last_read_notification_id=last_read_notification_id, - ) - - -@router.post("/organizations/{organization_id}/notifications/read") -async def mark_organization_notifications_read( - organization_id: OrganizationID, - read: OrganizationNotificationsMarkRead, - auth_subject: organization_auth.OrganizationsWrite, - session: AsyncSession = Depends(get_db_session), -) -> None: - """Mark organization notifications as read.""" - await notifications.set_organization_last_read( - session, organization_id, read.notification_id - ) - return None diff --git a/server/polar/notifications/schemas.py b/server/polar/notifications/schemas.py index ca21d9e056..9c7f80cf29 100644 --- a/server/polar/notifications/schemas.py +++ b/server/polar/notifications/schemas.py @@ -11,12 +11,3 @@ class NotificationsList(Schema): class NotificationsMarkRead(Schema): notification_id: UUID4 - - -class OrganizationNotificationsList(Schema): - notifications: list[Notification] - last_read_notification_id: UUID4 | None - - -class OrganizationNotificationsMarkRead(Schema): - notification_id: UUID4 diff --git a/server/polar/notifications/service.py b/server/polar/notifications/service.py index fc5572f250..df24bb8464 100644 --- a/server/polar/notifications/service.py +++ b/server/polar/notifications/service.py @@ -6,7 +6,6 @@ from polar.kit.extensions.sqlalchemy import sql from polar.models.notification import Notification -from polar.models.organization_notification import OrganizationNotification from polar.models.pledge import Pledge from polar.models.user_notification import UserNotification from polar.notifications.notification import Notification as NotificationSchema @@ -45,20 +44,6 @@ async def get_for_user( res = await session.execute(stmt) return res.scalars().unique().all() - async def get_for_organization( - self, session: AsyncSession, organization_id: UUID - ) -> Sequence[Notification]: - stmt = ( - sql.select(Notification) - .join(Pledge, Pledge.id == Notification.pledge_id, isouter=True) - .where(Notification.organization_id == organization_id) - .order_by(desc(Notification.created_at)) - .limit(100) - ) - - res = await session.execute(stmt) - return res.scalars().unique().all() - async def send_to_user( self, session: AsyncSession, @@ -92,25 +77,6 @@ async def send_to_org_members( notif=notif, ) - async def send_to_organization( - self, - session: AsyncSession, - organization_id: UUID, - notif: PartialNotification, - ) -> bool: - notification = Notification( - organization_id=organization_id, - type=notif.type, - pledge_id=notif.pledge_id, - payload=notif.payload.model_dump(mode="json"), - ) - - session.add(notification) - await session.flush() - enqueue_job("notifications.send", notification_id=notification.id) - enqueue_job("notifications.push", notification_id=notification.id) - return True - async def send_to_anonymous_email( self, session: AsyncSession, @@ -186,30 +152,5 @@ async def set_user_last_read( ) await session.execute(stmt) - async def get_organization_last_read( - self, session: AsyncSession, organization_id: UUID - ) -> UUID | None: - stmt = sql.select(OrganizationNotification).where( - OrganizationNotification.organization_id == organization_id - ) - res = await session.execute(stmt) - org_notif = res.scalar_one_or_none() - return org_notif.last_read_notification_id if org_notif else None - - async def set_organization_last_read( - self, session: AsyncSession, organization_id: UUID, notification_id: UUID - ) -> None: - stmt = ( - sql.insert(OrganizationNotification) - .values( - organization_id=organization_id, last_read_notification_id=notification_id - ) - .on_conflict_do_update( - index_elements=[OrganizationNotification.organization_id], - set_={"last_read_notification_id": notification_id}, - ) - ) - await session.execute(stmt) - notifications = NotificationsService() diff --git a/server/tests/notifications/test_endpoints.py b/server/tests/notifications/test_endpoints.py index b61a07c492..a180730d55 100644 --- a/server/tests/notifications/test_endpoints.py +++ b/server/tests/notifications/test_endpoints.py @@ -1,10 +1,5 @@ import pytest from httpx import AsyncClient -from uuid import uuid4 - -from polar.auth.scope import Scope -from polar.models import Organization, UserOrganization -from tests.fixtures.auth import AuthSubjectFixture @pytest.mark.asyncio @@ -13,35 +8,3 @@ async def test_get(client: AsyncClient) -> None: response = await client.get("/v1/notifications") assert response.status_code == 200 - - -@pytest.mark.asyncio -class TestOrganizationNotifications: - @pytest.mark.auth(AuthSubjectFixture(scopes={Scope.web_read, Scope.organizations_read})) - async def test_get_organization_notifications( - self, client: AsyncClient, organization: Organization - ) -> None: - response = await client.get(f"/v1/organizations/{organization.id}/notifications") - - assert response.status_code == 200 - data = response.json() - assert "notifications" in data - assert "last_read_notification_id" in data - - @pytest.mark.auth(AuthSubjectFixture(scopes={Scope.web_write, Scope.organizations_write})) - async def test_mark_organization_notifications_read( - self, client: AsyncClient, organization: Organization - ) -> None: - # First get notifications to get a notification ID - response = await client.get(f"/v1/organizations/{organization.id}/notifications") - assert response.status_code == 200 - - # Mark as read (this will work even if there are no notifications) - response = await client.post( - f"/v1/organizations/{organization.id}/notifications/read", - json={"notification_id": str(uuid4())} - ) - if response.status_code != 200: - print(f"Response status: {response.status_code}") - print(f"Response body: {response.text}") - assert response.status_code == 200 diff --git a/server/tests/notifications/test_service.py b/server/tests/notifications/test_service.py deleted file mode 100644 index 9515fb223e..0000000000 --- a/server/tests/notifications/test_service.py +++ /dev/null @@ -1,71 +0,0 @@ -import pytest -from uuid import uuid4 - -from polar.models import Organization, OrganizationNotification, User -from polar.notifications.service import notifications -from tests.fixtures.database import SaveFixture - - -@pytest.mark.asyncio -class TestNotificationsService: - async def test_get_for_organization( - self, session, organization: Organization - ) -> None: - # Test getting notifications for an organization - notifs = await notifications.get_for_organization(session, organization.id) - assert isinstance(notifs, list) - - async def test_get_organization_last_read( - self, session, organization: Organization - ) -> None: - # Test getting last read notification for an organization - last_read = await notifications.get_organization_last_read( - session, organization.id - ) - assert last_read is None # Should be None initially - - async def test_set_organization_last_read( - self, session, organization: Organization, save_fixture: SaveFixture - ) -> None: - # Test setting last read notification for an organization - notification_id = uuid4() - await notifications.set_organization_last_read( - session, organization.id, notification_id - ) - await session.commit() - - # Verify it was set - last_read = await notifications.get_organization_last_read( - session, organization.id - ) - assert last_read == notification_id - - async def test_send_to_organization( - self, session, organization: Organization - ) -> None: - # Test sending a notification to an organization - from polar.notifications.notification import NotificationPayload, NotificationType - - from polar.notifications.service import PartialNotification - - from polar.notifications.notification import MaintainerNewProductSaleNotificationPayload - - notif = PartialNotification( - type=NotificationType.maintainer_new_product_sale, - payload=MaintainerNewProductSaleNotificationPayload( - customer_name="Test Customer", - product_name="Test Product", - product_price_amount=1000, - organization_name="Test Org", - ), - ) - - result = await notifications.send_to_organization( - session, organization.id, notif - ) - assert result is True - - # Verify the notification was created - notifs = await notifications.get_for_organization(session, organization.id) - assert len(notifs) == 1 - assert notifs[0].organization_id == organization.id From ec5d4c4ee160c098c2be85b13d01174710989932 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:38:36 +0530 Subject: [PATCH 06/15] formatting issue fixed --- server/polar/order/schemas.py | 14 ++++++++------ server/tests/order/test_endpoints.py | 15 ++++++++++++--- server/tests/order/test_service.py | 3 ++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/server/polar/order/schemas.py b/server/polar/order/schemas.py index 3b804436bd..0edcdeca72 100644 --- a/server/polar/order/schemas.py +++ b/server/polar/order/schemas.py @@ -6,7 +6,7 @@ from pydantic import UUID4, AliasChoices, AliasPath, Field, computed_field from pydantic.json_schema import SkipJsonSchema -from polar.custom_field.data import CustomFieldDataInputMixin, CustomFieldDataOutputMixin +from polar.custom_field.data import CustomFieldDataOutputMixin from polar.customer.schemas.customer import CustomerBase from polar.discount.schemas import DiscountMinimal from polar.exceptions import ResourceNotFound @@ -184,18 +184,20 @@ class OrderUpdateBase(Schema): 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 = Field( - default=None, - description="Key-value object storing custom field values. Can be updated by merchants to correct errors.", + custom_field_data: dict[str, str | int | bool | datetime.datetime | None] | None = ( + Field( + default=None, + description="Key-value object storing custom field values. Can be updated by merchants to correct errors.", + ) ) diff --git a/server/tests/order/test_endpoints.py b/server/tests/order/test_endpoints.py index cf21a0032b..b5ff4903cf 100644 --- a/server/tests/order/test_endpoints.py +++ b/server/tests/order/test_endpoints.py @@ -216,7 +216,10 @@ async def test_user_valid( ) -> None: # Create a product with custom fields text_field = await create_custom_field( - save_fixture, type=CustomFieldType.text, slug="text", organization=organization + save_fixture, + type=CustomFieldType.text, + slug="text", + organization=organization, ) select_field = await create_custom_field( save_fixture, @@ -260,7 +263,10 @@ async def test_organization( ) -> None: # Create a product with custom fields text_field = await create_custom_field( - save_fixture, type=CustomFieldType.text, slug="text", organization=organization + save_fixture, + type=CustomFieldType.text, + slug="text", + organization=organization, ) select_field = await create_custom_field( save_fixture, @@ -308,7 +314,10 @@ async def test_update_existing_custom_field_data( ) -> None: # Create a product with custom fields text_field = await create_custom_field( - save_fixture, type=CustomFieldType.text, slug="text", organization=organization + save_fixture, + type=CustomFieldType.text, + slug="text", + organization=organization, ) select_field = await create_custom_field( save_fixture, diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index 0d54ccf435..422f489f1e 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -39,9 +39,9 @@ from polar.models.organization import Organization from polar.models.payment import PaymentStatus from polar.models.product import ProductBillingType +from polar.models.custom_field import CustomFieldType from polar.models.subscription import SubscriptionRecurringInterval, SubscriptionStatus from polar.models.transaction import PlatformFeeType, TransactionType -from polar.models.custom_field import CustomFieldType from polar.order.service import ( MissingCheckoutCustomer, NoPendingBillingEntries, @@ -55,6 +55,7 @@ RecurringProduct, SubscriptionDoesNotExist, ) +from polar.order.schemas import OrderUpdate from polar.order.service import order as order_service from polar.product.guard import is_fixed_price, is_static_price from polar.transaction.service.balance import ( From 86a76aebddd2d960ff857dd325522f8eb8968a13 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:40:13 +0530 Subject: [PATCH 07/15] service file fix --- server/polar/order/service.py | 2 +- server/tests/order/test_service.py | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/server/polar/order/service.py b/server/polar/order/service.py index c446791b1d..52d75f3f8c 100644 --- a/server/polar/order/service.py +++ b/server/polar/order/service.py @@ -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, @@ -27,7 +28,6 @@ from polar.enums import PaymentProcessor from polar.eventstream.service import publish as eventstream_publish from polar.exceptions import PolarError, PolarRequestValidationError, ValidationError -from polar.custom_field.data import validate_custom_field_data from polar.held_balance.service import held_balance as held_balance_service from polar.integrations.stripe.schemas import ProductType from polar.integrations.stripe.service import stripe as stripe_service diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index 422f489f1e..9c434d8849 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -34,14 +34,15 @@ UserOrganization, ) from polar.models.checkout import CheckoutStatus +from polar.models.custom_field import CustomFieldType from polar.models.discount import DiscountDuration, DiscountFixed, DiscountType from polar.models.order import OrderBillingReason, OrderStatus from polar.models.organization import Organization from polar.models.payment import PaymentStatus from polar.models.product import ProductBillingType -from polar.models.custom_field import CustomFieldType from polar.models.subscription import SubscriptionRecurringInterval, SubscriptionStatus from polar.models.transaction import PlatformFeeType, TransactionType +from polar.order.schemas import OrderUpdate from polar.order.service import ( MissingCheckoutCustomer, NoPendingBillingEntries, @@ -55,7 +56,6 @@ RecurringProduct, SubscriptionDoesNotExist, ) -from polar.order.schemas import OrderUpdate from polar.order.service import order as order_service from polar.product.guard import is_fixed_price, is_static_price from polar.transaction.service.balance import ( @@ -72,8 +72,8 @@ create_billing_entry, create_canceled_subscription, create_checkout, - create_customer, create_custom_field, + create_customer, create_discount, create_order, create_payment, @@ -2377,7 +2377,6 @@ async def test_update_custom_field_data( ) await save_fixture(order) - from polar.order.schemas import OrderUpdate updated_order = await order_service.update( session, @@ -2403,7 +2402,6 @@ async def test_update_billing_name( ) await save_fixture(order) - from polar.order.schemas import OrderUpdate updated_order = await order_service.update( session, @@ -2444,7 +2442,6 @@ async def test_update_billing_address( postal_code="90210", ) - from polar.order.schemas import OrderUpdate updated_order = await order_service.update( session, @@ -2478,7 +2475,6 @@ async def test_update_with_invoice_generated( order.invoice_path = "/path/to/invoice.pdf" # Invoice already generated await save_fixture(order) - from polar.order.schemas import OrderUpdate from polar.exceptions import PolarRequestValidationError with pytest.raises(PolarRequestValidationError) as e: @@ -2513,7 +2509,6 @@ async def test_update_custom_field_data_with_invoice_generated( order.invoice_path = "/path/to/invoice.pdf" # Invoice already generated await save_fixture(order) - from polar.order.schemas import OrderUpdate updated_order = await order_service.update( session, From 64a8c3549fded855a3a9d57d4c59ad2e109b31b6 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:42:03 +0530 Subject: [PATCH 08/15] test_service file fix --- server/tests/order/test_service.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index 9c434d8849..8fb62028d5 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -2377,7 +2377,6 @@ async def test_update_custom_field_data( ) await save_fixture(order) - updated_order = await order_service.update( session, order, @@ -2402,7 +2401,6 @@ async def test_update_billing_name( ) await save_fixture(order) - updated_order = await order_service.update( session, order, @@ -2442,7 +2440,6 @@ async def test_update_billing_address( postal_code="90210", ) - updated_order = await order_service.update( session, order, @@ -2509,7 +2506,6 @@ async def test_update_custom_field_data_with_invoice_generated( order.invoice_path = "/path/to/invoice.pdf" # Invoice already generated await save_fixture(order) - updated_order = await order_service.update( session, order, From 0ccf56397d537839dc9a450472acb92d51b0f72c Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:50:44 +0530 Subject: [PATCH 09/15] linter issue fix --- server/tests/order/test_endpoints.py | 2 +- server/tests/order/test_service.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/server/tests/order/test_endpoints.py b/server/tests/order/test_endpoints.py index b5ff4903cf..2fdc3d6a4e 100644 --- a/server/tests/order/test_endpoints.py +++ b/server/tests/order/test_endpoints.py @@ -5,9 +5,9 @@ from httpx import AsyncClient from polar.auth.scope import Scope +from polar.enums import SubscriptionRecurringInterval from polar.models import Customer, Order, Organization, Product, UserOrganization from polar.models.custom_field import CustomFieldType -from polar.models.subscription import SubscriptionRecurringInterval from tests.fixtures.auth import AuthSubjectFixture from tests.fixtures.database import SaveFixture from tests.fixtures.random_objects import ( diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index 8fb62028d5..947774b993 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -13,7 +13,7 @@ from polar.auth.models import AuthSubject from polar.checkout.eventstream import CheckoutEvent -from polar.enums import PaymentProcessor +from polar.enums import PaymentProcessor, SubscriptionRecurringInterval from polar.held_balance.service import held_balance as held_balance_service from polar.integrations.stripe.schemas import ProductType from polar.integrations.stripe.service import StripeService @@ -40,7 +40,7 @@ from polar.models.organization import Organization from polar.models.payment import PaymentStatus from polar.models.product import ProductBillingType -from polar.models.subscription import SubscriptionRecurringInterval, SubscriptionStatus +from polar.models.subscription import SubscriptionStatus from polar.models.transaction import PlatformFeeType, TransactionType from polar.order.schemas import OrderUpdate from polar.order.service import ( @@ -2418,7 +2418,7 @@ async def test_update_billing_address( ) -> None: """Test updating billing address for an order.""" original_address = Address( - country="US", + country=CountryAlpha2("US"), state="NY", line1="123 Original St", city="Original City", @@ -2433,7 +2433,7 @@ async def test_update_billing_address( await save_fixture(order) new_address = Address( - country="US", + country=CountryAlpha2("US"), state="CA", line1="456 Updated St", city="Updated City", @@ -2446,11 +2446,12 @@ async def test_update_billing_address( OrderUpdate(billing_address=new_address), ) - assert updated_order.billing_address["country"] == "US" - assert updated_order.billing_address["state"] == "US-CA" - assert updated_order.billing_address["line1"] == "456 Updated St" - assert updated_order.billing_address["city"] == "Updated City" - assert updated_order.billing_address["postal_code"] == "90210" + assert updated_order.billing_address is not None + assert updated_order.billing_address.country == "US" + assert updated_order.billing_address.state == "US-CA" + assert updated_order.billing_address.line1 == "456 Updated St" + assert updated_order.billing_address.city == "Updated City" + assert updated_order.billing_address.postal_code == "90210" async def test_update_with_invoice_generated( self, From e62a0c5e37d58f5e268fc991820f38bc8ebad7ea Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 17:57:12 +0530 Subject: [PATCH 10/15] test fix --- server/tests/order/test_service.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index 947774b993..ca1b3add23 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -2447,11 +2447,11 @@ async def test_update_billing_address( ) assert updated_order.billing_address is not None - assert updated_order.billing_address.country == "US" - assert updated_order.billing_address.state == "US-CA" - assert updated_order.billing_address.line1 == "456 Updated St" - assert updated_order.billing_address.city == "Updated City" - assert updated_order.billing_address.postal_code == "90210" + assert updated_order.billing_address["country"] == "US" + assert updated_order.billing_address["state"] == "US-CA" + assert updated_order.billing_address["line1"] == "456 Updated St" + assert updated_order.billing_address["city"] == "Updated City" + assert updated_order.billing_address["postal_code"] == "90210" async def test_update_with_invoice_generated( self, From d7bc98edf729c4d67a7a4c63107fb35e0c69b8bb Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 18:05:41 +0530 Subject: [PATCH 11/15] linter fix --- server/tests/order/test_service.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index ca1b3add23..6112381114 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -2447,11 +2447,11 @@ async def test_update_billing_address( ) assert updated_order.billing_address is not None - assert updated_order.billing_address["country"] == "US" - assert updated_order.billing_address["state"] == "US-CA" - assert updated_order.billing_address["line1"] == "456 Updated St" - assert updated_order.billing_address["city"] == "Updated City" - assert updated_order.billing_address["postal_code"] == "90210" + assert updated_order.billing_address["country"] == "US" # type: ignore + assert updated_order.billing_address["state"] == "US-CA" # type: ignore + assert updated_order.billing_address["line1"] == "456 Updated St" # type: ignore + assert updated_order.billing_address["city"] == "Updated City" # type: ignore + assert updated_order.billing_address["postal_code"] == "90210" # type: ignore async def test_update_with_invoice_generated( self, From 1dfd3315336960381193ebedf195133e8e7da899 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Fri, 29 Aug 2025 18:11:11 +0530 Subject: [PATCH 12/15] revert 1 --- server/polar/integrations/plain/service.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/polar/integrations/plain/service.py b/server/polar/integrations/plain/service.py index f6ff0803ec..59c488977b 100644 --- a/server/polar/integrations/plain/service.py +++ b/server/polar/integrations/plain/service.py @@ -581,11 +581,13 @@ async def create_appeal_review_thread( ], ) ), - # Admin Dashboard Link + # Backoffice Link ComponentContainerContentInput( component_link_button=ComponentLinkButtonInput( - link_button_url=f"{settings.FRONTEND_BASE_URL}/backoffice/organizations/{organization.id}", - link_button_label="View in Admin Dashboard", + link_button_url=settings.generate_external_url( + f"/backoffice/organizations/{organization.id}" + ), + link_button_label="View in Backoffice", ) ), ] From 38faed1f689a0f0c2f7427db66061b159f83d3b2 Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Thu, 4 Sep 2025 23:33:50 +0530 Subject: [PATCH 13/15] PR update --- server/polar/order/schemas.py | 14 ++--- server/polar/order/service.py | 11 ++-- server/tests/order/test_endpoints.py | 47 ----------------- server/tests/order/test_service.py | 78 +--------------------------- 4 files changed, 12 insertions(+), 138 deletions(-) diff --git a/server/polar/order/schemas.py b/server/polar/order/schemas.py index 0edcdeca72..e2db8dffbc 100644 --- a/server/polar/order/schemas.py +++ b/server/polar/order/schemas.py @@ -1,4 +1,3 @@ -import datetime from typing import Annotated from babel.numbers import format_currency @@ -6,7 +5,10 @@ from pydantic import UUID4, AliasChoices, AliasPath, Field, computed_field from pydantic.json_schema import SkipJsonSchema -from polar.custom_field.data import CustomFieldDataOutputMixin +from polar.custom_field.data import ( + CustomFieldDataInputMixin, + CustomFieldDataOutputMixin, +) from polar.customer.schemas.customer import CustomerBase from polar.discount.schemas import DiscountMinimal from polar.exceptions import ResourceNotFound @@ -193,11 +195,9 @@ class OrderUpdateBase(Schema): "Can't be updated after the invoice is generated." ), ) - custom_field_data: dict[str, str | int | bool | datetime.datetime | None] | None = ( - Field( - default=None, - description="Key-value object storing custom field values. Can be updated by merchants to correct errors.", - ) + custom_field_data: CustomFieldDataInputMixin | None = Field( + default=None, + description="Key-value object storing custom field values. Can be updated by merchants to correct errors.", ) diff --git a/server/polar/order/service.py b/server/polar/order/service.py index 52d75f3f8c..b34fc808f0 100644 --- a/server/polar/order/service.py +++ b/server/polar/order/service.py @@ -7,7 +7,7 @@ import stripe as stripe_lib import structlog from sqlalchemy import select -from sqlalchemy.orm import contains_eager, joinedload +from sqlalchemy.orm import contains_eager, joinedload, selectinload from polar.account.repository import AccountRepository from polar.auth.models import AuthSubject @@ -392,8 +392,9 @@ async def get( .options( *repository.get_eager_options( customer_load=contains_eager(Order.customer), - product_load=joinedload(Order.product).joinedload( - Product.organization + product_load=joinedload(Order.product).options( + joinedload(Product.organization), + selectinload(Product.attached_custom_fields), ), ) ) @@ -426,7 +427,6 @@ async def update( if errors: raise PolarRequestValidationError(errors) - # Handle custom field data validation and update update_dict = order_update.model_dump(exclude_unset=True) if "custom_field_data" in update_dict: @@ -441,9 +441,6 @@ async def update( repository = OrderRepository.from_session(session) 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"}) - await self.send_webhook(session, order, WebhookEventType.order_updated) return order diff --git a/server/tests/order/test_endpoints.py b/server/tests/order/test_endpoints.py index 2fdc3d6a4e..06336c54ae 100644 --- a/server/tests/order/test_endpoints.py +++ b/server/tests/order/test_endpoints.py @@ -353,53 +353,6 @@ async def test_update_existing_custom_field_data( 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( - 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( - 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: diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index 6112381114..3fdea5c22b 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -83,7 +83,7 @@ ) from tests.fixtures.stripe import construct_stripe_invoice from tests.transaction.conftest import create_transaction - +from polar.exceptions import PolarRequestValidationError def build_stripe_payment_intent( *, @@ -2375,7 +2375,6 @@ async def test_update_custom_field_data( customer=customer, custom_field_data={"text": "original", "select": "a"}, ) - await save_fixture(order) updated_order = await order_service.update( session, @@ -2399,7 +2398,6 @@ async def test_update_billing_name( customer=customer, billing_name="Original Name", ) - await save_fixture(order) updated_order = await order_service.update( session, @@ -2409,49 +2407,6 @@ async def test_update_billing_name( assert updated_order.billing_name == "Updated Name" - async def test_update_billing_address( - self, - session: AsyncSession, - save_fixture: SaveFixture, - product: Product, - customer: Customer, - ) -> None: - """Test updating billing address for an order.""" - original_address = Address( - country=CountryAlpha2("US"), - state="NY", - line1="123 Original St", - city="Original City", - postal_code="10001", - ) - order = await create_order( - save_fixture, - product=product, - customer=customer, - billing_address=original_address, - ) - await save_fixture(order) - - new_address = Address( - country=CountryAlpha2("US"), - state="CA", - line1="456 Updated St", - city="Updated City", - postal_code="90210", - ) - - updated_order = await order_service.update( - session, - order, - OrderUpdate(billing_address=new_address), - ) - - assert updated_order.billing_address is not None - assert updated_order.billing_address["country"] == "US" # type: ignore - assert updated_order.billing_address["state"] == "US-CA" # type: ignore - assert updated_order.billing_address["line1"] == "456 Updated St" # type: ignore - assert updated_order.billing_address["city"] == "Updated City" # type: ignore - assert updated_order.billing_address["postal_code"] == "90210" # type: ignore async def test_update_with_invoice_generated( self, @@ -2467,14 +2422,11 @@ async def test_update_with_invoice_generated( customer=customer, billing_name="Original Name", ) - await save_fixture(order) # Set invoice_path after creation order.invoice_path = "/path/to/invoice.pdf" # Invoice already generated await save_fixture(order) - from polar.exceptions import PolarRequestValidationError - with pytest.raises(PolarRequestValidationError) as e: await order_service.update( session, @@ -2486,31 +2438,3 @@ async def test_update_with_invoice_generated( assert len(errors) == 1 assert errors[0]["loc"] == ("body", "billing_name") assert "cannot be updated after the invoice is generated" in errors[0]["msg"] - - async def test_update_custom_field_data_with_invoice_generated( - self, - session: AsyncSession, - save_fixture: SaveFixture, - product_custom_fields: Product, - customer: Customer, - ) -> None: - """Test that custom field data can still be updated after invoice is generated.""" - order = await create_order( - save_fixture, - product=product_custom_fields, - customer=customer, - custom_field_data={"text": "original", "select": "a"}, - ) - await save_fixture(order) - - # Set invoice_path after creation - order.invoice_path = "/path/to/invoice.pdf" # Invoice already generated - await save_fixture(order) - - updated_order = await order_service.update( - session, - order, - OrderUpdate(custom_field_data={"text": "updated", "select": "b"}), - ) - - assert updated_order.custom_field_data == {"text": "updated", "select": "b"} From 6d13475dd16746cd13fcca424953f960d4700fda Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Thu, 4 Sep 2025 23:41:11 +0530 Subject: [PATCH 14/15] linter fix --- server/tests/order/test_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tests/order/test_service.py b/server/tests/order/test_service.py index 3fdea5c22b..73c9f7ad8b 100644 --- a/server/tests/order/test_service.py +++ b/server/tests/order/test_service.py @@ -14,6 +14,7 @@ from polar.auth.models import AuthSubject from polar.checkout.eventstream import CheckoutEvent from polar.enums import PaymentProcessor, SubscriptionRecurringInterval +from polar.exceptions import PolarRequestValidationError from polar.held_balance.service import held_balance as held_balance_service from polar.integrations.stripe.schemas import ProductType from polar.integrations.stripe.service import StripeService @@ -83,7 +84,7 @@ ) from tests.fixtures.stripe import construct_stripe_invoice from tests.transaction.conftest import create_transaction -from polar.exceptions import PolarRequestValidationError + def build_stripe_payment_intent( *, @@ -2407,7 +2408,6 @@ async def test_update_billing_name( assert updated_order.billing_name == "Updated Name" - async def test_update_with_invoice_generated( self, session: AsyncSession, From 2ee697b4db193c78204c483d44216e58e55005df Mon Sep 17 00:00:00 2001 From: ankitsingh <1999ankits@gmail.com> Date: Thu, 4 Sep 2025 23:57:10 +0530 Subject: [PATCH 15/15] test fix --- server/polar/order/schemas.py | 6 +----- server/polar/order/service.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/server/polar/order/schemas.py b/server/polar/order/schemas.py index e2db8dffbc..2261caa119 100644 --- a/server/polar/order/schemas.py +++ b/server/polar/order/schemas.py @@ -180,7 +180,7 @@ class Order(CustomFieldDataOutputMixin, MetadataOutputMixin, OrderBase): items: list[OrderItemSchema] = Field(description="Line items composing the order.") -class OrderUpdateBase(Schema): +class OrderUpdateBase(CustomFieldDataInputMixin, Schema): billing_name: str | None = Field( default=None, description=( @@ -195,10 +195,6 @@ class OrderUpdateBase(Schema): "Can't be updated after the invoice is generated." ), ) - custom_field_data: CustomFieldDataInputMixin | None = Field( - default=None, - description="Key-value object storing custom field values. Can be updated by merchants to correct errors.", - ) class OrderUpdate(OrderUpdateBase): diff --git a/server/polar/order/service.py b/server/polar/order/service.py index b34fc808f0..88acb1cd97 100644 --- a/server/polar/order/service.py +++ b/server/polar/order/service.py @@ -433,7 +433,7 @@ async def update( # Validate custom field data against the product's attached custom fields custom_field_data = validate_custom_field_data( order.product.attached_custom_fields, - update_dict["custom_field_data"], + order_update.custom_field_data, validate_required=False, # Allow merchants to update even if required fields are missing ) update_dict["custom_field_data"] = custom_field_data