diff options
| author | Greg Stupp | 2026-09-08 17:43:00 +0000 |
|---|---|---|
| committer | Greg Stupp | 2026-09-08 17:43:00 +0000 |
| commit | a65c38241f9b27abac166afb050b1ec846e5561d (patch) | |
| tree | e25cd8ec5ac74b301f0282ed5a6a359f3450c112 | |
| parent | 8896faf529f634985a0eb6e1ae77e4ebf7ab4ba0 (diff) | |
| parent | 70e2024b3b8abdf3965f59545aa35fda6270de1d (diff) | |
| download | generalresearch-a65c38241f9b27abac166afb050b1ec846e5561d.tar.gz generalresearch-a65c38241f9b27abac166afb050b1ec846e5561d.zip | |
Merges pull request #3
v3.4.6
Dev greg
21 files changed, 1346 insertions, 159 deletions
diff --git a/generalresearch/managers/events.py b/generalresearch/managers/events.py index 4a2afb2..85c451b 100644 --- a/generalresearch/managers/events.py +++ b/generalresearch/managers/events.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import math import socket import threading @@ -10,7 +11,6 @@ from typing import TYPE_CHECKING, Any from redis.client import PubSub, Redis -from generalresearch.incite.base import LOG from generalresearch.managers.base import RedisManager from generalresearch.models.custom_types import UUIDStr from generalresearch.models.definitions import Source @@ -40,6 +40,8 @@ if TYPE_CHECKING: else: InfluxDBClient = object +LOG = logging.getLogger(__name__) + # Sums all the values in a single hashmap SUM_HASH_LUA_SCRIPT = """ local vals = redis.call("HVALS", KEYS[1]) @@ -218,7 +220,6 @@ class UserStatsManager(RedisManager): class TaskStatsManager(RedisManager): - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -595,7 +596,6 @@ class SessionStatsManager(RedisManager): class StatsManager(UserStatsManager, SessionStatsManager, TaskStatsManager): - def get_stats_message(self, product_id: UUIDStr) -> StatsMessage: res = self.get_session_stats(product_id=product_id) res.update(self.get_user_stats(product_id=product_id)) diff --git a/generalresearch/managers/thl/cashout_method.py b/generalresearch/managers/thl/cashout_method.py index 6e32a45..910d37b 100644 --- a/generalresearch/managers/thl/cashout_method.py +++ b/generalresearch/managers/thl/cashout_method.py @@ -250,7 +250,6 @@ class CashoutMethodManager(PostgresManager): supported_payout_types = copy(product.user_wallet_config.supported_payout_types) if product.user_wallet_config.amt: supported_payout_types.add(PayoutType.AMT) - user_scoped_payout_types = [PayoutType.PAYPAL, PayoutType.CASH_IN_MAIL] params = { "user_scoped_payout_types": [x.value for x in user_scoped_payout_types], @@ -273,7 +272,6 @@ class CashoutMethodManager(PostgresManager): raise ValueError(f"Unexpectedly large number of cashout_methods: {user=}") cms = [self.format_from_db(x, user=user) for x in res] - # Only allow AMT if the BP is marked as AMT (already should have been # filtered in query) cms = [ diff --git a/generalresearch/managers/thl/ledger_manager/conditions.py b/generalresearch/managers/thl/ledger_manager/conditions.py index 38398b1..21a99b5 100644 --- a/generalresearch/managers/thl/ledger_manager/conditions.py +++ b/generalresearch/managers/thl/ledger_manager/conditions.py @@ -10,7 +10,6 @@ from generalresearch.currency import USDCent from generalresearch.models.custom_types import UUIDStr if TYPE_CHECKING: - from generalresearch.managers.thl.ledger_manager.ledger import ( LedgerManager, ) @@ -26,44 +25,44 @@ logger = logging.getLogger("LedgerManager") logger.setLevel(logging.INFO) -def generate_condition_mp_payment(wall: Wall) -> Callable[..., bool]: +def generate_condition_mp_payment(wall: Wall) -> Callable[..., tuple[bool, str]]: """This returns a function that checks if the payment for this wall event exists already. This function gets run after we acquire a lock. It should return True if we want to continue (create a tx). """ wall_uuid = wall.uuid - def _condition(lm: LedgerManager) -> bool: + def _condition(lm: LedgerManager) -> tuple[bool, str]: tag = f"{lm.currency.value}:mp_payment:{wall_uuid}" txs = lm.get_tx_ids_by_tag(tag=tag) - return len(txs) == 0 + return len(txs) == 0, "duplicate tag" return _condition -def generate_condition_bp_payment(session: Session) -> Callable[..., bool]: +def generate_condition_bp_payment(session: Session) -> Callable[..., tuple[bool, str]]: """This returns a function that checks if the payment for this Session exists already. This function gets run after we acquire a lock. It should return True if we want to continue (create a tx). """ session_uuid = session.uuid - def _condition(lm: LedgerManager) -> bool: + def _condition(lm: LedgerManager) -> tuple[bool, str]: tag = f"{lm.currency.value}:bp_payment:{session_uuid}" txs_ids = lm.get_tx_ids_by_tag(tag=tag) - return len(txs_ids) == 0 + return len(txs_ids) == 0, "duplicate tag" return _condition -def generate_condition_tag_exists(tag: str) -> Callable[..., bool]: +def generate_condition_tag_exists(tag: str) -> Callable[..., tuple[bool, str]]: """This returns a function that checks if a tx with this tag already exists. It should return True if we want to continue (create a tx). """ - def _condition(lm: LedgerManager) -> bool: + def _condition(lm: LedgerManager) -> tuple[bool, str]: txs_ids = lm.get_tx_ids_by_tag(tag=tag) - return len(txs_ids) == 0 + return len(txs_ids) == 0, "duplicate tag" return _condition diff --git a/generalresearch/managers/thl/ledger_manager/ledger.py b/generalresearch/managers/thl/ledger_manager/ledger.py index bbf7bfa..3a02cdf 100644 --- a/generalresearch/managers/thl/ledger_manager/ledger.py +++ b/generalresearch/managers/thl/ledger_manager/ledger.py @@ -37,7 +37,6 @@ from generalresearch.models.thl.ledger import ( ) if TYPE_CHECKING: - from generalresearch.models.thl.ledger import UserLedgerTransactionType from generalresearch.pg_helper import PostgresConfig from generalresearch.redis_helper import RedisConfig @@ -121,9 +120,9 @@ class LedgerManagerBasePostgres(PostgresManager, RedisManager): filters.append("key = %(metadata_key)s") params["metadata_key"] = metadata_key if metadata_value is not None: - assert ( - metadata_key is not None - ), "cannot filter by metadata_value without metadata_key" + assert metadata_key is not None, ( + "cannot filter by metadata_value without metadata_key" + ) filters.append("value = %(metadata_value)s") params["metadata_value"] = metadata_value @@ -132,7 +131,6 @@ class LedgerManagerBasePostgres(PostgresManager, RedisManager): class LedgerTransactionManager(LedgerManagerBasePostgres): - def create_tx( self, entries: list[LedgerEntry], @@ -148,9 +146,9 @@ class LedgerTransactionManager(LedgerManagerBasePostgres): lastrowid) """ - assert ( - Permission.CREATE in self.permissions - ), "LedgerTransactionManager has insufficient Permissions" + assert Permission.CREATE in self.permissions, ( + "LedgerTransactionManager has insufficient Permissions" + ) if metadata is None: metadata = {} @@ -357,10 +355,18 @@ class LedgerTransactionManager(LedgerManagerBasePostgres): raise ValueError(f"Too many txs with this tag: {tag}") return {x["id"] for x in res} - def get_tx_by_tag(self, tag: str) -> list[LedgerTransaction]: + def get_txs_by_tag(self, tag: str) -> list[LedgerTransaction]: tx_ids = self.get_tx_ids_by_tag(tag=tag) return self.get_tx_by_ids(transaction_ids=tx_ids) + def get_tx_by_tag_if_exists(self, tag: str) -> LedgerTransaction | None: + tx_ids = self.get_tx_ids_by_tag(tag=tag) + if not tx_ids: + return None + if len(tx_ids) != 1: + raise ValueError(f"Two transactions found for tag: {tag}!") + return self.get_tx_by_id(transaction_id=next(iter(tx_ids))) + def get_tx_ids_by_tags(self, tags: list[str]) -> set[PositiveInt]: res = self.pg_config.execute_sql_query( query=""" @@ -799,7 +805,6 @@ class LedgerMetadataManager(LedgerManagerBasePostgres): class LedgerEntryManager(LedgerManagerBasePostgres): - def get_tx_entries_by_tx(self, transaction: LedgerTransaction) -> list[LedgerEntry]: return self.get_tx_entries_by_txs(transactions=[transaction]) @@ -831,9 +836,9 @@ class LedgerAccountManager(LedgerManagerBasePostgres): """ def create_account(self, account: LedgerAccount) -> LedgerAccount: - assert ( - Permission.CREATE in self.permissions - ), "LedgerManager does not have sufficient permissions" + assert Permission.CREATE in self.permissions, ( + "LedgerManager does not have sufficient permissions" + ) d = account.model_dump(mode="json") diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index e5a308f..2fef50f 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -1,6 +1,5 @@ -from __future__ import annotations - import logging +from collections import defaultdict from collections.abc import Callable, Collection from datetime import UTC, datetime, timedelta from decimal import Decimal @@ -30,6 +29,7 @@ from generalresearch.managers.thl.ledger_manager.ledger import ( LedgerManager, ) from generalresearch.models.custom_types import UUIDStr +from generalresearch.models.thl.contest.contest_entry import ContestEntry from generalresearch.models.thl.contest.definitions import ( ContestPrizeKind, ContestType, @@ -51,20 +51,21 @@ from generalresearch.models.thl.ledger import ( from generalresearch.models.thl.ledger import ( TransactionMetadataColumns as tmc, ) +from generalresearch.models.thl.payout import UserPayoutEvent +from generalresearch.models.thl.payout_format import format_payout_format from generalresearch.models.thl.product import Product -from generalresearch.models.thl.session import Status +from generalresearch.models.thl.session import Session, Status, Wall +from generalresearch.models.thl.user import User from generalresearch.models.thl.wallet.definitions import PayoutType +from generalresearch.models.thl.wallet.user_wallet import ( + UserDisplayedWalletBalance, + UserLedgerWallet, + UserLedgerWallets, +) if TYPE_CHECKING: - + from generalresearch.managers.thl.session import SessionManager from generalresearch.models.thl.contest.contest import Contest, ContestWinner - from generalresearch.models.thl.contest.raffle import ( - ContestEntry, - ) - from generalresearch.models.thl.ledger import LedgerTransaction - from generalresearch.models.thl.payout import UserPayoutEvent - from generalresearch.models.thl.session import Session, Wall - from generalresearch.models.thl.user import User logging.basicConfig() logger = logging.getLogger("LedgerManager") @@ -72,7 +73,6 @@ logger.setLevel(logging.INFO) class ThlLedgerManager(LedgerManager): - def get_account_or_create_user_wallet(self, user: User) -> LedgerAccount: """ TODO: In the future we could create a user wallet account with a @@ -81,12 +81,36 @@ class ThlLedgerManager(LedgerManager): """ assert user.user_id, "User must be saved" + assert self.currency is not None, "Must set currency" + account_type = AccountType.USER_WALLET account = LedgerAccount( display_name=f"User Wallet {user.uuid}", - qualified_name=f"{self.currency.value}:user_wallet:{user.uuid}", + qualified_name=f"{self.currency.value}:{account_type.value}:{user.uuid}", + normal_balance=Direction.CREDIT, + account_type=account_type, + reference_type="user", + reference_uuid=user.uuid, + currency=self.currency, + ) + + return self.get_account_or_create(account=account) + + def get_account_or_create_user_attempt_credit(self, user: User) -> LedgerAccount: + """ + A wallet to hold a user's conditional credits which are + applied against future task earnings + """ + + assert user.user_id, "User must be saved" + assert self.currency is not None, "Must set currency" + account_type = AccountType.USER_ATTEMPT_CREDIT + + account = LedgerAccount( + display_name=f"User Attempt Credit {user.uuid}", + qualified_name=f"{self.currency.value}:{account_type.value}:{user.uuid}", normal_balance=Direction.CREDIT, - account_type=AccountType.USER_WALLET, + account_type=account_type, reference_type="user", reference_uuid=user.uuid, currency=self.currency, @@ -253,9 +277,9 @@ class ThlLedgerManager(LedgerManager): if time_end is None: time_end = datetime.now(tz=UTC) - assert all( - isinstance(item, str) for item in account_uuids - ), "Must pass account_uuid as str" + assert all(isinstance(item, str) for item in account_uuids), ( + "Must pass account_uuid as str" + ) params = { "time_start": time_start, @@ -363,13 +387,15 @@ class ThlLedgerManager(LedgerManager): f = lambda: self.create_tx_bp_payment_(session=session, created=created) condition = generate_condition_bp_payment(session) - lock_key = f"{self.currency.value}:thl_session:{session.uuid}" + lock_key = f"{self.currency.value}:user_uuid:{session.user.uuid}" + flag_key = f"{self.currency.value}:bp_payment:{session.uuid}" return self.create_tx_protected( lock_key=lock_key, condition=condition, create_tx_func=f, skip_flag_check=force, + flag_key=flag_key, ) def create_tx_bp_payment_( @@ -434,6 +460,7 @@ class ThlLedgerManager(LedgerManager): if user.product.user_wallet_enabled: bp_pay -= user_pay user_account = self.get_account_or_create_user_wallet(user) + ext_description = f"BP & User Payment {session.uuid}" if bp_pay: entries.append( @@ -452,8 +479,30 @@ class ThlLedgerManager(LedgerManager): amount=user_pay, ) ) - ext_description = f"BP & User Payment {session.uuid}" + if user.product.user_wallet_config.failed_attempt_credit_enabled: + settlement_amount = self.get_user_attempt_credit_to_settle( + user=user, user_pay=user_pay + ) + if settlement_amount: + attempt_credit_account = ( + self.get_account_or_create_user_attempt_credit(user) + ) + + entries.extend( + [ + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=attempt_credit_account.uuid, + amount=settlement_amount, + ), + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=bp_wallet_account.uuid, + amount=settlement_amount, + ), + ] + ) else: entries.append( LedgerEntry( @@ -995,8 +1044,8 @@ class ThlLedgerManager(LedgerManager): user: User, payout_event: UserPayoutEvent, created: datetime | None = None, - skip_flag_check: bool | None = False, - skip_wallet_balance_check: bool | None = False, + skip_flag_check: bool = False, + skip_wallet_balance_check: bool = False, ) -> LedgerTransaction: """ The funds move from the user's wallet into the BP's "pending" @@ -1008,9 +1057,9 @@ class ThlLedgerManager(LedgerManager): requesting from their USD wallet. No other currencies are supported now. """ - assert ( - user.product.user_wallet_enabled - ), "Can only call this on an wallet enabled BPs" + assert user.product.user_wallet_enabled, ( + "Can only call this on an wallet enabled BPs" + ) amount = USDCent(payout_event.amount) amt_str = f"${int(amount) / 100:,.2f}" @@ -1033,9 +1082,9 @@ class ThlLedgerManager(LedgerManager): bonus for task complete to the user. The 20% commission will be taken from the BP's wallet once the tx is completed. """ - assert ( - user.product.user_wallet_amt - ), "Can only call this on an AMT-enabled BPs" + assert user.product.user_wallet_amt, ( + "Can only call this on an AMT-enabled BPs" + ) f = lambda: self.create_tx_user_payout_request_( user=user, @@ -1048,7 +1097,6 @@ class ThlLedgerManager(LedgerManager): if payout_event.payout_type == PayoutType.AMT_HIT: # We allow the user's balance to reach up to -$1.00. min_balance = -100 + amount - if skip_wallet_balance_check: min_balance = None @@ -1082,9 +1130,9 @@ class ThlLedgerManager(LedgerManager): are taken from the BP's pending wallet, the commission will be recorded, and the cash debited. """ - assert ( - user.product.user_wallet_enabled - ), "Can only call this on an wallet enabled BPs" + assert user.product.user_wallet_enabled, ( + "Can only call this on an wallet enabled BPs" + ) # Before we even do anything, we should check that a ledger tx exists for the request request_tag = f"{self.currency.value}:user_payout:{payout_event.uuid}:request" @@ -1110,9 +1158,9 @@ class ThlLedgerManager(LedgerManager): PayoutType.AMT_HIT, PayoutType.AMT_BONUS, }: - assert ( - user.product.user_wallet_amt - ), "Can only call this on an AMT-enabled BP" + assert user.product.user_wallet_amt, ( + "Can only call this on an AMT-enabled BP" + ) bp_expense_account = self.get_account_or_create_bp_expense( product=user.product, expense_name="amt" ) @@ -1174,9 +1222,9 @@ class ThlLedgerManager(LedgerManager): created: datetime | None = None, skip_flag_check: bool | None = False, ) -> LedgerTransaction: - assert ( - user.product.user_wallet_enabled - ), "Can only call this on an wallet enabled BPs" + assert user.product.user_wallet_enabled, ( + "Can only call this on an wallet enabled BPs" + ) # Before we even do anything, we should check that a ledger tx exists for the request request_tag = f"{self.currency.value}:user_payout:{payout_event.uuid}:request" @@ -1401,9 +1449,9 @@ class ThlLedgerManager(LedgerManager): :param source_account: Is this paid from the bp's wallet? or from us? """ - assert ( - user.product.user_wallet_enabled - ), "Can only call this on an wallet enabled BPs" + assert user.product.user_wallet_enabled, ( + "Can only call this on an wallet enabled BPs" + ) assert user.product, "user.prefetch_product()" # This tag should uniquely id this tx. @@ -1472,6 +1520,120 @@ class ThlLedgerManager(LedgerManager): created=created, ) + def create_tx_attempt_credit( + self, + session: Session, + created: datetime | None = None, + skip_flag_check: bool = False, + ) -> LedgerTransaction: + """Record conditional credit for an eligible session attempt. + + Eligibility is determined by the caller. This method verifies that the + session failed and records the credit exactly once per session. + """ + user = session.user + assert user is not None, "Session must have a user" + product = user.product + assert product is not None, "user.prefetch_product()" + assert product.user_wallet_enabled, "Product does not have user_wallet enabled" + config = product.user_wallet_config + assert config.failed_attempt_credit_enabled, ( + "Product does not have failed_attempt_credit enabled" + ) + amount = USDCent(round(config.failed_attempt_credit * 100)) + + assert session.is_attempt_credit_eligible(), ( + "Session is not eligible for attempt credit" + ) + + tag = f"{self.currency.value}:{TransactionType.USER_ATTEMPT_CREDIT.value}:{session.uuid}" + condition = generate_condition_tag_exists(tag) + + def create() -> LedgerTransaction: + bp_account = self.get_account_or_create_bp_wallet(product) + attempt_credit_account = self.get_account_or_create_user_attempt_credit( + user + ) + metadata = { + tmc.USER.value: user.uuid, + tmc.SESSION.value: session.uuid, + tmc.TX_TYPE.value: TransactionType.USER_ATTEMPT_CREDIT.value, + } + entries = [ + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=bp_account.uuid, + amount=amount, + ), + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=attempt_credit_account.uuid, + amount=amount, + ), + ] + return self.create_tx( + entries=entries, + metadata=metadata, + tag=tag, + ext_description=f"Attempt Credit {session.uuid}", + created=created, + ) + + return self.create_tx_protected( + lock_key=f"{self.currency.value}:user_uuid:{user.uuid}", + flag_key=tag, + condition=condition, + create_tx_func=create, + skip_flag_check=skip_flag_check, + ) + + def claim_latest_attempt_credit( + self, + user: User, + session_manager: SessionManager, + skip_flag_check: bool = False, + ) -> LedgerTransaction: + """Claim attempt credit for a product user's most recent session. + This must be for an abandoned session, as when a session if finished + and is eligible for an attempt credit, then the credit is automatically + given.""" + assert user.user_id is not None + session = session_manager.get_latest_for_user(user_id=user.user_id) + if session is None: + raise ValueError("User has no session to claim attempt credit for") + if session.status is not None: + raise ValueError("User's latest session is already finalized") + + assert user.product + session.user.product = user.product + return self.create_tx_attempt_credit( + session=session, + skip_flag_check=skip_flag_check, + ) + + def get_session_attempt_credit( + self, + session_uuid: UUIDStr, + ) -> int | None: + tag = ( + f"{self.currency.value}:" + f"{TransactionType.USER_ATTEMPT_CREDIT.value}:" + f"{session_uuid}" + ) + tx = self.get_tx_by_tag_if_exists(tag) + if not tx: + return None + + credit_entries = [ + entry for entry in tx.entries if entry.direction == Direction.CREDIT + ] + if len(credit_entries) != 1: + raise ValueError( + f"Expected one credit entry for attempt-credit transaction {tx.id}" + ) + + return credit_entries[0].amount + def create_tx_user_enter_contest( self, contest_uuid: UUIDStr, @@ -1482,13 +1644,13 @@ class ThlLedgerManager(LedgerManager): User is requesting to enter a Raffle Contest. We'll DEBIT funds from their wallet and CREDIT the contest wallet. """ - assert ( - contest_entry.entry_type == ContestEntryType.CASH - ), "Can only call this for CASH Contests" + assert contest_entry.entry_type == ContestEntryType.CASH, ( + "Can only call this for CASH Contests" + ) user = contest_entry.user - assert ( - user.product.user_wallet_enabled - ), "Can only call this on an wallet enabled BPs" + assert user.product.user_wallet_enabled, ( + "Can only call this on an wallet enabled BPs" + ) assert user.product, "user.prefetch_product()" amount = contest_entry.amount entry_uuid = contest_entry.uuid @@ -1572,9 +1734,9 @@ class ThlLedgerManager(LedgerManager): Any remaining money goes back into the BP's wallet ? todo """ if contest.contest_type in {ContestType.RAFFLE, ContestType.MILESTONE}: - assert ( - contest.entry_type == ContestEntryType.CASH - ), "Can only call this for CASH Contests" + assert contest.entry_type == ContestEntryType.CASH, ( + "Can only call this for CASH Contests" + ) contest_account = self.get_account_or_create_contest_wallet_by_uuid( contest_uuid=contest.uuid @@ -1829,9 +1991,9 @@ class ThlLedgerManager(LedgerManager): :returns wallet balance in integer cents """ user.prefetch_product(self.pg_config) - assert ( - user.product.user_wallet_config.enabled - ), "Can't get wallet balance on non-managed account." + assert user.product.user_wallet_config.enabled, ( + "Can't get wallet balance on non-managed account." + ) now = datetime.now(tz=UTC) wallet = self.get_account_or_create_user_wallet(user) @@ -1886,7 +2048,9 @@ class ThlLedgerManager(LedgerManager): wall["user_payout"] = wall["user_payout"].astype(float) wall["user_payout_int"] = wall["user_payout"] * 100 wall["days_since_complete"] = (now - wall["finished"]).dt.days - wall["pct_rdm"] = wall["days_since_complete"].apply(self.get_redeemable_pct) + wall["pct_rdm"] = wall["days_since_complete"].apply( + self._get_redeemable_pct + ) wall.loc[wall["pct_rdm"] > 0.95, "pct_rdm"] = 1 wall["redeemable"] = wall["pct_rdm"] * wall["user_payout_int"] # Calculate money needed to save in reserve to cover the difference @@ -1903,7 +2067,7 @@ class ThlLedgerManager(LedgerManager): assert redeemable_balance <= user_wallet_balance return redeemable_balance - def get_redeemable_pct( + def _get_redeemable_pct( self, days_since_complete: float, user_trust: float = 0.0 ) -> float: """ @@ -1928,6 +2092,140 @@ class ThlLedgerManager(LedgerManager): # plt.plot([d.days for d in x], [self.get_redeemable_amount(d) for d in x]) return pct_rdm + def get_user_attempt_credit_balance(self, user: User) -> int: + """Return the user's outstanding conditional attempt credit in cents.""" + assert user.user_id, "User must be saved" + account = self.get_account_or_create_user_attempt_credit(user) + return self.get_account_balance(account) + + def get_user_wallets(self, user: User) -> UserLedgerWallets: + """Return every ledger wallet owned by a user, across currencies.""" + assert user.user_id, "User must be saved" + user.prefetch_product(self.pg_config) + assert user.product.user_wallet_enabled, "only user_wallet managed" + payout_format = user.product.payout_config.payout_format + assert payout_format is not None, "Product must have a payout format" + + self.get_account_or_create_user_wallet(user) + if user.product.user_wallet_config.failed_attempt_credit_enabled: + self.get_account_or_create_user_attempt_credit(user) + + user_account_types = { + AccountType.USER_WALLET.value, + AccountType.USER_ATTEMPT_CREDIT.value, + } + + rows = self.pg_config.execute_sql_query( + query=""" + SELECT + uuid, display_name, qualified_name, account_type, + normal_balance, reference_type, reference_uuid, currency + FROM ledger_account + WHERE reference_type = 'user' + AND reference_uuid = %(reference_uuid)s + AND account_type = ANY(%(account_types)s) + ORDER BY currency, account_type, qualified_name; + """, + params={ + "reference_uuid": user.uuid, + "account_types": list(user_account_types), + }, + ) + accounts = [LedgerAccount.model_validate(row) for row in rows] + + wallets = [] + displayed_amounts: dict[str, int] = defaultdict(int) + for account in accounts: + if ( + user.product_id == JAMES_BILLINGS_BPID + and account.account_type == AccountType.USER_WALLET + ): + balance = self.get_account_balance_timerange( + account=account, + time_start=JAMES_BILLINGS_TX_CUTOFF, + ) + else: + balance = self.get_account_balance(account) + + if account.account_type == AccountType.USER_ATTEMPT_CREDIT: + redeemable_balance = 0 + elif account.currency == self.currency.value: + redeemable_balance = self.get_user_redeemable_wallet_balance( + user=user, + user_wallet_balance=balance, + ) + else: + # We don't have redeemable logic for other currencies + redeemable_balance = max(balance, 0) + + # A Product has only one payout_format. It is not clear which + # currency it is to be applied to. If we have a non USD currency, + # we'd need multiple payout formats. + account_payout_format = ( + payout_format if account.currency == self.currency.value else None + ) + + wallet = UserLedgerWallet( + account_uuid=account.uuid, + account_type=account.account_type, + currency=account.currency, + display_name=account.display_name, + amount=balance, + redeemable_amount=redeemable_balance, + payout_format=account_payout_format, + amount_string=( + format_payout_format(account_payout_format, balance) + if account_payout_format is not None + else None + ), + redeemable_amount_string=( + format_payout_format(account_payout_format, redeemable_balance) + if account_payout_format is not None + else None + ), + ) + wallets.append(wallet) + + if ( + account.account_type == AccountType.USER_ATTEMPT_CREDIT + or user.product.user_wallet_config.balance_type == "wallet_balance" + ): + displayed_amount = wallet.amount + else: + displayed_amount = wallet.redeemable_amount + displayed_amounts[account.currency] += displayed_amount + + displayed_balances = [ + UserDisplayedWalletBalance( + currency=currency, + amount=amount, + amount_string=( + format_payout_format(payout_format, amount) + if currency == self.currency.value + else None + ), + ) + for currency, amount in sorted(displayed_amounts.items()) + ] + + return UserLedgerWallets( + wallets=wallets, + displayed_balances=displayed_balances, + ) + + def get_user_attempt_credit_to_settle( + self, + user: User, + user_pay: int, + ) -> int: + """Return how much pending credit a task payment of amount `user_pay` should consume.""" + assert user_pay >= 0 + + credit_balance = self.get_user_attempt_credit_balance(user) + assert credit_balance >= 0, "Attempt-credit balance cannot be negative" + + return min(credit_balance, user_pay) + def get_user_txs( self, user: User, diff --git a/generalresearch/managers/thl/paypal.py b/generalresearch/managers/thl/paypal.py new file mode 100644 index 0000000..5ef0966 --- /dev/null +++ b/generalresearch/managers/thl/paypal.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from typing import Any, Mapping + +import requests + +from generalresearch.currency import USDCent +from generalresearch.managers.thl.ledger_manager.thl_ledger import ( + ThlLedgerManager, +) +from generalresearch.managers.thl.payout import UserPayoutEventManager +from generalresearch.managers.thl.user_manager.user_manager import UserManager +from generalresearch.models.thl.definitions import PayoutStatus +from generalresearch.models.thl.payout import UserPayoutEvent +from generalresearch.models.thl.user import User +from generalresearch.models.thl.wallet import PayoutType +from generalresearch.models.thl.wallet.cashout_method import ( + CashoutMethod, + PaypalCashoutMethodData, +) + +PAYPAL_SANDBOX_URL = "https://api-m.sandbox.paypal.com" +PAYPAL_PROD_URL = "https://api-m.paypal.com" + + +class PayPalError(RuntimeError): + """Raised when PayPal returns an unsuccessful API response.""" + + +class PayPalPayoutManager: + """Small client for PayPal's Payouts REST API. + + Credentials are supplied by the caller so they can come from the + application's secret store. ``sender_batch_id`` and ``sender_item_id`` + should be stable application identifiers; PayPal uses them to prevent + duplicate payouts. + """ + + def __init__( + self, + *, + client_id: str, + client_secret: str, + base_url: str = PAYPAL_SANDBOX_URL, + timeout: float = 30.0, + session: requests.Session | None = None, + ) -> None: + if not client_id or not client_secret: + raise ValueError("PayPal client_id and client_secret are required") + + self.client_id = client_id + self.client_secret = client_secret + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = session or requests.Session() + self._access_token: str | None = None + self._access_token_expires_at: datetime | None = None + + def _get_access_token(self) -> str: + now = datetime.now(tz=timezone.utc) + if ( + self._access_token + and self._access_token_expires_at + and now < self._access_token_expires_at + ): + return self._access_token + + response = self.session.post( + f"{self.base_url}/v1/oauth2/token", + auth=(self.client_id, self.client_secret), + data={"grant_type": "client_credentials"}, + headers={"Accept": "application/json"}, + timeout=self.timeout, + ) + data = self._response_json(response) + token = data.get("access_token") + if not token: + raise PayPalError("PayPal token response did not include access_token") + + # Refresh one minute early to avoid using a token while it expires. + expires_in = max(int(data.get("expires_in", 300)) - 60, 0) + self._access_token = token + self._access_token_expires_at = now + timedelta(seconds=expires_in) + return token + + def _request( + self, + method: str, + path: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + response = self.session.request( + method, + f"{self.base_url}{path}", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {self._get_access_token()}", + }, + json=json, + params=params, + timeout=self.timeout, + ) + return self._response_json(response) + + @staticmethod + def _response_json(response: requests.Response) -> dict[str, Any]: + try: + data = response.json() + except ValueError: + data = {"message": response.text} + + if not response.ok: + debug_id = response.headers.get("PayPal-Debug-Id") + raise PayPalError( + f"PayPal API error {response.status_code}" + + (f" PayPal-Debug-Id={debug_id}" if debug_id else "") + + f": {data}" + ) + if not isinstance(data, dict): + raise PayPalError(f"Unexpected PayPal response: {data!r}") + return data + + def send_payment( + self, + *, + recipient_email: str, + amount_cents: USDCent, + sender_batch_id: str, + sender_item_id: str | None = None, + note: str | None = None, + email_subject: str | None = None, + ) -> dict[str, Any]: + """Create a one-recipient USD payout and return PayPal's response.""" + assert isinstance(amount_cents, USDCent) + if amount_cents <= 0: + raise ValueError("amount_cents must be positive") + if not recipient_email: + raise ValueError("recipient_email is required") + if not sender_batch_id: + raise ValueError("sender_batch_id is required") + + amount = (Decimal(amount_cents) / 100).quantize(Decimal("0.01")) + batch_header: dict[str, Any] = { + "sender_batch_id": sender_batch_id, + "recipient_type": "EMAIL", + } + if email_subject: + batch_header["email_subject"] = email_subject + + item: dict[str, Any] = { + "recipient_type": "EMAIL", + "receiver": recipient_email, + "amount": {"value": f"{amount:.2f}", "currency": "USD"}, + "sender_item_id": sender_item_id or sender_batch_id, + } + if note: + item["note"] = note + + return self._request( + "POST", + "/v1/payments/payouts", + json={"sender_batch_header": batch_header, "items": [item]}, + ) + + def get_payout(self, payout_batch_id: str) -> dict[str, Any]: + """Return the latest batch and item status for a payout.""" + if not payout_batch_id: + raise ValueError("payout_batch_id is required") + return self._request("GET", f"/v1/payments/payouts/{payout_batch_id}") + + def verify_webhook_signature( + self, + *, + headers: Mapping[str, str], + webhook_event: dict[str, Any], + webhook_id: str, + ) -> bool: + """Ask PayPal to verify a webhook notification's signature.""" + normalized_headers = {key.lower(): value for key, value in headers.items()} + + def required_header(name: str) -> str: + value = normalized_headers.get(name.lower()) + if not value: + raise ValueError(f"Missing PayPal webhook header: {name}") + return value + + result = self._request( + "POST", + "/v1/notifications/verify-webhook-signature", + json={ + "auth_algo": required_header("PAYPAL-AUTH-ALGO"), + "cert_url": required_header("PAYPAL-CERT-URL"), + "transmission_id": required_header("PAYPAL-TRANSMISSION-ID"), + "transmission_sig": required_header("PAYPAL-TRANSMISSION-SIG"), + "transmission_time": required_header("PAYPAL-TRANSMISSION-TIME"), + "webhook_id": webhook_id, + "webhook_event": webhook_event, + }, + ) + return result.get("verification_status") == "SUCCESS" + + +def create_paypal_payout( + *, + user: User, + cashout_method: CashoutMethod, + amount_cents: USDCent, + user_payout_event_manager: UserPayoutEventManager, + ledger_manager: ThlLedgerManager, + paypal: PayPalPayoutManager, + note: str | None = None, + email_subject: str | None = None, +) -> UserPayoutEvent: + """Create, reserve funds for, and submit a one-item PayPal payout. + + The payout-event UUID is used as both sender IDs, making a retry at + PayPal idempotent for 30 days. The PayPal-generated batch ID is persisted + in ``ext_ref_id`` and the initial API response is kept in ``order_data``. + + If submission raises, the payout event and its ledger reservation remain + in place. This is intentional: a timeout or 5xx response is ambiguous and + rolling back could allow the same money to be paid twice. The exception + includes the payout-event UUID for reconciliation or a same-ID retry. + """ + if cashout_method.type != PayoutType.PAYPAL: + raise ValueError("cashout_method must be a PayPal cashout method") + if not isinstance(cashout_method.data, PaypalCashoutMethodData): + raise ValueError("cashout_method does not contain PayPal data") + if ( + user.user_id is None + or cashout_method.user is None + or cashout_method.user.user_id != user.user_id + ): + raise ValueError("cashout_method does not belong to user") + + cashout_method.validate_requested_amount(amount_cents) + user_account = ledger_manager.get_account_or_create_user_wallet(user=user) + payout_event = user_payout_event_manager.create( + debit_account_uuid=user_account.uuid, + cashout_method_uuid=cashout_method.id, + payout_type=PayoutType.PAYPAL, + amount=amount_cents, + status=PayoutStatus.PENDING, + account_reference_type="user", + account_reference_uuid=user.uuid, + description=cashout_method.name, + request_data={ + "interface": "api", + "recipient_email": str(cashout_method.data.email), + }, + ) + ledger_manager.create_tx_user_payout_request( + user=user, + payout_event=payout_event, + ) + + try: + paypal_response = paypal.send_payment( + recipient_email=str(cashout_method.data.email), + amount_cents=amount_cents, + sender_batch_id=payout_event.uuid, + sender_item_id=payout_event.uuid, + note=note, + email_subject=email_subject, + ) + payout_batch_id = paypal_response["batch_header"]["payout_batch_id"] + except Exception as exc: + raise PayPalError( + f"PayPal submission failed for payout event {payout_event.uuid}" + ) from exc + + user_payout_event_manager.update( + payout_event=payout_event, + status=PayoutStatus.PENDING, + ext_ref_id=payout_batch_id, + order_data=paypal_response, + ) + return payout_event + + +def handle_paypal_payout_webhook( + *, + headers: Mapping[str, str], + webhook_event: dict[str, Any], + webhook_id: str, + paypal: PayPalPayoutManager, + user_payout_event_manager: UserPayoutEventManager, + user_manager: UserManager, + ledger_manager: ThlLedgerManager, +) -> UserPayoutEvent | None: + """Verify and apply a PayPal Payouts webhook idempotently.""" + if not paypal.verify_webhook_signature( + headers=headers, + webhook_event=webhook_event, + webhook_id=webhook_id, + ): + raise PayPalError("PayPal webhook signature verification failed") + + event_type = webhook_event.get("event_type", "") + if not event_type.startswith(("PAYMENT.PAYOUTSBATCH.", "PAYMENT.PAYOUTS-ITEM.")): + return None + + resource = webhook_event.get("resource") + if not isinstance(resource, dict): + raise PayPalError("PayPal payout webhook has no resource") + + sender_batch_header = resource.get("sender_batch_header") or {} + payout_item = resource.get("payout_item") or {} + payout_event_uuid = ( + resource.get("sender_batch_id") + or sender_batch_header.get("sender_batch_id") + or payout_item.get("sender_item_id") + ) + if not payout_event_uuid: + raise PayPalError("PayPal payout webhook has no sender payout ID") + + payout_event = user_payout_event_manager.get_by_uuid(payout_event_uuid) + if payout_event.payout_type != PayoutType.PAYPAL: + raise PayPalError(f"Payout event {payout_event.uuid} is not a PayPal payout") + if payout_event.status == PayoutStatus.COMPLETE: + return payout_event + + payout_batch_id = resource.get("payout_batch_id") or payout_event.ext_ref_id + if not payout_batch_id: + raise PayPalError("PayPal payout webhook has no payout_batch_id") + if payout_event.ext_ref_id and payout_event.ext_ref_id != payout_batch_id: + raise PayPalError("PayPal payout_batch_id does not match the payout event") + + details = paypal.get_payout(payout_batch_id) + items = details.get("items") or [] + if len(items) != 1: + raise PayPalError( + f"Expected one PayPal payout item for {payout_event.uuid}, got {len(items)}" + ) + item = items[0] + item_sender_id = (item.get("payout_item") or {}).get("sender_item_id") + if item_sender_id != payout_event.uuid: + raise PayPalError("PayPal sender_item_id does not match the payout event") + + amount = (item.get("payout_item") or {}).get("amount") or {} + if amount.get("currency") != "USD": + raise PayPalError("Only USD PayPal payouts are supported") + amount_cents = int(Decimal(amount["value"]) * 100) + if amount_cents != payout_event.amount: + raise PayPalError("PayPal payout amount does not match the payout event") + + # get_payout_detail() expects transaction_id at the top level. + details["transaction_id"] = item.get("transaction_id") + transaction_status = item.get("transaction_status") + if transaction_status == "SUCCESS": + fee = item.get("payout_item_fee") or {} + if fee.get("currency") != "USD" or "value" not in fee: + raise PayPalError("Successful PayPal payout has no USD fee") + + user = user_manager.get_user(user_uuid=payout_event.account_reference_uuid) + user.prefetch_product(pg_config=ledger_manager.pg_config) + complete_tag = ( + f"{ledger_manager.currency.value}:user_payout:" + f"{payout_event.uuid}:complete" + ) + complete_transactions = ledger_manager.get_tx_ids_by_tag(complete_tag) + if len(complete_transactions) > 1: + raise PayPalError(f"Multiple ledger transactions found for {complete_tag}") + if not complete_transactions: + ledger_manager.create_tx_user_payout_complete( + user=user, + payout_event=payout_event, + fee_amount=Decimal(fee["value"]), + ) + user_payout_event_manager.update( + payout_event=payout_event, + status=PayoutStatus.COMPLETE, + ext_ref_id=payout_batch_id, + order_data=details, + ) + elif transaction_status in { + "FAILED", + "BLOCKED", + "RETURNED", + "REFUNDED", + "REVERSED", + "CANCELED", + }: + user_payout_event_manager.update( + payout_event=payout_event, + status=PayoutStatus.FAILED, + ext_ref_id=payout_batch_id, + order_data=details, + ) + elif payout_event.status == PayoutStatus.PENDING: + user_payout_event_manager.update( + payout_event=payout_event, + status=PayoutStatus.APPROVED, + ext_ref_id=payout_batch_id, + order_data=details, + ) + + return payout_event diff --git a/generalresearch/managers/thl/session.py b/generalresearch/managers/thl/session.py index 5007f43..3f235fd 100644 --- a/generalresearch/managers/thl/session.py +++ b/generalresearch/managers/thl/session.py @@ -117,6 +117,12 @@ class SessionManager(PostgresManager): assert len(res) == 1 return self.session_from_mysql(res[0]) + def get_latest_for_user(self, user_id: int) -> Session | None: + """Return the most recently started session for a product user.""" + res, _ = self.filter_paginated(user_id=user_id, order_by="-started", size=1) + if res: + return res[0] + def get_from_id(self, session_id: int) -> Session: query = """ SELECT diff --git a/generalresearch/managers/thl/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py index f805872..1f0c30b 100644 --- a/generalresearch/managers/thl/wallet/__init__.py +++ b/generalresearch/managers/thl/wallet/__init__.py @@ -59,13 +59,13 @@ def manage_pending_cashout( user = user_manager.get_user(user_uuid=pe.account_reference_uuid) user.prefetch_product(user_manager.mysql_user_manager.pg_config) - assert ( - user.product.user_wallet_enabled - ), "manage_pending_cashout called on user without managed wallet" + assert user.product.user_wallet_enabled, ( + "manage_pending_cashout called on user without managed wallet" + ) assert not user.blocked, "manage_pending_cashout: Blocked user" - assert not user_ip_history_manager.is_user_anonymous( - user - ), "manage_pending_cashout: Anonymous user" + assert not user_ip_history_manager.is_user_anonymous(user), ( + "manage_pending_cashout: Anonymous user" + ) # Just assign it with direct casting/type annotation payout_event_manager: PayoutEventManager = user_payout_event_manager diff --git a/generalresearch/managers/thl/wallet/approve.py b/generalresearch/managers/thl/wallet/approve.py index 7fedec1..a1c01d2 100644 --- a/generalresearch/managers/thl/wallet/approve.py +++ b/generalresearch/managers/thl/wallet/approve.py @@ -13,6 +13,26 @@ if TYPE_CHECKING: from generalresearch.models.thl.user import User +def approve_amt_cashout( + user: User, + payout_event: UserPayoutEvent, + ledger_manager: ThlLedgerManager, + payout_event_manager: PayoutEventManager, +) -> None: + """ + This is going to be paid out by the requester (the jb-lambdas) as an AMT bonus. + """ + assert payout_event.status in { + PayoutStatus.PENDING, + PayoutStatus.FAILED, + }, ( + "attempting to manage payout that is not pending (or you can retry a failed order)" + ) + + payout_event_manager.update(payout_event, status=PayoutStatus.APPROVED) + ledger_manager.create_tx_user_payout_complete(user, payout_event=payout_event) + + def approve_paypal_order( payout_event: UserPayoutEvent, payout_event_manager: PayoutEventManager ): @@ -22,7 +42,9 @@ def approve_paypal_order( assert payout_event.status in { PayoutStatus.PENDING, PayoutStatus.FAILED, - }, "attempting to manage payout that is not pending (or you can retry a failed order)" + }, ( + "attempting to manage payout that is not pending (or you can retry a failed order)" + ) payout_event_manager.update(payout_event, status=PayoutStatus.APPROVED) @@ -38,21 +60,3 @@ def approve_paypal_order( pass return payout_event - - -def approve_amt_cashout( - user: User, - payout_event: UserPayoutEvent, - ledger_manager: ThlLedgerManager, - payout_event_manager: PayoutEventManager, -) -> None: - """ - This is going to be paid out by the requester (the jb-lambdas) as an AMT bonus. - """ - assert payout_event.status in { - PayoutStatus.PENDING, - PayoutStatus.FAILED, - }, "attempting to manage payout that is not pending (or you can retry a failed order)" - - payout_event_manager.update(payout_event, status=PayoutStatus.APPROVED) - ledger_manager.create_tx_user_payout_complete(user, payout_event=payout_event) diff --git a/generalresearch/models/thl/ledger.py b/generalresearch/models/thl/ledger.py index 2290a83..613d16e 100644 --- a/generalresearch/models/thl/ledger.py +++ b/generalresearch/models/thl/ledger.py @@ -102,8 +102,10 @@ class AccountType(StrEnum, metaclass=ReprEnumMeta): BP_COMMISSION = "bp_commission" # BP wallets (owed balance) BP_WALLET = "bp_wallet" - # User's wallet + # User's wallet: real earnings potentially available for redemption USER_WALLET = "user_wallet" + # User's conditional credits applied against future task earnings + USER_ATTEMPT_CREDIT = "user_attempt_credit" # Cash account CASH = "cash" # Revenue (money coming in) @@ -168,6 +170,9 @@ class TransactionType(StrEnum): # might be called "expenses" in finance reports). USER_BONUS = "user_bonus" + # A user is paid conditional credits applied against future task earnings + USER_ATTEMPT_CREDIT = "user_attempt_credit" + # A transaction is made to plug accounting imbalances PLUG = "plug" diff --git a/generalresearch/models/thl/product.py b/generalresearch/models/thl/product.py index 3fd149d..ad5b137 100644 --- a/generalresearch/models/thl/product.py +++ b/generalresearch/models/thl/product.py @@ -20,7 +20,6 @@ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from uuid import uuid4 import pandas as pd -from dask.distributed import Client from pydantic import ( BaseModel, ConfigDict, @@ -68,6 +67,8 @@ from generalresearch.models.utils import decimal_to_usd_cents from generalresearch.redis_helper import RedisConfig if TYPE_CHECKING: + from dask.distributed import Client + from generalresearch.incite.base import GRLDatasets from generalresearch.incite.mergers.pop_ledger import PopLedgerMerge from generalresearch.managers.thl.ledger_manager.thl_ledger import ( @@ -412,7 +413,12 @@ class UserWalletConfig(BaseModel): default=False, description="If enabled, the users' wallets are managed." ) - # This field could go in supported_payout_types ---v + balance_type: Literal["wallet_balance", "redeemable_balance"] = Field( + default="wallet_balance", + description="The balance used as the user's displayed balance.", + ) + + # Deprecated amt: bool = Field(default=False, description="Uses Amazon Mechanical Turk") supported_payout_types: set[PayoutType] = Field( @@ -427,13 +433,23 @@ class UserWalletConfig(BaseModel): examples=[Decimal("10.00")], ) + failed_attempt_credit: Decimal | None = Field( + default=None, + gt=0, + description=( + "Conditional credit awarded for an eligible failed attempt. " + "None disables failed-attempt credits." + ), + examples=[Decimal("0.05"), None], + ) + @field_serializer("supported_payout_types", when_used="json") def serialize_supported_payout_types_in_order( self, supported_payout_types: set[PayoutType] ) -> list[PayoutType]: return sorted(supported_payout_types) - @field_validator("min_cashout", mode="after") + @field_validator("min_cashout", "failed_attempt_credit", mode="after") @classmethod def check_payout_decimal_places(cls, v: Decimal) -> Decimal: if v is not None: @@ -457,6 +473,10 @@ class UserWalletConfig(BaseModel): self.min_cashout = Decimal("0.01") return self + @property + def failed_attempt_credit_enabled(self) -> bool: + return self.failed_attempt_credit is not None + class PayoutTransformationPercentArgs(BaseModel): pct: NonNegativeFloat = Field( diff --git a/generalresearch/models/thl/session.py b/generalresearch/models/thl/session.py index b3cb6af..6150ecd 100644 --- a/generalresearch/models/thl/session.py +++ b/generalresearch/models/thl/session.py @@ -434,20 +434,20 @@ class Wall(WallBase): # --- Properties --- - @computed_field + @computed_field() @property - def elapsed(self) -> timedelta: + def elapsed(self) -> timedelta | None: return self.finished - self.started if self.finished else None def to_json(self) -> str: # We have to handle the computed_fields manually. I'm not sure if there is a better way # to do this natively in pydantic... - d = self.model_dump(mode="json", exclude={"elapsed"}) + d = self.model_dump(mode="json", exclude_computed_fields=True) return json.dumps(d) def model_dump_mysql(self) -> dict[str, Any]: # Generate a dictionary representation of the model, with special handling for datetimes - d = self.model_dump(mode="json", exclude={"elapsed"}) + d = self.model_dump(mode="json", exclude_computed_fields=True) d["started"] = self.started.replace(tzinfo=None) if self.finished: d["finished"] = self.finished.replace(tzinfo=None) @@ -819,12 +819,39 @@ class Session(BaseModel): self.model_config["validate_assignment"] = True self.__class__.model_validate(self) - def model_dump_mysql(self) -> dict[str, str | int | datetime | float | None]: + def is_attempt_credit_eligible(self) -> bool: + """Return whether this session qualifies for attempt credit. + The status of the BP's user_wallet_config.failed_attempt_credit_enabled does + not matter here. + """ + now = datetime.now(tz=UTC) + min_session_length = timedelta(minutes=1) + + if self.status is None: + return now - self.started >= min_session_length + + ineligible_status_codes = { + StatusCode1.SESSION_START_FAIL, + StatusCode1.SESSION_START_QUALITY_FAIL, + StatusCode1.SESSION_CONTINUE_QUALITY_FAIL, + StatusCode1.BUYER_QUALITY_FAIL, + StatusCode1.PS_BLOCKED, + StatusCode1.PS_QUALITY, + } + return ( + self.status == Status.FAIL + and self.status_code_1 not in ineligible_status_codes + and self.finished is not None + and self.elapsed >= min_session_length + ) + + def model_dump_mysql( + self, **kwargs + ) -> dict[str, str | int | datetime | float | None]: # Generate a dictionary representation of the model, with special # handling for datetimes, and nested models such as User & Bucket - - d = self.model_dump(mode="json") + d = self.model_dump(mode="json", **kwargs) d["started"] = self.started.replace(tzinfo=None) if self.finished: diff --git a/generalresearch/models/thl/wallet/user_wallet.py b/generalresearch/models/thl/wallet/user_wallet.py index cbbc6d1..0a66fc2 100644 --- a/generalresearch/models/thl/wallet/user_wallet.py +++ b/generalresearch/models/thl/wallet/user_wallet.py @@ -1,11 +1,17 @@ from __future__ import annotations import logging +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt +from generalresearch.models.custom_types import UUIDStr from generalresearch.models.legacy.api_status import StatusResponse -from generalresearch.models.thl.payout_format import PayoutFormatField, PayoutFormatType +from generalresearch.models.thl.ledger import AccountType +from generalresearch.models.thl.payout_format import ( + PayoutFormatField, + PayoutFormatType, +) logger = logging.getLogger() @@ -37,3 +43,40 @@ class UserWalletBalance(BaseModel): class UserWalletBalanceResponse(StatusResponse): wallet: UserWalletBalance = Field() + + +class UserLedgerWallet(UserWalletBalance): + """A user-owned ledger account exposed by the wallets endpoint.""" + + payout_format: PayoutFormatType | None = Field(default=None) + amount_string: str | None = Field(default=None) + redeemable_amount_string: str | None = Field(default=None) + + account_uuid: UUIDStr = Field( + description="A unique identifier for this Ledger Account", + examples=["c3c3566b5b1b4961b63a5670a2dc923d"], + ) + account_type: Literal[ + AccountType.USER_WALLET, + AccountType.USER_ATTEMPT_CREDIT, + ] + currency: str = Field(default="USD", max_length=32) + display_name: str = Field( + max_length=64, + description="Human-readable description of the Ledger Account", + ) + + +class UserDisplayedWalletBalance(BaseModel): + """Combined user-visible balance for one ledger currency.""" + + currency: str = Field(max_length=32) + amount: int = Field( + description="Displayed balance in the currency's smallest ledger unit." + ) + amount_string: str | None = None + + +class UserLedgerWallets(BaseModel): + wallets: list[UserLedgerWallet] = Field(default_factory=list) + displayed_balances: list[UserDisplayedWalletBalance] = Field(default_factory=list) diff --git a/generalresearch/thl_django/app/manage.py b/generalresearch/thl_django/app/manage.py index dabd5b3..33f2367 100644 --- a/generalresearch/thl_django/app/manage.py +++ b/generalresearch/thl_django/app/manage.py @@ -1,7 +1,11 @@ #!/usr/bin/env python +import os import sys if __name__ == "__main__": + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "generalresearch.thl_django.app.settings" + ) from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) diff --git a/pyproject.toml b/pyproject.toml index 5719a43..8a5b5a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "generalresearch" -version = "3.4.5" +version = "3.4.6" description = "Python Utilities for General Research" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.14" dependencies = [ "Faker", "PyMySQL", @@ -65,4 +65,4 @@ exclude = [ [tool.pylint.messages_control] disable = ["all"] -enable = ["cyclic-import"]
\ No newline at end of file +enable = ["cyclic-import"] diff --git a/test_utils/conftest.py b/test_utils/conftest.py index 33a7e77..cd3fd45 100644 --- a/test_utils/conftest.py +++ b/test_utils/conftest.py @@ -16,7 +16,7 @@ import pytest from _pytest.config import Config from dotenv import load_dotenv from pydantic import MariaDBDsn, PostgresDsn, TypeAdapter -from pytest import FixtureRequest, TempPathFactory +from pytest import TempPathFactory from generalresearch.currency import USDCent from generalresearch.models.custom_types import InternalHostname, PostgresDict @@ -218,19 +218,17 @@ def gr_repo( @pytest.fixture(scope="session") def django_settings_file( postgres_instance_dict: PostgresDict, -) -> Callable[..., Path]: + tmp_path_factory: TempPathFactory, +) -> Callable[..., tuple[str, Path]]: - def _inner( - settings_dir: Path, extra_installed_apps: list[str] | None = None - ) -> Path: + def _inner(extra_installed_apps: list[str] | None = None) -> tuple[str, Path]: installed_apps = [ "django.contrib.postgres", "django.contrib.contenttypes", ] + (extra_installed_apps or []) - """ - This returns the directory path of where the settings file is in, - not the path of the settings file itself - """ + + settings_dir = tmp_path_factory.mktemp("django-settings") + settings_module = "test_settings" settings_content = f"""DATABASES = {{ "default": {{ @@ -250,21 +248,19 @@ USE_I18N = True USE_L10N = True USE_TZ = True """ - settings_file_path = settings_dir / "test_settings.py" - settings_file_path.unlink(missing_ok=True) - settings_file_path.write_text(settings_content) + settings_file_path = settings_dir / f"{settings_module}.py" + settings_file_path.write_text(settings_content, encoding="utf-8") - return settings_dir + return settings_module, settings_dir return _inner @pytest.fixture(scope="session") def django_db_factory( - request: FixtureRequest, postgres_instance: PostgresDsn, gr_repo: Callable[..., Path], - django_settings_file: Callable[..., Path], + django_settings_file: Callable[..., tuple[str, Path]], postgres_instance_dict: PostgresDict, tmp_path_factory: TempPathFactory, ) -> Callable[..., PostgresDsn | None]: @@ -280,17 +276,10 @@ def django_db_factory( return postgres_instance _ran[django_project] = True - # This is the generalresearch project root path, it's - # 1 directory up from test_utils/, or tests/ - base_dir = Path(request.config.rootpath).parent - if django_project == "generalresearch.thl_django": - _cwd = base_dir + _cwd = None _manage_path = "generalresearch.thl_django.app.manage" - _settings_dir = base_dir / "generalresearch/thl_django/app" - _settings_module = "generalresearch.thl_django.app.test_settings" - django_settings_file( - settings_dir=_settings_dir, + _settings_module, _settings_dir = django_settings_file( extra_installed_apps=[ "generalresearch.thl_django", ], @@ -299,19 +288,25 @@ def django_db_factory( elif django_project == "gr.common": _cwd = gr_repo() _manage_path = "gr.app.manage" - _settings_dir = gr_repo() / "gr/app" - _settings_module = "gr.app.test_settings" - django_settings_file( - settings_dir=_settings_dir, extra_installed_apps=["gr.common"] + _settings_module, _settings_dir = django_settings_file( + extra_installed_apps=["gr.common"] ) else: raise ValueError("Not implemented yet.") - assert _settings_dir + pythonpath = str(_settings_dir) + if existing_pythonpath := os.environ.get("PYTHONPATH"): + pythonpath += os.pathsep + existing_pythonpath + + env = { + **os.environ, + "DJANGO_SETTINGS_MODULE": _settings_module, + "PYTHONPATH": pythonpath, + } - env = {"DJANGO_SETTINGS_MODULE": str(_settings_module)} - res1 = subprocess.run( + # we check right after. if we check now, we won't print if bad + res1 = subprocess.run( # noqa: PLW1510 [ sys.executable, "-m", @@ -319,11 +314,10 @@ def django_db_factory( "makemigrations", f"--settings={_settings_module}", ], - cwd=str(_cwd), + cwd=str(_cwd) if _cwd is not None else None, env=env, capture_output=True, text=True, - check=True, ) if res1.returncode != 0: @@ -331,7 +325,7 @@ def django_db_factory( print("STDERR:", res1.stderr) res1.check_returncode() - res2 = subprocess.run( + res2 = subprocess.run( # noqa: PLW1510 [ sys.executable, "-m", @@ -340,10 +334,9 @@ def django_db_factory( f"--settings={_settings_module}", ], env=env, - cwd=str(_cwd), + cwd=str(_cwd) if _cwd is not None else None, capture_output=True, text=True, - check=True, ) if res2.returncode != 0: @@ -483,7 +476,6 @@ def clear_directory(path: Path | str): dir_path = Path(path) for entry in os.listdir(dir_path): - full_path = os.path.join(path, entry) if os.path.isfile(full_path) or os.path.islink(full_path): os.unlink(full_path) # remove file or symlink diff --git a/test_utils/managers/thl/conftest.py b/test_utils/managers/thl/conftest.py index 355a39d..3392559 100644 --- a/test_utils/managers/thl/conftest.py +++ b/test_utils/managers/thl/conftest.py @@ -225,6 +225,7 @@ def user_manager( pg_config=thl_web_rw, pg_config_rr=thl_web_rr, redis=settings.redis, + sql_permissions=[Permission.CREATE, Permission.UPDATE, Permission.READ], ) # rc = thl_redis_config.create_redis_client() diff --git a/test_utils/models/conftest.py b/test_utils/models/conftest.py index ffce272..7f82f69 100644 --- a/test_utils/models/conftest.py +++ b/test_utils/models/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from decimal import Decimal from typing import TYPE_CHECKING from uuid import uuid4 @@ -76,6 +77,21 @@ def product_amt_true( ) +@pytest.fixture +def product_failed_attempt_credit( + product_factory: Callable[..., Product], + payout_config: PayoutConfig, +) -> Product: + from generalresearch.models.thl.product import UserWalletConfig + + return product_factory( + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + payout_config=payout_config, + ) + + @pytest.fixture(scope="session") def buyer(buyer_manager: BuyerManager) -> Buyer: buyer_code = uuid4().hex diff --git a/test_utils/models/thl/conftest.py b/test_utils/models/thl/conftest.py index e09eadd..d20fdc4 100644 --- a/test_utils/models/thl/conftest.py +++ b/test_utils/models/thl/conftest.py @@ -700,6 +700,14 @@ def user_with_wallet_amt( return user_factory(save=True, product=product_amt_true) +@pytest.fixture +def user_failed_attempt_credit( + user_factory: Callable[..., User], product_failed_attempt_credit: Product +) -> User: + # A user on a product with user wallet enabled, failed_attempt_credit enabled + return user_factory(save=True, product=product_failed_attempt_credit) + + # --- User Payout Event --- diff --git a/tests/managers/thl/test_ledger/test_thl_lm_tx.py b/tests/managers/thl/test_ledger/test_thl_lm_tx.py index aa3b378..96ea0d6 100644 --- a/tests/managers/thl/test_ledger/test_thl_lm_tx.py +++ b/tests/managers/thl/test_ledger/test_thl_lm_tx.py @@ -22,7 +22,10 @@ from generalresearch.models.thl.definitions import ( WALL_ALLOWED_STATUS_STATUS_CODE, ) from generalresearch.models.thl.ledger import ( + AccountType, Direction, + LedgerAccount, + LedgerEntry, TransactionType, ) from generalresearch.models.thl.payout import UserPayoutEvent @@ -765,6 +768,355 @@ class TestThlLedgerTxManager: # Assert the balance came out of their user wallet assert ledger_manager.get_account_balance(account=user_account) == rand_amount + def test_create_tx_attempt_credit( + self, + bare_session_factory: Callable[..., Session], + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + session = bare_session_factory(user=user, started=utc_hour_ago) + session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + + tx = thl_ledger_manager.create_tx_attempt_credit(session) + + assert tx.tag == ( + f"{thl_ledger_manager.currency.value}:" + f"{TransactionType.USER_ATTEMPT_CREDIT.value}:{session.uuid}" + ) + assert tx.ext_description == f"Attempt Credit {session.uuid}" + assert [entry.amount for entry in tx.entries] == [5, 5] + assert [entry.direction for entry in tx.entries] == [ + Direction.DEBIT, + Direction.CREDIT, + ] + assert thl_ledger_manager.get_session_attempt_credit(session.uuid) == 5 + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 5 + + with pytest.raises( + LedgerTransactionConditionFailedError, match=r"^duplicate tag$" + ): + thl_ledger_manager.create_tx_attempt_credit(session, skip_flag_check=True) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 5 + + def test_create_tx_attempt_credit_rejects_ineligible_session( + self, + bare_session_factory: Callable[..., Session], + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + session = bare_session_factory(user=user, started=utc_hour_ago) + session.update( + status=Status.FAIL, + status_code_1=StatusCode1.SESSION_START_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + + with pytest.raises(AssertionError, match="not eligible"): + thl_ledger_manager.create_tx_attempt_credit(session) + + assert thl_ledger_manager.get_session_attempt_credit(session.uuid) is None + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 0 + + def test_bp_payment_settles_attempt_credit( + self, + bare_session_factory: Callable[..., Session], + session_factory: Callable[..., Session], + session_manager: SessionManager, + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + failed_session = bare_session_factory(user=user, started=utc_hour_ago) + failed_session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + thl_ledger_manager.create_tx_attempt_credit(failed_session) + + completed_session = session_factory(user=user) + _, status_code_1 = completed_session.determine_session_status() + _, _, bp_pay, user_pay = completed_session.determine_payments() + assert user_pay is not None and user_pay >= Decimal("0.05") + session_manager.finish_with_status( + session=completed_session, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=completed_session.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + + thl_ledger_manager.create_tx_bp_payment(completed_session) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 0 + assert thl_ledger_manager.get_user_wallet_balance(user) == round(user_pay * 100) + assert thl_ledger_manager.check_ledger_balanced() + + def test_bp_payment_with_zero_attempt_credit_balance( + self, + session_factory: Callable[..., Session], + session_manager: SessionManager, + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + completed_session = session_factory(user=user) + _, status_code_1 = completed_session.determine_session_status() + _, _, bp_pay, user_pay = completed_session.determine_payments() + assert user_pay is not None + session_manager.finish_with_status( + session=completed_session, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=completed_session.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + + thl_ledger_manager.create_tx_bp_payment(completed_session) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 0 + assert thl_ledger_manager.get_user_wallet_balance(user) == round(user_pay * 100) + assert thl_ledger_manager.check_ledger_balanced() + + def test_bp_payment_partially_settles_attempt_credit( + self, + bare_session_factory: Callable[..., Session], + session_factory: Callable[..., Session], + session_manager: SessionManager, + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + attempt_credit = Decimal("1.00") + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=attempt_credit + ), + ) + user = user_factory(product=product) + failed_session = bare_session_factory(user=user, started=utc_hour_ago) + failed_session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + thl_ledger_manager.create_tx_attempt_credit(failed_session) + + completed_session = session_factory(user=user) + _, status_code_1 = completed_session.determine_session_status() + _, _, bp_pay, user_pay = completed_session.determine_payments() + assert user_pay is not None + attempt_credit_cents = round(attempt_credit * 100) + user_pay_cents = round(user_pay * 100) + assert 0 < user_pay_cents < attempt_credit_cents + session_manager.finish_with_status( + session=completed_session, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=completed_session.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + + thl_ledger_manager.create_tx_bp_payment(completed_session) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == ( + attempt_credit_cents - user_pay_cents + ) + assert thl_ledger_manager.get_user_wallet_balance(user) == user_pay_cents + assert thl_ledger_manager.check_ledger_balanced() + + def test_get_user_wallets_with_attempt_credit_and_custom_currency( + self, + bare_session_factory: Callable[..., Session], + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + ledger_manager: LedgerManager, + thl_ledger_manager: ThlLedgerManager, + currency: LedgerCurrency, + utc_hour_ago: datetime, + session_factory, + session_manager, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + + wallets = thl_ledger_manager.get_user_wallets(user) + + assert wallets.wallets[0].amount == 0 + assert wallets.displayed_balances[0].amount == 0 + + failed_session = bare_session_factory(user=user, started=utc_hour_ago) + failed_session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + thl_ledger_manager.create_tx_attempt_credit(failed_session) + + wallets = thl_ledger_manager.get_user_wallets(user) + assert wallets.wallets[0].amount == 5 + assert wallets.wallets[0].redeemable_amount == 0 + assert wallets.displayed_balances[0].amount == 5 + assert wallets.displayed_balances[0].amount_string == "$0.05" + + gold_stars_currency = uuid4().hex + gold_stars_wallet = ledger_manager.create_account( + LedgerAccount( + display_name="Gold Stars", + qualified_name=( + f"{gold_stars_currency}:{AccountType.USER_WALLET.value}:{user.uuid}" + ), + normal_balance=Direction.CREDIT, + account_type=AccountType.USER_WALLET, + reference_type="user", + reference_uuid=user.uuid, + currency=gold_stars_currency, + ) + ) + gold_stars_source = ledger_manager.create_account( + LedgerAccount( + display_name="Gold Stars Source", + qualified_name=( + f"{gold_stars_currency}:" + f"{AccountType.BP_WALLET.value}:{product.uuid}" + ), + normal_balance=Direction.CREDIT, + account_type=AccountType.BP_WALLET, + reference_type="bp", + reference_uuid=product.uuid, + currency=gold_stars_currency, + ) + ) + ledger_manager.create_tx( + entries=[ + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=gold_stars_source.uuid, + amount=25, + ), + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=gold_stars_wallet.uuid, + amount=25, + ), + ], + tag=f"{gold_stars_currency}:gold_stars:{user.uuid}", + ) + + wallets = thl_ledger_manager.get_user_wallets(user) + wallets_by_type = { + (wallet.account_type, wallet.currency): wallet for wallet in wallets.wallets + } + displayed_by_currency = { + balance.currency: balance for balance in wallets.displayed_balances + } + + assert ( + wallets_by_type[ + (AccountType.USER_ATTEMPT_CREDIT, thl_ledger_manager.currency) + ].amount + == 5 + ) + assert ( + wallets_by_type[ + (AccountType.USER_WALLET, thl_ledger_manager.currency) + ].amount + == 0 + ) + assert displayed_by_currency[currency.value].amount == 5 + assert displayed_by_currency[currency.value].amount_string == "$0.05" + assert displayed_by_currency[gold_stars_currency].amount == 25 + assert displayed_by_currency[gold_stars_currency].amount_string is None + + s = session_factory(user=user, wall_req_cpi=Decimal(1)) + _, status_code_1 = s.determine_session_status() + _, _, bp_pay, user_pay = s.determine_payments() + assert user_pay is not None + session_manager.finish_with_status( + session=s, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=s.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + thl_ledger_manager.create_tx_bp_payment(s) + + wallets = thl_ledger_manager.get_user_wallets(user) + wallets_by_type = { + (wallet.account_type, wallet.currency): wallet for wallet in wallets.wallets + } + displayed_by_currency = { + balance.currency: balance for balance in wallets.displayed_balances + } + assert displayed_by_currency[currency.value].amount == 0 + 38 + assert ( + wallets_by_type[ + (AccountType.USER_ATTEMPT_CREDIT, thl_ledger_manager.currency) + ].amount + == 0 + ) + assert ( + wallets_by_type[ + (AccountType.USER_WALLET, thl_ledger_manager.currency) + ].amount + == 38 + ) + class TestThlLedgerTxManagerFlows: """Combine the various THL_LM methods to create actual "real world" diff --git a/tests/models/thl/test_product_userwalletconfig.py b/tests/models/thl/test_product_userwalletconfig.py index b1bfa60..9fb9c73 100644 --- a/tests/models/thl/test_product_userwalletconfig.py +++ b/tests/models/thl/test_product_userwalletconfig.py @@ -23,6 +23,7 @@ class TestProductUserWalletConfig: # Check the defaults assert not instance.enabled + assert instance.balance_type == "wallet_balance" assert not instance.amt assert isinstance(instance.supported_payout_types, set) @@ -30,6 +31,11 @@ class TestProductUserWalletConfig: assert instance.min_cashout is None + def test_redeemable_balance_type(self): + instance = UserWalletConfig(balance_type="redeemable_balance") + + assert instance.balance_type == "redeemable_balance" + def test_model_dump(self): instance = UserWalletConfig() |
