Coverage for polar/discount/schemas.py: 75%

129 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 15:52 +0000

1import inspect 1a

2from datetime import datetime 1a

3from typing import Annotated, Any, Literal, Self 1a

4 

5from annotated_types import Ge, Le 1a

6from pydantic import ( 1a

7 UUID4, 

8 AfterValidator, 

9 Discriminator, 

10 Field, 

11 Tag, 

12 TypeAdapter, 

13 model_validator, 

14) 

15 

16from polar.kit.metadata import ( 1a

17 MetadataInputMixin, 

18 MetadataOutputMixin, 

19) 

20from polar.kit.schemas import ( 1a

21 ClassName, 

22 EmptyStrToNone, 

23 IDSchema, 

24 MergeJSONSchema, 

25 Schema, 

26 SetSchemaReference, 

27 TimestampedSchema, 

28) 

29from polar.models.discount import DiscountDuration, DiscountType 1a

30from polar.organization.schemas import OrganizationID 1a

31from polar.product.schemas import ProductBase 1a

32 

33Name = Annotated[ 1a

34 str, 

35 Field( 

36 description=( 

37 "Name of the discount. " 

38 "Will be displayed to the customer when the discount is applied." 

39 ), 

40 min_length=1, 

41 ), 

42] 

43 

44 

45def _code_validator(value: str | None) -> str | None: 1a

46 if value is None: 

47 return None 

48 if not value.isalnum(): 

49 raise ValueError("Code must contain only alphanumeric characters.") 

50 if not 3 <= len(value) <= 256: 

51 raise ValueError("Code must be between 3 and 256 characters long.") 

52 return value 

53 

54 

55Code = Annotated[ 1a

56 EmptyStrToNone, 

57 AfterValidator(_code_validator), 

58 Field( 

59 description=( 

60 "Code customers can use to apply the discount during checkout. " 

61 "Must be between 3 and 256 characters long and " 

62 "contain only alphanumeric characters." 

63 "If not provided, the discount can only be applied via the API." 

64 ), 

65 ), 

66] 

67 

68 

69def _starts_at_ends_at_validator( 1a

70 starts_at: datetime | None, ends_at: datetime | None 

71) -> None: 

72 if starts_at is not None and ends_at is not None: 

73 if starts_at >= ends_at: 

74 raise ValueError("starts_at must be before ends_at") 

75 

76 

77StartsAt = Annotated[ 1a

78 datetime | None, 

79 Field( 

80 description="Optional timestamp after which the discount is redeemable.", 

81 ), 

82] 

83EndsAt = Annotated[ 1a

84 datetime | None, 

85 Field( 

86 description=( 

87 "Optional timestamp after which the discount is no longer redeemable." 

88 ), 

89 ), 

90] 

91MaxRedemptions = Annotated[ 1a

92 int | None, 

93 Field( 

94 description="Optional maximum number of times the discount can be redeemed.", 

95 ), 

96 Ge(1), 

97] 

98DurationOnceForever = Annotated[ 1a

99 Literal[DiscountDuration.once, DiscountDuration.forever], 

100 Field( 

101 description=( 

102 "For subscriptions, determines if the discount should be applied " 

103 "once on the first invoice or forever." 

104 ) 

105 ), 

106] 

107DurationRepeating = Annotated[ 1a

108 Literal[DiscountDuration.repeating], 

109 Field( 

110 description=( 

111 "For subscriptions, the discount should be applied on every invoice " 

112 "for a certain number of months, determined by `duration_in_months`." 

113 ) 

114 ), 

115] 

116DurationInMonths = Annotated[ 1a

117 int, 

118 Field( 

119 description=inspect.cleandoc(""" 

120 Number of months the discount should be applied. 

121 

122 For this to work on yearly pricing, you should multiply this by 12. 

123 For example, to apply the discount for 2 years, set this to 24. 

124 """) 

125 ), 

126 Ge(1), 

127 Le(999), 

128] 

