Coverage for polar/models/product.py: 78%
85 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 enum import StrEnum
2from typing import TYPE_CHECKING
3from uuid import UUID
5from sqlalchemy import (
6 Boolean,
7 ColumnElement,
8 ForeignKey,
9 Integer,
10 String,
11 Text,
12 Uuid,
13 case,
14 or_,
15 select,
16)
17from sqlalchemy.dialects.postgresql import CITEXT
18from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
19from sqlalchemy.ext.hybrid import hybrid_property
20from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
22from polar.enums import SubscriptionRecurringInterval
23from polar.kit.db.models import RecordModel
24from polar.kit.extensions.sqlalchemy import StringEnum
25from polar.kit.metadata import MetadataMixin
26from polar.kit.tax import TaxCode
27from polar.kit.trial import TrialConfigurationMixin
28from polar.models.product_price import ProductPriceAmountType, ProductPriceType
30from .product_price import ProductPrice
32if TYPE_CHECKING: 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true
33 from polar.models import (
34 Benefit,
35 Organization,
36 ProductBenefit,
37 ProductCustomField,
38 ProductMedia,
39 )
40 from polar.models.file import ProductMediaFile
43class ProductBillingType(StrEnum):
44 one_time = "one_time"
45 recurring = "recurring"
48class Product(TrialConfigurationMixin, MetadataMixin, RecordModel):
49 __tablename__ = "products"
51 name: Mapped[str] = mapped_column(CITEXT(), nullable=False)
52 description: Mapped[str | None] = mapped_column(Text, nullable=True)
53 is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
54 recurring_interval: Mapped[SubscriptionRecurringInterval | None] = mapped_column(
55 StringEnum(SubscriptionRecurringInterval),
56 nullable=True,
57 index=True,
58 default=None,
59 )
60 recurring_interval_count: Mapped[int | None] = mapped_column(
61 Integer, nullable=True, default=None
62 )
64 is_tax_applicable: Mapped[bool] = mapped_column(
65 Boolean, nullable=False, default=True
66 )
67 tax_code: Mapped[TaxCode] = mapped_column(
68 StringEnum(TaxCode),
69 nullable=False,
70 default=TaxCode.general_electronically_supplied_services,
71 )
73 stripe_product_id: Mapped[str | None] = mapped_column(
74 String, nullable=True, index=True
75 )
77 organization_id: Mapped[UUID] = mapped_column(
78 Uuid,
79 ForeignKey("organizations.id", ondelete="cascade"),
80 nullable=False,
81 index=True,
82 )
84 @declared_attr
85 def organization(cls) -> Mapped["Organization"]:
86 return relationship("Organization", lazy="raise", back_populates="products")
88 @declared_attr
89 def all_prices(cls) -> Mapped[list["ProductPrice"]]:
90 return relationship(
91 "ProductPrice", lazy="raise", cascade="all", back_populates="product"
92 )
94 @declared_attr
95 def prices(cls) -> Mapped[list["ProductPrice"]]:
96 # Prices are almost always needed, so eager loading makes sense
97 return relationship(
98 "ProductPrice",
99 lazy="selectin",
100 primaryjoin=(
101 "and_("
102 "ProductPrice.product_id == Product.id, "
103 "ProductPrice.is_archived.is_(False), "
104 "ProductPrice.source == 'catalog'"
105 ")"
106 ),
107 order_by="(case("
108 "(ProductPrice.amount_type.in_(['fixed', 'custom', 'free']), 0), "
109 "(ProductPrice.amount_type == 'metered_unit', 1), "
110 "), ProductPrice.created_at)",
111 viewonly=True,
112 )
114 product_benefits: Mapped[list["ProductBenefit"]] = relationship(
115 # Benefits are almost always needed, so eager loading makes sense
116 lazy="selectin",
117 order_by="ProductBenefit.order",
118 cascade="all, delete-orphan",
119 back_populates="product",
120 )
122 benefits: AssociationProxy[list["Benefit"]] = association_proxy(
123 "product_benefits", "benefit"
124 )
126 product_medias: Mapped[list["ProductMedia"]] = relationship(
127 lazy="raise",
128 order_by="ProductMedia.order",
129 cascade="all, delete-orphan",
130 back_populates="product",
131 )
133 medias: AssociationProxy[list["ProductMediaFile"]] = association_proxy(
134 "product_medias", "file"
135 )
137 attached_custom_fields: Mapped[list["ProductCustomField"]] = relationship(
138 lazy="raise",
139 order_by="ProductCustomField.order",
140 cascade="all, delete-orphan",
141 back_populates="product",
142 )
144 def get_stripe_name(self) -> str:
145 return f"{self.organization.slug} - {self.name}"
147 def get_price(
148 self, id: UUID, *, include_archived: bool = False
149 ) -> "ProductPrice | None":
150 prices = self.all_prices if include_archived else self.prices
151 for price in prices:
152 if price.id == id:
153 return price
154 return None
156 def get_static_price(
157 self, *, include_archived: bool = False
158 ) -> "ProductPrice | None":
159 prices = self.all_prices if include_archived else self.prices
160 for price in prices: 160 ↛ 163line 160 didn't jump to line 163 because the loop on line 160 didn't complete
161 if price.is_static: 161 ↛ 160line 161 didn't jump to line 160 because the condition on line 161 was always true
162 return price
163 return None
165 @property
166 def is_legacy_recurring_price(self) -> bool:
167 return any(price.is_recurring for price in self.prices)
169 @property
170 def has_seat_based_price(self) -> bool:
171 return any(
172 price.amount_type == ProductPriceAmountType.seat_based
173 for price in self.prices
174 )
176 @hybrid_property
177 def is_recurring(self) -> bool:
178 if self.recurring_interval is not None:
179 return True
181 # Check for Products that have legacy prices where recurring interval was set on them
182 return all(price.is_recurring for price in self.prices)
184 @is_recurring.inplace.expression
185 @classmethod
186 def _is_recurring_expression(cls) -> ColumnElement[bool]:
187 return or_(
188 cls.recurring_interval.is_not(None),
189 # Check for Products that have legacy prices where recurring interval was set on them
190 cls.id.in_(
191 select(ProductPrice.product_id).where(
192 ProductPrice.type == ProductPriceType.recurring,
193 ProductPrice.is_archived.is_(False),
194 )
195 ),
196 )
198 @hybrid_property
199 def billing_type(self) -> ProductBillingType:
200 if self.is_recurring:
201 return ProductBillingType.recurring
202 return ProductBillingType.one_time
204 @billing_type.inplace.expression
205 @classmethod
206 def _billing_type_expression(cls) -> ColumnElement[ProductBillingType]:
207 return case(
208 (cls.is_recurring.is_(True), ProductBillingType.recurring),
209 else_=ProductBillingType.one_time,
210 )