Coverage for polar/models/transaction.py: 96%

224 statements  

« 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 1ab

3from uuid import UUID 1ab

4 

5from sqlalchemy import BigInteger, ForeignKey, Integer, String, Uuid 1ab

6from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab

7 

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

9 

10if TYPE_CHECKING: 10 ↛ 11line 10 didn't jump to line 11 because the condition on line 10 was never true1ab

11 from polar.models import ( 

12 Account, 

13 Customer, 

14 IssueReward, 

15 Order, 

16 Organization, 

17 Payout, 

18 Pledge, 

19 Refund, 

20 User, 

21 ) 

22 

23 

24class Processor(StrEnum): 1ab

25 """ 

26 Supported payment or payout processors, i.e rails for transactions. 

27 """ 

28 

29 stripe = "stripe" 1ab

30 manual = "manual" 1ab

31 # Legacy 

32 open_collective = "open_collective" 1ab

33 

34 

35class TransactionType(StrEnum): 1ab

36 """ 

37 Type of transactions. 

38 """ 

39 

40 payment = "payment" 1ab

41 """Polar received a payment.""" 1ab

42 processor_fee = "processor_fee" 1ab

43 """A payment processor fee was charged to Polar.""" 1ab

44 refund = "refund" 1ab

45 """Polar refunded a payment (totally or partially).""" 1ab

46 refund_reversal = "refund_reversal" 1ab

47 """A Polar refund is reversed (totally or partially).""" 1ab

48 dispute = "dispute" 1ab

49 """A Polar payment is disputed (totally or partially).""" 1ab

50 dispute_reversal = "dispute_reversal" 1ab

51 """A Polar payment dispute is reversed (totally or partially).""" 1ab

52 balance = "balance" 1ab

53 """Money flow between Polar and a user's account.""" 1ab

54 payout = "payout" 1ab

55 """Money paid to the user's bank account.""" 1ab

56 

57 

58class ProcessorFeeType(StrEnum): 1ab

59 """ 

60 Type of fees applied by payment processors, and billed to the users. 

61 """ 

62 

63 payment = "payment" 1ab

64 """ 1ab

65 Fee applied to a payment, like a credit card fee. 

66 """ 

67 

68 refund = "refund" 1ab

69 """ 1ab

70 Fee applied when a refund is issued. 

71 """ 

72 

73 dispute = "dispute" 1ab

74 """ 1ab

75 Fee applied when a dispute is opened. Usually crazy high. 

76 """ 

77 

78 tax = "tax" 1ab

79 """ 1ab

80 Fee applied for automatic tax calculation and collection. 

81 

82 For Stripe, it corresponds to **0.5% of the amount**. 

83 """ 

84 

85 subscription = "subscription" 1ab

86 """ 1ab

87 Fee applied to a recurring subscription. 

88 

89 For Stripe, it corresponds to **0.5% of the subscription amount**. 

90 """ 

91 

92 invoice = "invoice" 1ab

93 """ 1ab

94 Fee applied to an issued invoice. 

95 

96 For Stripe, it corresponds to **0.4%%** on Starter, **0.5%** on Plus of the amount. 

97 """ 

98 

99 cross_border_transfer = "cross_border_transfer" 1ab

100 """ 1ab

101 Fee applied when money is transferred to a different country than Polar's. 

102 

103 For Stripe, it varies per country. Usually around **0.25% and 1% of the amount**. 

104 """ 

105 

106 payout = "payout" 1ab

107 """ 1ab

108 Fee applied when money is paid out to the user's bank account. 

109 

110 For Stripe, it's **0.25% of the amount + 0.25$ per payout**. 

111 """ 

112 

113 account = "account" 1ab

114 """ 1ab

115 Fee applied recurrently to an active account. 

116 

117 For Stripe, it's **2$ per month**. 

118 It considers an account active if a payout has been made in the month. 

119 """ 

120 

121 security = "security" 1ab

122 """ 1ab

123 Fee applied for safety and fraud prevention tools. 

124 """ 

125 

126 

127class PlatformFeeType(StrEnum): 1ab

128 """ 

129 Type of fees applied by Polar, and billed to the users. 

130 """ 

131 

132 payment = "payment" 1ab

133 """ 1ab

134 Fee applied to a payment. This is the base fee applied to all payments. 

135 """ 

136 

137 international_payment = "international_payment" 1ab

138 """ 1ab

139 Fee applied to an international payment, i.e. the payment method is not from the US. 

140 """ 

