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

1from datetime import datetime 1ab

2from enum import StrEnum 1ab

3from uuid import UUID 1ab

4 

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

19 

20from polar.kit.db.models import RecordModel 1ab

21from polar.kit.utils import utc_now 1ab

22 

23from .organization import Organization 1ab

24from .user import User 1ab

25 

26 

27class PledgeState(StrEnum): 1ab

28 # Initiated by customer. Polar has not received money yet. 

29 initiated = "initiated" 1ab

30 

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

35 

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

42 

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

51 

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 ] 

60 

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 # 

71 

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] 

78 

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] 

85 

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] 

92 

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] 

99 

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] 

106 

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] 

113 

114 @classmethod 1ab

115 def from_str(cls, s: str) -> "PledgeState": 1ab

116 return PledgeState.__members__[s] 

117 

118 

119class PledgeType(StrEnum): 1ab

120 # Up front pledges, paid to Polar directly, transfered to maintainer when completed. 

121 pay_upfront = "pay_upfront" 1ab

122 

123 # Pledge without upfront payment. The pledger pays after the issue is completed. 

124 pay_on_completion = "pay_on_completion" 1ab

125 

126 # Pay directly. Money is ready to transfered to maintainer without requiring 

127 # issue to be completed. 

128 pay_directly = "pay_directly" 1ab

129 

130 @classmethod 1ab

131 def from_str(cls, s: str) -> "PledgeType": 1ab

132 return PledgeType.__members__[s] 

133 

134 

135class Pledge(RecordModel): 1ab

136 __tablename__ = "pledges" 1ab

137 

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 ) 

142 

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 ) 

150 

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 ) 

155 

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

158 

159 # Stripe URL for hosted invoice 

160 invoice_hosted_url: Mapped[str | None] = mapped_column( 1ab

161 String, nullable=True, default=None 

162 ) 

163 

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

167 

168 email: Mapped[str] = mapped_column(String, nullable=True, index=True, default=None) 1ab

169 

170 amount: Mapped[int] = mapped_column(BigInteger, nullable=False) 1ab

171 currency: Mapped[str] = mapped_column(String(3), nullable=False) 1ab

172 

173 fee: Mapped[int] = mapped_column(BigInteger, nullable=False) 1ab

174 

175 @property 1ab

176 def amount_including_fee(self) -> int: 1ab

177 return self.amount + self.fee 

178 

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 ) 

185 

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 ) 

192 

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 ) 

197 

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 ) 

213 

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 ) 

224 

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 ) 

232 

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 ) 

245 

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 ) 

256 

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

260 

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 ) 

268 

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 ) 

276 

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 ) 

282 

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 ) 

288 

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 )