aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorstuppie2026-09-17 17:40:39 -0600
committerstuppie2026-09-17 17:40:39 -0600
commit8006c422a34d3bc4645915ac18b43bef1b2432a4 (patch)
tree8c8b5d2d785300d981163d717630363000200182
parent5a5474d3f3dda3030ae888c9716efdc5dc4b8931 (diff)
downloadgeneralresearch-8006c422a34d3bc4645915ac18b43bef1b2432a4.tar.gz
generalresearch-8006c422a34d3bc4645915ac18b43bef1b2432a4.zip
paypal and tango cashout request handling
-rw-r--r--generalresearch/managers/thl/cashout_method.py71
-rw-r--r--generalresearch/managers/thl/paypal.py139
-rw-r--r--generalresearch/managers/thl/wallet/__init__.py42
-rw-r--r--generalresearch/managers/thl/wallet/approve.py17
-rw-r--r--generalresearch/managers/thl/wallet/tango.py42
-rw-r--r--generalresearch/managers/thl/wallet/tango_tasks.py12
-rw-r--r--generalresearch/managers/thl/wallet/user_payout.py73
-rw-r--r--generalresearch/models/thl/ipinfo.py12
-rw-r--r--generalresearch/models/thl/wallet/cashout_method.py40
-rw-r--r--generalresearch/models/thl/wallet/definitions.py22
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}