Coverage for polar/models/benefit_grant.py: 64%
97 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1from datetime import UTC, datetime
2from typing import TYPE_CHECKING, Any, TypedDict
3from uuid import UUID
5from sqlalchemy import (
6 TIMESTAMP,
7 Boolean,
8 ColumnElement,
9 ForeignKey,
10 UniqueConstraint,
11 Uuid,
12 and_,
13 type_coerce,
14)
15from sqlalchemy.dialects.postgresql import JSONB
16from sqlalchemy.ext.hybrid import hybrid_property
17from sqlalchemy.orm import (
18 CompositeProperty,
19 Mapped,
20 composite,
21 declared_attr,
22 mapped_column,
23 relationship,
24)
26from polar.kit.db.models import RecordModel
28if TYPE_CHECKING: 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 from polar.benefit.strategies import BenefitGrantProperties
30 from polar.models import Benefit, Customer, Member, Order, Subscription
33class BenefitGrantError(TypedDict):
34 message: str
35 type: str
36 timestamp: str
39class BenefitGrantScope(TypedDict, total=False):
40 subscription: "Subscription"
41 order: "Order"
44if TYPE_CHECKING: 44 ↛ 46line 44 didn't jump to line 46 because the condition on line 44 was never true
46 class BenefitGrantScopeArgs(TypedDict, total=False):
47 subscription_id: UUID
48 order_id: UUID
50else:
52 class BenefitGrantScopeArgs(dict):
53 def __init__(
54 self, subscription_id: UUID | None = None, order_id: UUID | None = None
55 ) -> None:
56 d = {}
57 if subscription_id is not None:
58 d["subscription_id"] = subscription_id
59 if order_id is not None:
60 d["order_id"] = order_id
61 super().__init__(d)
63 def __composite_values__(self) -> tuple[UUID | None, UUID | None]:
64 return self.get("subscription_id"), self.get("order_id")
67class BenefitGrantScopeComparator(CompositeProperty.Comparator[BenefitGrantScopeArgs]):
68 def __eq__(self, other: Any) -> ColumnElement[bool]: # type: ignore[override]
69 if not isinstance(other, dict) or other == {}:
70 raise ValueError("A non-empty dictionary scope must be provided.")
71 clauses = []
72 composite_columns = self.__clause_element__().columns
73 for key, value in other.items():
74 clauses.append(composite_columns[f"{key}_id"] == value.id)
75 return and_(*clauses)
78class BenefitGrant(RecordModel):
79 """
80 Represents a benefit granted to a customer or member.
82 Unique constraints:
83 - benefit_grants_sbc_key: Ensures one grant per (subscription, customer, benefit)
84 - benefit_grants_smb_key: Ensures one grant per (subscription, member, benefit)
86 These constraints allow both customer-level and member-level benefit grants.
87 """
89 __tablename__ = "benefit_grants"
90 __table_args__ = (
91 UniqueConstraint(
92 "subscription_id",
93 "customer_id",
94 "benefit_id",
95 name="benefit_grants_sbc_key",
96 ),
97 UniqueConstraint(
98 "subscription_id",
99 "member_id",
100 "benefit_id",
101 name="benefit_grants_smb_key",
102 ),
103 )
105 granted_at: Mapped[datetime | None] = mapped_column(
106 TIMESTAMP(timezone=True), nullable=True
107 )
108 revoked_at: Mapped[datetime | None] = mapped_column(
109 TIMESTAMP(timezone=True), nullable=True
110 )
112 customer_id: Mapped[UUID] = mapped_column(
113 Uuid, ForeignKey("customers.id", ondelete="cascade"), nullable=False, index=True
114 )
116 @declared_attr
117 def customer(cls) -> Mapped["Customer"]:
118 return relationship("Customer", lazy="raise")
120 member_id: Mapped[UUID | None] = mapped_column(
121 Uuid,
122 ForeignKey("members.id", ondelete="cascade"),
123 nullable=True,
124 index=True,
125 )
127 @declared_attr
128 def member(cls) -> Mapped["Member | None"]:
129 return relationship("Member", lazy="raise")
131 benefit_id: Mapped[UUID] = mapped_column(
132 Uuid,
133 ForeignKey("benefits.id", ondelete="cascade"),
134 nullable=False,
135 index=True,
136 )
138 @declared_attr
139 def benefit(cls) -> Mapped["Benefit"]:
140 return relationship("Benefit", lazy="raise", back_populates="grants")
142 subscription_id: Mapped[UUID | None] = mapped_column(
143 Uuid,
144 ForeignKey("subscriptions.id", ondelete="cascade"),
145 nullable=True,
146 # Don't create an index for subscription_id
147 # as it's covered by the unique constraint, being the leading column of it
148 index=False,
149 )
151 @declared_attr
152 def subscription(cls) -> Mapped["Subscription | None"]:
153 return relationship("Subscription", lazy="raise", back_populates="grants")
155 order_id: Mapped[UUID | None] = mapped_column(
156 Uuid,
157 ForeignKey("orders.id", ondelete="cascade"),
158 nullable=True,
159 index=True,
160 )
162 @declared_attr
163 def order(cls) -> Mapped["Order | None"]:
164 return relationship("Order", lazy="raise")
166 scope: Mapped[BenefitGrantScopeArgs] = composite(
167 "subscription_id", "order_id", comparator_factory=BenefitGrantScopeComparator
168 )
170 @declared_attr
171 def properties(cls) -> Mapped["BenefitGrantProperties"]:
172 return mapped_column("properties", JSONB, nullable=False, default=dict)
174 @declared_attr
175 def error(cls) -> Mapped[BenefitGrantError | None]:
176 return mapped_column(
177 "error", JSONB(none_as_null=True), nullable=True, default=None
178 )
180 @hybrid_property
181 def is_granted(self) -> bool:
182 return self.granted_at is not None
184 @is_granted.inplace.expression
185 @classmethod
186 def _is_granted_expression(cls) -> ColumnElement[bool]:
187 return type_coerce(cls.granted_at.is_not(None), Boolean)
189 @hybrid_property
190 def is_revoked(self) -> bool:
191 return self.revoked_at is not None
193 @is_revoked.inplace.expression
194 @classmethod
195 def _is_revoked_expression(cls) -> ColumnElement[bool]:
196 return type_coerce(cls.revoked_at.is_not(None), Boolean)
198 def set_granted(self) -> None:
199 self.granted_at = datetime.now(UTC)
200 self.revoked_at = None
201 self.error = None
203 def set_revoked(self) -> None:
204 self.granted_at = None
205 self.revoked_at = datetime.now(UTC)
207 def set_grant_failed(self, error: Exception) -> None:
208 self.granted_at = None
209 self.revoked_at = None
210 self.error = BenefitGrantError(
211 message=str(error),
212 type=error.__class__.__name__,
213 timestamp=datetime.now(UTC).isoformat(),
214 )
216 @property
217 def previous_properties(self) -> "BenefitGrantProperties | None":
218 return getattr(self, "_previous_properties", None)
220 @previous_properties.setter
221 def previous_properties(self, value: "BenefitGrantProperties") -> None:
222 self._previous_properties = value