129Amount = Annotated[ 1a

130 int, 

131 Field( 

132 description="Fixed amount to discount from the invoice total.", 

133 ge=0, 

134 le=999999999999, 

135 ), 

136] 

137Currency = Annotated[ 1a

138 str, 

139 Field( 

140 pattern="usd", 

141 description="The currency. Currently, only `usd` is supported.", 

142 ), 

143] 

144BasisPoints = Annotated[ 1a

145 int, 

146 Field( 

147 description=( 

148 inspect.cleandoc(""" 

149 Discount percentage in basis points. 

150 

151 A basis point is 1/100th of a percent. 

152 For example, to create a 25.5% discount, set this to 2550. 

153 """) 

154 ), 

155 ge=1, 

156 le=10000, 

157 ), 

158] 

159ProductsList = Annotated[ 1a

160 list[UUID4], 

161 Field(description="List of product IDs the discount can be applied to."), 

162] 

163 

164 

165class DiscountCreateBase(MetadataInputMixin, Schema): 1a

166 name: Name 1a

167 type: DiscountType = Field(description="Type of the discount.") 1a

168 code: Code = None 1a

169 

170 starts_at: StartsAt = None 1a

171 ends_at: EndsAt = None 1a

172 max_redemptions: MaxRedemptions = None 1a

173 

174 duration: DiscountDuration 1a

175 

176 products: ProductsList | None = None 1a

177 

178 organization_id: OrganizationID | None = Field( 1a

179 default=None, 

180 description=( 

181 "The ID of the organization owning the discount. " 

182 "**Required unless you use an organization token.**" 

183 ), 

184 ) 

185 

186 @model_validator(mode="after") 1a

187 def validate_starts_at_ends_at(self) -> Self: 1a

188 _starts_at_ends_at_validator(self.starts_at, self.ends_at) 

189 return self 

190 

191 

192class DiscountOnceForeverDurationCreateBase(Schema): 1a

193 duration: DurationOnceForever 1a

194 

195 

196class DiscountRepeatDurationCreateBase(Schema): 1a

197 duration: DurationRepeating 1a

198 duration_in_months: DurationInMonths 1a

199 

200 

201class DiscountFixedCreateBase(Schema): 1a

202 type: Literal[DiscountType.fixed] = DiscountType.fixed 1a

203 amount: Amount 1a

204 currency: Currency = "usd" 1a

205 

206 

207class DiscountPercentageCreateBase(Schema): 1a

208 type: Literal[DiscountType.percentage] = DiscountType.percentage 1a

209 basis_points: BasisPoints 1a

210 

211 

212class DiscountFixedOnceForeverDurationCreate( 1a

213 DiscountCreateBase, DiscountFixedCreateBase, DiscountOnceForeverDurationCreateBase 

214): 

215 """Schema to create a fixed amount discount that is applied once or forever.""" 

216 

217 

218class DiscountFixedRepeatDurationCreate( 1a

219 DiscountCreateBase, DiscountFixedCreateBase, DiscountRepeatDurationCreateBase 

220): 

221 """ 

222 Schema to create a fixed amount discount that is applied on every invoice 

223 for a certain number of months. 

224 """ 

225 

226 

227class DiscountPercentageOnceForeverDurationCreate( 1a

228 DiscountCreateBase, 

229 DiscountPercentageCreateBase, 

230 DiscountOnceForeverDurationCreateBase, 

231): 

232 """Schema to create a percentage discount that is applied once or forever.""" 

233 

234 

235class DiscountPercentageRepeatDurationCreate( 1a

236 DiscountCreateBase, DiscountPercentageCreateBase, DiscountRepeatDurationCreateBase 

237): 

238 """ 

239 Schema to create a percentage discount that is applied on every invoice 

240 for a certain number of months. 

241 """ 

242 

243 

244class DiscountUpdate(MetadataInputMixin, Schema): 1a

