Skip to content

Commit 73eb6e6

Browse files
psincraianclaude
andauthored
feat: don't create customers for customer seats (#8789)
* feat: implement member model support for customer seats Decouple seat members from Customer entities by adding a member_model_enabled feature flag. When enabled, Members exist independently under the billing customer (purchaser), with customer_id on CustomerSeat referencing the purchaser rather than the seat occupant. Includes new email column on CustomerSeat for pending invitations. 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 <[email protected]> * chore: remove accidentally committed .npmrc file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: correct test assertions for member_model_enabled behavior The test was expecting seat.customer_id to equal the seat member's customer, but with member_model_enabled=True, seat.customer_id should equal the billing customer (subscription owner). Updated assertions to match the actual implementation behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: address PR review comments and add missing tests - Simplify revoke_seat by clearing all identifiers unconditionally - Combine validation conditions into single check in assign_seat - Add Order.product.organization eager loading in repository - Move MemberRepository import to top of file - Add imports guideline to CLAUDE.md - Add tests for member_model_enabled flows: - test_claim_seat_with_member_model_enabled - test_revoke_seat_with_member_model_enabled - test_resend_invitation_with_member_model_enabled - test_assign_seat_rejects_customer_id_when_member_model_enabled - test_assign_seat_requires_email_when_member_model_enabled 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: simplify seat assignment with SeatAssignmentTarget pattern Introduce SeatAssignmentTarget dataclass to unify the seat creation logic. The only branching point is now resolving the target (who the seat is for), while seat creation, token generation, and notifications are unified. Changes: - Add SeatAssignmentTarget dataclass with customer_id, member_id, email, seat_member_email - Add _resolve_member_model_target() for member_model_enabled=True path - Add _resolve_legacy_target() for member_model_enabled=False path - Refactor assign_seat() to use unified seat creation logic - Simplify claim_seat() to share validation and claim logic between paths This reduces code duplication and makes the feature flag logic clearer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Haiku 4.5 <[email protected]>
1 parent 903d966 commit 73eb6e6

File tree

8 files changed

+692
-107
lines changed

8 files changed

+692
-107
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ docker compose up -d # Start PostgreSQL, Redis, Minio
142142
- Include HTTP status codes in custom exceptions
143143
- Use dependency injection for database sessions
144144
- All DB queries should be in the Repository class. Use the right repository class.
145+
- Always place imports at the top of files, not inside functions or methods.
145146

146147
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.
147148

server/polar/customer_portal/endpoints/customer_seat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,12 @@ async def assign_seat(
168168
metadata=seat_assign.metadata,
169169
)
170170

171-
# Reload seat with customer relationship
171+
# Reload seat with customer and member relationships
172172
seat_repository = CustomerSeatRepository.from_session(session)
173173
seat_statement = (
174174
seat_repository.get_base_statement()
175175
.where(CustomerSeat.id == seat.id)
176-
.options(joinedload(CustomerSeat.customer))
176+
.options(joinedload(CustomerSeat.customer), joinedload(CustomerSeat.member))
177177
)
178178
reloaded_seat = await seat_repository.get_one_or_none(seat_statement)
179179