141 

142 subscription = "subscription" 1ab

143 """ 1ab

144 Fee applied to a recurring subscription. 

145 """ 

146 

147 invoice = "invoice" 1ab

148 """ 1ab

149 Fee applied to an issued invoice. 

150 """ 

151 

152 cross_border_transfer = "cross_border_transfer" 1ab

153 """ 1ab

154 Fee applied by the payment processor when money is transferred 

155 to a different country than Polar's. 

156 """ 

157 

158 payout = "payout" 1ab

159 """ 1ab

160 Fee applied by the payment processor when money 

161 is paid out to the user's bank account. 

162 """ 

163 

164 account = "account" 1ab

165 """ 1ab

166 Fee applied recurrently by the payment processor to an active account. 

167 """ 

168 

169 dispute = "dispute" 1ab

170 """ 1ab

171 Fee applied when a dispute was opened on a payment. 

172 """ 

173 

174 platform = "platform" 1ab

175 """ 1ab

176 Polar platform fee. 

177 

178 **Deprecated: we no longer have a generic platform fee. They're always associated with a specific reason.** 

179 """ 

180 

181 

182class Transaction(RecordModel): 1ab

183 """ 

184 Represent a money flow in the Polar system. 

185 """ 

186 

187 __tablename__ = "transactions" 1ab

188 

189 type: Mapped[TransactionType] = mapped_column(String, nullable=False, index=True) 1ab

190 """Type of transaction.""" 1ab

191 processor: Mapped[Processor | None] = mapped_column( 1ab

192 String, nullable=True, index=True 

193 ) 

194 """Payment processor. For TransactionType.balance, it should be `None`.""" 1ab

195 

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

197 """Currency of this transaction from Polar's perspective. Should be `usd`.""" 1ab

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

199 """Amount in cents of this transaction from Polar's perspective.""" 1ab

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

201 """Currency of this transaction from user's account perspective. Might not be `usd`.""" 1ab

202 account_amount: Mapped[int] = mapped_column(BigInteger, nullable=False) 1ab

203 """Amount in cents of this transaction from user's account perspective.""" 1ab

204 tax_amount: Mapped[int] = mapped_column(BigInteger, nullable=False) 1ab

205 """Amount of tax collected by Polar for this payment.""" 1ab

206 tax_country: Mapped[str] = mapped_column(String(2), nullable=True, index=True) 1ab

207 """Country for which Polar collected the tax.""" 1ab

208 tax_state: Mapped[str] = mapped_column(String(2), nullable=True, index=True) 1ab

209 """State for which Polar collected the tax.""" 1ab

210 presentment_amount: Mapped[int | None] = mapped_column(BigInteger, nullable=True) 1ab

211 """Amount in cents of this transaction from customer's perspective.""" 1ab

212 presentment_tax_amount: Mapped[int | None] = mapped_column( 1ab

213 BigInteger, nullable=True 

214 ) 

215 """Amount of tax in the presentment currency collected by Polar for this payment.""" 1ab

216 presentment_currency: Mapped[str | None] = mapped_column(String(3), nullable=True) 1ab

217 """Currency in which the customer made the payment.""" 1ab

218 tax_filing_amount: Mapped[int | None] = mapped_column(BigInteger, nullable=True) 1ab

219 """Amount of tax filed to the jurisdiction by Polar for this payment.""" 1ab

220 tax_filing_currency: Mapped[str | None] = mapped_column(String(3), nullable=True) 1ab

221 """Currency in which the tax was filed to the jurisdiction by Polar.""" 1ab

222 tax_processor_id: Mapped[str | None] = mapped_column( 1ab

223 String, nullable=True, default=None 

224 ) 

225 """ID of the tax transaction in the tax processor system.""" 1ab

226 

227 processor_fee_type: Mapped[ProcessorFeeType | None] = mapped_column( 1ab

228 String, nullable=True, index=True 

229 ) 

230 """ 1ab

231 Type of processor fee. Only applies to transactions of type `TransactionType.processor_fee`. 

232 """ 

233 

234 balance_correlation_key: Mapped[str | None] = mapped_column( 1ab

235 String, nullable=True, index=True 

236 ) 

237 """ 1ab

238 Internal key used to correlate a couple of balance transactions: 

239 the outgoing side and the incoming side. 

240 """ 

241 

