aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorstuppie2026-09-11 12:15:24 -0600
committerstuppie2026-09-11 12:15:24 -0600
commit0144d37b3e9c38e5acecc1f9f1750b56b3de9749 (patch)
treee74169e1bafe5c7809b65732b9a295bae9c3c8b5
parente278f65adc0cec52f354ab1dffaaf3507e49245b (diff)
downloadgeneralresearch-0144d37b3e9c38e5acecc1f9f1750b56b3de9749.tar.gz
generalresearch-0144d37b3e9c38e5acecc1f9f1750b56b3de9749.zip
wip: moving cashout method stuff over from grpc methods
-rw-r--r--generalresearch/managers/thl/cashout_method.py76
-rw-r--r--generalresearch/managers/thl/wallet/tango.py231
-rw-r--r--generalresearch/managers/thl/wallet/tango_admin.py193
-rw-r--r--generalresearch/managers/thl/wallet/tango_tasks.py127
-rw-r--r--generalresearch/models/thl/wallet/cashout_method.py4
-rw-r--r--generalresearch/models/thl/wallet/definitions.py2
6 files changed, 512 insertions, 121 deletions
diff --git a/generalresearch/managers/thl/cashout_method.py b/generalresearch/managers/thl/cashout_method.py
index 910d37b..dad1ef1 100644
--- a/generalresearch/managers/thl/cashout_method.py
+++ b/generalresearch/managers/thl/cashout_method.py
@@ -8,9 +8,14 @@ from uuid import UUID, uuid4
from pydantic import NonNegativeInt
+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 PayoutType
+from generalresearch.models.thl.wallet.definitions import (
+ SUPPORTED_CURRENCIES,
+ Currency,
+ PayoutType,
+)
if TYPE_CHECKING:
from generalresearch.models.thl.user import User
@@ -86,7 +91,7 @@ class CashoutMethodManager(PostgresManager):
name="Cash in Mail",
description="USPS delivery of cash",
id=uuid4().hex,
- currency="USD",
+ currency=Currency.USD,
image_url="https://www.shutterstock.com/shutterstock/photos/2175413929/display_1500/stock-vector-opened"
"-envelope-with-money-dollar-bills-salary-earning-and-savings-concept-d-web-vector-2175413929.jpg",
min_value=500, # $5.00
@@ -131,7 +136,7 @@ class CashoutMethodManager(PostgresManager):
name="PayPal",
description="Cashout via PayPal",
id=uuid4().hex,
- currency="USD",
+ currency=Currency.USD,
image_url="https://cdn.mmfwcl.com/images/brands/p439786-1200w-326ppi.png",
min_value=100, # $1.00
max_value=25_000, # $250.00
@@ -280,8 +285,70 @@ class CashoutMethodManager(PostgresManager):
if (x.type == PayoutType.AMT and product.user_wallet_config.amt)
or (x.type != PayoutType.AMT)
]
+ # This is b/c there might be some Tango cards in here for currency we don't support
+ cms = [
+ x
+ for x in cms
+ if x.original_currency is None
+ or x.original_currency in SUPPORTED_CURRENCIES
+ ]
return cms
+ def get_user_cashout_methods(
+ self,
+ user: User,
+ country_iso: str,
+ usd_exchange_rate: dict[Currency, float],
+ ):
+ """
+ Get the cashout methods allowed for this user. Does not check financial stuff at all.
+ This checks the user's country for filtering purposes.
+ Gets cashoutmethods from accounting_cashoutmethod, along with a consistent UUID.
+ If BP has a min_cashout, modifies the cashout method's min_value based on the BP's min cashout.
+ :return: Dict with keys: the UIID, values: Dict with keys: 'id', 'provider', 'ext_id', 'data',
+ where 'data' is provider-specific JSON data containing details about the cashout method.
+
+ The available cashout methods is based off the user's latest IP address -> country, and
+ also (in the future) a risk assessment (maybe riskier user aren't allowed certain
+ methods). Also, a user may have different minimums based off "stuff".
+ # country_iso = get_user_latest_country(user_iph_manager, user) or "us"
+ If a user requests their cashout methods before they ever enter a survey, we won't have
+ saved their IP address, and this will fail. I think this call should just default to US.
+ assert country_iso, "unknown country from IP address"
+ """
+
+ # BP can set their own min in USD or equivalent
+ user.prefetch_product(pg_config=self.pg_config)
+ product = user.product
+ min_value_usd = product.user_wallet_config.min_cashout or 0
+ min_value = USDCent(round(min_value_usd * 100))
+
+ cms = self.get_cashout_methods(user=user)
+
+ for x in cms:
+ # assets in non-USD need to be converted to USD here
+ if x.original_currency is not None:
+ if x.original_currency == Currency.USD:
+ x.usd_exchange_rate = 1.0
+ else:
+ x.usd_exchange_rate = usd_exchange_rate[x.original_currency]
+ # If the user has foreign cards available, we need to show their min_value in USD
+ x.min_value_usd = USDCent(round(x.min_value * x.usd_exchange_rate))
+ x.max_value_usd = USDCent(round(x.max_value * x.usd_exchange_rate))
+ # Adjust min_value for BP
+ x.min_value = max(x.min_value_usd, min_value)
+
+ cms = [
+ cm
+ for cm in cms
+ if (
+ cm.type == PayoutType.TANGO and country_iso.lower() in cm.data.countries
+ )
+ or cm.type != PayoutType.TANGO
+ ]
+
+ return {x.id: x for x in cms}
+
@staticmethod
def format_from_db(x: dict[str, Any], user: User | None = None) -> CashoutMethod:
x["id"] = UUID(x["id"]).hex
@@ -302,5 +369,6 @@ class CashoutMethodManager(PostgresManager):
x["data"]["type"] = x["type"]
if user and x["type"] in {PayoutType.PAYPAL, PayoutType.CASH_IN_MAIL}:
x["user"] = user.to_user_ref()
-
+ x["original_currency"] = x.get("currency") or Currency.USD
+ x["currency"] = Currency.USD
return CashoutMethod.model_validate(x)
diff --git a/generalresearch/managers/thl/wallet/tango.py b/generalresearch/managers/thl/wallet/tango.py
index 038f67f..aa780c3 100644
--- a/generalresearch/managers/thl/wallet/tango.py
+++ b/generalresearch/managers/thl/wallet/tango.py
@@ -1,127 +1,128 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Any
-
-from generalresearch.config import (
- is_debug,
+from datetime import timedelta
+from decimal import Decimal
+from threading import Lock
+from typing import 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.definitions import (
+ CURRENCY_FORMATTER,
+ Currency,
+ PayoutType,
)
-from generalresearch.managers.thl.tango_api import TangoOrderRequest
-from generalresearch.models.thl.definitions import PayoutStatus
-
-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.models.thl.payout import UserPayoutEvent
- from generalresearch.models.thl.user import User
-
-
-def complete_tango_order(
- user: User,
- payout_event: UserPayoutEvent,
- payout_event_manager: PayoutEventManager,
- ledger_manager: ThlLedgerManager,
- tango_client: TangoClient,
-):
- """
- We approved the Tango card redemption. Actually request the card.
- (Note: we're skipping the PENDING -> APPROVED -> COMPLETE order for tango.
- When a tango request gets APPROVED, we COMPLETE it (or FAIL!) in the
- same step)
- """
- assert payout_event.status in {
- PayoutStatus.PENDING,
- PayoutStatus.FAILED,
- }, "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"])
- # Note: tango uses the ref_id to uniquify orders, so locking is not actually needed as long
- # as the ref_id is the same.
- try:
- order = create_tango_order(
- request_data=payout_event.request_data,
- ref_id=ref_id,
- tango_client=tango_client,
+class TangoManager:
+ def __init__(
+ self,
+ tango_client: TangoClient,
+ tango_account_id: str,
+ tango_customer_id: str,
+ cashout_method_manager: CashoutMethodManager,
+ ) -> None:
+ self.tango_client = tango_client
+ self.tango_account_id = tango_account_id
+ self.tango_customer_id = tango_customer_id
+ self.cashout_method_manager = cashout_method_manager
+ self.supported_currencies = frozenset({currency.value for currency in Currency})
+ self._exchange_rate_cache = TTLCache(
+ maxsize=1, ttl=timedelta(minutes=30).total_seconds()
)
+ self._name_cache = TTLCache(
+ maxsize=1000, ttl=timedelta(minutes=30).total_seconds()
+ )
+ self._exchange_rate_lock = Lock()
+ self._name_lock = Lock()
- except AssertionError:
- # 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)
- return payout_event
-
- # update TangoPayoutEvent with the order data
- payout_event_manager.update(
- payout_event,
- status=order["status"],
- ext_ref_id=order["referenceOrderID"],
- order_data=order,
+ @cachedmethod(
+ cache=lambda self: self._exchange_rate_cache,
+ lock=lambda self: self._exchange_rate_lock,
)
-
- ledger_manager.create_tx_user_payout_complete(user, payout_event=payout_event)
-
- return payout_event
-
-
-def create_tango_order(
- request_data: dict[str, Any], ref_id: str, tango_client: TangoClient
-) -> dict[str, Any]:
- """
- Create a tango gift card order.
- Throws exception if anything is not right.
- - https://integration-www.tangocard.com/raas_api_console/v2/
- - https://www.apimatic.io/apidocs/tangocard/v/2_3_4#/python
-
- :param utid: Card identifier
- :param amount: requested card value in USD
- :param ref_id: TangoPayoutEvent.uuid
- :return:
- """
- # make sure we don't create more than one tango order for a single PayoutEvent
- assert tango_client.get_order_if_exists(ref_id) is None
- amount = request_data["amount"]
- request_data.pop("amount_usd", None)
- request_data.pop("description", None)
-
- if is_debug():
+ def get_exchange_rates(self) -> dict[Currency, float]:
+ """Return supported foreign-currency-to-USD Tango exchange rates."""
+ rates = self.tango_client.get_exchange_rates()["exchangeRates"]
return {
- "status": "COMPLETE",
- "referenceOrderID": "test",
- "reward": {
- "credentials": {
- "Security Code": "XXXX-XXXX",
- "Redemption URL": "https://codes.rewardcodes.com/r2/1/XXXX",
- },
- "credentialList": [
- {
- "type": "text",
- "label": "Security Code",
- "value": "XXXX-XXXX",
- },
- {
- "type": "url",
- "label": "Redemption URL",
- "value": "https://codes.rewardcodes.com/r2/1/XXXX",
- },
- ],
- "redemptionInstructions": "do your thang fam",
- },
+ Currency(rate["baseCurrency"]): rate["baseFx"]
+ for rate in rates
+ if rate["rewardCurrency"] == "USD"
+ and rate["baseCurrency"] in self.supported_currencies
}
- request = TangoOrderRequest.model_validate(request_data)
- order = tango_client.create_order(request)
-
- amount_f: float = float(amount)
- assert order["status"] == "COMPLETE"
- assert abs(order["amountCharged"]["total"] - amount_f) < 0.0200001
- assert order["amountCharged"]["currencyCode"] == "USD"
- if order["denomination"]["currencyCode"] == "USD":
- assert order["denomination"]["value"] == amount_f
+ def get_order_detail(self, tango_order_id: str) -> dict[str, Any]:
+ return self.tango_client.get_order(tango_order_id)
+
+ def make_request(
+ self, amount_usd: Decimal, cashout_method: Any, external_ref_id: str
+ ) -> dict[str, Any]:
+ """Build the data needed to place a Tango order."""
+ assert type(amount_usd) is Decimal
+ utid = cashout_method.data.utid
+ amount: Decimal | float = amount_usd
+ currency = cashout_method.original_currency
+ currency_code = getattr(currency, "value", currency)
+ if currency_code and currency_code != "USD":
+ amount = round(float(amount) / self.get_exchange_rates()[currency_code], 2)
+ return {
+ "accountIdentifier": self.tango_account_id,
+ "customerIdentifier": self.tango_customer_id,
+ "utid": utid,
+ "amount": str(amount),
+ "amount_usd": str(amount_usd),
+ "campaign": "300large",
+ "sendEmail": False,
+ "externalRefID": external_ref_id,
+ "description": self.get_name(utid),
+ }
- return order
+ @cachedmethod(
+ cache=lambda self: self._name_cache,
+ lock=lambda self: self._name_lock,
+ )
+ def get_name(self, utid: str) -> str:
+ methods = self.cashout_method_manager.filter(
+ ext_id=utid, payout_types=[PayoutType.TANGO], is_live=None
+ )
+ 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()
+ with self._name_lock:
+ self._name_cache.clear()
diff --git a/generalresearch/managers/thl/wallet/tango_admin.py b/generalresearch/managers/thl/wallet/tango_admin.py
new file mode 100644
index 0000000..579b21f
--- /dev/null
+++ b/generalresearch/managers/thl/wallet/tango_admin.py
@@ -0,0 +1,193 @@
+import re
+
+from generalresearch.managers.base import PostgresManager
+from generalresearch.managers.thl.tango_api import TangoClient
+from generalresearch.models.thl.wallet.definitions import (
+ CURRENCY_MAX_VALUE,
+)
+
+
+class TangoAdmin(PostgresManager):
+ def __init__(self, *, tango_client: TangoClient, tango_account_id: str, **kwargs):
+ super().__init__(**kwargs)
+ self.tango_client = tango_client
+ self.tango_account_id = tango_account_id
+
+ def get_account_balance(self) -> float:
+ account = self.tango_client.get_account(
+ account_identifier=self.tango_account_id
+ )
+ return account["currentBalance"]
+
+ @staticmethod
+ def is_available_tango_card(item: dict):
+ """
+ We're supporting cards that meet the following conditions or checks:
+ - see below
+ - non USD card support variable value only (todo: handling conversion of fixed amounts)
+ """
+ from generalresearch.models.thl.wallet.definitions import SUPPORTED_CURRENCIES
+
+ reward_types = ["cash equivalent", "gift card"] # these are the only options
+ value_types = ["VARIABLE_VALUE", "FIXED_VALUE"] # these are the only options
+ ignore_cards = {
+ "U557938", # Southwest® Gift Card $25, doesn't work...?
+ }
+ return bool(
+ item["rewardType"] in reward_types
+ and item["status"] == "active"
+ and item["valueType"] in value_types
+ and item["utid"] not in ignore_cards
+ and item["currencyCode"] in SUPPORTED_CURRENCIES
+ and (
+ item["currencyCode"] == "USD"
+ or item["currencyCode"] != "USD"
+ and item["valueType"] == "VARIABLE_VALUE"
+ )
+ )
+
+ @staticmethod
+ def format_tango_card(item: dict, brand: dict):
+ """
+ For non-USD, we support variable value only. We aren't doing any currency conversion here, for
+ them. To handle the $250USD max value, I'm setting a hard limit in each currency. These are
+ for our supported, stable currencies only, so this shouldn't be a problem....
+ """
+ tango_card_info = {
+ # 'currency': 'USD',
+ "provider": "TANGO",
+ "cashout_method_type": "GIFT_CARD",
+ }
+ brand_dict = {
+ "image_url": brand["imageUrls"]["1200w-326ppi"],
+ "description": brand["description"],
+ "disclaimer": brand["disclaimer"],
+ "terms": brand["terms"],
+ }
+ value_type_map = {"VARIABLE_VALUE": "variable", "FIXED_VALUE": "fixed"}
+ item["rewardName"] = re.sub(r"\s+", " ", item["rewardName"])
+ # if this is a fixed value, non USD card, we need to save the original value before
+ # we convert it to USD, b/c we need that value to make the request
+ if item["valueType"] == "FIXED_VALUE" and item["currencyCode"] != "USD":
+ raise NotImplementedError("FIXED VALUE non-USD not supported")
+ if item["valueType"] == "VARIABLE_VALUE" and item["currencyCode"] != "USD":
+ pass
+ if item["valueType"] == "FIXED_VALUE":
+ item["minValue"] = item["faceValue"]
+ item["maxValue"] = item["faceValue"]
+
+ card = {
+ "name": item["rewardName"],
+ "utid": item["utid"],
+ "type": item["rewardType"],
+ # 'min_value_usd': round(item.min_value, 2),
+ # 'max_value_usd': round(min(item.max_value, MAX_VALUE[item.currency_code]), 2),
+ "min_value": round(100 * float(item["minValue"])),
+ "max_value": min(
+ round(100 * float(item["maxValue"])),
+ CURRENCY_MAX_VALUE[item["currencyCode"]] * 100,
+ ),
+ "countries": item["countries"],
+ "value_type": value_type_map[item["valueType"]],
+ # This is the currency the reward is based in. We'll need this to convert the request
+ # to USD when the user requests it.
+ "currency": item["currencyCode"],
+ }
+ card.update(brand_dict)
+ card.update(tango_card_info)
+ return card
+
+ def get_tango_cards(self) -> list:
+ """
+ This is called to populate the accounting_cashoutmethod table ONLY. It is not LIVE.
+ For non-USD cards, the $ fields are in foreign currency because this is stored in
+ our DB for lookup purposes and has to be converted when requested.
+ """
+ # usd_exchange_rate = get_tango_exchange_rates()
+ cards: list[dict] = []
+ catalog = self.tango_client.get_catalog()
+ brands = [b for b in catalog["brands"] if b["status"] == "active"]
+ for brand in brands:
+ for item in brand["items"]:
+ if self.is_available_tango_card(item):
+ print(
+ item["currencyCode"],
+ item["rewardName"],
+ item["valueType"],
+ (
+ f"{item['minValue']}-{item['maxValue']}"
+ if "minValue" in item
+ else item["faceValue"]
+ ),
+ )
+ cards.append(self.format_tango_card(item, brand))
+ return cards
+
+#
+# def update_tango_cashout_method_db():
+# # Synchronizes Tango cashout methods/gift cards with the accounting_cashoutmethod table.
+# # Should only need to be run sporadically.
+# # Note: Nothing calls this at the moment. It was run once manually.
+# now = datetime.utcnow()
+# tango_cards = get_tango_cards()
+# provider = "TANGO"
+#
+# db_res = THL_WEB_RW.execute_sql_query(
+# f"""
+# SELECT `id`, `provider`, `ext_id` FROM `{THL_WEB_RW.db}`.`accounting_cashoutmethod`"""
+# )
+# existing_ids = {(provider, x["ext_id"]): x["id"] for x in db_res}
+#
+# fields = ["id", "last_updated", "is_live", "provider", "ext_id", "name", "data"]
+# values = [
+# [
+# existing_ids.get((provider, x["utid"]), uuid.uuid4().hex),
+# now,
+# True,
+# provider,
+# x["utid"],
+# x["name"],
+# json.dumps(x),
+# ]
+# for x in tango_cards
+# ]
+#
+# THL_WEB_RW.bulk_update("accounting_cashoutmethod", fields, values)
+#
+#
+# def check_and_refill_balance() -> None:
+# # dont do this more often than once a week!!!
+# REFILL_AMOUNT = 5000
+# LOW_BALANCE = 1000
+# balance = get_account_balance()
+#
+# if balance < LOW_BALANCE:
+# SC.chat_postMessage(
+# channel=settings.SLACK_CHANNEL,
+# text=f"Refilling Tango account. current balance: {balance}",
+# )
+# depost_request = {
+# "accountIdentifier": settings.TANGO_ACCOUNT_ID,
+# "amount": REFILL_AMOUNT,
+# "creditCardToken": settings.TANGO_CREDITCARD_TOKEN,
+# "customerIdentifier": settings.TANGO_CUSTOMER_ID,
+# }
+# response = None
+# try:
+# response = api_client.fund.add_funds(depost_request)
+# assert response.status == "SUCCESS"
+# response = json.loads(APIHelper.json_serialize(response))
+# except Exception as e:
+# SC.chat_postMessage(
+# channel=settings.SLACK_CHANNEL,
+# text=f"Tango refill error: {e}. Response: {response}",
+# )
+# SC.chat_postMessage(
+# channel=settings.SLACK_CHANNEL,
+# text=f"Tango refill success!: Response: {response}",
+# )
+# elif balance < (LOW_BALANCE * 1.5):
+# SC.chat_postMessage(
+# channel=settings.SLACK_CHANNEL,
+# text=f"Tango balance low. current balance: {balance}",
+# )
diff --git a/generalresearch/managers/thl/wallet/tango_tasks.py b/generalresearch/managers/thl/wallet/tango_tasks.py
new file mode 100644
index 0000000..038f67f
--- /dev/null
+++ b/generalresearch/managers/thl/wallet/tango_tasks.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from generalresearch.config import (
+ is_debug,
+)
+from generalresearch.managers.thl.tango_api import TangoOrderRequest
+from generalresearch.models.thl.definitions import PayoutStatus
+
+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.models.thl.payout import UserPayoutEvent
+ from generalresearch.models.thl.user import User
+
+
+def complete_tango_order(
+ user: User,
+ payout_event: UserPayoutEvent,
+ payout_event_manager: PayoutEventManager,
+ ledger_manager: ThlLedgerManager,
+ tango_client: TangoClient,
+):
+ """
+ We approved the Tango card redemption. Actually request the card.
+
+ (Note: we're skipping the PENDING -> APPROVED -> COMPLETE order for tango.
+ When a tango request gets APPROVED, we COMPLETE it (or FAIL!) in the
+ same step)
+ """
+ assert payout_event.status in {
+ PayoutStatus.PENDING,
+ PayoutStatus.FAILED,
+ }, "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"])
+
+ # Note: tango uses the ref_id to uniquify orders, so locking is not actually needed as long
+ # as the ref_id is the same.
+ try:
+ order = create_tango_order(
+ request_data=payout_event.request_data,
+ ref_id=ref_id,
+ tango_client=tango_client,
+ )
+
+ except AssertionError:
+ # 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)
+ return payout_event
+
+ # update TangoPayoutEvent with the order data
+ payout_event_manager.update(
+ payout_event,
+ status=order["status"],
+ ext_ref_id=order["referenceOrderID"],
+ order_data=order,
+ )
+
+ ledger_manager.create_tx_user_payout_complete(user, payout_event=payout_event)
+
+ return payout_event
+
+
+def create_tango_order(
+ request_data: dict[str, Any], ref_id: str, tango_client: TangoClient
+) -> dict[str, Any]:
+ """
+ Create a tango gift card order.
+ Throws exception if anything is not right.
+ - https://integration-www.tangocard.com/raas_api_console/v2/
+ - https://www.apimatic.io/apidocs/tangocard/v/2_3_4#/python
+
+ :param utid: Card identifier
+ :param amount: requested card value in USD
+ :param ref_id: TangoPayoutEvent.uuid
+ :return:
+ """
+ # make sure we don't create more than one tango order for a single PayoutEvent
+ assert tango_client.get_order_if_exists(ref_id) is None
+ amount = request_data["amount"]
+ request_data.pop("amount_usd", None)
+ request_data.pop("description", None)
+
+ if is_debug():
+ return {
+ "status": "COMPLETE",
+ "referenceOrderID": "test",
+ "reward": {
+ "credentials": {
+ "Security Code": "XXXX-XXXX",
+ "Redemption URL": "https://codes.rewardcodes.com/r2/1/XXXX",
+ },
+ "credentialList": [
+ {
+ "type": "text",
+ "label": "Security Code",
+ "value": "XXXX-XXXX",
+ },
+ {
+ "type": "url",
+ "label": "Redemption URL",
+ "value": "https://codes.rewardcodes.com/r2/1/XXXX",
+ },
+ ],
+ "redemptionInstructions": "do your thang fam",
+ },
+ }
+
+ request = TangoOrderRequest.model_validate(request_data)
+ order = tango_client.create_order(request)
+
+ amount_f: float = float(amount)
+ assert order["status"] == "COMPLETE"
+ assert abs(order["amountCharged"]["total"] - amount_f) < 0.0200001
+ assert order["amountCharged"]["currencyCode"] == "USD"
+ if order["denomination"]["currencyCode"] == "USD":
+ assert order["denomination"]["value"] == amount_f
+
+ return order
diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py
index 6ad0bf2..1e96818 100644
--- a/generalresearch/models/thl/wallet/cashout_method.py
+++ b/generalresearch/models/thl/wallet/cashout_method.py
@@ -58,8 +58,8 @@ class CashoutMethodBase(BaseModel):
id: UUIDStr = Field(description="Unique ID for this cashout method")
- currency: Literal["USD"] = Field(
- default="USD",
+ currency: Literal[Currency.USD] = Field(
+ default=Currency.USD,
description="The currency of the cashout. Only USD is supported.",
)
original_currency: Currency | None = Field(
diff --git a/generalresearch/models/thl/wallet/definitions.py b/generalresearch/models/thl/wallet/definitions.py
index 2d1eb8d..4d9729b 100644
--- a/generalresearch/models/thl/wallet/definitions.py
+++ b/generalresearch/models/thl/wallet/definitions.py
@@ -57,6 +57,8 @@ class Currency(StrEnum):
# Mexican Peso
MXN = "MXN"
+SUPPORTED_CURRENCIES = {currency.value for currency in Currency}
+
CURRENCY_FORMATTER = {
"USD": lambda x: f"${x / 100:,.2f}",