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

1from enum import StrEnum 1ab

2from typing import TYPE_CHECKING 1ab

3from uuid import UUID 1ab

4 

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

21 

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

29 

30from .product_price import ProductPrice 1ab

31 

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 

41 

42 

43class ProductBillingType(StrEnum): 1ab

44 one_time = "one_time" 1ab

45 recurring = "recurring" 1ab

46 

47 

48class Product(TrialConfigurationMixin, MetadataMixin, RecordModel): 1ab

49 __tablename__ = "products" 1ab

50 

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 ) 

63 

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 ) 

72 

73 stripe_product_id: Mapped[str | None] = mapped_column( 1ab

74 String, nullable=True, index=True 

75 ) 

76 

77 organization_id: Mapped[UUID] = mapped_column( 1ab

78 Uuid, 

79 ForeignKey("organizations.id", ondelete="cascade"), 

80 nullable=False, 

81 index=True, 

82 ) 

83 

84 @declared_attr 1ab

85 def organization(cls) -> Mapped["Organization"]: 1ab

86 return relationship("Organization", lazy="raise", back_populates="products") 1ab

87 

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 ) 

93 

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 ) 

113 

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 ) 

121 

122 benefits: AssociationProxy[list["Benefit"]] = association_proxy( 1ab

123 "product_benefits", "benefit" 

124 ) 

125 

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 ) 

132 

133 medias: AssociationProxy[list["ProductMediaFile"]] = association_proxy( 1ab

134 "product_medias", "file" 

135 ) 

136 

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 ) 

143 

144 def get_stripe_name(self) -> str: 1ab

145 return f"{self.organization.slug} - {self.name}" 

146 

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 

155 

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 

164 

165 @property 1ab

166 def is_legacy_recurring_price(self) -> bool: 1ab

167 return any(price.is_recurring for price in self.prices) 

168 

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 ) 

175 

176 @hybrid_property 1ab

177 def is_recurring(self) -> bool: 1ab

178 if self.recurring_interval is not None: 

179 return True 

180 

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) 

183 

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 ) 

197 

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 

203 

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 )