server/polar/customer_seat/endpoints.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,12 +341,22 @@ async def get_claim_info(
341341
if not organization:
342342
raise ResourceNotFound("Organization not found")
343343

344+
# Get customer email with priority: seat.email > seat.member.email > seat.customer.email
345+
# This handles both member_model_enabled=True (email on seat) and False (email on customer)
346+
customer_email = ""
347+
if seat.email:
348+
customer_email = seat.email
349+
elif seat.member:
350+
customer_email = seat.member.email
351+
elif seat.customer:
352+
customer_email = seat.customer.email
353+
344354
return SeatClaimInfo(
345355
product_name=product.name,
346356
product_id=product.id,
347357
organization_name=organization.name,
348358
organization_slug=organization.slug,
349-
customer_email=seat.customer.email if seat.customer else "",
359+
customer_email=customer_email,
350360
can_claim=seat.status == SeatStatus.pending,
351361
)
352362

server/polar/customer_seat/repository.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,59 @@ async def get_by_container_and_customer(
5959
container.id, customer_id, options=options
6060
)
6161

62+
async def get_by_container_and_email(
63+
self,
64+
container: SeatContainer,
65+
email: str,
66+
*,
67+
options: Options = (),
68+
) -> CustomerSeat | None:
69+
"""Get seat by container and email (for member_model_enabled path)."""
70+
if isinstance(container, Subscription):
71+
return await self.get_by_subscription_and_email(
72+
container.id, email, options=options
73+
)
74+
else:
75+
return await self.get_by_order_and_email(
76+
container.id, email, options=options
77+
)
78+
79+
async def get_by_subscription_and_email(
80+
self,
81+
subscription_id: UUID,
82+
email: str,
83+
*,
84+
options: Options = (),
85+
) -> CustomerSeat | None:
86+
"""Get seat by subscription ID and email."""
87+
statement = (
88+
select(CustomerSeat)
89+
.where(
90+
CustomerSeat.subscription_id == subscription_id,
91+
func.lower(CustomerSeat.email) == email.lower(),
92+
)
93+
.options(*options)
94+
)
95+
return await self.get_one_or_none(statement)
96+
97+
async def get_by_order_and_email(
98+
self,
99+
order_id: UUID,
100+
email: str,
101+
*,
102+
options: Options = (),
103+
) -> CustomerSeat | None:
104+
"""Get seat by order ID and email."""
105+
statement = (
106+
select(CustomerSeat)
107+
.where(
108+
CustomerSeat.order_id == order_id,
109+
func.lower(CustomerSeat.email) == email.lower(),
110+
)
111+
.options(*options)
112+
)
113+
return await self.get_one_or_none(statement)
114+
62115
async def get_revoked_seat_by_container(
63116
self,
64117
container: SeatContainer,
@@ -358,10 +411,11 @@ def get_eager_options(self) -> Options:
358411
joinedload(Subscription.customer),
359412
),
360413
joinedload(CustomerSeat.order).options(
361-
joinedload(Order.product),
414+
joinedload(Order.product).joinedload(Product.organization),
362415
joinedload(Order.customer).joinedload(Customer.organization),
363416
),
364417
joinedload(CustomerSeat.customer),
418+
joinedload(CustomerSeat.member),
365419
)
366420

367421
def get_eager_options_with_prices(self) -> Options:

server/polar/customer_seat/schemas.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,20 @@ class CustomerSeat(TimestampedSchema):
9595
None, description="The order ID (for one-time purchase seats)"
9696
)
9797
status: SeatStatus = Field(..., description="Status of the seat")
98-
customer_id: UUID | None = Field(None, description="The assigned customer ID")
98+
customer_id: UUID | None = Field(
99+
None,
100+
description=(
101+
"The customer ID. When member_model_enabled is true, this is the billing "
102+
"customer (purchaser). When false, this is the seat member customer."
103+
),
104+
)
105+
member_id: UUID | None = Field(
106+
None, description="The member ID of the seat occupant"
107+
)
108+
email: str | None = Field(
109+
None,
110+
description="Email of the seat member (set when member_model_enabled is true)",
111+
)
99112
customer_email: str | None = Field(None, description="The assigned customer email")
100113
invitation_token_expires_at: datetime | None = Field(
101114
None, description="When the invitation token expires"
@@ -110,20 +123,30 @@ class CustomerSeat(TimestampedSchema):
110123
@classmethod
111124
def extract_customer_email(cls, data: Any) -> Any:
112125
if isinstance(data, dict):
113-
# For dict data
114-
if "customer" in data and data["customer"]:
126+
# For dict data - priority: email > member.email > customer.email
127+
if "email" in data and data["email"]:
128+
data["customer_email"] = data["email"]
129+
elif "member" in data and data["member"]:
130+
data["customer_email"] = data.get("member", {}).get("email")
131+
elif "customer" in data and data["customer"]:
115132
data["customer_email"] = data.get("customer", {}).get("email")
116133
return data
117134
elif hasattr(data, "__dict__"):
118-
# For SQLAlchemy models - check if customer is loaded
119-
state = inspect(data)
120-
if "customer" not in state.unloaded:
121-
# Customer is loaded, we can extract the email
122-
# But we need to let Pydantic handle the model conversion
123-
# We'll just add the customer_email field if customer is available
124-
if hasattr(data, "customer") and data.customer:
125-
# Add customer_email as a temporary attribute
126-
object.__setattr__(data, "customer_email", data.customer.email)
135+
# For SQLAlchemy models - check if email is set on the seat first
136+
# Priority: seat.email > seat.member.email > seat.customer.email
137+
if hasattr(data, "email") and data.email:
138+
object.__setattr__(data, "customer_email", data.email)
139+
else:
140+
state = inspect(data)
141+
# Try member first
142+
if "member" not in state.unloaded:
143+
if hasattr(data, "member") and data.member:
144+
object.__setattr__(data, "customer_email", data.member.email)
145+
return data
146+
# Fall back to customer
147+
if "customer" not in state.unloaded:
148+
if hasattr(data, "customer") and data.customer:
149+
object.__setattr__(data, "customer_email", data.customer.email)
127150
return data
128151

129152

0 commit comments

Comments
 (0)