Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ docker compose up -d # Start PostgreSQL, Redis, Minio
- Include HTTP status codes in custom exceptions
- Use dependency injection for database sessions
- All DB queries should be in the Repository class. Use the right repository class.
- Always place imports at the top of files, not inside functions or methods.

In most cases, you should never call `session.commit()` directly in business logic. We have established patterns for that: the API backend automatically commits the session at the end of each request, and background workers commit the session at the end of each task. It avoids to have a database in an inconsistent state in case of exceptions. If you have a `session.commit()` in your code, it's likely a mistake. Otherwise, please explicitly document why it's necessary.

Expand Down
4 changes: 2 additions & 2 deletions server/polar/customer_portal/endpoints/customer_seat.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,12 @@ async def assign_seat(
metadata=seat_assign.metadata,
)

# Reload seat with customer relationship
# Reload seat with customer and member relationships
seat_repository = CustomerSeatRepository.from_session(session)
seat_statement = (
seat_repository.get_base_statement()
.where(CustomerSeat.id == seat.id)
.options(joinedload(CustomerSeat.customer))
.options(joinedload(CustomerSeat.customer), joinedload(CustomerSeat.member))
)
reloaded_seat = await seat_repository.get_one_or_none(seat_statement)

Expand Down
12 changes: 11 additions & 1 deletion server/polar/customer_seat/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,22 @@ async def get_claim_info(
if not organization:
raise ResourceNotFound("Organization not found")

# Get customer email with priority: seat.email > seat.member.email > seat.customer.email
# This handles both member_model_enabled=True (email on seat) and False (email on customer)
customer_email = ""
if seat.email:
customer_email = seat.email
elif seat.member:
customer_email = seat.member.email
elif seat.customer:
customer_email = seat.customer.email

return SeatClaimInfo(
product_name=product.name,
product_id=product.id,
organization_name=organization.name,
organization_slug=organization.slug,
customer_email=seat.customer.email if seat.customer else "",
customer_email=customer_email,
can_claim=seat.status == SeatStatus.pending,
)

Expand Down
56 changes: 55 additions & 1 deletion server/polar/customer_seat/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,59 @@ async def get_by_container_and_customer(
container.id, customer_id, options=options
)

async def get_by_container_and_email(
self,
container: SeatContainer,
email: str,
*,
options: Options = (),
) -> CustomerSeat | None:
"""Get seat by container and email (for member_model_enabled path)."""
if isinstance(container, Subscription):
return await self.get_by_subscription_and_email(
container.id, email, options=options
)
else:
return await self.get_by_order_and_email(
container.id, email, options=options
)

async def get_by_subscription_and_email(
self,
subscription_id: UUID,
email: str,
*,
options: Options = (),
) -> CustomerSeat | None:
"""Get seat by subscription ID and email."""
statement = (
select(CustomerSeat)
.where(
CustomerSeat.subscription_id == subscription_id,
func.lower(CustomerSeat.email) == email.lower(),
)
.options(*options)
)
return await self.get_one_or_none(statement)

async def get_by_order_and_email(
self,
order_id: UUID,
email: str,
*,
options: Options = (),
) -> CustomerSeat | None:
"""Get seat by order ID and email."""
statement = (
select(CustomerSeat)
.where(
CustomerSeat.order_id == order_id,
func.lower(CustomerSeat.email) == email.lower(),
)
.options(*options)
)
return await self.get_one_or_none(statement)

async def get_revoked_seat_by_container(
self,
container: SeatContainer,
Expand Down Expand Up @@ -358,10 +411,11 @@ def get_eager_options(self) -> Options:
joinedload(Subscription.customer),
),
joinedload(CustomerSeat.order).options(
joinedload(Order.product),
joinedload(Order.product).joinedload(Product.organization),
joinedload(Order.customer).joinedload(Customer.organization),
),
joinedload(CustomerSeat.customer),
joinedload(CustomerSeat.member),
)

def get_eager_options_with_prices(self) -> Options:
Expand Down
47 changes: 35 additions & 12 deletions server/polar/customer_seat/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,20 @@ class CustomerSeat(TimestampedSchema):
None, description="The order ID (for one-time purchase seats)"
)
status: SeatStatus = Field(..., description="Status of the seat")
customer_id: UUID | None = Field(None, description="The assigned customer ID")
customer_id: UUID | None = Field(
None,
description=(
"The customer ID. When member_model_enabled is true, this is the billing "
"customer (purchaser). When false, this is the seat member customer."
),
)
member_id: UUID | None = Field(
None, description="The member ID of the seat occupant"
)
email: str | None = Field(
None,
description="Email of the seat member (set when member_model_enabled is true)",
)
customer_email: str | None = Field(None, description="The assigned customer email")
invitation_token_expires_at: datetime | None = Field(
None, description="When the invitation token expires"
Expand All @@ -110,20 +123,30 @@ class CustomerSeat(TimestampedSchema):
@classmethod
def extract_customer_email(cls, data: Any) -> Any:
if isinstance(data, dict):
# For dict data
if "customer" in data and data["customer"]:
# For dict data - priority: email > member.email > customer.email
if "email" in data and data["email"]:
data["customer_email"] = data["email"]
elif "member" in data and data["member"]:
data["customer_email"] = data.get("member", {}).get("email")
elif "customer" in data and data["customer"]:
data["customer_email"] = data.get("customer", {}).get("email")
return data
elif hasattr(data, "__dict__"):
# For SQLAlchemy models - check if customer is loaded
state = inspect(data)
if "customer" not in state.unloaded:
# Customer is loaded, we can extract the email
# But we need to let Pydantic handle the model conversion
# We'll just add the customer_email field if customer is available
if hasattr(data, "customer") and data.customer:
# Add customer_email as a temporary attribute
object.__setattr__(data, "customer_email", data.customer.email)
# For SQLAlchemy models - check if email is set on the seat first
# Priority: seat.email > seat.member.email > seat.customer.email
if hasattr(data, "email") and data.email:
object.__setattr__(data, "customer_email", data.email)
else:
state = inspect(data)
# Try member first
if "member" not in state.unloaded:
if hasattr(data, "member") and data.member:
object.__setattr__(data, "customer_email", data.member.email)
return data
# Fall back to customer
if "customer" not in state.unloaded:
if hasattr(data, "customer") and data.customer:
object.__setattr__(data, "customer_email", data.customer.email)
return data


Expand Down
Loading