Coverage for polar/models/organization.py: 81%
176 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, Self, TypedDict 1ab
4from uuid import UUID 1ab
6from sqlalchemy import ( 1ab
7 TIMESTAMP,
8 Boolean,
9 CheckConstraint,
10 ColumnElement,
11 ForeignKey,
12 Integer,
13 String,
14 Text,
15 UniqueConstraint,
16 Uuid,
17 and_,
18)
19from sqlalchemy.dialects.postgresql import CITEXT, JSONB 1ab
20from sqlalchemy.ext.hybrid import hybrid_property 1ab
21from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab
23from polar.config import settings 1ab
24from polar.email.sender import DEFAULT_REPLY_TO_EMAIL_ADDRESS, EmailFromReply 1ab
25from polar.enums import InvoiceNumbering, SubscriptionProrationBehavior 1ab
26from polar.kit.db.models import RateLimitGroupMixin, RecordModel 1ab
27from polar.kit.extensions.sqlalchemy import StringEnum 1ab
29from .account import Account 1ab
31if TYPE_CHECKING: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true1ab
32 from .organization_review import OrganizationReview
33 from .product import Product
36class OrganizationSocials(TypedDict): 1ab
37 platform: str 1ab
38 url: str 1ab
41class OrganizationDetails(TypedDict): 1ab
42 about: str 1ab
43 product_description: str 1ab
44 intended_use: str 1ab
45 customer_acquisition: list[str] 1ab
46 future_annual_revenue: int 1ab
47 switching: bool 1ab
48 switching_from: str | None 1ab
49 previous_annual_revenue: int 1ab
52class OrganizationNotificationSettings(TypedDict): 1ab
53 new_order: bool 1ab
54 new_subscription: bool 1ab
57_default_notification_settings: OrganizationNotificationSettings = { 1ab
58 "new_order": True,
59 "new_subscription": True,
60}
63class OrganizationSubscriptionSettings(TypedDict): 1ab
64 allow_multiple_subscriptions: bool 1ab
65 allow_customer_updates: bool 1ab
66 proration_behavior: SubscriptionProrationBehavior 1ab
67 benefit_revocation_grace_period: int 1ab
68 prevent_trial_abuse: bool 1ab
71_default_subscription_settings: OrganizationSubscriptionSettings = { 1ab
72 "allow_multiple_subscriptions": False,
73 "allow_customer_updates": True,
74 "proration_behavior": SubscriptionProrationBehavior.prorate,
75 "benefit_revocation_grace_period": 0,
76 "prevent_trial_abuse": False,
77}
80class OrganizationOrderSettings(TypedDict): 1ab
81 invoice_numbering: InvoiceNumbering 1ab
84_default_order_settings: OrganizationOrderSettings = { 1ab
85 "invoice_numbering": InvoiceNumbering.customer,
86}
89class OrganizationCustomerEmailSettings(TypedDict): 1ab
90 order_confirmation: bool 1ab
91 subscription_cancellation: bool 1ab
92 subscription_confirmation: bool 1ab
93 subscription_cycled: bool 1ab
94 subscription_past_due: bool 1ab
95 subscription_revoked: bool 1ab
96 subscription_uncanceled: bool 1ab
97 subscription_updated: bool 1ab
100_default_customer_email_settings: OrganizationCustomerEmailSettings = { 1ab
101 "order_confirmation": True,
102 "subscription_cancellation": True,
103 "subscription_confirmation": True,
104 "subscription_cycled": True,
105 "subscription_past_due": True,
106 "subscription_revoked": True,
107 "subscription_uncanceled": True,
108 "subscription_updated": True,
109}
112class OrganizationStatus(StrEnum): 1ab
113 CREATED = "created" 1ab
114 ONBOARDING_STARTED = "onboarding_started" 1ab
115 INITIAL_REVIEW = "initial_review" 1ab
116 ONGOING_REVIEW = "ongoing_review" 1ab
117 DENIED = "denied" 1ab
118 ACTIVE = "active" 1ab
120 def get_display_name(self) -> str: 1ab
121 return {
122 OrganizationStatus.CREATED: "Created",
123 OrganizationStatus.ONBOARDING_STARTED: "Onboarding Started",
124 OrganizationStatus.INITIAL_REVIEW: "Initial Review",
125 OrganizationStatus.ONGOING_REVIEW: "Ongoing Review",
126 OrganizationStatus.DENIED: "Denied",
127 OrganizationStatus.ACTIVE: "Active",
128 }[self]
130 @classmethod 1ab
131 def review_statuses(cls) -> set[Self]: 1ab
132 return {cls.INITIAL_REVIEW, cls.ONGOING_REVIEW} # type: ignore
134 @classmethod 1ab
135 def payment_ready_statuses(cls) -> set[Self]: 1ab
136 return {cls.ACTIVE, *cls.review_statuses()} # type: ignore
139class Organization(RateLimitGroupMixin, RecordModel): 1ab
140 __tablename__ = "organizations" 1ab
141 __table_args__ = ( 1ab
142 UniqueConstraint("slug"),
143 CheckConstraint(
144 "next_review_threshold >= 0", name="next_review_threshold_positive"
145 ),
146 )
148 name: Mapped[str] = mapped_column(String, nullable=False, index=True) 1ab
149 slug: Mapped[str] = mapped_column(CITEXT, nullable=False, unique=True) 1ab
150 avatar_url: Mapped[str | None] = mapped_column(String, nullable=True) 1ab
152 email: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
153 website: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
154 socials: Mapped[list[OrganizationSocials]] = mapped_column( 1ab
155 JSONB, nullable=False, default=list
156 )
157 details: Mapped[OrganizationDetails] = mapped_column( 1ab
158 JSONB, nullable=False, default=dict
159 )
160 details_submitted_at: Mapped[datetime | None] = mapped_column( 1ab
161 TIMESTAMP(timezone=True)
162 )
164 customer_invoice_prefix: Mapped[str] = mapped_column(String, nullable=False) 1ab
165 customer_invoice_next_number: Mapped[int] = mapped_column( 1ab
166 Integer, nullable=False, default=1
167 )
169 account_id: Mapped[UUID | None] = mapped_column( 1ab
170 Uuid, ForeignKey("accounts.id", ondelete="set null"), nullable=True
171 )
172 status: Mapped[OrganizationStatus] = mapped_column( 1ab
173 StringEnum(OrganizationStatus),
174 nullable=False,
175 default=OrganizationStatus.CREATED,
176 )
177 next_review_threshold: Mapped[int] = mapped_column( 1ab
178 Integer, nullable=False, default=0
179 )
180 status_updated_at: Mapped[datetime | None] = mapped_column( 1ab
181 TIMESTAMP(timezone=True), nullable=True
182 )
183 initially_reviewed_at: Mapped[datetime | None] = mapped_column( 1ab
184 TIMESTAMP(timezone=True), nullable=True
185 )
187 internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True) 1ab
189 @declared_attr 1ab
190 def account(cls) -> Mapped[Account | None]: 1ab
191 return relationship(Account, lazy="raise", back_populates="organizations") 1ab
193 onboarded_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) 1ab
195 # Time of blocking traffic/activity to given organization
196 blocked_at: Mapped[datetime | None] = mapped_column( 1ab
197 TIMESTAMP(timezone=True),
198 nullable=True,
199 default=None,
200 )
202 profile_settings: Mapped[dict[str, Any]] = mapped_column( 1ab
203 JSONB, nullable=False, default=dict
204 )
206 subscription_settings: Mapped[OrganizationSubscriptionSettings] = mapped_column( 1ab
207 JSONB, nullable=False, default=_default_subscription_settings
208 )
210 order_settings: Mapped[OrganizationOrderSettings] = mapped_column( 1ab
211 JSONB, nullable=False, default=_default_order_settings
212 )
214 notification_settings: Mapped[OrganizationNotificationSettings] = mapped_column( 1ab
215 JSONB, nullable=False, default=_default_notification_settings
216 )
218 customer_email_settings: Mapped[OrganizationCustomerEmailSettings] = mapped_column( 1ab
219 JSONB, nullable=False, default=_default_customer_email_settings
220 )
222 #
223 # Feature Flags
224 #
226 feature_settings: Mapped[dict[str, Any]] = mapped_column( 1ab
227 JSONB, nullable=False, default=dict
228 )
229 subscriptions_billing_engine: Mapped[bool] = mapped_column( 1ab
230 Boolean, nullable=False, default=settings.ORGANIZATIONS_BILLING_ENGINE_DEFAULT
231 )
233 #
234 # Fields synced from GitHub
235 #
237 # Org description or user bio
238 bio: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
239 company: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
240 blog: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
241 location: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
242 twitter_username: Mapped[str | None] = mapped_column( 1ab
243 String, nullable=True, default=None
244 )
246 #
247 # End: Fields synced from GitHub
248 #
250 @hybrid_property 1ab
251 def can_authenticate(self) -> bool: 1ab
252 return self.deleted_at is None and self.blocked_at is None
254 @can_authenticate.inplace.expression 1ab
255 @classmethod 1ab
256 def _can_authenticate_expression(cls) -> ColumnElement[bool]: 1ab
257 return and_(cls.deleted_at.is_(None), cls.blocked_at.is_(None)) 1c
259 @hybrid_property 1ab
260 def storefront_enabled(self) -> bool: 1ab
261 return self.profile_settings.get("enabled", False)
263 @storefront_enabled.inplace.expression 1ab
264 @classmethod 1ab
265 def _storefront_enabled_expression(cls) -> ColumnElement[bool]: 1ab
266 return Organization.profile_settings["enabled"].as_boolean()
268 @hybrid_property 1ab
269 def is_under_review(self) -> bool: 1ab
270 return self.status in OrganizationStatus.review_statuses()
272 @is_under_review.inplace.expression 1ab
273 @classmethod 1ab
274 def _is_under_review_expression(cls) -> ColumnElement[bool]: 1ab
275 return cls.status.in_(OrganizationStatus.review_statuses())
277 @property 1ab
278 def polar_site_url(self) -> str: 1ab
279 return f"{settings.FRONTEND_BASE_URL}/{self.slug}"
281 @property 1ab
282 def account_url(self) -> str: 1ab
283 return f"{settings.FRONTEND_BASE_URL}/dashboard/{self.slug}/finance/account"
285 @property 1ab
286 def allow_multiple_subscriptions(self) -> bool: 1ab
287 return self.subscription_settings["allow_multiple_subscriptions"]
289 @property 1ab
290 def allow_customer_updates(self) -> bool: 1ab
291 return self.subscription_settings["allow_customer_updates"]
293 @property 1ab
294 def proration_behavior(self) -> SubscriptionProrationBehavior: 1ab
295 return SubscriptionProrationBehavior(
296 self.subscription_settings["proration_behavior"]
297 )
299 @property 1ab
300 def benefit_revocation_grace_period(self) -> int: 1ab
301 return self.subscription_settings["benefit_revocation_grace_period"]
303 @property 1ab
304 def prevent_trial_abuse(self) -> bool: 1ab
305 return self.subscription_settings.get("prevent_trial_abuse", False)
307 @property 1ab
308 def invoice_numbering(self) -> InvoiceNumbering: 1ab
309 return InvoiceNumbering(self.order_settings["invoice_numbering"])
311 @declared_attr 1ab
312 def all_products(cls) -> Mapped[list["Product"]]: 1ab
313 return relationship("Product", lazy="raise", back_populates="organization") 1ab
315 @declared_attr 1ab
316 def products(cls) -> Mapped[list["Product"]]: 1ab
317 return relationship( 1ab
318 "Product",
319 lazy="raise",
320 primaryjoin=(
321 "and_("
322 "Product.organization_id == Organization.id, "
323 "Product.is_archived.is_(False)"
324 ")"
325 ),
326 viewonly=True,
327 )
329 @declared_attr 1ab
330 def review(cls) -> Mapped["OrganizationReview | None"]: 1ab
331 return relationship( 1ab
332 "OrganizationReview",
333 lazy="raise",
334 back_populates="organization",
335 cascade="delete, delete-orphan",
336 uselist=False, # This makes it a one-to-one relationship
337 )
339 def is_blocked(self) -> bool: 1ab
340 if self.blocked_at is not None:
341 return True
342 return False
344 def is_active(self) -> bool: 1ab
345 return self.status == OrganizationStatus.ACTIVE
347 def statement_descriptor(self, suffix: str = "") -> str: 1ab
348 max_length = settings.stripe_descriptor_suffix_max_length
349 if suffix:
350 space_for_slug = max_length - len(suffix)
351 return self.slug[:space_for_slug] + suffix
352 return self.slug[:max_length]
354 @property 1ab
355 def statement_descriptor_prefixed(self) -> str: 1ab
356 # Cannot use *. Setting separator to # instead.
357 return f"{settings.STRIPE_STATEMENT_DESCRIPTOR}# {self.statement_descriptor()}"
359 @property 1ab
360 def email_from_reply(self) -> EmailFromReply: 1ab
361 return {
362 "from_name": f"{self.name} (via {settings.EMAIL_FROM_NAME})",
363 "from_email_addr": f"{self.slug}@{settings.EMAIL_FROM_DOMAIN}",
364 "reply_to_name": self.name,
365 "reply_to_email_addr": self.email or DEFAULT_REPLY_TO_EMAIL_ADDRESS,
366 }