From 4e3fbea26ca1aeee7459d6026b8403c862f85fca Mon Sep 17 00:00:00 2001 From: stuppie Date: Tue, 1 Sep 2026 22:56:16 -0600 Subject: user failed attempt credit WIP. create_tx_attempt_credit, get_user_attempt_credit_to_settle, and create_tx_bp_payment --- .../managers/thl/ledger_manager/thl_ledger.py | 253 ++++++++++++++++----- generalresearch/models/thl/ledger.py | 7 +- generalresearch/models/thl/product.py | 15 +- 3 files changed, 213 insertions(+), 62 deletions(-) diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index 2fd0a2a..27a5ac7 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -1,10 +1,10 @@ from __future__ import annotations import logging -from collections.abc import Collection +from collections.abc import Callable, Collection from datetime import datetime, timedelta, timezone from decimal import Decimal -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING from uuid import UUID import numpy as np @@ -77,12 +77,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=AccountType.USER_WALLET, + 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=account_type, reference_type="user", reference_uuid=user.uuid, currency=self.currency, @@ -249,9 +273,9 @@ class ThlLedgerManager(LedgerManager): if time_end is None: time_end = datetime.now(tz=timezone.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, @@ -359,13 +383,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_( @@ -406,8 +432,7 @@ class ThlLedgerManager(LedgerManager): f"bp_pay {bp_pay} > thl_net {thl_net}. Capping bp_pay to thl_net." ) bp_pay = thl_net - if user_pay > bp_pay: - user_pay = bp_pay + user_pay = min(user_pay, bp_pay) commission_amount = round(thl_net - bp_pay) @@ -449,7 +474,31 @@ class ThlLedgerManager(LedgerManager): amount=user_pay, ) ) - ext_description = f"BP & User Payment {session.uuid}" + if user.product_id == JAMES_BILLINGS_BPID: + 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, + ), + ] + ) + ext_description = f"BP & User Payment {session.uuid}" else: entries.append( @@ -537,7 +586,7 @@ class ThlLedgerManager(LedgerManager): ] else: - logger.info(f"create_transaction_task_adjustment. No transactions needed.") + logger.info("create_transaction_task_adjustment. No transactions needed.") return None amt_str = f"${abs(change_amount) / 100:,.2f}" @@ -664,9 +713,7 @@ class ThlLedgerManager(LedgerManager): ) else: - logger.info( - f"create_transaction_bp_adjustment. No transactions needed." - ) + logger.info("create_transaction_bp_adjustment. No transactions needed.") return None else: new_bp_payout = new_payout @@ -736,9 +783,7 @@ class ThlLedgerManager(LedgerManager): ) else: - logger.info( - f"create_transaction_bp_adjustment. No transactions needed." - ) + logger.info("create_transaction_bp_adjustment. No transactions needed.") return None logger.info(entries) @@ -797,9 +842,9 @@ class ThlLedgerManager(LedgerManager): if skip_one_per_day_check or skip_wallet_balance_check: skip_flag_check = True - assert ( - datetime.now(tz=timezone.utc) > created - ), "created cannot be in the future" + assert datetime.now(tz=timezone.utc) > created, ( + "created cannot be in the future" + ) f = lambda: self.create_tx_bp_payout_( product=product, amount=amount, @@ -855,7 +900,7 @@ class ThlLedgerManager(LedgerManager): ), ] - ext_description = f"BP Payout" + ext_description = "BP Payout" t = self.create_tx( entries=entries, metadata=metadata, @@ -903,9 +948,9 @@ class ThlLedgerManager(LedgerManager): :param skip_flag_check: If True, we skip the flag check to allow for retry of a failed previous call. """ - assert ( - datetime.now(tz=timezone.utc) > created - ), "created cannot be in the future" + assert datetime.now(tz=timezone.utc) > created, ( + "created cannot be in the future" + ) assert isinstance(amount, int) assert isinstance(amount, USDCent) @@ -982,7 +1027,7 @@ class ThlLedgerManager(LedgerManager): raise ValueError("Invalid Direction") if description is None: - description = f"BP Plug" + description = "BP Plug" t = self.create_tx( entries=entries, @@ -1012,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}" @@ -1037,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, @@ -1086,9 +1131,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" @@ -1114,9 +1159,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" ) @@ -1178,9 +1223,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" @@ -1190,7 +1235,7 @@ class ThlLedgerManager(LedgerManager): f"Trying to cancel user payout {payout_event.uuid} with no request tx found." ) - description = f"User Payout Cancelled" + description = "User Payout Cancelled" f = lambda: self.create_tx_user_payout_cancelled_( user=user, payout_event=payout_event, @@ -1405,9 +1450,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. @@ -1476,6 +1521,73 @@ 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 failed session. + + 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.status == Status.FAIL, "Attempt credit requires a failed session" + + 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 create_tx_user_enter_contest( self, contest_uuid: UUIDStr, @@ -1486,13 +1598,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 @@ -1576,9 +1688,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 @@ -1833,9 +1945,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=timezone.utc) wallet = self.get_account_or_create_user_wallet(user) @@ -1890,7 +2002,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 @@ -1899,7 +2013,7 @@ class ThlLedgerManager(LedgerManager): reserve = round(wall["user_payout_int"].sum() - wall["redeemable"].sum()) redeemable_balance = user_wallet_balance - reserve - redeemable_balance = 0 if redeemable_balance < 0 else redeemable_balance + redeemable_balance = max(redeemable_balance, 0) if redeemable_balance > 0: # it is possible the user_wallet_balance is negative, in which case @@ -1907,7 +2021,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: """ @@ -1932,6 +2046,25 @@ 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_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/models/thl/ledger.py b/generalresearch/models/thl/ledger.py index 3f8b123..d30f78a 100644 --- a/generalresearch/models/thl/ledger.py +++ b/generalresearch/models/thl/ledger.py @@ -62,8 +62,10 @@ class AccountType(str, Enum, 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) @@ -128,6 +130,9 @@ class TransactionType(str, Enum): # 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 3f21d92..3faacb4 100644 --- a/generalresearch/models/thl/product.py +++ b/generalresearch/models/thl/product.py @@ -428,13 +428,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] ) -> set[PayoutType]: return set(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: @@ -458,6 +468,9 @@ 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( -- cgit v1.2.3 From ce5a2d02389b0879598dc5c5fbab989ea0913028 Mon Sep 17 00:00:00 2001 From: stuppie Date: Wed, 2 Sep 2026 12:36:00 -0600 Subject: add is_attempt_credit_eligible, get_user_wallets --- .../managers/thl/ledger_manager/thl_ledger.py | 86 ++++++++++++++++++++-- generalresearch/models/thl/session.py | 23 ++++++ generalresearch/models/thl/wallet/user_wallet.py | 25 +++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index 27a5ac7..fa09a45 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -54,10 +54,15 @@ 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 Session, Status, Wall from generalresearch.models.thl.user import User from generalresearch.models.thl.wallet import PayoutType +from generalresearch.models.thl.wallet.user_wallet import ( + UserLedgerWallet, + UserLedgerWallets, +) if TYPE_CHECKING: from generalresearch.models.thl.contest.contest import ContestWinner @@ -68,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 @@ -474,7 +478,7 @@ class ThlLedgerManager(LedgerManager): amount=user_pay, ) ) - if user.product_id == JAMES_BILLINGS_BPID: + 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 ) @@ -1536,9 +1540,7 @@ class ThlLedgerManager(LedgerManager): 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" - ) + 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" @@ -1546,6 +1548,9 @@ class ThlLedgerManager(LedgerManager): amount = USDCent(round(config.failed_attempt_credit * 100)) assert session.status == Status.FAIL, "Attempt credit requires a failed session" + 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) @@ -2052,6 +2057,77 @@ class ThlLedgerManager(LedgerManager): 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) + payout_format = user.product.payout_config.payout_format + assert payout_format is not None, "Product must have a payout format" + + 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": [ + AccountType.USER_WALLET.value, + AccountType.USER_ATTEMPT_CREDIT.value, + ], + }, + ) + accounts = [LedgerAccount.model_validate(row) for row in rows] + + wallets = [] + 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: + # The session-based USD reserve calculation cannot be applied + # to another ledger currency. + redeemable_balance = max(balance, 0) + + wallets.append( + 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=payout_format, + amount_string=format_payout_format(payout_format, balance), + redeemable_amount_string=format_payout_format( + payout_format, redeemable_balance + ), + ) + ) + + return UserLedgerWallets(wallets=wallets) + def get_user_attempt_credit_to_settle( self, user: User, diff --git a/generalresearch/models/thl/session.py b/generalresearch/models/thl/session.py index 17142f3..a270257 100644 --- a/generalresearch/models/thl/session.py +++ b/generalresearch/models/thl/session.py @@ -823,6 +823,29 @@ class Session(BaseModel): self.model_config["validate_assignment"] = True self.__class__.model_validate(self) + @property + 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. + """ + min_session_length = timedelta(minutes=1) + + 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, *args, **kwargs ) -> dict[str, str | int | datetime | float | None]: diff --git a/generalresearch/models/thl/wallet/user_wallet.py b/generalresearch/models/thl/wallet/user_wallet.py index dbe66fa..0671d38 100644 --- a/generalresearch/models/thl/wallet/user_wallet.py +++ b/generalresearch/models/thl/wallet/user_wallet.py @@ -1,10 +1,13 @@ 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.ledger import AccountType from generalresearch.models.thl.payout_format import ( PayoutFormatField, PayoutFormatType, @@ -40,3 +43,25 @@ class UserWalletBalance(BaseModel): class UserWalletBalanceResponse(StatusResponse): wallet: UserWalletBalance = Field() + + +class UserLedgerWallet(UserWalletBalance): + """A user-owned ledger account exposed by the wallets endpoint.""" + + 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 UserLedgerWallets(BaseModel): + wallets: list[UserLedgerWallet] = Field(default_factory=list) -- cgit v1.2.3 From dc79688a2ddc3b50b5df5c42323a4d237be2c47a Mon Sep 17 00:00:00 2001 From: stuppie Date: Wed, 2 Sep 2026 13:27:05 -0600 Subject: add a claim_latest_attempt_credit method for un-finished/abandoned sessions --- .../managers/thl/ledger_manager/thl_ledger.py | 35 ++++++++++++-- generalresearch/managers/thl/session.py | 56 ++++++++++++---------- generalresearch/models/thl/session.py | 7 ++- 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index fa09a45..2d7a588 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -57,7 +57,7 @@ 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 Session, Status, Wall -from generalresearch.models.thl.user import User +from generalresearch.models.thl.user import BPUIDStr, User from generalresearch.models.thl.wallet import PayoutType from generalresearch.models.thl.wallet.user_wallet import ( UserLedgerWallet, @@ -66,6 +66,8 @@ from generalresearch.models.thl.wallet.user_wallet import ( if TYPE_CHECKING: from generalresearch.models.thl.contest.contest import ContestWinner + from generalresearch.managers.thl.session import SessionManager + logging.basicConfig() logger = logging.getLogger("LedgerManager") @@ -1531,7 +1533,7 @@ class ThlLedgerManager(LedgerManager): created: datetime | None = None, skip_flag_check: bool = False, ) -> LedgerTransaction: - """Record conditional credit for an eligible failed session. + """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. @@ -1547,8 +1549,7 @@ class ThlLedgerManager(LedgerManager): ) amount = USDCent(round(config.failed_attempt_credit * 100)) - assert session.status == Status.FAIL, "Attempt credit requires a failed session" - assert session.is_attempt_credit_eligible, ( + assert session.is_attempt_credit_eligible(), ( "Session is not eligible for attempt credit" ) @@ -1593,6 +1594,32 @@ class ThlLedgerManager(LedgerManager): 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.""" + + 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 create_tx_user_enter_contest( self, contest_uuid: UUIDStr, diff --git a/generalresearch/managers/thl/session.py b/generalresearch/managers/thl/session.py index 746a518..8834f8d 100644 --- a/generalresearch/managers/thl/session.py +++ b/generalresearch/managers/thl/session.py @@ -114,6 +114,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 @@ -190,14 +196,12 @@ class SessionManager(PostgresManager): # re-run model_validate after finished = finished if finished else datetime.now(tz=timezone.utc) session.update( - **{ - "status": status, - "status_code_1": status_code_1, - "status_code_2": status_code_2, - "finished": finished, - "payout": payout, - "user_payout": user_payout, - } + status=status, + status_code_1=status_code_1, + status_code_2=status_code_2, + finished=finished, + payout=payout, + user_payout=user_payout, ) d = session.model_dump_mysql() self.pg_config.execute_write( @@ -453,15 +457,15 @@ class SessionManager(PostgresManager): if started_before or started_after: started_after = started_after or datetime(2017, 1, 1, tzinfo=timezone.utc) started_before = started_before or datetime.now(tz=timezone.utc) - assert ( - started_after.tzinfo == timezone.utc - ), "started_after must be tz-aware as UTC" - assert ( - started_before.tzinfo == timezone.utc - ), "started_before must be tz-aware as UTC" - assert ( - started_after < started_before - ), "started_after must be before started_before" + assert started_after.tzinfo == timezone.utc, ( + "started_after must be tz-aware as UTC" + ) + assert started_before.tzinfo == timezone.utc, ( + "started_before must be tz-aware as UTC" + ) + assert started_after < started_before, ( + "started_after must be before started_before" + ) filters.append("started BETWEEN %(started_after)s AND %(started_before)s") params["started_after"] = started_after params["started_before"] = started_before @@ -469,15 +473,15 @@ class SessionManager(PostgresManager): if adjusted_before or adjusted_after: adjusted_after = adjusted_after or datetime(2017, 1, 1, tzinfo=timezone.utc) adjusted_before = adjusted_before or datetime.now(tz=timezone.utc) - assert ( - adjusted_after.tzinfo == timezone.utc - ), "adjusted_after must be tz-aware as UTC" - assert ( - adjusted_before.tzinfo == timezone.utc - ), "adjusted_before must be tz-aware as UTC" - assert ( - adjusted_after < adjusted_before - ), "adjusted_after must be before adjusted_before" + assert adjusted_after.tzinfo == timezone.utc, ( + "adjusted_after must be tz-aware as UTC" + ) + assert adjusted_before.tzinfo == timezone.utc, ( + "adjusted_before must be tz-aware as UTC" + ) + assert adjusted_after < adjusted_before, ( + "adjusted_after must be before adjusted_before" + ) filters.append( "adjusted_timestamp BETWEEN %(adjusted_after)s AND %(adjusted_before)s" ) diff --git a/generalresearch/models/thl/session.py b/generalresearch/models/thl/session.py index a270257..b5e4401 100644 --- a/generalresearch/models/thl/session.py +++ b/generalresearch/models/thl/session.py @@ -823,21 +823,24 @@ class Session(BaseModel): self.model_config["validate_assignment"] = True self.__class__.model_validate(self) - @property 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=timezone.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 + StatusCode1.PS_QUALITY, } return ( self.status == Status.FAIL -- cgit v1.2.3 From 8d1c0c6b98c403186516f8e6bcadf0be5bcf7a84 Mon Sep 17 00:00:00 2001 From: stuppie Date: Wed, 2 Sep 2026 16:36:53 -0600 Subject: add a balance_type product config, Add UserDisplayedWalletBalance model and implement. Remove same amt stuff --- generalresearch/managers/thl/cashout_method.py | 12 --- .../managers/thl/ledger_manager/thl_ledger.py | 114 ++++++++++++--------- generalresearch/managers/thl/session.py | 2 +- generalresearch/managers/thl/wallet/__init__.py | 21 +--- generalresearch/managers/thl/wallet/approve.py | 16 --- generalresearch/models/thl/product.py | 7 +- generalresearch/models/thl/wallet/user_wallet.py | 15 +++ tests/models/thl/test_product_userwalletconfig.py | 6 ++ 8 files changed, 97 insertions(+), 96 deletions(-) diff --git a/generalresearch/managers/thl/cashout_method.py b/generalresearch/managers/thl/cashout_method.py index 7365878..91d6a42 100644 --- a/generalresearch/managers/thl/cashout_method.py +++ b/generalresearch/managers/thl/cashout_method.py @@ -243,9 +243,6 @@ class CashoutMethodManager(PostgresManager): product = user.product 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], @@ -268,15 +265,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 = [ - x - for x in cms - if (x.type == PayoutType.AMT and product.user_wallet_config.amt) - or (x.type != PayoutType.AMT) - ] return cms @staticmethod diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index 2d7a588..aac47ed 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections import defaultdict from collections.abc import Callable, Collection from datetime import datetime, timedelta, timezone from decimal import Decimal @@ -57,16 +58,17 @@ 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 Session, Status, Wall -from generalresearch.models.thl.user import BPUIDStr, User +from generalresearch.models.thl.user import User from generalresearch.models.thl.wallet import PayoutType from generalresearch.models.thl.wallet.user_wallet import ( + UserDisplayedWalletBalance, UserLedgerWallet, UserLedgerWallets, ) if TYPE_CHECKING: - from generalresearch.models.thl.contest.contest import ContestWinner from generalresearch.managers.thl.session import SessionManager + from generalresearch.models.thl.contest.contest import ContestWinner logging.basicConfig() @@ -1050,8 +1052,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" @@ -1070,28 +1072,12 @@ class ThlLedgerManager(LedgerManager): amt_str = f"${int(amount) / 100:,.2f}" descriptions = { - PayoutType.AMT_HIT: f"User Payout AMT Assignment Request {amt_str}", - PayoutType.AMT_BONUS: f"User Payout AMT Bonus Request {amt_str}", PayoutType.PAYPAL: f"User Payout Paypal Request {amt_str}", PayoutType.CASH_IN_MAIL: f"User Payout Cash Request {amt_str}", PayoutType.TANGO: f"User Payout Tango Request {amt_str}", } description = descriptions[payout_event.payout_type] - if payout_event.payout_type in { - PayoutType.AMT_HIT, - PayoutType.AMT_BONUS, - }: - """ - This is for AMT accounts only (currently JB). This is the - payment of a either 1) 1c or 5c (typically) assignment or 2) a - 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" - ) - f = lambda: self.create_tx_user_payout_request_( user=user, payout_event=payout_event, @@ -1100,10 +1086,6 @@ class ThlLedgerManager(LedgerManager): ) min_balance: int | None = int(amount) - 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 @@ -1605,9 +1587,7 @@ class ThlLedgerManager(LedgerManager): and is eligible for an attempt credit, then the credit is automatically given.""" - session = session_manager.get_latest_for_user( - user_id=user.user_id - ) + 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: @@ -2090,6 +2070,10 @@ class ThlLedgerManager(LedgerManager): user.prefetch_product(self.pg_config) payout_format = user.product.payout_config.payout_format assert payout_format is not None, "Product must have a payout format" + user_account_types = { + AccountType.USER_WALLET.value, + AccountType.USER_ATTEMPT_CREDIT.value, + } rows = self.pg_config.execute_sql_query( query=""" @@ -2104,15 +2088,13 @@ class ThlLedgerManager(LedgerManager): """, params={ "reference_uuid": user.uuid, - "account_types": [ - AccountType.USER_WALLET.value, - AccountType.USER_ATTEMPT_CREDIT.value, - ], + "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 @@ -2133,27 +2115,63 @@ class ThlLedgerManager(LedgerManager): user_wallet_balance=balance, ) else: - # The session-based USD reserve calculation cannot be applied - # to another ledger currency. + # We don't have redeemable logic for other currencies redeemable_balance = max(balance, 0) - wallets.append( - 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=payout_format, - amount_string=format_payout_format(payout_format, balance), - redeemable_amount_string=format_payout_format( - payout_format, redeemable_balance - ), - ) + # 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) - return UserLedgerWallets(wallets=wallets) + 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, diff --git a/generalresearch/managers/thl/session.py b/generalresearch/managers/thl/session.py index 8834f8d..9d3661c 100644 --- a/generalresearch/managers/thl/session.py +++ b/generalresearch/managers/thl/session.py @@ -116,7 +116,7 @@ class SessionManager(PostgresManager): 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) + res, _ = self.filter_paginated(user_id=user_id, order_by="-started", size=1) if res: return res[0] diff --git a/generalresearch/managers/thl/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py index b063e54..70fcacf 100644 --- a/generalresearch/managers/thl/wallet/__init__.py +++ b/generalresearch/managers/thl/wallet/__init__.py @@ -14,7 +14,6 @@ from generalresearch.managers.thl.user_manager.user_manager import ( ) from generalresearch.managers.thl.userhealth import UserIpHistoryManager from generalresearch.managers.thl.wallet.approve import ( - approve_amt_cashout, approve_paypal_order, ) from generalresearch.models.thl.definitions import PayoutStatus @@ -32,8 +31,8 @@ def manage_pending_cashout( user_ip_history_manager: UserIpHistoryManager, user_manager: UserManager, ledger_manager: ThlLedgerManager, - order_data: Optional[Union[Dict[str, Any], CashMailOrderData]] = None, - tango_client: Optional[TangoClient] = None, + order_data: dict[str, Any] | CashMailOrderData | None = None, + tango_client: TangoClient | None = None, ) -> UserPayoutEvent: """ Called by a UI actions performed by Todd. This rejects/approves/cancels @@ -89,14 +88,6 @@ def manage_pending_cashout( payout_event=pe, payout_event_manager=payout_event_manager ) - elif pe.payout_type in {PayoutType.AMT_BONUS, PayoutType.AMT_HIT}: - approve_amt_cashout( - user=user, - payout_event=pe, - payout_event_manager=payout_event_manager, - ledger_manager=ledger_manager, - ) - elif pe.payout_type == PayoutType.CASH_IN_MAIL: assert order_data, "must pass order_data" payout_event_manager.update( @@ -114,14 +105,8 @@ def manage_pending_cashout( return pe elif new_status == PayoutStatus.COMPLETE: - # Used only for AMT/dummy cashouts that are actually paid out not - # by us. They are informing us that the cashout was successfully - # sent to the user - if pe.payout_type in {PayoutType.AMT_BONUS, PayoutType.AMT_HIT}: - # We already do this under approve_amt_cashout() - pass - elif pe.payout_type == PayoutType.PAYPAL: + if pe.payout_type == PayoutType.PAYPAL: # This is an issue here in that we actually don't know what the # fee is until it is sent and we read it back from paypal's csv # result. We have to just run this with a custom script, which diff --git a/generalresearch/managers/thl/wallet/approve.py b/generalresearch/managers/thl/wallet/approve.py index 4a7ae5e..012b406 100644 --- a/generalresearch/managers/thl/wallet/approve.py +++ b/generalresearch/managers/thl/wallet/approve.py @@ -34,19 +34,3 @@ def approve_paypal_order( 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/product.py b/generalresearch/models/thl/product.py index 3faacb4..a179142 100644 --- a/generalresearch/models/thl/product.py +++ b/generalresearch/models/thl/product.py @@ -413,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( diff --git a/generalresearch/models/thl/wallet/user_wallet.py b/generalresearch/models/thl/wallet/user_wallet.py index 0671d38..0a66fc2 100644 --- a/generalresearch/models/thl/wallet/user_wallet.py +++ b/generalresearch/models/thl/wallet/user_wallet.py @@ -48,6 +48,10 @@ class UserWalletBalanceResponse(StatusResponse): 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"], @@ -63,5 +67,16 @@ class UserLedgerWallet(UserWalletBalance): ) +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/tests/models/thl/test_product_userwalletconfig.py b/tests/models/thl/test_product_userwalletconfig.py index 4f6a6cc..614fc0a 100644 --- a/tests/models/thl/test_product_userwalletconfig.py +++ b/tests/models/thl/test_product_userwalletconfig.py @@ -21,6 +21,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) @@ -28,6 +29,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() -- cgit v1.2.3 From f9213cdbe4e1000898f9fc259603daa88546df73 Mon Sep 17 00:00:00 2001 From: stuppie Date: Thu, 3 Sep 2026 10:38:27 -0600 Subject: add tx manager: get_session_attempt_credit and helpers --- .../managers/thl/ledger_manager/ledger.py | 44 ++++++++++++---------- .../managers/thl/ledger_manager/thl_ledger.py | 25 +++++++++++- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/generalresearch/managers/thl/ledger_manager/ledger.py b/generalresearch/managers/thl/ledger_manager/ledger.py index 864f1dd..a344430 100644 --- a/generalresearch/managers/thl/ledger_manager/ledger.py +++ b/generalresearch/managers/thl/ledger_manager/ledger.py @@ -2,9 +2,9 @@ from __future__ import annotations import logging from collections import defaultdict -from collections.abc import Collection +from collections.abc import Callable, Collection from datetime import datetime, timedelta, timezone -from typing import Any, Callable +from typing import Any from uuid import UUID import redis @@ -118,9 +118,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 @@ -129,7 +129,6 @@ class LedgerManagerBasePostgres(PostgresManager, RedisManager): class LedgerTransactionManager(LedgerManagerBasePostgres): - def create_tx( self, entries: list[LedgerEntry], @@ -145,9 +144,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 = dict() @@ -342,7 +341,7 @@ class LedgerTransactionManager(LedgerManagerBasePostgres): assert len(tag) > 6, "Please confirm the tag is valid" res = self.pg_config.execute_sql_query( - query=f""" + query=""" SELECT lt.id FROM ledger_transaction AS lt WHERE tag = %s @@ -354,13 +353,21 @@ 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=f""" + query=""" SELECT lt.id, lt.tag, lt.created, lt.ext_description FROM ledger_transaction AS lt WHERE tag = ANY(%s) @@ -796,7 +803,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]) @@ -828,9 +834,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") @@ -868,7 +874,7 @@ class LedgerAccountManager(LedgerManagerBasePostgres): # qualified_name has a unique index so there can only be 0 or 1 match. res = self.pg_config.execute_sql_query( - query=f""" + query=""" SELECT uuid, display_name, qualified_name, account_type, normal_balance, reference_type, @@ -927,7 +933,7 @@ class LedgerAccountManager(LedgerManagerBasePostgres): # TODO: Move to RR with long timeout (2min+), it causes problems res = self.pg_config.execute_sql_query( - query=f""" + query=""" SELECT SUM(amount * direction) AS total FROM ledger_entry WHERE account_id = %s @@ -1045,7 +1051,7 @@ class LedgerManager( """This is for testing only, as it'll take forever to run this if the ledger_manager is huge """ - res = self.pg_config.execute_sql_query(f""" + res = self.pg_config.execute_sql_query(""" SELECT SUM(CASE WHEN normal_balance = -1 THEN total ELSE 0 END) AS credit_total, SUM(CASE WHEN normal_balance = 1 THEN total ELSE 0 END) AS debit_total diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index aac47ed..295385d 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -1586,7 +1586,7 @@ class ThlLedgerManager(LedgerManager): 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") @@ -1600,6 +1600,29 @@ class ThlLedgerManager(LedgerManager): 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, -- cgit v1.2.3 From 80fd8aab4c7271ddb619b0de18741d7ac77b490b Mon Sep 17 00:00:00 2001 From: stuppie Date: Thu, 3 Sep 2026 12:24:53 -0600 Subject: working on paypal api manager and cashouts --- generalresearch/managers/thl/paypal.py | 403 +++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 generalresearch/managers/thl/paypal.py 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 -- cgit v1.2.3 From 343d0299fe75ee8ec0e87847e2ee20dd61123d80 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 11:57:41 -0600 Subject: Fix merge --- generalresearch/managers/thl/ledger_manager/thl_ledger.py | 8 +++----- generalresearch/models/thl/session.py | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index f1a76c4..b6a6f5b 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging from collections import defaultdict from collections.abc import Callable, Collection @@ -10,7 +8,6 @@ from uuid import UUID import numpy as np import pandas as pd -from generalresearch.models.thl.wallet.definitions import PayoutType from pydantic import AwareDatetime, PositiveInt from generalresearch.config import ( @@ -32,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, @@ -58,6 +56,7 @@ from generalresearch.models.thl.payout_format import format_payout_format from generalresearch.models.thl.product import Product 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, @@ -66,8 +65,7 @@ from generalresearch.models.thl.wallet.user_wallet import ( if TYPE_CHECKING: from generalresearch.managers.thl.session import SessionManager - from generalresearch.models.thl.contest.contest import ContestWinner - + from generalresearch.models.thl.contest.contest import Contest, ContestWinner logging.basicConfig() logger = logging.getLogger("LedgerManager") diff --git a/generalresearch/models/thl/session.py b/generalresearch/models/thl/session.py index a9fe442..6150ecd 100644 --- a/generalresearch/models/thl/session.py +++ b/generalresearch/models/thl/session.py @@ -442,12 +442,12 @@ class Wall(WallBase): 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) -- cgit v1.2.3 From 17234dd26679b1b8a2c89e26c81fb27b4f553455 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 13:08:27 -0600 Subject: add user attempt credit tests. Fix a bug in make tx with description not being set under a certain condition. Add auto-creation of user wallets needed for get_wallets --- .../managers/thl/ledger_manager/conditions.py | 19 +- .../managers/thl/ledger_manager/thl_ledger.py | 9 +- tests/managers/thl/test_ledger/test_thl_lm_tx.py | 352 +++++++++++++++++++++ 3 files changed, 368 insertions(+), 12 deletions(-) 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/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index b6a6f5b..eccc99f 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -460,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( @@ -502,8 +503,6 @@ class ThlLedgerManager(LedgerManager): ), ] ) - ext_description = f"BP & User Payment {session.uuid}" - else: entries.append( LedgerEntry( @@ -2084,8 +2083,14 @@ class ThlLedgerManager(LedgerManager): """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, 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" -- cgit v1.2.3 From 63dde379996190f1827d76cbaa155b6c33702f7b Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 16:02:03 -0600 Subject: requires-python 3.14. Add back django environ --- generalresearch/thl_django/app/manage.py | 4 ++++ pyproject.toml | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) 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"] -- cgit v1.2.3 From 075c8d762af7d7d97e50ffc06eebdc249891f151 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 16:11:50 -0600 Subject: put amt stuff back for now --- generalresearch/managers/thl/cashout_method.py | 10 ++++++++ .../managers/thl/ledger_manager/thl_ledger.py | 19 ++++++++++++++ generalresearch/managers/thl/wallet/__init__.py | 29 ++++++++++++++++------ generalresearch/managers/thl/wallet/approve.py | 26 ++++++++++++++++--- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/generalresearch/managers/thl/cashout_method.py b/generalresearch/managers/thl/cashout_method.py index 7d2366d..910d37b 100644 --- a/generalresearch/managers/thl/cashout_method.py +++ b/generalresearch/managers/thl/cashout_method.py @@ -248,6 +248,8 @@ class CashoutMethodManager(PostgresManager): product = user.product 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], @@ -270,6 +272,14 @@ 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 = [ + x + for x in cms + if (x.type == PayoutType.AMT and product.user_wallet_config.amt) + or (x.type != PayoutType.AMT) + ] return cms @staticmethod diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index eccc99f..2fef50f 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -1064,12 +1064,28 @@ class ThlLedgerManager(LedgerManager): amt_str = f"${int(amount) / 100:,.2f}" descriptions = { + PayoutType.AMT_HIT: f"User Payout AMT Assignment Request {amt_str}", + PayoutType.AMT_BONUS: f"User Payout AMT Bonus Request {amt_str}", PayoutType.PAYPAL: f"User Payout Paypal Request {amt_str}", PayoutType.CASH_IN_MAIL: f"User Payout Cash Request {amt_str}", PayoutType.TANGO: f"User Payout Tango Request {amt_str}", } description = descriptions[payout_event.payout_type] + if payout_event.payout_type in { + PayoutType.AMT_HIT, + PayoutType.AMT_BONUS, + }: + """ + This is for AMT accounts only (currently JB). This is the + payment of a either 1) 1c or 5c (typically) assignment or 2) a + 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" + ) + f = lambda: self.create_tx_user_payout_request_( user=user, payout_event=payout_event, @@ -1078,6 +1094,9 @@ class ThlLedgerManager(LedgerManager): ) min_balance: int | None = int(amount) + 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 diff --git a/generalresearch/managers/thl/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py index 7a8b4c9..1f0c30b 100644 --- a/generalresearch/managers/thl/wallet/__init__.py +++ b/generalresearch/managers/thl/wallet/__init__.py @@ -2,6 +2,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Any from generalresearch.managers.thl.wallet.approve import ( + approve_amt_cashout, approve_paypal_order, ) from generalresearch.models.thl.definitions import PayoutStatus @@ -58,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 @@ -90,6 +91,14 @@ def manage_pending_cashout( payout_event=pe, payout_event_manager=payout_event_manager ) + elif pe.payout_type in {PayoutType.AMT_BONUS, PayoutType.AMT_HIT}: + approve_amt_cashout( + user=user, + payout_event=pe, + payout_event_manager=payout_event_manager, + ledger_manager=ledger_manager, + ) + elif pe.payout_type == PayoutType.CASH_IN_MAIL: assert order_data, "must pass order_data" payout_event_manager.update( @@ -107,8 +116,14 @@ def manage_pending_cashout( return pe elif new_status == PayoutStatus.COMPLETE: + # Used only for AMT/dummy cashouts that are actually paid out not + # by us. They are informing us that the cashout was successfully + # sent to the user + if pe.payout_type in {PayoutType.AMT_BONUS, PayoutType.AMT_HIT}: + # We already do this under approve_amt_cashout() + pass - if pe.payout_type == PayoutType.PAYPAL: + elif pe.payout_type == PayoutType.PAYPAL: # This is an issue here in that we actually don't know what the # fee is until it is sent and we read it back from paypal's csv # result. We have to just run this with a custom script, which diff --git a/generalresearch/managers/thl/wallet/approve.py b/generalresearch/managers/thl/wallet/approve.py index 7cae025..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,5 +60,3 @@ def approve_paypal_order( pass return payout_event - - -- cgit v1.2.3 From 9cee794bf9e0f39b76b2ce373ea5d30cf21bb1e1 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 16:39:24 -0600 Subject: dont import LOG from incite --- generalresearch/managers/events.py | 6 +++--- 1 file changed, 3 insertions(+), 3 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)) -- cgit v1.2.3 From d81dc180f7fda20f46c939d1b7608eff010a22c6 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 16:47:50 -0600 Subject: move dask under type checking --- generalresearch/models/thl/product.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/generalresearch/models/thl/product.py b/generalresearch/models/thl/product.py index 7441318..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 ( @@ -439,7 +440,7 @@ class UserWalletConfig(BaseModel): "Conditional credit awarded for an eligible failed attempt. " "None disables failed-attempt credits." ), - examples=[Decimal("0.05"), None] + examples=[Decimal("0.05"), None], ) @field_serializer("supported_payout_types", when_used="json") @@ -476,6 +477,7 @@ class UserWalletConfig(BaseModel): def failed_attempt_credit_enabled(self) -> bool: return self.failed_attempt_credit is not None + class PayoutTransformationPercentArgs(BaseModel): pct: NonNegativeFloat = Field( le=1.0, -- cgit v1.2.3 From 7b2f48152109ed17b88886ce3a53fbdc3bedb0ac Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 18:24:58 -0600 Subject: Make Django database fixtures portable across projects Generate test settings in pytest's temporary directory and expose them through PYTHONPATH instead of writing into a project-specific settings package. --- test_utils/conftest.py | 66 ++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 37 deletions(-) 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 -- cgit v1.2.3 From 70e2024b3b8abdf3965f59545aa35fda6270de1d Mon Sep 17 00:00:00 2001 From: stuppie Date: Tue, 8 Sep 2026 11:42:46 -0600 Subject: user manager fixture add permissions; some stuff actually checks this. Add user and product fixture with failed attempt credit --- test_utils/managers/thl/conftest.py | 1 + test_utils/models/conftest.py | 16 ++++++++++++++++ test_utils/models/thl/conftest.py | 8 ++++++++ 3 files changed, 25 insertions(+) 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 --- -- cgit v1.2.3