245 """ 

246 Schema to update a discount. 

247 """ 

248 

249 name: Name | None = None 1a

250 code: Code = None 1a

251 

252 starts_at: StartsAt = None 1a

253 ends_at: EndsAt = None 1a

254 max_redemptions: MaxRedemptions = None 1a

255 

256 duration: DiscountDuration | None = None 1a

257 duration_in_months: DurationInMonths | None = None 1a

258 

259 type: DiscountType | None = None 1a

260 amount: Amount | None = None 1a

261 currency: Currency | None = None 1a

262 basis_points: BasisPoints | None = None 1a

263 

264 products: ProductsList | None = None 1a

265 

266 @model_validator(mode="after") 1a

267 def validate_starts_at_ends_at(self) -> Self: 1a

268 _starts_at_ends_at_validator(self.starts_at, self.ends_at) 

269 return self 

270 

271 

272class DiscountProduct(ProductBase, MetadataOutputMixin): 1a

273 """A product that a discount can be applied to.""" 

274 

275 ... 1a

276 

277 

278class DiscountBase(MetadataOutputMixin, IDSchema, TimestampedSchema): 1a

279 name: str = Field( 1a

280 description=( 

281 "Name of the discount. " 

282 "Will be displayed to the customer when the discount is applied." 

283 ) 

284 ) 

285 type: DiscountType 1a

286 duration: DiscountDuration 1a

287 code: str | None = Field( 1a

288 description="Code customers can use to apply the discount during checkout." 

289 ) 

290 

291 starts_at: datetime | None = Field( 1a

292 description="Timestamp after which the discount is redeemable." 

293 ) 

294 ends_at: datetime | None = Field( 1a

295 description="Timestamp after which the discount is no longer redeemable." 

296 ) 

297 max_redemptions: int | None = Field( 1a

298 description="Maximum number of times the discount can be redeemed." 

299 ) 

300 

301 redemptions_count: int = Field( 1a

302 description="Number of times the discount has been redeemed." 

303 ) 

304 

305 organization_id: OrganizationID 1a

306 

307 

308class DiscountOnceForeverDurationBase(Schema): 1a

309 duration: Literal[DiscountDuration.once, DiscountDuration.forever] = Field( 1a

310 description=( 

311 "For subscriptions, determines if the discount should be applied " 

312 "once on the first invoice or forever." 

313 ) 

314 ) 

315 

316 

317class DiscountRepeatDurationBase(Schema): 1a

318 duration: Literal[DiscountDuration.repeating] = Field( 1a

319 description=( 

320 "For subscriptions, the discount should be applied on every invoice " 

321 "for a certain number of months, determined by `duration_in_months`." 

322 ) 

323 ) 

324 duration_in_months: int 1a

325 

326 

327class DiscountFixedBase(Schema): 1a

328 type: Literal[DiscountType.fixed] = DiscountType.fixed 1a

329 amount: int = Field(examples=[1000]) 1a

330 currency: str = Field(examples=["usd"]) 1a

331 

332 

333class DiscountPercentageBase(Schema): 1a

334 type: Literal[DiscountType.percentage] = DiscountType.percentage 1a

335 basis_points: int = Field( 1a

336 examples=[1000], 

337 description=( 

338 "Discount percentage in basis points. " 

339 "A basis point is 1/100th of a percent. " 

340 "For example, 1000 basis points equals a 10% discount." 

341 ), 

342 ) 

343 

344 

345class DiscountFixedOnceForeverDurationBase( 1a

346 DiscountBase, DiscountFixedBase, DiscountOnceForeverDurationBase 

347): ... 

348 

349 

350class DiscountFixedRepeatDurationBase( 1a

351 DiscountBase, DiscountFixedBase, DiscountRepeatDurationBase 

352): ... 

353 

354 

355class DiscountPercentageOnceForeverDurationBase( 1a

356 DiscountBase, DiscountPercentageBase, DiscountOnceForeverDurationBase 

357): ... 