242 platform_fee_type: Mapped[PlatformFeeType | None] = mapped_column( 1ab

243 String, nullable=True, index=True 

244 ) 

245 """ 1ab

246 Type of platform fee. 

247 

248 Only applies to transactions of type `TransactionType.balance` 

249 with a set `account_id`. 

250 """ 

251 

252 customer_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True) 1ab

253 """ID of the customer in the payment processor system.""" 1ab

254 charge_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True) 1ab

255 """ID of the charge (payment) in the payment processor system.""" 1ab

256 refund_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True) 1ab

257 """ID of the refund in the payment processor system.""" 1ab

258 dispute_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True) 1ab

259 """ID of the dispute in the payment processor system.""" 1ab

260 transfer_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True) 1ab

261 """ID of the transfer in the payment processor system.""" 1ab

262 transfer_reversal_id: Mapped[str | None] = mapped_column( 1ab

263 String, nullable=True, index=True 

264 ) 

265 """ID of the transfer reversal in the payment processor system.""" 1ab

266 fee_balance_transaction_id: Mapped[str | None] = mapped_column( 1ab

267 String, nullable=True, index=True 

268 ) 

269 """ID of the fee's balance transaction in the payment processor system.""" 1ab

270 

271 risk_level: Mapped[str | None] = mapped_column(String, nullable=True, index=False) 1ab

272 """Payment risk level.""" 1ab

273 risk_score: Mapped[int | None] = mapped_column(Integer, nullable=True, index=False) 1ab

274 """Payment risk score (0-100) for payments.""" 1ab

275 

276 account_id: Mapped[UUID | None] = mapped_column( 1ab

277 Uuid, 

278 ForeignKey("accounts.id", ondelete="restrict"), 

279 nullable=True, 

280 index=True, 

281 ) 

282 """ 1ab

283 ID of the `Account` concerned by this transaction. 

284 

285 If `None`, this transaction concerns Polar directly. 

286 """ 

287 

288 @declared_attr 1ab

289 def account(cls) -> Mapped["Account | None"]: 1ab

290 return relationship("Account", lazy="raise") 1ab

291 

292 payment_customer_id: Mapped[UUID | None] = mapped_column( 1ab

293 Uuid, 

294 ForeignKey("customers.id", ondelete="set null"), 

295 nullable=True, 

296 index=True, 

297 ) 

298 """ID of the `Customer` who made the payment.""" 1ab

299 

300 @declared_attr 1ab

301 def payment_customer(cls) -> Mapped["Customer | None"]: 1ab

302 return relationship("Customer", lazy="raise") 1ab

303 

304 payment_organization_id: Mapped[UUID | None] = mapped_column( 1ab

305 Uuid, 

306 ForeignKey("organizations.id", ondelete="set null"), 

307 nullable=True, 

308 index=True, 

309 ) 

310 """ID of the `Organization` who made the payment.""" 1ab

311 

312 @declared_attr 1ab

313 def payment_organization(cls) -> Mapped["Organization | None"]: 1ab

314 return relationship("Organization", lazy="raise") 1ab

315 

316 payment_user_id: Mapped[UUID | None] = mapped_column( 1ab

317 Uuid, 

318 ForeignKey("users.id", ondelete="set null"), 

319 nullable=True, 

320 index=True, 

321 ) 

322 """ 1ab

323 ID of the `User` who made the payment. 

324 

325 Used for pledges. Orders and subscriptions should use `payment_customer_id`. 

326 """ 

327 

328 @declared_attr 1ab

329 def payment_user(cls) -> Mapped["User | None"]: 1ab

330 return relationship("User", lazy="raise") 1ab

331 

332 pledge_id: Mapped[UUID | None] = mapped_column( 1ab

333 Uuid, 

334 ForeignKey("pledges.id", ondelete="set null"), 

335 nullable=True, 

336 index=True, 

337 ) 

338 """ID of the `Pledge` related to this transaction.""" 1ab

339 

340 @declared_attr 1ab

341 def pledge(cls) -> Mapped["Pledge | None"]: 1ab

342 return relationship("Pledge", lazy="raise") 1ab

343 

344 order_id: Mapped[UUID | None] = mapped_column( 1ab

345 Uuid, 

346 ForeignKey("orders.id", ondelete="set null"), 

347 nullable=True, 

348 index=True, 

349 ) 

350 """ID of the `Order` related to this transaction.""" 1ab

351 

352 @declared_attr 1ab

353 def order(cls) -> Mapped["Order | None"]: 1ab

