Coverage for polar/models/order_item.py: 56%

54 statements  

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

1from datetime import datetime 1ab

2from typing import TYPE_CHECKING, Self 1ab

3from uuid import UUID 1ab

4 

5from babel.dates import format_date 1ab

6from sqlalchemy import Boolean, ForeignKey, Integer, String, Uuid 1ab

7from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy 1ab

8from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab

9 

10from polar.kit.db.models import RecordModel 1ab

11from polar.models.product_price import ( 1ab

12 LegacyRecurringProductPriceCustom, 

13 LegacyRecurringProductPriceFixed, 

14 LegacyRecurringProductPriceFree, 

15 ProductPrice, 

16 ProductPriceCustom, 

17 ProductPriceFixed, 

18 ProductPriceFree, 

19 ProductPriceSeatUnit, 

20) 

21 

22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true1ab

23 from polar.models import Order, Product, Wallet 

24 

25 

26class OrderItem(RecordModel): 1ab

27 __tablename__ = "order_items" 1ab

28 

29 label: Mapped[str] = mapped_column(String, nullable=False) 1ab

30 amount: Mapped[int] = mapped_column(Integer, nullable=False) 1ab

31 tax_amount: Mapped[int] = mapped_column(Integer, nullable=False) 1ab

32 proration: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 1ab

33 order_id: Mapped[UUID] = mapped_column( 1ab

34 Uuid, ForeignKey("orders.id", ondelete="cascade") 

35 ) 

36 product_price_id: Mapped[UUID | None] = mapped_column( 1ab

37 Uuid, ForeignKey("product_prices.id", ondelete="restrict"), nullable=True 

38 ) 

39 

40 @declared_attr 1ab

41 def product_price(cls) -> Mapped["ProductPrice | None"]: 1ab

42 return relationship("ProductPrice", lazy="raise_on_sql") 1ab

43 

44 @declared_attr 1ab

45 def order(cls) -> Mapped["Order"]: 1ab

46 return relationship("Order", lazy="raise_on_sql", back_populates="items") 1ab

47 

48 product: AssociationProxy["Product"] = association_proxy("product_price", "product") 1ab

49 

50 @property 1ab

51 def total_amount(self) -> int: 1ab

52 return self.amount + self.tax_amount 

53 

54 @property 1ab

55 def discountable(self) -> bool: 1ab

56 # Simple logic for now: only non-prorated items are discountable 

57 # But could be expanded in the future by having a dedicated column 

58 return not self.proration 

59 

60 @classmethod 1ab

61 def from_price( 1ab

62 cls, 

63 price: ProductPrice, 

64 tax_amount: int, 

65 amount: int | None = None, 

66 seats: int | None = None, 

67 ) -> Self: 

68 if isinstance(price, ProductPriceFixed | LegacyRecurringProductPriceFixed): 

69 amount = price.price_amount 

70 elif isinstance(price, ProductPriceCustom | LegacyRecurringProductPriceCustom): 

71 assert amount is not None, "amount must be provided for custom prices" 

72 elif isinstance(price, ProductPriceFree | LegacyRecurringProductPriceFree): 

73 amount = 0 

74 elif isinstance(price, ProductPriceSeatUnit): 

75 assert seats is not None, "seats must be provided for seat-based prices" 

76 amount = price.calculate_amount(seats) 

77 return cls( 

78 label=price.product.name, 

79 amount=amount, 

80 tax_amount=tax_amount, 

81 proration=False, 

82 product_price=price, 

83 ) 

84 

85 @classmethod 1ab

86 def from_trial(cls, product: "Product", start: datetime, end: datetime) -> Self: 1ab

87 formatted_start = format_date(start.date(), locale="en_US") 

88 formatted_end = format_date(end.date(), locale="en_US") 

89 label = f"Trial period for {product.name} ({formatted_start} - {formatted_end})" 

90 return cls(label=label, amount=0, tax_amount=0, proration=False) 

91 

92 @classmethod 1ab

93 def from_wallet(cls, wallet: "Wallet", amount: int) -> Self: 1ab

94 label = f"Wallet Top-Up for {wallet.organization.name}" 

95 return cls(label=label, amount=amount, tax_amount=0, proration=False)