358 

359 

360class DiscountPercentageRepeatDurationBase( 1a

361 DiscountBase, DiscountPercentageBase, DiscountRepeatDurationBase 

362): ... 

363 

364 

365class DiscountFullBase(DiscountBase): 1a

366 products: list[DiscountProduct] 1a

367 

368 

369class DiscountFixedOnceForeverDuration( 1a

370 DiscountFullBase, DiscountFixedBase, DiscountOnceForeverDurationBase 

371): 

372 """Schema for a fixed amount discount that is applied once or forever.""" 

373 

374 

375class DiscountFixedRepeatDuration( 1a

376 DiscountFullBase, DiscountFixedBase, DiscountRepeatDurationBase 

377): 

378 """ 

379 Schema for a fixed amount discount that is applied on every invoice 

380 for a certain number of months. 

381 """ 

382 

383 

384class DiscountPercentageOnceForeverDuration( 1a

385 DiscountFullBase, DiscountPercentageBase, DiscountOnceForeverDurationBase 

386): 

387 """Schema for a percentage discount that is applied once or forever.""" 

388 

389 

390class DiscountPercentageRepeatDuration( 1a

391 DiscountFullBase, DiscountPercentageBase, DiscountRepeatDurationBase 

392): 

393 """ 

394 Schema for a percentage discount that is applied on every invoice 

395 for a certain number of months. 

396 """ 

397 

398 

399def get_discriminator_value(v: Any) -> str | None: 1a

400 if isinstance(v, dict): 

401 type = v.get("type") 

402 duration = v.get("duration") 

403 else: 

404 type = getattr(v, "type", None) 

405 duration = getattr(v, "duration", None) 

406 

407 if type is None or duration is None: 

408 return None 

409 

410 duration_tag = ( 

411 "once_forever" 

412 if duration in {DiscountDuration.once, DiscountDuration.forever} 

413 else "repeat" 

414 ) 

415 return f"{type}.{duration_tag}" 

416 

417 

418DiscountCreate = Annotated[ 1a

419 Annotated[DiscountFixedOnceForeverDurationCreate, Tag("fixed.once_forever")] 

420 | Annotated[DiscountFixedRepeatDurationCreate, Tag("fixed.repeat")] 

421 | Annotated[ 

422 DiscountPercentageOnceForeverDurationCreate, Tag("percentage.once_forever") 

423 ] 

424 | Annotated[DiscountPercentageRepeatDurationCreate, Tag("percentage.repeat")], 

425 Discriminator(get_discriminator_value), 

426] 

427DiscountMinimal = Annotated[ 1a

428 Annotated[DiscountFixedOnceForeverDurationBase, Tag("fixed.once_forever")] 

429 | Annotated[DiscountFixedRepeatDurationBase, Tag("fixed.repeat")] 

430 | Annotated[ 

431 DiscountPercentageOnceForeverDurationBase, Tag("percentage.once_forever") 

432 ] 

433 | Annotated[DiscountPercentageRepeatDurationBase, Tag("percentage.repeat")], 

434 Discriminator(get_discriminator_value), 

435] 

436Discount = Annotated[ 1a

437 Annotated[DiscountFixedOnceForeverDuration, Tag("fixed.once_forever")] 

438 | Annotated[DiscountFixedRepeatDuration, Tag("fixed.repeat")] 

439 | Annotated[DiscountPercentageOnceForeverDuration, Tag("percentage.once_forever")] 

440 | Annotated[DiscountPercentageRepeatDuration, Tag("percentage.repeat")], 

441 Discriminator(get_discriminator_value), 

442 SetSchemaReference("Discount"), 

443 MergeJSONSchema({"title": "Discount"}), 

444 ClassName("Discount"), 

445] 

446DiscountAdapter: TypeAdapter[Discount] = TypeAdapter(Discount) 1a