Coverage for dibbler / models / Transaction.py: 94%
122 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-12 13:57 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-12 13:57 +0000
1from __future__ import annotations
3from datetime import datetime
4from typing import TYPE_CHECKING, Self
6from sqlalchemy import (
7 CheckConstraint,
8 DateTime,
9 ForeignKey,
10 Integer,
11 Text,
12 and_,
13 column,
14 func,
15 or_,
16)
17from sqlalchemy.orm import (
18 Mapped,
19 mapped_column,
20 relationship,
21)
22from sqlalchemy.orm.collections import (
23 InstrumentedDict,
24 InstrumentedList,
25 InstrumentedSet,
26)
27from sqlalchemy.sql.schema import Index
29from .Base import Base
30from .TransactionType import TransactionType, TransactionTypeSQL
32if TYPE_CHECKING:
33 from .Product import Product
34 from .User import User
36# NOTE: these only matter when there are no adjustments made in the database.
37DEFAULT_INTEREST_RATE_PERCENT = 100
38DEFAULT_PENALTY_THRESHOLD = -100
39DEFAULT_PENALTY_MULTIPLIER_PERCENT = 200
41_DYNAMIC_FIELDS: set[str] = {
42 "amount",
43 "interest_rate_percent",
44 "joint_transaction_id",
45 "penalty_multiplier_percent",
46 "penalty_threshold",
47 "per_product",
48 "product_count",
49 "product_id",
50 "transfer_user_id",
51}
53EXPECTED_FIELDS: dict[TransactionType, set[str]] = {
54 TransactionType.ADD_PRODUCT: {"amount", "per_product", "product_count", "product_id"},
55 TransactionType.ADJUST_BALANCE: {"amount"},
56 TransactionType.ADJUST_INTEREST: {"interest_rate_percent"},
57 TransactionType.ADJUST_PENALTY: {"penalty_multiplier_percent", "penalty_threshold"},
58 TransactionType.ADJUST_STOCK: {"product_count", "product_id"},
59 TransactionType.BUY_PRODUCT: {"product_count", "product_id"},
60 TransactionType.JOINT: {"product_count", "product_id"},
61 TransactionType.JOINT_BUY_PRODUCT: {"joint_transaction_id"},
62 TransactionType.THROW_PRODUCT: {"product_count", "product_id"},
63 TransactionType.TRANSFER: {"amount", "transfer_user_id"},
64}
66assert all(x <= _DYNAMIC_FIELDS for x in EXPECTED_FIELDS.values()), (
67 "All expected fields must be part of _DYNAMIC_FIELDS."
68)
71def _transaction_type_field_constraints(
72 transaction_type: TransactionType,
73 expected_fields: set[str],
74) -> CheckConstraint:
75 unexpected_fields = _DYNAMIC_FIELDS - expected_fields
77 return CheckConstraint(
78 or_(
79 column("type") != transaction_type.value,
80 and_(
81 *[column(field).is_not(None) for field in expected_fields],
82 *[column(field).is_(None) for field in unexpected_fields],
83 ),
84 ),
85 name=f"trx_type_{transaction_type.value}_expected_fields",
86 )
89class Transaction(Base):
90 __table_args__ = (
91 *[
92 _transaction_type_field_constraints(transaction_type, expected_fields)
93 for transaction_type, expected_fields in EXPECTED_FIELDS.items()
94 ],
95 CheckConstraint(
96 or_(
97 column("type") != TransactionType.TRANSFER.value,
98 column("user_id") != column("transfer_user_id"),
99 ),
100 name="trx_type_transfer_no_self_transfers",
101 ),
102 CheckConstraint(
103 func.coalesce(column("product_count"), 1) != 0,
104 name="trx_product_count_non_zero",
105 ),
106 CheckConstraint(
107 func.coalesce(column("penalty_multiplier_percent"), 100) >= 100,
108 name="trx_penalty_multiplier_percent_min_100",
109 ),
110 CheckConstraint(
111 func.coalesce(column("interest_rate_percent"), 0) >= 0,
112 name="trx_interest_rate_percent_non_negative",
113 ),
114 CheckConstraint(
115 func.coalesce(column("amount"), 1) != 0,
116 name="trx_amount_non_zero",
117 ),
118 CheckConstraint(
119 func.coalesce(column("per_product"), 1) > 0,
120 name="trx_per_product_positive",
121 ),
122 CheckConstraint(
123 func.coalesce(column("penalty_threshold"), 0) <= 0,
124 name="trx_penalty_threshold_max_0",
125 ),
126 CheckConstraint(
127 or_(
128 column("joint_transaction_id").is_(None),
129 column("joint_transaction_id") != column("id"),
130 ),
131 name="trx_joint_transaction_id_not_self",
132 ),
134 # Speed up product stock calculation
135 Index("ix__transaction__product_id_type_time", "product_id", "type", "time"),
137 # Speed up product owner calculation
138 Index("ix__transaction__user_id_product_time", "user_id", "product_id", "time"),
140 # Speed up user transaction list / credit calculation
141 Index("ix__transaction__user_id_time", "user_id", "time"),
142 )
144 id: Mapped[int] = mapped_column(Integer, primary_key=True)
145 """
146 A unique identifier for the transaction.
148 Not used for anything else than identifying the transaction in the database.
149 """
151 time: Mapped[datetime] = mapped_column(DateTime, index=True)
152 """
153 The time when the transaction took place.
155 This is used to order transactions chronologically, and to calculate
156 all kinds of state.
157 """
159 message: Mapped[str | None] = mapped_column(Text, nullable=True)
160 """
161 A message that can be set by the user to describe the reason
162 behind the transaction (or potentially a place to write som fan fiction).
164 This is not used for any calculations, but can be useful for debugging.
165 """
167 type_: Mapped[TransactionType] = mapped_column(TransactionTypeSQL, name="type", index=True)
168 """
169 Which type of transaction this is.
171 The type determines which fields are expected to be set.
172 """
174 amount: Mapped[int | None] = mapped_column(Integer)
175 """
176 This field means different things depending on the transaction type:
178 - `ADD_PRODUCT`: The real amount spent on the products.
180 - `ADJUST_BALANCE`: The amount of credit to add or subtract from the user's balance.
182 - `TRANSFER`: The amount of balance to transfer to another user.
183 """
185 per_product: Mapped[int | None] = mapped_column(Integer)
186 """
187 If adding products, how much is each product worth
189 Note that this is distinct from the total amount of the transaction,
190 because this gets rounded up to the nearest integer, while the total amount
191 that the user paid in the store would be stored in the `amount` field.
192 """
194 user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True)
195 """The user who performs the transaction. See `user` for more details."""
196 user: Mapped[User] = relationship(
197 lazy="joined",
198 foreign_keys=[user_id],
199 )
200 """
201 The user who performs the transaction.
203 For some transaction types, like `TRANSFER` and `ADD_PRODUCT`, this is a
204 functional field with "real world consequences" for price calculations.
206 For others, like `ADJUST_PENALTY` and `ADJUST_STOCK`, this is just a record of who
207 performed the transaction, and does not affect any state calculations.
209 In the case of `JOINT` transactions, this is the user who initiated the joint transaction.
210 """
212 joint_transaction_id: Mapped[int | None] = mapped_column(
213 ForeignKey("transaction.id"),
214 index=True,
215 )
216 """
217 An optional ID to group multiple transactions together as part of a joint transaction.
219 This is used for `JOINT` and `JOINT_BUY_PRODUCT` transactions, where multiple users
220 are involved in a single transaction.
221 """
222 joint_transaction: Mapped[Transaction | None] = relationship(
223 lazy="joined",
224 foreign_keys=[joint_transaction_id],
225 )
226 """
227 The joint transaction that this transaction is part of, if any.
228 """
230 # Receiving user when moving credit from one user to another
231 transfer_user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"), index=True)
232 """The user who receives money in a `TRANSFER` transaction."""
233 transfer_user: Mapped[User | None] = relationship(
234 lazy="joined",
235 foreign_keys=[transfer_user_id],
236 )
237 """The user who receives money in a `TRANSFER` transaction."""
239 # The product that is either being added or bought
240 product_id: Mapped[int | None] = mapped_column(ForeignKey("product.id"), index=True)
241 """The product being added or bought."""
242 product: Mapped[Product | None] = relationship(lazy="joined")
243 """The product being added or bought."""
245 # The amount of products being added or bought
246 product_count: Mapped[int | None] = mapped_column(Integer)
247 """
248 The amount of products being added or bought.
250 This is always relative to the existing stock.
252 - `ADD_PRODUCT` increases the stock by this amount.
254 - `BUY_PRODUCT` decreases the stock by this amount.
256 - `ADJUST_STOCK` increases or decreases the stock by this amount,
257 depending on whether the amount is positive or negative.
258 """
260 penalty_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True)
261 """
262 On `ADJUST_PENALTY` transactions, this is the threshold in krs for when the user
263 should start getting penalized for low credit.
265 See also `penalty_multiplier`.
266 """
268 penalty_multiplier_percent: Mapped[int | None] = mapped_column(Integer, nullable=True)
269 """
270 On `ADJUST_PENALTY` transactions, this is the multiplier for the amount of
271 money the user has to pay when they have too low credit.
273 The multiplier is a percentage, so `100` means the user has to pay the full
274 price of the product, `200` means they have to pay double, etc.
276 See also `penalty_threshold`.
277 """
279 interest_rate_percent: Mapped[int | None] = mapped_column(Integer, nullable=True)
280 """
281 On `ADJUST_INTEREST` transactions, this is the interest rate in percent
282 that the user has to pay on their balance.
284 The interest rate is a percentage, so `100` means the user has to pay the full
285 price of the product, `200` means they have to pay double, etc.
286 """
288 economy_spec_version: Mapped[int] = mapped_column(Integer, default=1)
289 """
290 The version of the economy specification that this transaction adheres to.
292 This is used to handle changes in the economy rules over time.
293 """
295 def __init__(
296 self: Self,
297 type_: TransactionType,
298 user_id: int,
299 amount: int | None = None,
300 interest_rate_percent: int | None = None,
301 joint_transaction_id: int | None = None,
302 message: str | None = None,
303 penalty_multiplier_percent: int | None = None,
304 penalty_threshold: int | None = None,
305 per_product: int | None = None,
306 product_count: int | None = None,
307 product_id: int | None = None,
308 time: datetime | None = None,
309 transfer_user_id: int | None = None,
310 ) -> None:
311 """
312 Please do not call this constructor directly, use the factory methods instead.
313 """
314 if time is None:
315 time = datetime.now()
317 self.amount = amount
318 self.interest_rate_percent = interest_rate_percent
319 self.joint_transaction_id = joint_transaction_id
320 self.message = message
321 self.penalty_multiplier_percent = penalty_multiplier_percent
322 self.penalty_threshold = penalty_threshold
323 self.per_product = per_product
324 self.product_count = product_count
325 self.product_id = product_id
326 self.time = time
327 self.transfer_user_id = transfer_user_id
328 self.type_ = type_
329 self.user_id = user_id
331 self._validate_by_transaction_type()
333 def _validate_by_transaction_type(self: Self) -> None:
334 """
335 Validates the transaction's fields based on its type.
336 Raises `ValueError` if the transaction is invalid.
337 """
338 if self.amount == 0: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
339 raise ValueError("Amount must not be zero.")
341 for field in EXPECTED_FIELDS[self.type_]:
342 if getattr(self, field) is None: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 raise ValueError(f"{field} must not be None for {self.type_.value} transactions.")
345 for field in _DYNAMIC_FIELDS - EXPECTED_FIELDS[self.type_]:
346 if getattr(self, field) is not None: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 raise ValueError(f"{field} must be None for {self.type_.value} transactions.")
349 if self.per_product is not None and self.per_product <= 0: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true
350 raise ValueError("per_product must be greater than zero.")
352 if (
353 self.per_product is not None
354 and self.product_count is not None
355 and self.amount is not None
356 and self.amount > self.per_product * self.product_count
357 ):
358 raise ValueError(
359 "The real amount of the transaction must be less than the total value of the products."
360 )
362 # TODO: improve printing further
364 def __repr__(self) -> str:
365 sort_order = [
366 "id",
367 "time",
368 ]
370 columns = ", ".join(
371 f"{k}={repr(v)}"
372 for k, v in sorted(
373 self.__dict__.items(),
374 key=lambda item: chr(sort_order.index(item[0]))
375 if item[0] in sort_order
376 else item[0],
377 )
378 if not any(
379 [
380 k == "type_",
381 (k == "message" and v is None),
382 k.startswith("_"),
383 # Ensure that we don't try to print out the entire list of
384 # relationships, which could create an infinite loop
385 isinstance(v, Base),
386 isinstance(v, InstrumentedList),
387 isinstance(v, InstrumentedSet),
388 isinstance(v, InstrumentedDict),
389 *[k in (_DYNAMIC_FIELDS - EXPECTED_FIELDS[self.type_])],
390 ]
391 )
392 )
393 return f"{self.type_.upper()}({columns})"
395 ###################
396 # FACTORY METHODS #
397 ###################
399 @classmethod
400 def adjust_balance(
401 cls: type[Self],
402 amount: int,
403 user_id: int,
404 time: datetime | None = None,
405 message: str | None = None,
406 ) -> Self:
407 """
408 Convenience constructor for creating an `ADJUST_BALANCE` transaction.
410 Should NOT be used directly in the application code; use the various queries instead.
411 """
412 return cls(
413 time=time,
414 type_=TransactionType.ADJUST_BALANCE,
415 amount=amount,
416 user_id=user_id,
417 message=message,
418 )
420 @classmethod
421 def adjust_interest(
422 cls: type[Self],
423 interest_rate_percent: int,
424 user_id: int,
425 time: datetime | None = None,
426 message: str | None = None,
427 ) -> Self:
428 """
429 Convenience constructor for creating an `ADJUST_INTEREST` transaction.
431 Should NOT be used directly in the application code; use the various queries instead.
432 """
434 return cls(
435 time=time,
436 type_=TransactionType.ADJUST_INTEREST,
437 interest_rate_percent=interest_rate_percent,
438 user_id=user_id,
439 message=message,
440 )
442 @classmethod
443 def adjust_penalty(
444 cls: type[Self],
445 penalty_multiplier_percent: int,
446 penalty_threshold: int,
447 user_id: int,
448 time: datetime | None = None,
449 message: str | None = None,
450 ) -> Self:
451 """
452 Convenience constructor for creating an `ADJUST_PENALTY` transaction.
454 Should NOT be used directly in the application code; use the various queries instead.
455 """
456 return cls(
457 time=time,
458 type_=TransactionType.ADJUST_PENALTY,
459 penalty_multiplier_percent=penalty_multiplier_percent,
460 penalty_threshold=penalty_threshold,
461 user_id=user_id,
462 message=message,
463 )
465 @classmethod
466 def adjust_stock(
467 cls: type[Self],
468 user_id: int,
469 product_id: int,
470 product_count: int,
471 time: datetime | None = None,
472 message: str | None = None,
473 ) -> Self:
474 """
475 Convenience constructor for creating an `ADJUST_STOCK` transaction.
477 Should NOT be used directly in the application code; use the various queries instead.
478 """
479 return cls(
480 time=time,
481 type_=TransactionType.ADJUST_STOCK,
482 user_id=user_id,
483 product_id=product_id,
484 product_count=product_count,
485 message=message,
486 )
488 @classmethod
489 def add_product(
490 cls: type[Self],
491 amount: int,
492 user_id: int,
493 product_id: int,
494 per_product: int,
495 product_count: int,
496 time: datetime | None = None,
497 message: str | None = None,
498 ) -> Self:
499 """
500 Convenience constructor for creating an `ADD_PRODUCT` transaction.
502 Should NOT be used directly in the application code; use the various queries instead.
503 """
504 return cls(
505 time=time,
506 type_=TransactionType.ADD_PRODUCT,
507 amount=amount,
508 user_id=user_id,
509 product_id=product_id,
510 per_product=per_product,
511 product_count=product_count,
512 message=message,
513 )
515 @classmethod
516 def buy_product(
517 cls: type[Self],
518 user_id: int,
519 product_id: int,
520 product_count: int,
521 time: datetime | None = None,
522 message: str | None = None,
523 ) -> Self:
524 """
525 Convenience constructor for creating a `BUY_PRODUCT` transaction.
527 Should NOT be used directly in the application code; use the various queries instead.
528 """
529 return cls(
530 time=time,
531 type_=TransactionType.BUY_PRODUCT,
532 user_id=user_id,
533 product_id=product_id,
534 product_count=product_count,
535 message=message,
536 )
538 @classmethod
539 def joint(
540 cls: type[Self],
541 user_id: int,
542 product_id: int,
543 product_count: int,
544 time: datetime | None = None,
545 message: str | None = None,
546 ) -> Self:
547 """
548 Convenience constructor for creating a `JOINT` transaction.
550 Should NOT be used directly in the application code; use the various queries instead.
551 """
552 return cls(
553 time=time,
554 type_=TransactionType.JOINT,
555 user_id=user_id,
556 product_id=product_id,
557 product_count=product_count,
558 message=message,
559 )
561 @classmethod
562 def joint_buy_product(
563 cls: type[Self],
564 joint_transaction_id: int,
565 user_id: int,
566 time: datetime | None = None,
567 message: str | None = None,
568 ) -> Self:
569 """
570 Convenience constructor for creating a `JOINT_BUY_PRODUCT` transaction.
572 Should NOT be used directly in the application code; use the various queries instead.
573 """
574 return cls(
575 time=time,
576 type_=TransactionType.JOINT_BUY_PRODUCT,
577 joint_transaction_id=joint_transaction_id,
578 user_id=user_id,
579 message=message,
580 )
582 @classmethod
583 def transfer(
584 cls: type[Self],
585 amount: int,
586 user_id: int,
587 transfer_user_id: int,
588 time: datetime | None = None,
589 message: str | None = None,
590 ) -> Self:
591 """
592 Convenience constructor for creating a `TRANSFER` transaction.
594 Should NOT be used directly in the application code; use the various queries instead.
595 """
596 return cls(
597 time=time,
598 type_=TransactionType.TRANSFER,
599 amount=amount,
600 user_id=user_id,
601 transfer_user_id=transfer_user_id,
602 message=message,
603 )
605 @classmethod
606 def throw_product(
607 cls: type[Self],
608 user_id: int,
609 product_id: int,
610 product_count: int,
611 time: datetime | None = None,
612 message: str | None = None,
613 ) -> Self:
614 """
615 Convenience constructor for creating a `THROW_PRODUCT` transaction.
617 Should NOT be used directly in the application code; use the various queries instead.
618 """
619 return cls(
620 time=time,
621 type_=TransactionType.THROW_PRODUCT,
622 user_id=user_id,
623 product_id=product_id,
624 product_count=product_count,
625 message=message,
626 )