354 return relationship("Order", lazy="raise") 1ab

355 

356 issue_reward_id: Mapped[UUID | None] = mapped_column( 1ab

357 Uuid, 

358 ForeignKey("issue_rewards.id", ondelete="set null"), 

359 nullable=True, 

360 index=True, 

361 ) 

362 """ID of the `IssueReward` related to this transaction.""" 1ab

363 

364 @declared_attr 1ab

365 def issue_reward(cls) -> Mapped["IssueReward | None"]: 1ab

366 return relationship("IssueReward", lazy="raise") 1ab

367 

368 payment_transaction_id: Mapped[UUID | None] = mapped_column( 1ab

369 Uuid, 

370 ForeignKey("transactions.id", ondelete="set null"), 

371 nullable=True, 

372 index=True, 

373 ) 

374 """ID of the transaction that pays for this transaction.""" 1ab

375 

376 @declared_attr 1ab

377 def payment_transaction(cls) -> Mapped["Transaction | None"]: 1ab

378 """Transaction that pays for this transaction.""" 

379 return relationship( 1ab

380 "Transaction", 

381 lazy="raise", 

382 # Ref: https://docs.sqlalchemy.org/en/20/orm/self_referential.html 

383 remote_side=[ 

384 cls.id, # type: ignore 

385 ], 

386 foreign_keys="[Transaction.payment_transaction_id]", 

387 back_populates="balance_transactions", 

388 ) 

389 

390 # TODO: Hopefully temporary naming. Want to prefix all processor IDs 

391 # with `processor_` here and be able to rename this then to `refund_id` 

392 polar_refund_id: Mapped[UUID | None] = mapped_column( 1ab

393 Uuid, 

394 ForeignKey("refunds.id", ondelete="set null"), 

395 nullable=True, 

396 index=True, 

397 ) 

398 """ID of the `Refund` related to this transaction.""" 1ab

399 

400 @declared_attr 1ab

401 def refund(cls) -> Mapped["Refund | None"]: 1ab

402 return relationship("Refund", lazy="raise") 1ab

403 

404 payout_id: Mapped[UUID | None] = mapped_column( 1ab

405 Uuid, ForeignKey("payouts.id"), nullable=True, index=True 

406 ) 

407 """ID of the `Payout` related to this transaction.""" 1ab

408 

409 @declared_attr 1ab

410 def payout(cls) -> Mapped["Payout | None"]: 1ab

411 return relationship("Payout", lazy="raise", back_populates="transaction") 1ab

412 

413 @declared_attr 1ab

414 def balance_transactions(cls) -> Mapped[list["Transaction"]]: 1ab

415 """Transactions that were balanced by this payment transaction.""" 

416 return relationship( 1ab

417 "Transaction", 

418 lazy="raise", 

419 back_populates="payment_transaction", 

420 foreign_keys="[Transaction.payment_transaction_id]", 

421 ) 

422 

423 balance_reversal_transaction_id: Mapped[UUID | None] = mapped_column( 1ab

424 Uuid, 

425 ForeignKey("transactions.id", ondelete="set null"), 

426 nullable=True, 

427 index=True, 

428 ) 

429 """ID of the balance transaction which is reversed by this transaction.""" 1ab

430 

431 @declared_attr 1ab

432 def balance_reversal_transaction(cls) -> Mapped["Transaction | None"]: 1ab

433 """Balance transaction which is reversed by this transaction.""" 

434 return relationship( 1ab

435 "Transaction", 

436 lazy="raise", 

437 # Ref: https://docs.sqlalchemy.org/en/20/orm/self_referential.html 

438 remote_side=[ 

439 cls.id, # type: ignore 

440 ], 

441 foreign_keys="[Transaction.balance_reversal_transaction_id]", 

442 back_populates="balance_reversal_transactions", 

443 ) 

444 

445 @declared_attr 1ab

446 def balance_reversal_transactions(cls) -> Mapped[list["Transaction"]]: 1ab

447 """Balance transactions that reverses this transaction.""" 

448 return relationship( 1ab

449 "Transaction", 

450 lazy="raise", 

451 foreign_keys="[Transaction.balance_reversal_transaction_id]", 

452 back_populates="balance_reversal_transaction", 

453 ) 

454 

455 payout_transaction_id: Mapped[UUID | None] = mapped_column( 1ab

456 Uuid, 

457 ForeignKey("transactions.id", ondelete="set null"), 

458 nullable=True, 

459 index=True, 

460 ) 

