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

1from __future__ import annotations 

2 

3from datetime import datetime 

4from typing import TYPE_CHECKING, Self 

5 

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 

28 

29from .Base import Base 

30from .TransactionType import TransactionType, TransactionTypeSQL 

31 

32if TYPE_CHECKING: 

33 from .Product import Product 

34 from .User import User 

35 

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 

40 

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} 

52 

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} 

65 

66assert all(x <= _DYNAMIC_FIELDS for x in EXPECTED_FIELDS.values()), ( 

67 "All expected fields must be part of _DYNAMIC_FIELDS." 

68) 

69 

70 

71def _transaction_type_field_constraints( 

72 transaction_type: TransactionType, 

73 expected_fields: set[str], 

74) -> CheckConstraint: 

75 unexpected_fields = _DYNAMIC_FIELDS - expected_fields 

76 

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 ) 

87 

88 

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 ), 

133 

134 # Speed up product stock calculation 

135 Index("ix__transaction__product_id_type_time", "product_id", "type", "time"), 

136 

137 # Speed up product owner calculation 

138 Index("ix__transaction__user_id_product_time", "user_id", "product_id", "time"), 

139 

140 # Speed up user transaction list / credit calculation 

141 Index("ix__transaction__user_id_time", "user_id", "time"), 

142 ) 

143 

144 id: Mapped[int] = mapped_column(Integer, primary_key=True) 

145 """ 

146 A unique identifier for the transaction. 

147 

148 Not used for anything else than identifying the transaction in the database. 

149 """ 

150 

151 time: Mapped[datetime] = mapped_column(DateTime, index=True) 

152 """ 

153 The time when the transaction took place. 

154 

155 This is used to order transactions chronologically, and to calculate 

156 all kinds of state. 

157 """ 

158 

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). 

163 

164 This is not used for any calculations, but can be useful for debugging. 

165 """ 

166 

167 type_: Mapped[TransactionType] = mapped_column(TransactionTypeSQL, name="type", index=True) 

168 """ 

169 Which type of transaction this is. 

170 

171 The type determines which fields are expected to be set. 

172 """ 

173 

174 amount: Mapped[int | None] = mapped_column(Integer) 

175 """ 

176 This field means different things depending on the transaction type: 

177 

178 - `ADD_PRODUCT`: The real amount spent on the products. 

179 

180 - `ADJUST_BALANCE`: The amount of credit to add or subtract from the user's balance. 

181 

182 - `TRANSFER`: The amount of balance to transfer to another user. 

183 """ 

184 

185 per_product: Mapped[int | None] = mapped_column(Integer) 

186 """ 

187 If adding products, how much is each product worth 

188 

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 """ 

193 

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. 

202 

203 For some transaction types, like `TRANSFER` and `ADD_PRODUCT`, this is a 

204 functional field with "real world consequences" for price calculations. 

205 

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. 

208 

209 In the case of `JOINT` transactions, this is the user who initiated the joint transaction. 

210 """ 

211 

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. 

218 

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 """ 

229 

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.""" 

238 

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.""" 

244 

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. 

249 

250 This is always relative to the existing stock. 

251 

252 - `ADD_PRODUCT` increases the stock by this amount. 

253 

254 - `BUY_PRODUCT` decreases the stock by this amount. 

255 

256 - `ADJUST_STOCK` increases or decreases the stock by this amount, 

257 depending on whether the amount is positive or negative. 

258 """ 

259 

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. 

264 

265 See also `penalty_multiplier`. 

266 """ 

267 

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. 

272 

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. 

275 

276 See also `penalty_threshold`. 

277 """ 

278 

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. 

283 

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 """ 

287 

288 economy_spec_version: Mapped[int] = mapped_column(Integer, default=1) 

289 """ 

290 The version of the economy specification that this transaction adheres to. 

291 

292 This is used to handle changes in the economy rules over time. 

293 """ 

294 

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() 

316 

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 

330 

331 self._validate_by_transaction_type() 

332 

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.") 

340 

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.") 

344 

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.") 

348 

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.") 

351 

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 ) 

361 

362 # TODO: improve printing further 

363 

364 def __repr__(self) -> str: 

365 sort_order = [ 

366 "id", 

367 "time", 

368 ] 

369 

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})" 

394 

395 ################### 

396 # FACTORY METHODS # 

397 ################### 

398 

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. 

409 

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 ) 

419 

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. 

430 

431 Should NOT be used directly in the application code; use the various queries instead. 

432 """ 

433 

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 ) 

441 

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. 

453 

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 ) 

464 

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. 

476 

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 ) 

487 

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. 

501 

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 ) 

514 

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. 

526 

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 ) 

537 

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. 

549 

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 ) 

560 

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. 

571 

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 ) 

581 

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. 

593 

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 ) 

604 

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. 

616 

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 )