Coverage for polar/models/order_item.py: 56%
54 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 datetime import datetime
2from typing import TYPE_CHECKING, Self
3from uuid import UUID
5from babel.dates import format_date
6from sqlalchemy import Boolean, ForeignKey, Integer, String, Uuid
7from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
8from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
10from polar.kit.db.models import RecordModel
11from polar.models.product_price import (
12 LegacyRecurringProductPriceCustom,
13 LegacyRecurringProductPriceFixed,
14 LegacyRecurringProductPriceFree,
15 ProductPrice,
16 ProductPriceCustom,
17 ProductPriceFixed,
18 ProductPriceFree,
19 ProductPriceSeatUnit,
20)
22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true
23 from polar.models import Order, Product, Wallet
26class OrderItem(RecordModel):
27 __tablename__ = "order_items"
29 label: Mapped[str] = mapped_column(String, nullable=False)
30 amount: Mapped[int] = mapped_column(Integer, nullable=False)
31 tax_amount: Mapped[int] = mapped_column(Integer, nullable=False)
32 proration: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
33 order_id: Mapped[UUID] = mapped_column(
34 Uuid, ForeignKey("orders.id", ondelete="cascade")
35 )
36 product_price_id: Mapped[UUID | None] = mapped_column(
37 Uuid, ForeignKey("product_prices.id", ondelete="restrict"), nullable=True
38 )
40 @declared_attr
41 def product_price(cls) -> Mapped["ProductPrice | None"]:
42 return relationship("ProductPrice", lazy="raise_on_sql")
44 @declared_attr
45 def order(cls) -> Mapped["Order"]:
46 return relationship("Order", lazy="raise_on_sql", back_populates="items")
48 product: AssociationProxy["Product"] = association_proxy("product_price", "product")
50 @property
51 def total_amount(self) -> int:
52 return self.amount + self.tax_amount
54 @property
55 def discountable(self) -> bool:
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
60 @classmethod
61 def from_price(
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 )
85 @classmethod
86 def from_trial(cls, product: "Product", start: datetime, end: datetime) -> Self:
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)
92 @classmethod
93 def from_wallet(cls, wallet: "Wallet", amount: int) -> Self:
94 label = f"Wallet Top-Up for {wallet.organization.name}"
95 return cls(label=label, amount=amount, tax_amount=0, proration=False)