Coverage for polar/models/refund.py: 63%
112 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 enum import StrEnum
2from typing import TYPE_CHECKING, Any, Literal
3from uuid import UUID
5from sqlalchemy import (
6 Boolean,
7 ColumnElement,
8 ForeignKey,
9 Integer,
10 String,
11 Uuid,
12 type_coerce,
13)
14from sqlalchemy.dialects.postgresql import JSONB
15from sqlalchemy.ext.hybrid import hybrid_property
16from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
18from polar.enums import PaymentProcessor
19from polar.kit.db.models import RecordModel
20from polar.kit.metadata import MetadataMixin
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 (
24 Customer,
25 Order,
26 Organization,
27 Pledge,
28 Subscription,
29 )
32class RefundStatus(StrEnum):
33 pending = "pending"
34 succeeded = "succeeded"
35 failed = "failed"
36 canceled = "canceled"
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):
44 duplicate = "duplicate"
45 fraudulent = "fraudulent"
46 customer_request = "customer_request"
47 service_disruption = "service_disruption"
48 satisfaction_guarantee = "satisfaction_guarantee"
49 other = "other"
51 @classmethod
52 def from_stripe(
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
73 def to_stripe(
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):
86 unknown = "unknown"
87 declined = "declined"
88 card_expired = "card_expired"
89 card_lost = "card_lost"
90 disputed = "disputed"
91 insufficient_funds = "insufficient_funds"
92 merchant_request = "merchant_request"
94 @classmethod
95 def from_stripe(
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):
126 __tablename__ = "refunds"
128 status: Mapped[RefundStatus] = mapped_column(String, nullable=False)
129 reason: Mapped[RefundReason] = mapped_column(String, nullable=False)
130 amount: Mapped[int] = mapped_column(Integer, nullable=False)
131 tax_amount: Mapped[int] = mapped_column(Integer, nullable=False)
132 currency: Mapped[str] = mapped_column(String(3), nullable=False)
134 comment: Mapped[str | None] = mapped_column(String, nullable=True)
136 failure_reason: Mapped[RefundFailureReason | None] = mapped_column(
137 String, nullable=True
138 )
140 destination_details: Mapped[dict[str, Any]] = mapped_column(
141 JSONB, nullable=False, default=dict
142 )
144 order_id: Mapped[UUID | None] = mapped_column(
145 Uuid, ForeignKey("orders.id"), nullable=True, index=True
146 )
148 @declared_attr
149 def order(cls) -> Mapped["Order | None"]:
150 return relationship("Order", lazy="raise")
152 subscription_id: Mapped[UUID | None] = mapped_column(
153 Uuid, ForeignKey("subscriptions.id"), nullable=True, index=True
154 )
156 @declared_attr
157 def subscription(cls) -> Mapped["Subscription | None"]:
158 return relationship("Subscription", lazy="raise")
160 organization_id: Mapped[UUID | None] = mapped_column(
161 Uuid, ForeignKey("organizations.id"), nullable=True, index=True
162 )
164 @declared_attr
165 def organization(cls) -> Mapped["Organization | None"]:
166 return relationship("Organization", lazy="raise")
168 customer_id: Mapped[UUID | None] = mapped_column(
169 Uuid, ForeignKey("customers.id"), nullable=True, index=True
170 )
172 @declared_attr
173 def customer(cls) -> Mapped["Customer | None"]:
174 return relationship("Customer", lazy="raise")
176 pledge_id: Mapped[UUID | None] = mapped_column(
177 Uuid,
178 ForeignKey("pledges.id"),
179 nullable=True,
180 )
182 @declared_attr
183 def pledge(cls) -> Mapped["Pledge | None"]:
184 return relationship("Pledge", lazy="raise")
186 # Created refund was set to revoke customer benefits?
187 revoke_benefits: Mapped[bool] = mapped_column(
188 Boolean,
189 nullable=False,
190 default=False,
191 )
193 processor: Mapped[PaymentProcessor] = mapped_column(
194 String,
195 nullable=False,
196 )
197 processor_id: Mapped[str] = mapped_column(
198 String,
199 nullable=False,
200 unique=True,
201 index=True,
202 )
203 processor_reason: Mapped[str] = mapped_column(String, nullable=False)
204 processor_receipt_number: Mapped[str | None] = mapped_column(String, nullable=True)
205 processor_balance_transaction_id: Mapped[str | None] = mapped_column(
206 String, nullable=True
207 )
209 tax_transaction_processor_id: Mapped[str | None] = mapped_column(
210 String, nullable=True, default=None
211 )
213 @hybrid_property
214 def succeeded(self) -> bool:
215 return self.status == RefundStatus.succeeded
217 @succeeded.inplace.expression
218 @classmethod
219 def _succeeded_expression(cls) -> ColumnElement[bool]:
220 return type_coerce(
221 cls.status.in_(RefundStatus.succeeded),
222 Boolean,
223 )
225 @hybrid_property
226 def total_amount(self) -> int:
227 return self.amount + self.tax_amount
229 @total_amount.inplace.expression
230 @classmethod
231 def _total_amount_expression(cls) -> ColumnElement[int]:
232 return cls.amount + cls.tax_amount