Coverage for polar/models/transaction.py: 96%
224 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-12-05 16:17 +0000
1from enum import StrEnum 1ab
2from typing import TYPE_CHECKING 1ab
3from uuid import UUID 1ab
5from sqlalchemy import BigInteger, ForeignKey, Integer, String, Uuid 1ab
6from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship 1ab
8from polar.kit.db.models import RecordModel 1ab
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 )
24class Processor(StrEnum): 1ab
25 """
26 Supported payment or payout processors, i.e rails for transactions.
27 """
29 stripe = "stripe" 1ab
30 manual = "manual" 1ab
31 # Legacy
32 open_collective = "open_collective" 1ab
35class TransactionType(StrEnum): 1ab
36 """
37 Type of transactions.
38 """
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
58class ProcessorFeeType(StrEnum): 1ab
59 """
60 Type of fees applied by payment processors, and billed to the users.
61 """
63 payment = "payment" 1ab
64 """ 1ab
65 Fee applied to a payment, like a credit card fee.
66 """
68 refund = "refund" 1ab
69 """ 1ab
70 Fee applied when a refund is issued.
71 """
73 dispute = "dispute" 1ab
74 """ 1ab
75 Fee applied when a dispute is opened. Usually crazy high.
76 """
78 tax = "tax" 1ab
79 """ 1ab
80 Fee applied for automatic tax calculation and collection.
82 For Stripe, it corresponds to **0.5% of the amount**.
83 """
85 subscription = "subscription" 1ab
86 """ 1ab
87 Fee applied to a recurring subscription.
89 For Stripe, it corresponds to **0.5% of the subscription amount**.
90 """
92 invoice = "invoice" 1ab
93 """ 1ab
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" 1ab
100 """ 1ab
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" 1ab
107 """ 1ab
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" 1ab
114 """ 1ab
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" 1ab
122 """ 1ab
123 Fee applied for safety and fraud prevention tools.
124 """
127class PlatformFeeType(StrEnum): 1ab
128 """
129 Type of fees applied by Polar, and billed to the users.
130 """
132 payment = "payment" 1ab
133 """ 1ab
134 Fee applied to a payment. This is the base fee applied to all payments.
135 """
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 """
142 subscription = "subscription" 1ab
143 """ 1ab
144 Fee applied to a recurring subscription.
145 """
147 invoice = "invoice" 1ab
148 """ 1ab
149 Fee applied to an issued invoice.
150 """
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 """
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 """
164 account = "account" 1ab
165 """ 1ab
166 Fee applied recurrently by the payment processor to an active account.
167 """
169 dispute = "dispute" 1ab
170 """ 1ab
171 Fee applied when a dispute was opened on a payment.
172 """
174 platform = "platform" 1ab
175 """ 1ab
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): 1ab
183 """
184 Represent a money flow in the Polar system.
185 """
187 __tablename__ = "transactions" 1ab
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
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
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 """
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 """
242 platform_fee_type: Mapped[PlatformFeeType | None] = mapped_column( 1ab
243 String, nullable=True, index=True
244 )
245 """ 1ab
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) 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
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
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.
285 If `None`, this transaction concerns Polar directly.
286 """
288 @declared_attr 1ab
289 def account(cls) -> Mapped["Account | None"]: 1ab
290 return relationship("Account", lazy="raise") 1ab
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
300 @declared_attr 1ab
301 def payment_customer(cls) -> Mapped["Customer | None"]: 1ab
302 return relationship("Customer", lazy="raise") 1ab
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
312 @declared_attr 1ab
313 def payment_organization(cls) -> Mapped["Organization | None"]: 1ab
314 return relationship("Organization", lazy="raise") 1ab
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.
325 Used for pledges. Orders and subscriptions should use `payment_customer_id`.
326 """
328 @declared_attr 1ab
329 def payment_user(cls) -> Mapped["User | None"]: 1ab
330 return relationship("User", lazy="raise") 1ab
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
340 @declared_attr 1ab
341 def pledge(cls) -> Mapped["Pledge | None"]: 1ab
342 return relationship("Pledge", lazy="raise") 1ab
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
352 @declared_attr 1ab
353 def order(cls) -> Mapped["Order | None"]: 1ab
354 return relationship("Order", lazy="raise") 1ab
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
364 @declared_attr 1ab
365 def issue_reward(cls) -> Mapped["IssueReward | None"]: 1ab
366 return relationship("IssueReward", lazy="raise") 1ab
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
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 )
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
400 @declared_attr 1ab
401 def refund(cls) -> Mapped["Refund | None"]: 1ab
402 return relationship("Refund", lazy="raise") 1ab
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
409 @declared_attr 1ab
410 def payout(cls) -> Mapped["Payout | None"]: 1ab
411 return relationship("Payout", lazy="raise", back_populates="transaction") 1ab
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 )
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
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 )
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 )
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
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 )
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 )
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 """
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 )
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 )
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 )
546 @property 1ab
547 def incurred_amount(self) -> int: 1ab
548 return sum(
549 transaction.amount for transaction in self.account_incurred_transactions
550 )
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
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
562 @property 1ab
563 def reversed_amount(self) -> int: 1ab
564 return sum(
565 transaction.amount for transaction in self.balance_reversal_transactions
566 )
568 @property 1ab
569 def transferable_amount(self) -> int: 1ab
570 return self.amount + self.reversed_amount
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})"