461 """ID of the transaction that paid out this transaction.""" 1ab

462 

463 @declared_attr 1ab

464 def payout_transaction(cls) -> Mapped["Transaction | None"]: 1ab

465 """Transaction that paid out this transaction.""" 

466 return relationship( 1ab

467 "Transaction", 

468 lazy="raise", 

469 # Ref: https://docs.sqlalchemy.org/en/20/orm/self_referential.html 

470 remote_side=[ 

471 cls.id, # type: ignore 

472 ], 

473 foreign_keys="[Transaction.payout_transaction_id]", 

474 back_populates="paid_transactions", 

475 ) 

476 

477 @declared_attr 1ab

478 def paid_transactions(cls) -> Mapped[list["Transaction"]]: 1ab

479 """Transactions that were paid out by this transaction.""" 

480 return relationship( 1ab

481 "Transaction", 

482 lazy="raise", 

483 back_populates="payout_transaction", 

484 foreign_keys="[Transaction.payout_transaction_id]", 

485 ) 

486 

487 incurred_by_transaction_id: Mapped[UUID | None] = mapped_column( 1ab

488 Uuid, 

489 ForeignKey("transactions.id", ondelete="set null"), 

490 nullable=True, 

491 index=True, 

492 ) 

493 """ 1ab

494 ID of the transaction that incurred this transaction. 

495 Generally applies to transactions of type `TransactionType.processor_fee` 

496 or platform fees balances. 

497 """ 

498 

499 @declared_attr 1ab

500 def incurred_by_transaction(cls) -> Mapped["Transaction | None"]: 1ab

501 """ 

502 Transaction that incurred this transaction. 

503 Generally applies to transactions of type `TransactionType.processor_fee` 

504 or platform fees balances. 

505 """ 

506 return relationship( 1ab

507 "Transaction", 

508 lazy="raise", 

509 # Ref: https://docs.sqlalchemy.org/en/20/orm/self_referential.html 

510 remote_side=[ 

511 cls.id, # type: ignore 

512 ], 

513 back_populates="incurred_transactions", 

514 foreign_keys="[Transaction.incurred_by_transaction_id]", 

515 ) 

516 

517 @declared_attr 1ab

518 def incurred_transactions(cls) -> Mapped[list["Transaction"]]: 1ab

519 """Transactions that were incurred by this transaction.""" 

520 return relationship( 1ab

521 "Transaction", 

522 lazy="raise", 

523 back_populates="incurred_by_transaction", 

524 foreign_keys="[Transaction.incurred_by_transaction_id]", 

525 ) 

526 

527 @declared_attr 1ab

528 def account_incurred_transactions(cls) -> Mapped[list["Transaction"]]: 1ab

529 """ 

530 Transactions that were incurred by this transaction, 

531 filtered on the current transaction account. 

532 """ 

533 return relationship( 1ab

534 "Transaction", 

535 lazy="raise", 

536 foreign_keys="[Transaction.incurred_by_transaction_id]", 

537 primaryjoin=( 

538 "and_(" 

539 "foreign(Transaction.incurred_by_transaction_id) == Transaction.id, " 

540 "foreign(Transaction.account_id) == Transaction.account_id," 

541 ")" 

542 ), 

543 viewonly=True, 

544 ) 

545 

546 @property 1ab

547 def incurred_amount(self) -> int: 1ab

548 return sum( 

549 transaction.amount for transaction in self.account_incurred_transactions 

550 ) 

551 

552 @property 1ab

553 def gross_amount(self) -> int: 1ab

554 inclusive = 0 if self.type == TransactionType.balance else 1 

555 return self.amount + inclusive * self.incurred_amount 

556 

557 @property 1ab

558 def net_amount(self) -> int: 1ab

559 inclusive = 1 if self.type == TransactionType.balance else -1 

560 return self.gross_amount + inclusive * self.incurred_amount 

561 

562 @property 1ab

563 def reversed_amount(self) -> int: 1ab

564 return sum( 

565 transaction.amount for transaction in self.balance_reversal_transactions 

566 ) 

567 

568 @property 1ab

569 def transferable_amount(self) -> int: 1ab

570 return self.amount + self.reversed_amount 

571 

572 def __repr__(self) -> str: 1ab

573 return f"Transaction(id={self.id!r}, type={self.type!r}, amount={self.amount!r}, currency={self.currency!r})"