Coverage for polar/models/refund.py: 63%
112 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 15:52 +0000
1from enum import StrEnum 1ab
2from typing import TYPE_CHECKING, Any, Literal 1ab
3from uuid import UUID 1ab
5from sqlalchemy import ( 1ab
6 Boolean,
7 ColumnElement,
8 ForeignKey,
9 Integer,
10 String,
11 Uuid,
12 type_coerce,
13)
14from sqlalchemy.dialects.postgresql import JSONB 1ab
15from sqlalchemy.ext.hybrid import hybrid_property 1ab
16from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab
18from polar.enums import PaymentProcessor 1ab
19from polar.kit.db.models import RecordModel 1ab
20from polar.kit.metadata import MetadataMixin 1ab
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 (
24 Customer,
25 Order,
26 Organization,
27 Pledge,
28 Subscription,
29 )
32class RefundStatus(StrEnum): 1ab
33 pending = "pending" 1ab
34 succeeded = "succeeded" 1ab
35 failed = "failed" 1ab
36 canceled = "canceled" 1ab
39# Decoupled from Stripe
40# 1) Allowing more reasons (good signals)
41# 2) Allowing us to enable merchants to set `fraudulent` without automatically
42# tagging it as such on Stripe.
43class RefundReason(StrEnum): 1ab
44 duplicate = "duplicate" 1ab
45 fraudulent = "fraudulent" 1ab
46 customer_request = "customer_request" 1ab
47 service_disruption = "service_disruption" 1ab
48 satisfaction_guarantee = "satisfaction_guarantee" 1ab
49 other = "other" 1ab
51 @classmethod 1ab
52 def from_stripe( 1ab
53 cls,
54 reason: (
55 Literal[
56 "duplicate",
57 "expired_uncaptured_charge",
58 "fraudulent",
59 "requested_by_customer",
60 ]
61 | None
62 ),
63 ) -> "RefundReason":
64 if reason == "requested_by_customer":
65 return cls.customer_request
66 elif reason == "fraudulent":
67 return cls.fraudulent
68 elif reason == "duplicate":
69 return cls.duplicate
70 return cls.other
72 @classmethod 1ab
73 def to_stripe( 1ab
74 cls, reason: "RefundReason"
75 ) -> Literal["requested_by_customer", "duplicate"]:
76 if reason == cls.duplicate:
77 return "duplicate"
79 # Avoid directly setting fraudulent since that blocks customers and can
80 # be abused, i.e we should monitor our own fraudulent status and set it
81 # retroactively on Stripe.
82 return "requested_by_customer"
85class RefundFailureReason(StrEnum): 1ab
86 unknown = "unknown" 1ab
87 declined = "declined" 1ab
88 card_expired = "card_expired" 1ab
89 card_lost = "card_lost" 1ab
90 disputed = "disputed" 1ab
91 insufficient_funds = "insufficient_funds" 1ab
92 merchant_request = "merchant_request" 1ab
94 @classmethod 1ab
95 def from_stripe( 1ab
96 cls,
97 reason: (
98 Literal[
99 "lost_or_stolen_card",
100 "expired_or_canceled_card",
101 "charge_for_pending_refund_disputed",
102 "insufficient_funds",
103 "merchant_request",
104 "unknown",
105 ]
106 | None
107 ),
108 ) -> "RefundFailureReason | None":
109 if reason is None:
110 return None
112 if reason == "lost_or_stolen_card":
113 return cls.card_lost
114 elif reason == "expired_or_canceled_card":
115 return cls.card_expired
116 elif reason == "charge_for_pending_refund_disputed":
117 return cls.disputed
118 elif reason == "insufficient_funds":
119 return cls.insufficient_funds
120 elif reason == "merchant_request":
121 return cls.merchant_request
122 return cls.unknown
125class Refund(MetadataMixin, RecordModel): 1ab
126 __tablename__ = "refunds" 1ab
128 status: Mapped[RefundStatus] = mapped_column(String, nullable=False) 1ab
129 reason: Mapped[RefundReason] = mapped_column(String, nullable=False) 1ab
130 amount: Mapped[int] = mapped_column(Integer, nullable=False) 1ab
131 tax_amount: Mapped[int] = mapped_column(Integer, nullable=False) 1ab
132 currency: Mapped[str] = mapped_column(String(3), nullable=False) 1ab
134 comment: Mapped[str | None] = mapped_column(String, nullable=True) 1ab
136 failure_reason: Mapped[RefundFailureReason | None] = mapped_column( 1ab
137 String, nullable=True
138 )
140 destination_details: Mapped[dict[str, Any]] = mapped_column( 1ab
141 JSONB, nullable=False, default=dict
142 )
144 order_id: Mapped[UUID | None] = mapped_column( 1ab
145 Uuid, ForeignKey("orders.id"), nullable=True, index=True
146 )
148 @declared_attr 1ab
149 def order(cls) -> Mapped["Order | None"]: 1ab
150 return relationship("Order", lazy="raise") 1ab
152 subscription_id: Mapped[UUID | None] = mapped_column( 1ab
153 Uuid, ForeignKey("subscriptions.id"), nullable=True, index=True
154 )
156 @declared_attr 1ab
157 def subscription(cls) -> Mapped["Subscription | None"]: 1ab
158 return relationship("Subscription", lazy="raise") 1ab
160 organization_id: Mapped[UUID | None] = mapped_column( 1ab
161 Uuid, ForeignKey("organizations.id"), nullable=True, index=True
162 )
164 @declared_attr 1ab
165 def organization(cls) -> Mapped["Organization | None"]: 1ab
166 return relationship("Organization", lazy="raise") 1ab
168 customer_id: Mapped[UUID | None] = mapped_column( 1ab
169 Uuid, ForeignKey("customers.id"), nullable=True, index=True
170 )
172 @declared_attr 1ab
173 def customer(cls) -> Mapped["Customer | None"]: 1ab
174 return relationship("Customer", lazy="raise") 1ab
176 pledge_id: Mapped[UUID | None] = mapped_column( 1ab
177 Uuid,
178 ForeignKey("pledges.id"),
179 nullable=True,
180 )
182 @declared_attr 1ab
183 def pledge(cls) -> Mapped["Pledge | None"]: 1ab
184 return relationship("Pledge", lazy="raise") 1ab
186 # Created refund was set to revoke customer benefits?
187 revoke_benefits: Mapped[bool] = mapped_column( 1ab
188 Boolean,
189 nullable=False,
190 default=False,
191 )
193 processor: Mapped[PaymentProcessor] = mapped_column( 1ab
194 String,
195 nullable=False,
196 )
197 processor_id: Mapped[str] = mapped_column( 1ab
198 String,
199 nullable=False,
200 unique=True,
201 index=True,
202 )
203 processor_reason: Mapped[str] = mapped_column(String, nullable=False) 1ab
204 processor_receipt_number: Mapped[str | None] = mapped_column(String, nullable=True) 1ab
205 processor_balance_transaction_id: Mapped[str | None] = mapped_column( 1ab
206 String, nullable=True
207 )
209 tax_transaction_processor_id: Mapped[str | None] = mapped_column( 1ab
210 String, nullable=True, default=None
211 )
213 @hybrid_property 1ab
214 def succeeded(self) -> bool: 1ab
215 return self.status == RefundStatus.succeeded
217 @succeeded.inplace.expression 1ab
218 @classmethod 1ab
219 def _succeeded_expression(cls) -> ColumnElement[bool]: 1ab
220 return type_coerce(
221 cls.status.in_(RefundStatus.succeeded),
222 Boolean,
223 )
225 @hybrid_property 1ab
226 def total_amount(self) -> int: 1ab
227 return self.amount + self.tax_amount
229 @total_amount.inplace.expression 1ab
230 @classmethod 1ab
231 def _total_amount_expression(cls) -> ColumnElement[int]: 1ab
232 return cls.amount + cls.tax_amount