diff options
| -rw-r--r-- | generalresearch/managers/thl/cashout_method.py | 71 | ||||
| -rw-r--r-- | generalresearch/managers/thl/paypal.py | 139 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/__init__.py | 42 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/approve.py | 17 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/tango.py | 42 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/tango_tasks.py | 12 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/user_payout.py | 73 | ||||
| -rw-r--r-- | generalresearch/models/thl/ipinfo.py | 12 | ||||
| -rw-r--r-- | generalresearch/models/thl/wallet/cashout_method.py | 40 | ||||
| -rw-r--r-- | generalresearch/models/thl/wallet/definitions.py | 22 |
10 files changed, 241 insertions, 229 deletions
diff --git a/generalresearch/managers/thl/cashout_method.py b/generalresearch/managers/thl/cashout_method.py index dad1ef1..46bde38 100644 --- a/generalresearch/managers/thl/cashout_method.py +++ b/generalresearch/managers/thl/cashout_method.py @@ -12,12 +12,14 @@ from generalresearch.currency import USDCent from generalresearch.managers.base import PostgresManager from generalresearch.models.thl.user_ref import UserRef from generalresearch.models.thl.wallet.definitions import ( + CURRENCY_FORMATTER, SUPPORTED_CURRENCIES, Currency, PayoutType, ) if TYPE_CHECKING: + from generalresearch.managers.thl.wallet.tango import TangoManager from generalresearch.models.thl.user import User from generalresearch.models.thl.wallet.cashout_method import ( CashMailCashoutMethodData, @@ -75,8 +77,8 @@ class CashoutMethodManager(PostgresManager): ) def create_cash_in_mail_cashout_method( - self, data: CashMailCashoutMethodData, user: User - ) -> str: + self, data: CashMailCashoutMethodData, user: UserRef | User + ) -> CashoutMethod: """ Each user can create 1 or more "cash in mail" cashout method. This stores their address and possible shipping requests ? Each address @@ -87,6 +89,8 @@ class CashoutMethodManager(PostgresManager): # todo: validate shipping address? from generalresearch.models.thl.wallet.cashout_method import CashoutMethod + user = user if isinstance(user, UserRef) else user.to_user_ref() + cm = CashoutMethod( name="Cash in Mail", description="USPS delivery of cash", @@ -98,7 +102,7 @@ class CashoutMethodManager(PostgresManager): max_value=25000, # $250.00 data=data, type=PayoutType.CASH_IN_MAIL, - user=user.to_user_ref(), + user=user, ext_id=data.delivery_address.md5sum(), ) @@ -113,25 +117,25 @@ class CashoutMethodManager(PostgresManager): if res: # Already exists with the same address assert len(res) == 1 - return res[0].id + return res[0] self.create(cm) - return cm.id + return cm def create_paypal_cashout_method( - self, data: PaypalCashoutMethodData, user: User - ) -> str: + self, data: PaypalCashoutMethodData, user: UserRef | User + ) -> CashoutMethod: """ If it already exists, and the emails are the same, do nothing. If the - email is different, raises an error + email is different, it raises an error - :param data: - :param user: :return: the uuid of the created cashout method """ from generalresearch.models.thl.wallet.cashout_method import CashoutMethod + user = user if isinstance(user, UserRef) else user.to_user_ref() + cm = CashoutMethod( name="PayPal", description="Cashout via PayPal", @@ -142,7 +146,7 @@ class CashoutMethodManager(PostgresManager): max_value=25_000, # $250.00 data=data, type=PayoutType.PAYPAL, - user=user.to_user_ref(), + user=user, ext_id=data.email, ) # Make sure this user doesn't already have one @@ -151,7 +155,7 @@ class CashoutMethodManager(PostgresManager): assert len(res) == 1 if res[0].data.email == data.email: # Already exists with the same email, just return it - return res[0].id + return res[0] else: raise ValueError( "User already has a cashout method of this type. " @@ -159,7 +163,7 @@ class CashoutMethodManager(PostgresManager): ) else: self.create(cm) - return cm.id + return cm @staticmethod def make_filter_str( @@ -350,7 +354,9 @@ class CashoutMethodManager(PostgresManager): return {x.id: x for x in cms} @staticmethod - def format_from_db(x: dict[str, Any], user: User | None = None) -> CashoutMethod: + def format_from_db( + x: dict[str, Any], user: User | UserRef | None = None + ) -> CashoutMethod: x["id"] = UUID(x["id"]).hex # The data column here is inconsistent. Pulling keys from the mysql 'data' col @@ -368,7 +374,42 @@ class CashoutMethodManager(PostgresManager): x["data"].update(x.pop("_data_")) x["data"]["type"] = x["type"] if user and x["type"] in {PayoutType.PAYPAL, PayoutType.CASH_IN_MAIL}: - x["user"] = user.to_user_ref() + user = user if isinstance(user, UserRef) else user.to_user_ref() + x["user"] = user x["original_currency"] = x.get("currency") or Currency.USD x["currency"] = Currency.USD return CashoutMethod.model_validate(x) + + def get_expected_foreign_redemption_value( + self, cashout_method_id: str, amount: USDCent, tango_manager: TangoManager + ) -> tuple[int, Currency]: + """ + Convert USD cents to a Tango card's smallest currency unit. + for e.g.: A user wants a variable value CAD visa card. He redeems $10 USD (amount=1000), + this function returns the amount that will be redeemed in CAD. + :param cashout_method_id: ID of tango card. expected to be non USD. If USD, just returns the amount. + :param amount: amount the user is redeeming from their wallet in USD integer cents. + :return: amount that will be redeemed through tango on their foreign card, in the card's + (specified by the UTID) foreign currency (in integer units of the lowest denomination) + # example CAD visa: U121653 + """ + assert type(amount) is USDCent + # We don't **need** the method to be live? They can see the rate but won't + # be able to request it. + res = self.filter(uuid=cashout_method_id, is_live=None) + assert len(res) == 1, f"No cashout method found with id {cashout_method_id}" + cm = res[0] + if cm.type == PayoutType.TANGO: + assert cm.original_currency is not None + if cm.original_currency == Currency.USD: + return int(amount), Currency.USD + foreign_amount = round( + int(amount) / tango_manager.get_exchange_rates()[cm.original_currency] + ) + return foreign_amount, cm.original_currency + else: + # todo: bitcoin... etc + return int(amount), Currency.USD + + def format_currency(self, amount: int, currency: Currency): + return CURRENCY_FORMATTER[currency](amount) diff --git a/generalresearch/managers/thl/paypal.py b/generalresearch/managers/thl/paypal.py index 5ef0966..7e33da0 100644 --- a/generalresearch/managers/thl/paypal.py +++ b/generalresearch/managers/thl/paypal.py @@ -1,8 +1,9 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from collections.abc import Mapping +from datetime import UTC, datetime, timedelta from decimal import Decimal -from typing import Any, Mapping +from typing import Any import requests @@ -10,16 +11,11 @@ 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.managers.thl.wallet.user_payout import UserPayoutEventManager 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, -) +from generalresearch.models.thl.wallet.definitions import PayoutType PAYPAL_SANDBOX_URL = "https://api-m.sandbox.paypal.com" PAYPAL_PROD_URL = "https://api-m.paypal.com" @@ -59,7 +55,7 @@ class PayPalPayoutManager: self._access_token_expires_at: datetime | None = None def _get_access_token(self) -> str: - now = datetime.now(tz=timezone.utc) + now = datetime.now(tz=UTC) if ( self._access_token and self._access_token_expires_at @@ -204,83 +200,55 @@ class PayPalPayoutManager: ) return result.get("verification_status") == "SUCCESS" + def attempt_paypal_payout( + self, + *, + payout_event: UserPayoutEvent, + user_payout_event_manager: UserPayoutEventManager, + note: str | None = None, + email_subject: str | None = None, + ) -> UserPayoutEvent: + """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. + """ + assert payout_event.payout_type == PayoutType.PAYPAL, ( + "payout_event must be PayPal" + ) + assert payout_event.status == PayoutStatus.PENDING, "status must be PENDING" + amount_cents = USDCent(payout_event.amount) + recipient_email = payout_event.request_data["email"] -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 = self.send_payment( + recipient_email=str(recipient_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: + user_payout_event_manager.update(payout_event, status=PayoutStatus.FAILED) + raise PayPalError( + f"PayPal submission failed for payout event {payout_event.uuid}" + ) from exc - 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, + user_payout_event_manager.update( + payout_event=payout_event, + status=PayoutStatus.APPROVED, + ext_ref_id=payout_batch_id, + order_data=paypal_response, ) - 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 + return payout_event def handle_paypal_payout_webhook( @@ -360,8 +328,7 @@ def handle_paypal_payout_webhook( 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" + f"{ledger_manager.currency.value}:user_payout:{payout_event.uuid}:complete" ) complete_transactions = ledger_manager.get_tx_ids_by_tag(complete_tag) if len(complete_transactions) > 1: diff --git a/generalresearch/managers/thl/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py index 006908b..08ac110 100644 --- a/generalresearch/managers/thl/wallet/__init__.py +++ b/generalresearch/managers/thl/wallet/__init__.py @@ -6,6 +6,7 @@ from generalresearch.managers.thl.wallet.approve import ( approve_amt_cashout, approve_paypal_order, ) +from generalresearch.managers.thl.wallet.tango_tasks import complete_tango_order from generalresearch.models.thl.definitions import PayoutStatus from generalresearch.models.thl.wallet.definitions import PayoutType @@ -13,10 +14,8 @@ if TYPE_CHECKING: from generalresearch.managers.thl.ledger_manager.thl_ledger import ( ThlLedgerManager, ) - from generalresearch.managers.thl.payout import ( - PayoutEventManager, - UserPayoutEventManager, - ) + 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, @@ -35,9 +34,10 @@ def manage_pending_cashout( user_ip_history_manager: UserIpHistoryManager, user_manager: UserManager, ledger_manager: ThlLedgerManager, - geoip_info_manager:GeoIpInfoManager, + geoip_info_manager: GeoIpInfoManager, order_data: dict[str, Any] | CashMailOrderData | None = None, tango_client: TangoClient | None = None, + paypal_client: PayPalPayoutManager | None = None, ) -> UserPayoutEvent: """ Called by a UI actions performed by Todd. This rejects/approves/cancels @@ -65,45 +65,45 @@ def manage_pending_cashout( "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,geoip_info_manager=geoip_info_manager), ( - "manage_pending_cashout: Anonymous user" - ) + assert not user_ip_history_manager.is_user_anonymous( + user, geoip_info_manager=geoip_info_manager + ), "manage_pending_cashout: Anonymous user" # Just assign it with direct casting/type annotation - payout_event_manager: PayoutEventManager = user_payout_event_manager + # payout_event_manager: PayoutEventManager = user_payout_event_manager if new_status == PayoutStatus.APPROVED: if pe.payout_type == PayoutType.TANGO: - from generalresearch.managers.thl.wallet.tango import ( - complete_tango_order, - ) - assert tango_client is not None complete_tango_order( user=user, payout_event=pe, - payout_event_manager=payout_event_manager, + user_payout_event_manager=user_payout_event_manager, ledger_manager=ledger_manager, tango_client=tango_client, ) elif pe.payout_type == PayoutType.PAYPAL: + assert paypal_client is not None + approve_paypal_order( - payout_event=pe, payout_event_manager=payout_event_manager + payout_event=pe, + user_payout_event_manager=user_payout_event_manager, + paypal_client=paypal_client, ) 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, + payout_event_manager=user_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( + user_payout_event_manager.update( pe, status=PayoutStatus.APPROVED, order_data=order_data ) ledger_manager.create_tx_user_payout_complete( @@ -132,24 +132,24 @@ def manage_pending_cashout( # uses manual_complete_paypal_order() raise ValueError("user custom paypal script for this") - payout_event_manager.update(pe, status=new_status) + user_payout_event_manager.update(pe, status=new_status) return pe elif new_status == PayoutStatus.REJECTED: # They lose the money in their wallet at this point, no ledger txs occur. - payout_event_manager.update(pe, status=new_status) + user_payout_event_manager.update(pe, status=new_status) return pe elif new_status == PayoutStatus.CANCELLED: # create another ledger item putting the money back into their wallet. - payout_event_manager.update(pe, status=new_status) + user_payout_event_manager.update(pe, status=new_status) ledger_manager.create_tx_user_payout_cancelled(user, payout_event=pe) return pe elif new_status == PayoutStatus.FAILED: # We just update the status (like in PayoutStatus.REJECTED). No ledger xs - payout_event_manager.update(pe, status=new_status) + user_payout_event_manager.update(pe, status=new_status) return pe else: diff --git a/generalresearch/managers/thl/wallet/approve.py b/generalresearch/managers/thl/wallet/approve.py index a1c01d2..44382fa 100644 --- a/generalresearch/managers/thl/wallet/approve.py +++ b/generalresearch/managers/thl/wallet/approve.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TYPE_CHECKING from generalresearch.models.thl.definitions import PayoutStatus @@ -9,6 +7,8 @@ if TYPE_CHECKING: ThlLedgerManager, ) from generalresearch.managers.thl.payout import PayoutEventManager + from generalresearch.managers.thl.paypal import PayPalPayoutManager + from generalresearch.managers.thl.wallet.user_payout import UserPayoutEventManager from generalresearch.models.thl.payout import UserPayoutEvent from generalresearch.models.thl.user import User @@ -34,7 +34,9 @@ def approve_amt_cashout( def approve_paypal_order( - payout_event: UserPayoutEvent, payout_event_manager: PayoutEventManager + payout_event: UserPayoutEvent, + user_payout_event_manager: UserPayoutEventManager, + paypal_client: PayPalPayoutManager | None = None, ): """ The order has been approved, but it hasn't actually been sent. @@ -46,17 +48,18 @@ def approve_paypal_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) - interface = payout_event.request_data.get("interface") if interface == "api": # todo: Use the Payouts API to sent this payout, and then update the DB - pass + paypal_client.attempt_paypal_payout( + payout_event=payout_event, + user_payout_event_manager=user_payout_event_manager, + ) else: # Flow monitoring for payouts where the type is paypal, the status is # approved, and the interface is web, and then it'll send the payout and # update the status to complete (and create a ledger item for the fee) - pass + user_payout_event_manager.update(payout_event, status=PayoutStatus.APPROVED) return payout_event diff --git a/generalresearch/managers/thl/wallet/tango.py b/generalresearch/managers/thl/wallet/tango.py index fc6763b..ca993ea 100644 --- a/generalresearch/managers/thl/wallet/tango.py +++ b/generalresearch/managers/thl/wallet/tango.py @@ -2,24 +2,25 @@ from __future__ import annotations from datetime import timedelta from threading import Lock -from typing import Any +from typing import TYPE_CHECKING, Any from cachetools import TTLCache, cachedmethod from generalresearch.currency import USDCent -from generalresearch.managers.thl.cashout_method import CashoutMethodManager -from generalresearch.managers.thl.tango_api import TangoClient from generalresearch.models.thl.wallet.cashout_method import ( - TangoCashoutMethodData, CashoutMethod, TangoCashoutMethodRequestData, ) from generalresearch.models.thl.wallet.definitions import ( - CURRENCY_FORMATTER, Currency, PayoutType, ) +if TYPE_CHECKING: + from generalresearch.managers.thl.cashout_method import CashoutMethodManager + from generalresearch.managers.thl.tango_api import TangoClient + + class TangoManager: def __init__( @@ -93,37 +94,6 @@ class TangoManager: cashout_method = next(iter(methods), None) return cashout_method.name if cashout_method else "Tango Gift Card" - def get_expected_redemption_value( - self, cashout_method_id: str, amount: USDCent - ) -> tuple[int, Currency]: - """ - Convert USD cents to a Tango card's smallest currency unit. - for e.g.: A user wants a variable value CAD visa card. He redeems $10 USD (amount=1000), - this function returns the amount that will be redeemed in CAD. - :param cashout_method_id: ID of tango card. expected to be non USD. If USD, just returns the amount. - :param amount: amount the user is redeeming from their wallet in USD integer cents. - :return: amount that will be redeemed through tango on their foreign card, in the card's - (specified by the UTID) foreign currency (in integer units of the lowest denomination) - # example CAD visa: U121653 - """ - assert type(amount) is USDCent - res = self.cashout_method_manager.filter(uuid=cashout_method_id, is_live=True) - if not res: - raise ValueError(f"no cashout method found for {cashout_method_id!r}") - cashout_method = res[0] - assert cashout_method.type == PayoutType.TANGO - assert cashout_method.original_currency is not None - - currency = cashout_method.original_currency - if currency == Currency.USD: - return int(amount), currency - - foreign_amount = round(int(amount) / self.get_exchange_rates()[currency]) - return foreign_amount, currency - - def format_currency(self, amount: int, currency: Currency): - return CURRENCY_FORMATTER[currency](amount) - def clear_caches(self) -> None: with self._exchange_rate_lock: self._exchange_rate_cache.clear() diff --git a/generalresearch/managers/thl/wallet/tango_tasks.py b/generalresearch/managers/thl/wallet/tango_tasks.py index 038f67f..b5e810c 100644 --- a/generalresearch/managers/thl/wallet/tango_tasks.py +++ b/generalresearch/managers/thl/wallet/tango_tasks.py @@ -12,8 +12,8 @@ if TYPE_CHECKING: from generalresearch.managers.thl.ledger_manager.thl_ledger import ( ThlLedgerManager, ) - from generalresearch.managers.thl.payout import PayoutEventManager from generalresearch.managers.thl.tango_api import TangoClient + from generalresearch.managers.thl.wallet.user_payout import UserPayoutEventManager from generalresearch.models.thl.payout import UserPayoutEvent from generalresearch.models.thl.user import User @@ -21,7 +21,7 @@ if TYPE_CHECKING: def complete_tango_order( user: User, payout_event: UserPayoutEvent, - payout_event_manager: PayoutEventManager, + user_payout_event_manager: UserPayoutEventManager, ledger_manager: ThlLedgerManager, tango_client: TangoClient, ): @@ -35,7 +35,9 @@ def complete_tango_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)" + ) request = payout_event.request_data ref_id = request["externalRefID"] # amount_usd = Decimal(payout_event.request_data["amount_usd"]) @@ -53,11 +55,11 @@ def complete_tango_order( # todo: its possible the order went through, but something else was wrong # we should try to retrieve the order by its ref_id and confirm it really # failed... - payout_event_manager.update(payout_event, status=PayoutStatus.FAILED) + user_payout_event_manager.update(payout_event, status=PayoutStatus.FAILED) return payout_event # update TangoPayoutEvent with the order data - payout_event_manager.update( + user_payout_event_manager.update( payout_event, status=order["status"], ext_ref_id=order["referenceOrderID"], diff --git a/generalresearch/managers/thl/wallet/user_payout.py b/generalresearch/managers/thl/wallet/user_payout.py index 88505c0..ca47611 100644 --- a/generalresearch/managers/thl/wallet/user_payout.py +++ b/generalresearch/managers/thl/wallet/user_payout.py @@ -11,6 +11,7 @@ from redis import Redis from generalresearch.currency import USDCent from generalresearch.managers.thl.cashout_method import CashoutMethodManager from generalresearch.managers.thl.ipinfo import GeoIpInfoManager +from generalresearch.managers.thl.ledger_manager.exceptions import LedgerTransactionCreateError from generalresearch.managers.thl.ledger_manager.thl_ledger import ThlLedgerManager from generalresearch.managers.thl.payout import PayoutEventManager from generalresearch.managers.thl.userhealth import UserIpHistoryManager @@ -20,12 +21,12 @@ 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.cashout_method import ( + CashMailCashoutMethodRequestData, CashMailOrderData, CashoutMethod, CashoutRequestInfo, PaypalCashoutMethodRequestData, TangoCashoutMethodRequestData, - CashMailCashoutMethodRequestData, ) from generalresearch.models.thl.wallet.definitions import PayoutType @@ -70,7 +71,7 @@ class UserPayoutEventManager(PayoutEventManager): pe = self.get_by_uuid(pe_uuid=pe_uuid) transaction_info = {} - order: dict[str, Any] = pe.order_data + order: dict[str, Any] | CashMailOrderData = pe.order_data if pe.payout_type == PayoutType.TANGO and pe.status == PayoutStatus.COMPLETE: reward = order["reward"] if "credentialList" in reward: @@ -85,7 +86,7 @@ class UserPayoutEventManager(PayoutEventManager): pe.payout_type == PayoutType.CASH_IN_MAIL and pe.status == PayoutStatus.COMPLETE ): - transaction_info = pe.order_data.model_dump(mode="json") + transaction_info = order.model_dump(mode="json") return CashoutRequestInfo( id=pe_uuid, @@ -249,10 +250,38 @@ class UserPayoutEventManager(PayoutEventManager): return payout_event + def try_user_request_redeem( + self, + user: User, + cashout_method_id: str, + amount: USDCent, + tango_manager: TangoManager, + cashout_method_manager: CashoutMethodManager, + ledger_manager: ThlLedgerManager, + user_ip_history_manager: UserIpHistoryManager, + geoip_info_manager: GeoIpInfoManager, + redis_client: Redis, + slack_client: slack.WebClient | None = None, + ) -> tuple[UserPayoutEvent | None, str | None]: + try: + return self.user_request_redeem( + user=user, + cashout_method_id=cashout_method_id, + amount=amount, + tango_manager=tango_manager, + cashout_method_manager=cashout_method_manager, + ledger_manager=ledger_manager, + user_ip_history_manager=user_ip_history_manager, + geoip_info_manager=geoip_info_manager, + redis_client=redis_client, + slack_client=slack_client, + ), None + except (AssertionError, LedgerTransactionCreateError) as e: + return None, str(e) + def user_request_redeem( self, user: User, - country_iso: str, cashout_method_id: str, amount: USDCent, tango_manager: TangoManager, @@ -260,7 +289,7 @@ class UserPayoutEventManager(PayoutEventManager): ledger_manager: ThlLedgerManager, user_ip_history_manager: UserIpHistoryManager, geoip_info_manager: GeoIpInfoManager, - redis_client: Redis, + redis_client: Redis, slack_client: slack.WebClient | None = None, ) -> UserPayoutEvent: """ @@ -271,6 +300,12 @@ class UserPayoutEventManager(PayoutEventManager): """ now = datetime.now(tz=UTC) user.prefetch_product(pg_config=self.pg_config) + + country_iso = user_ip_history_manager.get_user_latest_country( + user, geoip_info_manager + ) + assert country_iso, "user has no country" + usd_exchange_rates = tango_manager.get_exchange_rates() cashout_methods = cashout_method_manager.get_user_cashout_methods( user, country_iso=country_iso, usd_exchange_rate=usd_exchange_rates @@ -311,22 +346,23 @@ class UserPayoutEventManager(PayoutEventManager): assert not user_ip_history_manager.is_user_anonymous( user, geoip_info_manager=geoip_info_manager ), "Anonymous user requesting redemption" - ipr = user_ip_history_manager.get_user_latest_ip_record( - user, geoip_info_manager=geoip_info_manager - ) - if ipr is not None and ipr.country_iso in banned_countries: + 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) + 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}") + raise ValueError( + f"unexpected balance_type={product.user_wallet_config.balance_type}" + ) assert amount <= redeemable_amount, ( - f"User requesting more than their redeemable balance ({amount} > {redeemable_amount}}" + f"User requesting more than their redeemable balance ({amount} > {redeemable_amount})" ) # Simple dedupe mechanism. Don't allow more than 1 per user_id per minute per cashout_method. @@ -356,9 +392,15 @@ class UserPayoutEventManager(PayoutEventManager): amount, cashout_method, pe_uuid ) elif payout_type == PayoutType.PAYPAL: - request_data: PaypalCashoutMethodRequestData = make_request_paypal(cashout_method) + request_data: PaypalCashoutMethodRequestData = make_request_paypal( + cashout_method + ) elif payout_type == PayoutType.CASH_IN_MAIL: - request_data: CashMailCashoutMethodRequestData = CashMailCashoutMethodRequestData.model_validate(cashout_method.data.model_dump()) + request_data: CashMailCashoutMethodRequestData = ( + CashMailCashoutMethodRequestData.model_validate( + cashout_method.data.model_dump() + ) + ) else: raise ValueError(f"unknown {payout_type=}") @@ -378,9 +420,6 @@ class UserPayoutEventManager(PayoutEventManager): return pe - - - def make_request_paypal( cashout_method: CashoutMethod, ) -> PaypalCashoutMethodRequestData: diff --git a/generalresearch/models/thl/ipinfo.py b/generalresearch/models/thl/ipinfo.py index be9ac14..9fe86e4 100644 --- a/generalresearch/models/thl/ipinfo.py +++ b/generalresearch/models/thl/ipinfo.py @@ -133,6 +133,18 @@ class IPInformation(BaseModel): description="The ISO code of the country associated with the IP address.", examples=[fake.country_code().lower()], ) + country_name: str | None = Field(default=None, max_length=64) + + subdivision_1_iso: str | None = Field( + default=None, + description="The ISO code of the primary subdivision (e.g., state or province).", + max_length=3, + ) + subdivision_1_name: str | None = Field( + default=None, + description="The name of the primary subdivision (e.g., state or province).", + max_length=255, + ) is_anonymous: bool | None = Field( default=None, diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py index d9bc34c..a82fa31 100644 --- a/generalresearch/models/thl/wallet/cashout_method.py +++ b/generalresearch/models/thl/wallet/cashout_method.py @@ -29,7 +29,6 @@ from generalresearch.models.thl.locales import CountryISO from generalresearch.models.thl.user_identifiers import BPUIDStr from generalresearch.models.thl.user_ref import UserRef from generalresearch.models.thl.wallet.definitions import Currency, PayoutType -from generalresearch.utils.enum import ReprEnumMeta logger = logging.getLogger() @@ -119,11 +118,12 @@ class CashoutMethodBase(BaseModel): # return None # return self.min_value * self.usd_exchange_rate - def validate_requested_amount(self, amount: PositiveInt): + def validate_requested_amount(self, amount: USDCent): """ Check if 'amount' is a valid amount that can be requested. :param amount: The amount to be requested in USD Cents """ + amount = int(amount) if amount <= 0: raise ValueError("Amount must be positive") if not self.min_value <= amount <= self.max_value: @@ -214,9 +214,11 @@ class CashMailCashoutMethodData(BaseModel): description="Delivery address where payment should be sent" ) + class CashMailCashoutMethodRequestData(CashMailCashoutMethodData): pass + class PaypalCashoutMethodData(BaseModel): type: Literal[PayoutType.PAYPAL] = Field(default=PayoutType.PAYPAL) @@ -225,6 +227,7 @@ class PaypalCashoutMethodData(BaseModel): examples=["test@example.com"], ) + class PaypalCashoutMethodRequestData(BaseModel): email: EmailStr = Field( description="Email address of the paypal user", @@ -232,6 +235,7 @@ class PaypalCashoutMethodRequestData(BaseModel): ) interface: Literal["api"] = Field(default="api") + class TangoCashoutMethodRequestData(BaseModel): accountIdentifier: str = Field() customerIdentifier: str = Field() @@ -242,6 +246,7 @@ class TangoCashoutMethodRequestData(BaseModel): externalRefID: str = Field(description="External Ref ID") description: str = Field() + class TangoCashoutMethodData(BaseModel): type: Literal[PayoutType.TANGO] = Field(default=PayoutType.TANGO) utid: str = Field(description="tango utid") @@ -408,33 +413,6 @@ example_foreign_value = { } -class RedemptionCurrency(StrEnum, metaclass=ReprEnumMeta): - """ - Supported Currencies for Foreign Redemptions - """ - - # US Dollars. Smallest Unit: Cents. - USD = "USD" - # Canadian Dollars. Smallest Unit: Cents. - CAD = "CAD" - # British Pounds. Smallest Unit: Pence. - GBP = "GBP" - # Euros. Smallest Unit: Cents. - EUR = "EUR" - # Indian Rupees. Smallest Unit: Paise. - INR = "INR" - # Australian Dollars. Smallest Unit: Cents. - AUD = "AUD" - # Polish Zloty. Smallest Unit: Grosz. - PLN = "PLN" - # Swedish Krona. Smallest Unit: Öre. - SEK = "SEK" - # Singapore Dollars. Smallest Unit: Cents. - SGD = "SGD" - # Mexican Pesos. Smallest Unit: Centavos. - MXN = "MXN" - - class CashoutMethodForeignValue(BaseModel): """ Shows the expected value of a redemption in a foreign currency. @@ -445,8 +423,8 @@ class CashoutMethodForeignValue(BaseModel): value: NonNegativeInt = Field( description="Value of the redemption in the currency's smallest unit." ) - currency: RedemptionCurrency = Field( - description=RedemptionCurrency.as_openapi_with_value_descriptions() + currency: Currency = Field( + description=Currency.as_openapi_with_value_descriptions() ) value_string: str = Field( description="A string representation of the value in the currency." diff --git a/generalresearch/models/thl/wallet/definitions.py b/generalresearch/models/thl/wallet/definitions.py index 4d9729b..4474745 100644 --- a/generalresearch/models/thl/wallet/definitions.py +++ b/generalresearch/models/thl/wallet/definitions.py @@ -35,26 +35,26 @@ class PayoutType(StrEnum, metaclass=ReprEnumMeta): AMT_ASSIGNMENT = "AMT_ASSIGNMENT" -class Currency(StrEnum): - # United States Dollar +class Currency(StrEnum, metaclass=ReprEnumMeta): + # United States Dollar. Smallest Unit: Cents. USD = "USD" - # Canadian Dollar + # Canadian Dollar. Smallest Unit: Cents. CAD = "CAD" - # British Pound Sterling + # British Pound. Smallest Unit: Pence. GBP = "GBP" - # Euro + # Euro. Smallest Unit: Cents. EUR = "EUR" - # Indian Rupee + # Indian Rupee. Smallest Unit: Paise. INR = "INR" - # Australian Dollar + # Australian Dollar. Smallest Unit: Cents. AUD = "AUD" - # Polish Zloty + # Polish Zloty. Smallest Unit: Grosz. PLN = "PLN" - # Swedish Krona + # Swedish Krona. Smallest Unit: Öre. SEK = "SEK" - # Singapore Dollar + # Singapore Dollar. Smallest Unit: Cents. SGD = "SGD" - # Mexican Peso + # Mexican Peso. Smallest Unit: Centavos. MXN = "MXN" SUPPORTED_CURRENCIES = {currency.value for currency in Currency} |
