Coverage for polar/models/pledge.py: 88%
98 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 datetime import datetime 1ab
2from enum import StrEnum 1ab
3from uuid import UUID 1ab
5from sqlalchemy import ( 1ab
6 TIMESTAMP,
7 BigInteger,
8 Boolean,
9 ColumnElement,
10 ForeignKey,
11 String,
12 Uuid,
13 and_,
14 or_,
15 type_coerce,
16)
17from sqlalchemy.ext.hybrid import hybrid_property 1ab
18from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab
20from polar.kit.db.models import RecordModel 1ab
21from polar.kit.utils import utc_now 1ab
23from .organization import Organization 1ab
24from .user import User 1ab
27class PledgeState(StrEnum): 1ab
28 # Initiated by customer. Polar has not received money yet.
29 initiated = "initiated" 1ab
31 # The pledge has been created.
32 # Type=pay_upfront: polar has recevied the money
33 # Type=pay_on_completion: polar has not recevied the money
34 created = "created" 1ab
36 # The fix was confirmed, and rewards have been created.
37 # See issue rewards to track payment status.
38 #
39 # Type=pay_upfront: polar has recevied the money
40 # Type=pay_on_completion: polar has recevied the money
41 pending = "pending" 1ab
43 # The pledge was refunded in full before being paid out.
44 refunded = "refunded" 1ab
45 # The pledge was disputed by the customer (via Polar)
46 disputed = "disputed" 1ab
47 # The charge was disputed by the customer (via Stripe, aka "chargeback")
48 charge_disputed = "charge_disputed" 1ab
49 # Manually cancalled by a Polar admin.
50 cancelled = "cancelled" 1ab
52 # The states in which this pledge is "active", i.e. is listed on the issue
53 @classmethod 1ab
54 def active_states(cls) -> list["PledgeState"]: 1ab
55 return [
56 cls.created,
57 cls.pending,
58 cls.disputed,
59 ]
61 # Happy paths:
62 # Pay upfront:
63 # initiated -> created -> pending -> (transfer)
64 #
65 # Pay later (pay_on_completion / pay_from_maintainer):
66 # created -> pending -> (transfer)
67 #
68 # Pay regardless:
69 # pending -> (transfer)
70 #
72 @classmethod 1ab
73 def to_created_states(cls) -> list["PledgeState"]: 1ab
74 """
75 Allowed states to move into initiated from
76 """
77 return [cls.initiated]
79 @classmethod 1ab
80 def to_confirmation_pending_states(cls) -> list["PledgeState"]: 1ab
81 """
82 Allowed states to move into confirmation pending from
83 """
84 return [cls.created]
86 @classmethod 1ab
87 def to_pending_states(cls) -> list["PledgeState"]: 1ab
88 """
89 Allowed states to move into pending from
90 """
91 return [cls.created]
93 @classmethod 1ab
94 def to_disputed_states(cls) -> list["PledgeState"]: 1ab
95 """
96 # Allowed states to move into disputed from
97 """
98 return [cls.created, cls.pending]
100 @classmethod 1ab
101 def to_paid_states(cls) -> list["PledgeState"]: 1ab
102 """
103 Allowed states to move into paid from
104 """
105 return [cls.pending]
107 @classmethod 1ab
108 def to_refunded_states(cls) -> list["PledgeState"]: 1ab
109 """
110 Allowed states to move into refunded from
111 """
112 return [cls.created, cls.pending, cls.disputed]
114 @classmethod 1ab
115 def from_str(cls, s: str) -> "PledgeState": 1ab
116 return PledgeState.__members__[s]
119class PledgeType(StrEnum): 1ab
120 # Up front pledges, paid to Polar directly, transfered to maintainer when completed.
121 pay_upfront = "pay_upfront" 1ab
123 # Pledge without upfront payment. The pledger pays after the issue is completed.
124 pay_on_completion = "pay_on_completion" 1ab
126 # Pay directly. Money is ready to transfered to maintainer without requiring
127 # issue to be completed.
128 pay_directly = "pay_directly" 1ab
130 @classmethod 1ab
131 def from_str(cls, s: str) -> "PledgeType": 1ab
132 return PledgeType.__members__[s]
135class Pledge(RecordModel): 1ab
136 __tablename__ = "pledges" 1ab
138 issue_reference: Mapped[str] = mapped_column(String, nullable=False, index=True) 1ab
139 organization_id: Mapped[UUID | None] = mapped_column( 1ab
140 Uuid, ForeignKey("organizations.id"), nullable=True, index=True
141 )
143 @declared_attr 1ab
144 def organization(cls) -> Mapped[Organization | None]: 1ab
145 return relationship( 1ab
146 Organization,
147 primaryjoin=Organization.id == cls.organization_id,
148 lazy="raise",
149 )
151 # Stripe Payment Intents (may or may not have been paid)
152 payment_id: Mapped[str | None] = mapped_column( 1ab
153 String, nullable=True, index=True, default=None
154 )
156 # Stripe Invoice ID (if pay later and the invoice has been created)
157 invoice_id: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
159 # Stripe URL for hosted invoice
160 invoice_hosted_url: Mapped[str | None] = mapped_column( 1ab
161 String, nullable=True, default=None
162 )
164 # Deprecated: Not relevant after introduction of split rewards.
165 # Instead see pledge_transactions table.
166 transfer_id: Mapped[str | None] = mapped_column(String, nullable=True, default=None) 1ab
168 email: Mapped[str] = mapped_column(String, nullable=True, index=True, default=None) 1ab
170 amount: Mapped[int] = mapped_column(BigInteger, nullable=False) 1ab
171 currency: Mapped[str] = mapped_column(String(3), nullable=False) 1ab
173 fee: Mapped[int] = mapped_column(BigInteger, nullable=False) 1ab
175 @property 1ab
176 def amount_including_fee(self) -> int: 1ab
177 return self.amount + self.fee
179 # For paid pledges, this is the amount of monye actually received.
180 # For pledges paid by invoice, this amount can be smaller or larger than
181 # amount_including_fee.
182 amount_received: Mapped[int | None] = mapped_column( 1ab
183 BigInteger, nullable=True, default=None
184 )
186 state: Mapped[PledgeState] = mapped_column( 1ab
187 String, nullable=False, default=PledgeState.initiated
188 )
189 type: Mapped[PledgeType] = mapped_column( 1ab
190 String, nullable=False, default=PledgeType.pay_upfront
191 )
193 # often 7 days after the state changes to pending
194 scheduled_payout_at: Mapped[datetime | None] = mapped_column( 1ab
195 TIMESTAMP(timezone=True), nullable=True, default=None
196 )
198 dispute_reason: Mapped[str | None] = mapped_column( 1ab
199 String, nullable=True, default=None
200 )
201 disputed_by_user_id: Mapped[UUID | None] = mapped_column( 1ab
202 Uuid,
203 ForeignKey("users.id"),
204 nullable=True,
205 default=None,
206 )
207 disputed_at: Mapped[datetime | None] = mapped_column( 1ab
208 TIMESTAMP(timezone=True), nullable=True, default=None
209 )
210 refunded_at: Mapped[datetime | None] = mapped_column( 1ab
211 TIMESTAMP(timezone=True), nullable=True, default=None
212 )
214 # by_user_id, by_organization_id are mutually exclusive
215 #
216 # They determine who paid for this pledge (or who's going to pay for it).
217 by_user_id: Mapped[UUID | None] = mapped_column( 1ab
218 Uuid,
219 ForeignKey("users.id"),
220 nullable=True,
221 index=True,
222 default=None,
223 )
225 by_organization_id: Mapped[UUID | None] = mapped_column( 1ab
226 Uuid,
227 ForeignKey("organizations.id"),
228 nullable=True,
229 index=True,
230 default=None,
231 )
233 # on_behalf_of_organization_id can be set when by_user_id is set.
234 # This means that the "credz" if the pledge will go to the organization, but that
235 # the by_user_id-user is still the one that paid/will pay for the pledge.
236 #
237 # on_behalf_of_organization_id can not be set when by_organization_id is set.
238 on_behalf_of_organization_id: Mapped[UUID | None] = mapped_column( 1ab
239 Uuid,
240 ForeignKey("organizations.id"),
241 nullable=True,
242 index=True,
243 default=None,
244 )
246 # created_by_user_id is the user/actor that created the pledge, unrelated to who's
247 # going to pay for it.
248 #
249 # If by_organization_id is set, this is the user that pressed the "Pledge" button.
250 created_by_user_id: Mapped[UUID | None] = mapped_column( 1ab
251 Uuid,
252 ForeignKey("users.id"),
253 nullable=True,
254 default=None,
255 )
257 @declared_attr 1ab
258 def user(cls) -> Mapped[User | None]: 1ab
259 return relationship(User, primaryjoin=User.id == cls.by_user_id, lazy="raise") 1ab
261 @declared_attr 1ab
262 def by_organization(cls) -> Mapped[Organization]: 1ab
263 return relationship( 1ab
264 Organization,
265 primaryjoin=Organization.id == cls.by_organization_id,
266 lazy="raise",
267 )
269 @declared_attr 1ab
270 def on_behalf_of_organization(cls) -> Mapped[Organization]: 1ab
271 return relationship( 1ab
272 Organization,
273 primaryjoin=Organization.id == cls.on_behalf_of_organization_id,
274 lazy="raise",
275 )
277 @declared_attr 1ab
278 def created_by_user(cls) -> Mapped[User | None]: 1ab
279 return relationship( 1ab
280 User, primaryjoin=User.id == cls.created_by_user_id, lazy="raise"
281 )
283 @hybrid_property 1ab
284 def ready_for_transfer(self) -> bool: 1ab
285 return self.state == PledgeState.pending and (
286 self.scheduled_payout_at is None or self.scheduled_payout_at < utc_now()
287 )
289 @ready_for_transfer.inplace.expression 1ab
290 @classmethod 1ab
291 def _ready_for_transfer_expression(cls) -> ColumnElement[bool]: 1ab
292 return type_coerce(
293 and_(
294 cls.state == PledgeState.pending,
295 or_(
296 cls.scheduled_payout_at.is_(None),
297 cls.scheduled_payout_at < utc_now(),
298 ),
299 ),
300 Boolean,
301 )