Coverage for polar/models/customer_seat.py: 77%
48 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
1from datetime import datetime 1ab
2from enum import StrEnum 1ab
3from typing import TYPE_CHECKING, Any 1ab
4from uuid import UUID 1ab
6from sqlalchemy import TIMESTAMP, CheckConstraint, ForeignKey, String, Uuid 1ab
7from sqlalchemy.dialects.postgresql import JSONB 1ab
8from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab
10from polar.kit.db.models import RecordModel 1ab
11from polar.product.guard import is_metered_price 1ab
13if TYPE_CHECKING: 13 ↛ 14line 13 didn't jump to line 14 because the condition on line 13 was never true1ab
14 from .customer import Customer
15 from .order import Order
16 from .subscription import Subscription
19class SeatStatus(StrEnum): 1ab
20 pending = "pending" 1ab
21 claimed = "claimed" 1ab
22 revoked = "revoked" 1ab
25class CustomerSeat(RecordModel): 1ab
26 """
27 Represents a seat that can be assigned to a customer for access to benefits. It's
28 mainly used for the seat-based billing.
30 Seats can be associated with either:
31 - A recurring subscription (subscription_id) for ongoing seat-based billing
32 - A one-time order (order_id) for perpetual seat-based purchases
34 Lifecycle:
35 1. pending: Seat created but not yet assigned/claimed by an end customer
36 2. claimed: Customer has accepted the invitation and benefits are granted
37 3. revoked: Seat has been revoked, benefits removed, can be reassigned
39 Key constraints:
40 - Exactly one of subscription_id OR order_id must be set (enforced by seat_source_check)
41 - For subscriptions: seats are tied to billing cycle, revoked when subscription ends
42 - For orders: seats are perpetual, never expire once claimed
43 """
45 __tablename__ = "customer_seats" 1ab
46 __table_args__ = ( 1ab
47 CheckConstraint(
48 "(subscription_id IS NOT NULL AND order_id IS NULL) OR "
49 "(subscription_id IS NULL AND order_id IS NOT NULL)",
50 name="seat_source_check",
51 ),
52 )
54 subscription_id: Mapped[UUID | None] = mapped_column( 1ab
55 Uuid,
56 ForeignKey("subscriptions.id", ondelete="cascade"),
57 nullable=True,
58 index=True,
59 )
61 order_id: Mapped[UUID | None] = mapped_column( 1ab
62 Uuid,
63 ForeignKey("orders.id", ondelete="cascade"),
64 nullable=True,
65 index=True,
66 )
68 status: Mapped[SeatStatus] = mapped_column( 1ab
69 String, nullable=False, default=SeatStatus.pending
70 )
72 customer_id: Mapped[UUID | None] = mapped_column( 1ab
73 Uuid,
74 ForeignKey("customers.id", ondelete="set null"),
75 nullable=True,
76 index=True,
77 )
79 invitation_token: Mapped[str | None] = mapped_column( 1ab
80 String, nullable=True, default=None, index=True
81 )
83 invitation_token_expires_at: Mapped[datetime | None] = mapped_column( 1ab
84 TIMESTAMP(timezone=True), nullable=True, default=None
85 )
87 claimed_at: Mapped[datetime | None] = mapped_column( 1ab
88 TIMESTAMP(timezone=True), nullable=True, default=None
89 )
91 revoked_at: Mapped[datetime | None] = mapped_column( 1ab
92 TIMESTAMP(timezone=True), nullable=True, default=None
93 )
95 seat_metadata: Mapped[dict[str, Any] | None] = mapped_column( 1ab
96 "metadata", JSONB, nullable=True, default=None
97 )
99 @declared_attr 1ab
100 def subscription(cls) -> Mapped["Subscription | None"]: 1ab
101 return relationship("Subscription", lazy="raise") 1ab
103 @declared_attr 1ab
104 def order(cls) -> Mapped["Order | None"]: 1ab
105 return relationship("Order", lazy="raise") 1ab
107 @declared_attr 1ab
108 def customer(cls) -> Mapped["Customer | None"]: 1ab
109 return relationship("Customer", lazy="raise") 1ab
111 def is_pending(self) -> bool: 1ab
112 return self.status == SeatStatus.pending
114 def is_claimed(self) -> bool: 1ab
115 return self.status == SeatStatus.claimed
117 def is_revoked(self) -> bool: 1ab
118 return self.status == SeatStatus.revoked
120 def has_metered_pricing(self) -> bool: 1ab
121 if not self.subscription:
122 return False
124 return any(
125 is_metered_price(spp.product_price)
126 for spp in self.subscription.subscription_product_prices
127 )