From ca73e3876ee241726ef0162dd78a0c2bf35174ee Mon Sep 17 00:00:00 2001 From: stuppie Date: Tue, 29 Sep 2026 10:19:33 -0600 Subject: cashout methods : with an optional can_redeem --- .../managers/thl/ledger_manager/thl_ledger.py | 10 ++++++++++ generalresearch/managers/thl/wallet/user_payout.py | 19 ++++--------------- generalresearch/models/thl/wallet/cashout_method.py | 16 +++++++++++++++- tests/managers/thl/test_ledger/test_wallet.py | 2 ++ 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index de7cae9..cd82d4a 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -2009,6 +2009,16 @@ class ThlLedgerManager(LedgerManager): ) return self.get_account_balance(wallet) + def get_user_cashout_balance(self, user: User) -> int: + """Return the balance currently available for user cashouts.""" + wallet_balance = self.get_user_wallet_balance(user) + balance_type = user.product.user_wallet_config.balance_type + if balance_type == "wallet_balance": + return wallet_balance + if balance_type == "redeemable_balance": + return self.get_user_redeemable_wallet_balance(user, wallet_balance) + raise ValueError(f"unexpected balance_type={balance_type}") + def get_user_redeemable_wallet_balance( self, user: User, user_wallet_balance: int ) -> PositiveInt: diff --git a/generalresearch/managers/thl/wallet/user_payout.py b/generalresearch/managers/thl/wallet/user_payout.py index 432eac9..99f80ef 100644 --- a/generalresearch/managers/thl/wallet/user_payout.py +++ b/generalresearch/managers/thl/wallet/user_payout.py @@ -8,7 +8,7 @@ import slack from pydantic import NonNegativeInt, PositiveInt from redis import Redis -from generalresearch.currency import USDCent +from generalresearch.currency import USDCent, format_usd_cent from generalresearch.managers.thl.cashout_method import CashoutMethodManager from generalresearch.managers.thl.ledger_manager.exceptions import ( LedgerTransactionCreateError, @@ -106,7 +106,7 @@ class UserPayoutEventManager(PayoutEventManager): product_user_id=pe.user.product_user_id, amount=USDCent(pe.amount), payout_type=pe.payout_type, - created=pe.created + created=pe.created, ) @staticmethod @@ -430,7 +430,6 @@ class UserPayoutEventManager(PayoutEventManager): f"Amount must be between 0 and $250.00. Got {amount.to_usd_str()}" ) - product = user.product banned_countries = user.product.user_health_config.banned_countries assert not user_ip_history_manager.is_user_anonymous(user), ( @@ -439,20 +438,10 @@ class UserPayoutEventManager(PayoutEventManager): if country_iso in banned_countries: raise AssertionError("Banned country requesting redemption") - wallet_balance = ledger_manager.get_user_wallet_balance(user) - if product.user_wallet_config.balance_type == "wallet_balance": - redeemable_amount = wallet_balance - elif product.user_wallet_config.balance_type == "redeemable_balance": - redeemable_amount = ledger_manager.get_user_redeemable_wallet_balance( - user, wallet_balance - ) - else: - raise ValueError( - f"unexpected balance_type={product.user_wallet_config.balance_type}" - ) + redeemable_amount = ledger_manager.get_user_cashout_balance(user) assert amount <= redeemable_amount, ( - f"User requesting more than their redeemable balance ({amount} > {redeemable_amount})" + f"User requesting more than their redeemable balance ({amount.to_usd_str()} > {format_usd_cent(redeemable_amount)})" ) # Simple dedupe mechanism. Don't allow more than 1 per user_id per minute per cashout_method. diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py index 5502f86..518aa2f 100644 --- a/generalresearch/models/thl/wallet/cashout_method.py +++ b/generalresearch/models/thl/wallet/cashout_method.py @@ -195,9 +195,23 @@ class CashoutMethodOut(CashoutMethodBase): "incrementing values.", ) + can_redeem: bool | None = Field( + default=None, + description="Whether the user currently has enough redeemable wallet balance " + "to meet this cashout method's minimum value. None when not evaluated for " + "a user.", + ) + @classmethod - def from_cashout_method(cls, cm: CashoutMethod) -> Self: + def from_cashout_method( + cls, + cm: CashoutMethod, + *, + cashout_balance: int | None = None, + ) -> Self: d = cm.model_dump() + if cashout_balance is not None and cm.min_value_usd is not None: + d["can_redeem"] = cashout_balance >= int(cm.min_value_usd) if cm.user: d["product_id"] = cm.user.product_id d["product_user_id"] = cm.user.product_user_id diff --git a/tests/managers/thl/test_ledger/test_wallet.py b/tests/managers/thl/test_ledger/test_wallet.py index 0a1da73..f154c19 100644 --- a/tests/managers/thl/test_ledger/test_wallet.py +++ b/tests/managers/thl/test_ledger/test_wallet.py @@ -59,6 +59,7 @@ class TestGetUserWalletBalance: user: User = user_factory(product=schrute_product) balance = thl_ledger_manager.get_user_wallet_balance(user=user) assert balance == 0 + assert thl_ledger_manager.get_user_cashout_balance(user=user) == 0 assert isinstance(user.product, Product) balance_string = user.product.format_payout_format(Decimal(balance) / 100) assert balance_string == "0 Schrute Bucks" @@ -97,3 +98,4 @@ class TestGetUserWalletBalance: user=user, user_wallet_balance=balance ) assert redeemable_balance == 20 + 100 + assert thl_ledger_manager.get_user_cashout_balance(user=user) == 20 + 100 -- cgit v1.2.3 From 015a825433a75c8d840341607be5f68afe8330b9 Mon Sep 17 00:00:00 2001 From: stuppie Date: Tue, 29 Sep 2026 12:07:18 -0600 Subject: implement cashout method popularity_rank --- generalresearch/managers/thl/wallet/__init__.py | 3 +- generalresearch/managers/thl/wallet/user_payout.py | 135 +++++++++++++++++++++ .../models/thl/wallet/cashout_method.py | 21 ++++ 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/generalresearch/managers/thl/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py index cc799d6..1e80397 100644 --- a/generalresearch/managers/thl/wallet/__init__.py +++ b/generalresearch/managers/thl/wallet/__init__.py @@ -14,13 +14,13 @@ if TYPE_CHECKING: from generalresearch.managers.thl.ledger_manager.thl_ledger import ( ThlLedgerManager, ) - from generalresearch.managers.thl.payout import UserPayoutEventManager from generalresearch.managers.thl.paypal import PayPalPayoutManager from generalresearch.managers.thl.tango_api import TangoClient from generalresearch.managers.thl.user_manager.user_manager import ( UserManager, ) from generalresearch.managers.thl.userhealth import UserIpHistoryManager + from generalresearch.managers.thl.wallet.user_payout import UserPayoutEventManager from generalresearch.models.thl.payout import UserPayoutEvent from generalresearch.models.thl.wallet.cashout_method import ( CashMailOrderData, @@ -34,7 +34,6 @@ def manage_pending_cashout( user_ip_history_manager: UserIpHistoryManager, user_manager: UserManager, ledger_manager: ThlLedgerManager, - geoip_info_manager: GeoIpInfoManager, order_data: dict[str, Any] | CashMailOrderData | None = None, tango_client: TangoClient | None = None, paypal_client: PayPalPayoutManager | None = None, diff --git a/generalresearch/managers/thl/wallet/user_payout.py b/generalresearch/managers/thl/wallet/user_payout.py index 99f80ef..2e9e7df 100644 --- a/generalresearch/managers/thl/wallet/user_payout.py +++ b/generalresearch/managers/thl/wallet/user_payout.py @@ -1,6 +1,8 @@ +import logging import uuid from collections.abc import Collection from datetime import UTC, datetime +from threading import Thread from typing import Any from uuid import uuid4 @@ -26,14 +28,147 @@ from generalresearch.models.thl.wallet.cashout_method import ( CashMailCashoutMethodRequestData, CashMailOrderData, CashoutMethod, + CashoutMethodOut, CashoutRequestDetail, PaypalCashoutMethodRequestData, TangoCashoutMethodRequestData, ) from generalresearch.models.thl.wallet.definitions import PayoutType +logger = logging.getLogger(__name__) + class UserPayoutEventManager(PayoutEventManager): + CASHOUT_METHOD_REDEMPTION_COUNT_KEY = "cashout-method:redemption-count:v1" + + def update( + self, + payout_event: UserPayoutEvent, + status: PayoutStatus, + ext_ref_id: str | None = None, + order_data: dict[str, Any] | None = None, + ) -> None: + was_complete = payout_event.status == PayoutStatus.COMPLETE + super().update( + payout_event=payout_event, + status=status, + ext_ref_id=ext_ref_id, + order_data=order_data, + ) + if status != PayoutStatus.COMPLETE or was_complete: + return + + assert payout_event.cashout_method_uuid is not None + key = CashoutMethod.make_redemption_key( + payout_event.payout_type, + payout_event.cashout_method_uuid, + ) + try: + redis_key = self.CASHOUT_METHOD_REDEMPTION_COUNT_KEY + if self.redis_client.exists(redis_key): + self.redis_client.zincrby(redis_key, 1, key) + except Exception: + logger.exception( + "Unable to increment cashout-method redemption count", + extra={"payout_event_uuid": payout_event.uuid}, + ) + + def _query_cashout_method_redemption_counts(self) -> dict[str, int]: + rows = self.pg_config.execute_sql_query( + query=""" + SELECT CASE + WHEN ac.user_id IS NOT NULL + THEN 'provider:' || ac.provider + ELSE 'id:' || REPLACE(ep.cashout_method_uuid::text, '-', '') + END AS key, + COUNT(*) AS redemption_count + FROM event_payout AS ep + JOIN accounting_cashoutmethod AS ac + ON ac.id = ep.cashout_method_uuid + JOIN ledger_account AS la + ON la.uuid = ep.debit_account_uuid + WHERE ac.provider != 'AMT' + AND la.reference_type = 'user' + AND ep.status = 'COMPLETE' + GROUP BY key + """ + ) + return {row["key"]: int(row["redemption_count"]) for row in rows} + + def rebuild_cashout_method_redemption_counts(self) -> dict[str, int]: + counts = self._query_cashout_method_redemption_counts() + redis_key = self.CASHOUT_METHOD_REDEMPTION_COUNT_KEY + with self.redis_client.pipeline() as pipeline: + pipeline.delete(redis_key) + pipeline.zadd(redis_key, counts) + pipeline.execute() + return counts + + def _rebuild_cashout_method_redemption_counts_if_missing(self) -> None: + redis_key = self.CASHOUT_METHOD_REDEMPTION_COUNT_KEY + lock = self.redis_client.lock( + f"{redis_key}:rebuild-lock", + timeout=60, + blocking_timeout=0, + ) + if not lock.acquire(): + return + try: + if not self.redis_client.exists(redis_key): + self.rebuild_cashout_method_redemption_counts() + except Exception: + logger.exception("Unable to rebuild cashout-method redemption counts") + finally: + lock.release() + + def get_cashout_method_redemption_counts( + self, cashout_methods: Collection[CashoutMethod | CashoutMethodOut] + ) -> dict[str, int]: + methods = list(cashout_methods) + redis_key = self.CASHOUT_METHOD_REDEMPTION_COUNT_KEY + try: + if not self.redis_client.exists(redis_key): + Thread( + target=self._rebuild_cashout_method_redemption_counts_if_missing, + daemon=True, + ).start() + return {method.id: 0 for method in methods} + + with self.redis_client.pipeline() as pipeline: + for method in methods: + pipeline.zscore( + redis_key, + CashoutMethod.make_redemption_key(method.type, method.id), + ) + scores = pipeline.execute() + return { + method.id: int(score or 0) + for method, score in zip(methods, scores, strict=True) + } + except Exception: + logger.exception("Unable to read cashout-method redemption counts") + return {method.id: 0 for method in methods} + + def apply_cashout_method_popularity( + self, + cashout_methods: list[CashoutMethodOut], + ) -> list[CashoutMethodOut]: + redemption_counts = self.get_cashout_method_redemption_counts(cashout_methods) + ranked_methods = sorted( + cashout_methods, + key=lambda method: ( + -redemption_counts[method.id], + method.name.casefold(), + method.id, + ), + ) + popularity_ranks = { + method.id: rank for rank, method in enumerate(ranked_methods, start=1) + } + for method in cashout_methods: + method.popularity_rank = popularity_ranks[method.id] + return ranked_methods + def get_by_uuid(self, pe_uuid: UUIDStr) -> UserPayoutEvent: res = self.pg_config.execute_sql_query( diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py index 518aa2f..6357f9f 100644 --- a/generalresearch/models/thl/wallet/cashout_method.py +++ b/generalresearch/models/thl/wallet/cashout_method.py @@ -178,6 +178,19 @@ class CashoutMethod(CashoutMethodBase): ) return self + @property + def redemption_key(self) -> str: + return self.make_redemption_key(self.type, self.id) + + @staticmethod + def make_redemption_key(payout_type: PayoutType, cashout_method_id: str) -> str: + # The format of the key is dependent on the cashout method provider. + # e.g., Paypal has individual cashout methods per user, so they are grouped. + # "provider:PAYPAL", "provider:CASH_IN_MAIL", "id:941d489c3ce04eb39a0ddb7f8f75db74" + if payout_type in {PayoutType.PAYPAL, PayoutType.CASH_IN_MAIL}: + return f"provider:{payout_type.value}" + return f"id:{cashout_method_id}" + class CashoutMethodOut(CashoutMethodBase): product_id: UUIDStr | None = Field( @@ -201,6 +214,11 @@ class CashoutMethodOut(CashoutMethodBase): "to meet this cashout method's minimum value. None when not evaluated for " "a user.", ) + popularity_rank: PositiveInt | None = Field( + default=None, + description="The method's popularity rank among the available methods. " + "One is the most popular.", + ) @classmethod def from_cashout_method( @@ -208,8 +226,11 @@ class CashoutMethodOut(CashoutMethodBase): cm: CashoutMethod, *, cashout_balance: int | None = None, + popularity_rank: int | None = None, ) -> Self: d = cm.model_dump() + if popularity_rank is not None: + d["popularity_rank"] = popularity_rank if cashout_balance is not None and cm.min_value_usd is not None: d["can_redeem"] = cashout_balance >= int(cm.min_value_usd) if cm.user: -- cgit v1.2.3 From a2033fbb00c4f09e7dd184120b9271d66e59ff76 Mon Sep 17 00:00:00 2001 From: stuppie Date: Tue, 29 Sep 2026 12:37:03 -0600 Subject: bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 312b8b9..7d866d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "generalresearch" -version = "3.6.3" +version = "3.6.4" description = "Python Utilities for General Research" readme = "README.md" requires-python = ">=3.14" -- cgit v1.2.3