Coverage for polar/models/transaction.py: 96%
224 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
3from uuid import UUID
5from sqlalchemy import BigInteger, ForeignKey, Integer, String, Uuid
6from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
8from polar.kit.db.models import RecordModel
10if TYPE_CHECKING: 10 ↛ 11line 10 didn't jump to line 11 because the condition on line 10 was never true
11 from polar.models import (
12 Account,
13 Customer,
14 IssueReward,
15 Order,
16 Organization,
17 Payout,
18 Pledge,
19 Refund,
20 User,
21 )
24class Processor(StrEnum):
25 """
26 Supported payment or payout processors, i.e rails for transactions.
27 """
29 stripe = "stripe"
30 manual = "manual"
31 # Legacy
32 open_collective = "open_collective"
35class TransactionType(StrEnum):
36 """
37 Type of transactions.
38 """
40 payment = "payment"
41 """Polar received a payment."""
42 processor_fee = "processor_fee"
43 """A payment processor fee was charged to Polar."""
44 refund = "refund"
45 """Polar refunded a payment (totally or partially)."""
46 refund_reversal = "refund_reversal"
47 """A Polar refund is reversed (totally or partially)."""
48 dispute = "dispute"
49 """A Polar payment is disputed (totally or partially)."""
50 dispute_reversal = "dispute_reversal"
51 """A Polar payment dispute is reversed (totally or partially)."""
52 balance = "balance"
53 """Money flow between Polar and a user's account."""
54 payout = "payout"
55 """Money paid to the user's bank account."""
58class ProcessorFeeType(StrEnum):
59 """
60 Type of fees applied by payment processors, and billed to the users.
61 """
63 payment = "payment"
64 """
65 Fee applied to a payment, like a credit card fee.
66 """
68 refund = "refund"
69 """
70 Fee applied when a refund is issued.
71 """
73 dispute = "dispute"
74 """
75 Fee applied when a dispute is opened. Usually crazy high.
76 """
78 tax = "tax"
79 """
80 Fee applied for automatic tax calculation and collection.
82 For Stripe, it corresponds to **0.5% of the amount**.
83 """
85 subscription = "subscription"
86 """
87 Fee applied to a recurring subscription.
89 For Stripe, it corresponds to **0.5% of the subscription amount**.
90 """
92 invoice = "invoice"
93 """
94 Fee applied to an issued invoice.
96 For Stripe, it corresponds to **0.4%%** on Starter, **0.5%** on Plus of the amount.
97 """
99 cross_border_transfer = "cross_border_transfer"
100 """
101 Fee applied when money is transferred to a different country than Polar's.
103 For Stripe, it varies per country. Usually around **0.25% and 1% of the amount**.
104 """
106 payout = "payout"
107 """
108 Fee applied when money is paid out to the user's bank account.
110 For Stripe, it's **0.25% of the amount + 0.25$ per payout**.
111 """
113 account = "account"
114 """
115 Fee applied recurrently to an active account.
117 For Stripe, it's **2$ per month**.
118 It considers an account active if a payout has been made in the month.
119 """
121 security = "security"
122 """
123 Fee applied for safety and fraud prevention tools.
124 """
127class PlatformFeeType(StrEnum):
128 """
129 Type of fees applied by Polar, and billed to the users.
130 """
132 payment = "payment"
133 """
134 Fee applied to a payment. This is the base fee applied to all payments.
135 """
137 international_payment = "international_payment"
138 """
139 Fee applied to an international payment, i.e. the payment method is not from the US.
140 """
142 subscription = "subscription"
143 """
144 Fee applied to a recurring subscription.
145 """
147 invoice = "invoice"
148 """
149 Fee applied to an issued invoice.
150 """
152 cross_border_transfer = "cross_border_transfer"
153 """
154 Fee applied by the payment processor when money is transferred
155 to a different country than Polar's.
156 """
158 payout = "payout"
159 """
160 Fee applied by the payment processor when money
161 is paid out to the user's bank account.
162 """
164 account = "account"
165 """
166 Fee applied recurrently by the payment processor to an active account.
167 """
169 dispute = "dispute"
170 """
171 Fee applied when a dispute was opened on a payment.
172 """
174 platform = "platform"
175 """
176 Polar platform fee.
178 **Deprecated: we no longer have a generic platform fee. They're always associated with a specific reason.**
179 """
182class Transaction(RecordModel):
183 """
184 Represent a money flow in the Polar system.
185 """
187 __tablename__ = "transactions"
189 type: Mapped[TransactionType] = mapped_column(String, nullable=False, index=True)
190 """Type of transaction."""
191 processor: Mapped[Processor | None] = mapped_column(
192 String, nullable=True, index=True
193 )
194 """Payment processor. For TransactionType.balance, it should be `None`."""
196 currency: Mapped[str] = mapped_column(String(3), nullable=False)
197 """Currency of this transaction from Polar's perspective. Should be `usd`."""
198 amount: Mapped[int] = mapped_column(BigInteger, nullable=False)
199 """Amount in cents of this transaction from Polar's perspective."""
200 account_currency: Mapped[str] = mapped_column(String(3), nullable=False)
201 """Currency of this transaction from user's account perspective. Might not be `usd`."""
202 account_amount: Mapped[int] = mapped_column(BigInteger, nullable=False)
203 """Amount in cents of this transaction from user's account perspective."""
204 tax_amount: Mapped[int] = mapped_column(BigInteger, nullable=False)
205 """Amount of tax collected by Polar for this payment."""
206 tax_country: Mapped[str] = mapped_column(String(2), nullable=True, index=True)
207 """Country for which Polar collected the tax."""
208 tax_state: Mapped[str] = mapped_column(String(2), nullable=True, index=True)
209 """State for which Polar collected the tax."""
210 presentment_amount: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
211 """Amount in cents of this transaction from customer's perspective."""
212 presentment_tax_amount: Mapped[int | None] = mapped_column(
213 BigInteger, nullable=True
214 )
215 """Amount of tax in the presentment currency collected by Polar for this payment."""
216 presentment_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
217 """Currency in which the customer made the payment."""
218 tax_filing_amount: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
219 """Amount of tax filed to the jurisdiction by Polar for this payment."""
220 tax_filing_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
221 """Currency in which the tax was filed to the jurisdiction by Polar."""
222 tax_processor_id: Mapped[str | None] = mapped_column(
223 String, nullable=True, default=None
224 )
225 """ID of the tax transaction in the tax processor system."""
227 processor_fee_type: Mapped[ProcessorFeeType | None] = mapped_column(
228 String, nullable=True, index=True
229 )
230 """
231 Type of processor fee. Only applies to transactions of type `TransactionType.processor_fee`.
232 """
234 balance_correlation_key: Mapped[str | None] = mapped_column(
235 String, nullable=True, index=True
236 )
237 """
238 Internal key used to correlate a couple of balance transactions:
239 the outgoing side and the incoming side.
240 """
242 platform_fee_type: Mapped[PlatformFeeType | None] = mapped_column(
243 String, nullable=True, index=True
244 )
245 """
246 Type of platform fee.
248 Only applies to transactions of type `TransactionType.balance`
249 with a set `account_id`.
250 """
252 customer_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
253 """ID of the customer in the payment processor system."""
254 charge_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
255 """ID of the charge (payment) in the payment processor system."""
256 refund_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
257 """ID of the refund in the payment processor system."""
258 dispute_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
259 """ID of the dispute in the payment processor system."""
260 transfer_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
261 """ID of the transfer in the payment processor system."""
262 transfer_reversal_id: Mapped[str | None] = mapped_column(
263 String, nullable=True, index=True
264 )
265 """ID of the transfer reversal in the payment processor system."""
266 fee_balance_transaction_id: Mapped[str | None] = mapped_column(
267 String, nullable=True, index=True
268 )
269 """ID of the fee's balance transaction in the payment processor system."""
271 risk_level: Mapped[str | None] = mapped_column(String, nullable=True, index=False)
272 """Payment risk level."""
273 risk_score: Mapped[int | None] = mapped_column(Integer, nullable=True, index=False)
274 """Payment risk score (0-100) for payments."""
276 account_id: Mapped[UUID | None] = mapped_column(
277 Uuid,
278 ForeignKey("accounts.id", ondelete="restrict"),
279 nullable=True,
280 index=True,
281 )
282 """
283 ID of the `Account` concerned by this transaction.
285 If `None`, this transaction concerns Polar directly.
286 """
288 @declared_attr
289 def account(cls) -> Mapped["Account | None"]:
290 return relationship("Account", lazy="raise")
292 payment_customer_id: Mapped[UUID | None] = mapped_column(
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."""
300 @declared_attr
301 def payment_customer(cls) -> Mapped["Customer | None"]:
302 return relationship("Customer", lazy="raise")
304 payment_organization_id: Mapped[UUID | None] = mapped_column(
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."""
312 @declared_attr
313 def payment_organization(cls) -> Mapped["Organization | None"]:
314 return relationship("Organization", lazy="raise")
316 payment_user_id: Mapped[UUID | None] = mapped_column(
317 Uuid,
318 ForeignKey("users.id", ondelete="set null"),
319 nullable=True,
320 index=True,
321 )
322 """
323 ID of the `User` who made the payment.
325 Used for pledges. Orders and subscriptions should use `payment_customer_id`.
326 """
328 @declared_attr
329 def payment_user(cls) -> Mapped["User | None"]:
330 return relationship("User", lazy="raise")
332 pledge_id: Mapped[UUID | None] = mapped_column(
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."""
340 @declared_attr
341 def pledge(cls) -> Mapped["Pledge | None"]:
342 return relationship("Pledge", lazy="raise")
344 order_id: Mapped[UUID | None] = mapped_column(
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."""
352 @declared_attr
353 def order(cls) -> Mapped["Order | None"]:
354 return relationship("Order", lazy="raise")
356 issue_reward_id: Mapped[UUID | None] = mapped_column(
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."""
364 @declared_attr
365 def issue_reward(cls) -> Mapped["IssueReward | None"]:
366 return relationship("IssueReward", lazy="raise")
368 payment_transaction_id: Mapped[UUID | None] = mapped_column(
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."""
376 @declared_attr
377 def payment_transaction(cls) -> Mapped["Transaction | None"]:
378 """Transaction that pays for this transaction."""
379 return relationship(
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 )
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(
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."""
400 @declared_attr
401 def refund(cls) -> Mapped["Refund | None"]:
402 return relationship("Refund", lazy="raise")
404 payout_id: Mapped[UUID | None] = mapped_column(
405 Uuid, ForeignKey("payouts.id"), nullable=True, index=True
406 )
407 """ID of the `Payout` related to this transaction."""
409 @declared_attr
410 def payout(cls) -> Mapped["Payout | None"]:
411 return relationship("Payout", lazy="raise", back_populates="transaction")
413 @declared_attr
414 def balance_transactions(cls) -> Mapped[list["Transaction"]]:
415 """Transactions that were balanced by this payment transaction."""
416 return relationship(
417 "Transaction",
418 lazy="raise",
419 back_populates="payment_transaction",
420 foreign_keys="[Transaction.payment_transaction_id]",
421 )
423 balance_reversal_transaction_id: Mapped[UUID | None] = mapped_column(
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."""
431 @declared_attr
432 def balance_reversal_transaction(cls) -> Mapped["Transaction | None"]:
433 """Balance transaction which is reversed by this transaction."""
434 return relationship(
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 )
445 @declared_attr
446 def balance_reversal_transactions(cls) -> Mapped[list["Transaction"]]:
447 """Balance transactions that reverses this transaction."""
448 return relationship(
449 "Transaction",
450 lazy="raise",
451 foreign_keys="[Transaction.balance_reversal_transaction_id]",
452 back_populates="balance_reversal_transaction",
453 )
455 payout_transaction_id: Mapped[UUID | None] = mapped_column(
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."""
463 @declared_attr
464 def payout_transaction(cls) -> Mapped["Transaction | None"]:
465 """Transaction that paid out this transaction."""
466 return relationship(
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 )
477 @declared_attr
478 def paid_transactions(cls) -> Mapped[list["Transaction"]]:
479 """Transactions that were paid out by this transaction."""
480 return relationship(
481 "Transaction",
482 lazy="raise",
483 back_populates="payout_transaction",
484 foreign_keys="[Transaction.payout_transaction_id]",
485 )
487 incurred_by_transaction_id: Mapped[UUID | None] = mapped_column(
488 Uuid,
489 ForeignKey("transactions.id", ondelete="set null"),
490 nullable=True,
491 index=True,
492 )
493 """
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 """
499 @declared_attr
500 def incurred_by_transaction(cls) -> Mapped["Transaction | None"]:
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(
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 )
517 @declared_attr
518 def incurred_transactions(cls) -> Mapped[list["Transaction"]]:
519 """Transactions that were incurred by this transaction."""
520 return relationship(
521 "Transaction",
522 lazy="raise",
523 back_populates="incurred_by_transaction",
524 foreign_keys="[Transaction.incurred_by_transaction_id]",
525 )
527 @declared_attr
528 def account_incurred_transactions(cls) -> Mapped[list["Transaction"]]:
529 """
530 Transactions that were incurred by this transaction,
531 filtered on the current transaction account.
532 """
533 return relationship(
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 )
546 @property
547 def incurred_amount(self) -> int:
548 return sum(
549 transaction.amount for transaction in self.account_incurred_transactions
550 )
552 @property
553 def gross_amount(self) -> int:
554 inclusive = 0 if self.type == TransactionType.balance else 1
555 return self.amount + inclusive * self.incurred_amount
557 @property
558 def net_amount(self) -> int:
559 inclusive = 1 if self.type == TransactionType.balance else -1
560 return self.gross_amount + inclusive * self.incurred_amount
562 @property
563 def reversed_amount(self) -> int:
564 return sum(
565 transaction.amount for transaction in self.balance_reversal_transactions
566 )
568 @property
569 def transferable_amount(self) -> int:
570 return self.amount + self.reversed_amount
572 def __repr__(self) -> str:
573 return f"Transaction(id={self.id!r}, type={self.type!r}, amount={self.amount!r}, currency={self.currency!r})"