Coverage for polar/models/order.py: 67%
184 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
1import inspect 1ab
2from datetime import datetime 1ab
3from enum import StrEnum 1ab
4from typing import TYPE_CHECKING 1ab
5from uuid import UUID 1ab
7from sqlalchemy import ( 1ab
8 TIMESTAMP,
9 ColumnElement,
10 ForeignKey,
11 Index,
12 Integer,
13 String,
14 Uuid,
15 func,
16 text,
17)
18from sqlalchemy.dialects.postgresql import JSONB 1ab
19from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy 1ab
20from sqlalchemy.ext.hybrid import hybrid_property 1ab
21from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab
23from polar.custom_field.data import CustomFieldDataMixin 1ab
24from polar.exceptions import PolarError 1ab
25from polar.kit.address import Address, AddressType 1ab
26from polar.kit.db.models import RecordModel 1ab
27from polar.kit.metadata import MetadataMixin 1ab
28from polar.kit.tax import TaxabilityReason, TaxID, TaxIDType, TaxRate 1ab
29from polar.models.order_item import OrderItem 1ab
31if TYPE_CHECKING: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true1ab
32 from polar.models import (
33 Checkout,
34 Customer,
35 CustomerSeat,
36 Discount,
37 Organization,
38 Product,
39 ProductPrice,
40 Subscription,
41 )
44class OrderBillingReasonInternal(StrEnum): 1ab
45 """
46 Internal billing reasons with additional granularity.
47 """
49 purchase = "purchase" 1ab
50 subscription_create = "subscription_create" 1ab
51 subscription_cycle = "subscription_cycle" 1ab
52 subscription_cycle_after_trial = "subscription_cycle_after_trial" 1ab
53 subscription_update = "subscription_update" 1ab
56class OrderBillingReason(StrEnum): 1ab
57 purchase = "purchase" 1ab
58 subscription_create = "subscription_create" 1ab
59 subscription_cycle = "subscription_cycle" 1ab
60 subscription_update = "subscription_update" 1ab
63class OrderStatus(StrEnum): 1ab
64 pending = "pending" 1ab
65 paid = "paid" 1ab
66 refunded = "refunded" 1ab
67 partially_refunded = "partially_refunded" 1ab
70class OrderRefundExceedsBalance(PolarError): 1ab
71 def __init__( 1ab
72 self, order: "Order", attempted_amount: int, attempted_tax_amount: int
73 ) -> None:
74 self.type = type
75 super().__init__(
76 inspect.cleandoc(
77 f"""
78 Order({order.id}) with remaining amount {order.refundable_amount}
79 and tax {order.refundable_tax_amount} attempted to be refunded with
80 {attempted_amount} and {attempted_tax_amount} in taxes.
81 """
82 )
83 )
86class Order(CustomFieldDataMixin, MetadataMixin, RecordModel): 1ab
87 __tablename__ = "orders" 1ab
88 __table_args__ = ( 1ab
89 Index("ix_net_amount", text("(subtotal_amount - discount_amount)")),
90 Index(
91 "ix_total_amount", text("(subtotal_amount - discount_amount + tax_amount)")
92 ),
93 )
95 status: Mapped[OrderStatus] = mapped_column( 1ab
96 String, nullable=False, default=OrderStatus.pending, index=True
97 )
98 subtotal_amount: Mapped[int] = mapped_column(Integer, nullable=False) 1ab
99 discount_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 1ab
100 tax_amount: Mapped[int] = mapped_column(Integer, nullable=False) 1ab
101 applied_balance_amount: Mapped[int] = mapped_column( 1ab
102 Integer, nullable=False, default=0
103 )
104 currency: Mapped[str] = mapped_column(String(3), nullable=False) 1ab
105 billing_reason: Mapped[OrderBillingReasonInternal] = mapped_column( 1ab
106 String, nullable=False, index=True
107 )
109 refunded_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 1ab
110 refunded_tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 1ab
112 platform_fee_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 1ab
114 billing_name: Mapped[str | None] = mapped_column( 1ab
115 String, nullable=True, default=None
116 )
117 billing_address: Mapped[Address | None] = mapped_column(AddressType, nullable=True) 1ab
118 stripe_invoice_id: Mapped[str | None] = mapped_column( 1ab
119 String, nullable=True, unique=True, default=None
120 )
122 taxability_reason: Mapped[TaxabilityReason | None] = mapped_column( 1ab
123 String, nullable=True, default=None
124 )
125 tax_id: Mapped[TaxID | None] = mapped_column(TaxIDType, nullable=True, default=None) 1ab
126 tax_rate: Mapped[TaxRate | None] = mapped_column( 1ab
127 JSONB(none_as_null=True), nullable=True, default=None
128 )
129 tax_calculation_processor_id: Mapped[str | None] = mapped_column( 1ab
130 String, nullable=True, default=None
131 )
132 tax_transaction_processor_id: Mapped[str | None] = mapped_column( 1ab
133 String, nullable=True, default=None
134 )
136 invoice_number: Mapped[str] = mapped_column(String, nullable=False, unique=True) 1ab
137 invoice_path: Mapped[str | None] = mapped_column( 1ab
138 String, nullable=True, default=None
139 )
141 next_payment_attempt_at: Mapped[datetime | None] = mapped_column( 1ab
142 TIMESTAMP(timezone=True), nullable=True, default=None, index=True
143 )
145 payment_lock_acquired_at: Mapped[datetime | None] = mapped_column( 1ab
146 TIMESTAMP(timezone=True), nullable=True, default=None
147 )
149 customer_id: Mapped[UUID] = mapped_column( 1ab
150 Uuid, ForeignKey("customers.id"), nullable=False, index=True
151 )
153 @declared_attr 1ab
154 def customer(cls) -> Mapped["Customer"]: 1ab
155 return relationship("Customer", lazy="raise") 1ab
157 product_id: Mapped[UUID | None] = mapped_column( 1ab
158 Uuid, ForeignKey("products.id"), nullable=True, index=True
159 )
161 @declared_attr 1ab
162 def product(cls) -> Mapped["Product | None"]: 1ab
163 return relationship("Product", lazy="raise") 1ab
165 discount_id: Mapped[UUID | None] = mapped_column( 1ab
166 Uuid, ForeignKey("discounts.id", ondelete="set null"), nullable=True
167 )
169 @declared_attr 1ab
170 def discount(cls) -> Mapped["Discount | None"]: 1ab
171 return relationship("Discount", lazy="raise") 1ab
173 organization: AssociationProxy["Organization"] = association_proxy( 1ab
174 "customer", "organization"
175 )
177 subscription_id: Mapped[UUID | None] = mapped_column( 1ab
178 Uuid, ForeignKey("subscriptions.id"), nullable=True
179 )
181 @declared_attr 1ab
182 def subscription(cls) -> Mapped["Subscription | None"]: 1ab
183 return relationship("Subscription", lazy="raise") 1ab
185 checkout_id: Mapped[UUID | None] = mapped_column( 1ab
186 Uuid, ForeignKey("checkouts.id", ondelete="set null"), nullable=True, index=True
187 )
189 @declared_attr 1ab
190 def checkout(cls) -> Mapped["Checkout | None"]: 1ab
191 return relationship("Checkout", lazy="raise") 1ab
193 seats: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) 1ab
195 items: Mapped[list["OrderItem"]] = relationship( 1ab
196 "OrderItem",
197 back_populates="order",
198 cascade="all, delete-orphan",
199 # Items are almost always needed, so eager loading makes sense
200 lazy="selectin",
201 )
203 @declared_attr 1ab
204 def customer_seats(cls) -> Mapped[list["CustomerSeat"]]: 1ab
205 return relationship( 1ab
206 "CustomerSeat",
207 lazy="raise",
208 back_populates="order",
209 cascade="all, delete-orphan",
210 )
212 @property 1ab
213 def legacy_product_price(self) -> "ProductPrice | None": 1ab
214 """
215 Dummy method to keep API backward compatibility
216 by fetching a product price at all costs.
217 """
218 if self.product is None:
219 return None
220 for item in self.items:
221 if item.product_price:
222 return item.product_price
223 return self.product.prices[0]
225 @property 1ab
226 def legacy_product_price_id(self) -> UUID | None: 1ab
227 price = self.legacy_product_price
228 if price is None:
229 return None
230 return price.id
232 @hybrid_property 1ab
233 def paid(self) -> bool: 1ab
234 return self.status in {
235 OrderStatus.paid,
236 OrderStatus.refunded,
237 OrderStatus.partially_refunded,
238 }
240 @paid.inplace.expression 1ab
241 @classmethod 1ab
242 def _paid_expression(cls) -> ColumnElement[bool]: 1ab
243 return cls.status.in_(
244 (OrderStatus.paid, OrderStatus.refunded, OrderStatus.partially_refunded)
245 )
247 @hybrid_property 1ab
248 def net_amount(self) -> int: 1ab
249 return self.subtotal_amount - self.discount_amount
251 @net_amount.inplace.expression 1ab
252 @classmethod 1ab
253 def _net_amount_expression(cls) -> ColumnElement[int]: 1ab
254 return cls.subtotal_amount - cls.discount_amount
256 @hybrid_property 1ab
257 def total_amount(self) -> int: 1ab
258 return self.net_amount + self.tax_amount
260 @total_amount.inplace.expression 1ab
261 @classmethod 1ab
262 def _total_amount_expression(cls) -> ColumnElement[int]: 1ab
263 return cls.net_amount + cls.tax_amount
265 @hybrid_property 1ab
266 def due_amount(self) -> int: 1ab
267 return max(0, self.total_amount + self.applied_balance_amount)
269 @due_amount.inplace.expression 1ab
270 @classmethod 1ab
271 def _due_amount_expression(cls) -> ColumnElement[int]: 1ab
272 return func.greatest(0, cls.total_amount + cls.applied_balance_amount)
274 @hybrid_property 1ab
275 def payout_amount(self) -> int: 1ab
276 return self.net_amount - self.platform_fee_amount - self.refunded_amount
278 @payout_amount.inplace.expression 1ab
279 @classmethod 1ab
280 def _payout_amount_expression(cls) -> ColumnElement[int]: 1ab
281 return cls.net_amount - cls.platform_fee_amount - cls.refunded_amount
283 @property 1ab
284 def taxed(self) -> int: 1ab
285 return self.tax_amount > 0
287 @property 1ab
288 def refunded(self) -> bool: 1ab
289 return self.status == OrderStatus.refunded
291 @property 1ab
292 def refundable_amount(self) -> int: 1ab
293 return self.net_amount - self.refunded_amount
295 @property 1ab
296 def refundable_tax_amount(self) -> int: 1ab
297 return self.tax_amount - self.refunded_tax_amount
299 @property 1ab
300 def remaining_balance(self) -> int: 1ab
301 return self.refundable_amount + self.refundable_tax_amount
303 def update_refunds(self, refunded_amount: int, refunded_tax_amount: int) -> None: 1ab
304 new_amount = self.refunded_amount + refunded_amount
305 new_tax_amount = self.refunded_tax_amount + refunded_tax_amount
306 exceeds_original_amount = (
307 new_amount < 0
308 or new_amount > self.net_amount
309 or new_tax_amount < 0
310 or new_tax_amount > self.tax_amount
311 )
312 if exceeds_original_amount:
313 raise OrderRefundExceedsBalance(self, refunded_amount, refunded_tax_amount)
315 if new_amount == 0:
316 new_status = OrderStatus.paid
317 elif new_amount == self.net_amount:
318 new_status = OrderStatus.refunded
319 else:
320 new_status = OrderStatus.partially_refunded
322 self.status = new_status
323 self.refunded_amount = new_amount
324 self.refunded_tax_amount = new_tax_amount
326 @property 1ab
327 def is_invoice_generated(self) -> bool: 1ab
328 return self.invoice_path is not None
330 @property 1ab
331 def invoice_filename(self) -> str: 1ab
332 return f"Invoice-{self.invoice_number}.pdf"
334 @property 1ab
335 def statement_descriptor_suffix(self) -> str: 1ab
336 if (
337 self.billing_reason
338 == OrderBillingReasonInternal.subscription_cycle_after_trial
339 ):
340 return self.organization.statement_descriptor(" TRIAL OVER")
341 return self.organization.statement_descriptor()
343 @property 1ab
344 def description(self) -> str: 1ab
345 if self.product is not None:
346 return self.product.name
347 return self.items[0].label