Coverage for polar/models/product.py: 64%
85 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 17:15 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 17:15 +0000
1from enum import StrEnum 1ab
2from typing import TYPE_CHECKING 1ab
3from uuid import UUID 1ab
5from sqlalchemy import ( 1ab
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 1ab
18from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy 1ab
19from sqlalchemy.ext.hybrid import hybrid_property 1ab
20from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab
22from polar.enums import SubscriptionRecurringInterval 1ab
23from polar.kit.db.models import RecordModel 1ab
24from polar.kit.extensions.sqlalchemy import StringEnum 1ab
25from polar.kit.metadata import MetadataMixin 1ab
26from polar.kit.tax import TaxCode 1ab
27from polar.kit.trial import TrialConfigurationMixin 1ab
28from polar.models.product_price import ProductPriceAmountType, ProductPriceType 1ab
30from .product_price import ProductPrice 1ab
32if TYPE_CHECKING: 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true1ab
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): 1ab
44 one_time = "one_time" 1ab
45 recurring = "recurring" 1ab
48class Product(TrialConfigurationMixin, MetadataMixin, RecordModel): 1ab
49 __tablename__ = "products" 1ab
51 name: Mapped[str] = mapped_column(CITEXT(), nullable=False) 1ab
52 description: Mapped[str | None] = mapped_column(Text, nullable=True) 1ab
53 is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 1ab
54 recurring_interval: Mapped[SubscriptionRecurringInterval | None] = mapped_column( 1ab
55 StringEnum(SubscriptionRecurringInterval),
56 nullable=True,
57 index=True,
58 default=None,
59 )
60 recurring_interval_count: Mapped[int | None] = mapped_column( 1ab
61 Integer, nullable=True, default=None
62 )
64 is_tax_applicable: Mapped[bool] = mapped_column( 1ab
65 Boolean, nullable=False, default=True
66 )
67 tax_code: Mapped[TaxCode] = mapped_column( 1ab
68 StringEnum(TaxCode),
69 nullable=False,
70 default=TaxCode.general_electronically_supplied_services,
71 )
73 stripe_product_id: Mapped[str | None] = mapped_column( 1ab
74 String, nullable=True, index=True
75 )
77 organization_id: Mapped[UUID] = mapped_column( 1ab
78 Uuid,
79 ForeignKey("organizations.id", ondelete="cascade"),
80 nullable=False,
81 index=True,
82 )
84 @declared_attr 1ab
85 def organization(cls) -> Mapped["Organization"]: 1ab
86 return relationship("Organization", lazy="raise", back_populates="products") 1ab
88 @declared_attr 1ab
89 def all_prices(cls) -> Mapped[list["ProductPrice"]]: 1ab
90 return relationship( 1ab
91 "ProductPrice", lazy="raise", cascade="all", back_populates="product"
92 )
94 @declared_attr 1ab
95 def prices(cls) -> Mapped[list["ProductPrice"]]: 1ab
96 # Prices are almost always needed, so eager loading makes sense
97 return relationship( 1ab
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( 1ab
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( 1ab
123 "product_benefits", "benefit"
124 )
126 product_medias: Mapped[list["ProductMedia"]] = relationship( 1ab
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( 1ab
134 "product_medias", "file"
135 )
137 attached_custom_fields: Mapped[list["ProductCustomField"]] = relationship( 1ab
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: 1ab
145 return f"{self.organization.slug} - {self.name}"
147 def get_price( 1ab
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( 1ab
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:
161 if price.is_static:
162 return price
163 return None
165 @property 1ab
166 def is_legacy_recurring_price(self) -> bool: 1ab
167 return any(price.is_recurring for price in self.prices)
169 @property 1ab
170 def has_seat_based_price(self) -> bool: 1ab
171 return any(
172 price.amount_type == ProductPriceAmountType.seat_based
173 for price in self.prices
174 )
176 @hybrid_property 1ab
177 def is_recurring(self) -> bool: 1ab
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 1ab
185 @classmethod 1ab
186 def _is_recurring_expression(cls) -> ColumnElement[bool]: 1ab
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 1ab
199 def billing_type(self) -> ProductBillingType: 1ab
200 if self.is_recurring:
201 return ProductBillingType.recurring
202 return ProductBillingType.one_time
204 @billing_type.inplace.expression 1ab
205 @classmethod 1ab
206 def _billing_type_expression(cls) -> ColumnElement[ProductBillingType]: 1ab
207 return case(
208 (cls.is_recurring.is_(True), ProductBillingType.recurring),
209 else_=ProductBillingType.one_time,
210 )