diff options
| -rw-r--r-- | generalresearch/managers/thl/giftbit.py | 419 | ||||
| -rw-r--r-- | generalresearch/managers/thl/giftronaut.py | 346 | ||||
| -rw-r--r-- | generalresearch/managers/thl/virtual_incentives_api.py | 18 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/__init__.py | 14 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/user_payout.py | 11 | ||||
| -rw-r--r-- | generalresearch/models/thl/wallet/cashout_method.py | 51 | ||||
| -rw-r--r-- | generalresearch/models/thl/wallet/definitions.py | 9 | ||||
| -rw-r--r-- | pyproject.toml | 2 |
8 files changed, 866 insertions, 4 deletions
diff --git a/generalresearch/managers/thl/giftbit.py b/generalresearch/managers/thl/giftbit.py new file mode 100644 index 0000000..1c7a79c --- /dev/null +++ b/generalresearch/managers/thl/giftbit.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import logging +from collections.abc import Collection +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import requests + +from generalresearch.currency import USDCent +from generalresearch.locales import Localelator +from generalresearch.managers.base import Permission, PostgresManagerWithRedis +from generalresearch.managers.thl.cashout_method import CashoutMethodManager +from generalresearch.managers.thl.ledger_manager.thl_ledger import ThlLedgerManager +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 ( + CashoutMethod, + GiftbitCashoutMethodData, + GiftbitCashoutMethodRequestData, +) +from generalresearch.models.thl.wallet.definitions import Currency, PayoutType +from generalresearch.pg_helper import PostgresConfig +from generalresearch.redis_helper import RedisConfig + +if TYPE_CHECKING: + from generalresearch.managers.thl.wallet.user_payout import UserPayoutEventManager + +SANDBOX_BASE_URL = "https://api-testbed.giftbit.com/papi/v1" +BASE_URL = "https://api.giftbit.com/papi/v1" + +locales = Localelator() + +logging.basicConfig() +logger = logging.getLogger() +logger.setLevel("INFO") + + +class GiftbitError(RuntimeError): + pass + + +# 3 is "global" +region_to_country_iso = { + 1: "ca", + 2: "us", + 4: "au", + 5: "at", + 6: "be", + 7: "cz", + 8: "fi", + 9: "gr", + 10: "lu", + 11: "nl", + 12: "in", + 13: "jp", + 14: "nz", + 15: "fr", + 16: "de", + 17: "ie", + 18: "it", + 19: "es", + 20: "gb", + 21: "mx", + 22: "ar", + 23: "br", + 24: "cl", + 25: "co", + 26: "pe", + 27: "uy", + 28: "ve", + 29: "cn", + 30: "ch", + 31: "dk", + 32: "hu", + 33: "no", + 34: "pl", + 35: "ro", + 36: "se", + 37: "tr", + 38: "hk", + 39: "sg", + 40: "ph", + 41: "my", + 42: "id", + 43: "th", + 44: "kr", + 45: "tw", + 46: "vn", + 47: "bh", + 48: "eg", + 49: "jo", + 50: "om", + 51: "qa", + 52: "ae", + 53: "ke", + 54: "pt", + 55: "bg", + 56: "hr", + 57: "ee", + 58: "lv", + 59: "lt", + 60: "sa", + 61: "sk", + 62: "si", + 63: "za", +} + + +class GiftbitClient: + """ + https://www.giftbit.com/api-documentation + """ + + def __init__( + self, + *, + token: str, + ) -> None: + self.base_url = BASE_URL.rstrip("/") + self.timeout = 5 + + self.session = requests.Session() + self.session.headers.update( + { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + } + ) + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> Any: + response = self.session.request( + method=method, + url=f"{self.base_url}{path}", + params=params, + json=json, + timeout=self.timeout, + ) + + if response.ok: + if not response.content: + return None + return response.json() + + try: + body = response.json() + except ValueError: + body = response.text + + raise GiftbitError(f"Giftbit API error {response.status_code}" + f": {body}") + + def ping(self): + return self._request("GET", "/ping") + + def get_regions(self): + # retarded endpoint... e.g. {'id': 5, 'name': 'Austria'}, + # Claude converted it to ISO for me + res = self._request("GET", "/regions")["regions"] + + def get_funds(self): + return self._request("GET", "/funds")["fundsbycurrency"] + + def list_brands_page(self, limit: int = 100, offset: int = 0) -> dict[str, Any]: + params = {"limit": limit, "offset": offset, "embeddable": True} + _res = self._request("GET", "/brands", params=params) + return _res + + def list_brands(self) -> list[dict[str, Any]]: + offset = 0 + limit = 100 + res = [] + while True: + _res = self.list_brands_page(limit=limit, offset=offset) + logger.info(f"list_brands {limit=} {offset=} total={_res['total_count']}") + res.extend(_res["brands"]) + if _res["total_count"] == len(res): + break + offset += len(_res["brands"]) + return res + + def get_brand(self, brand_code: str) -> dict[str, Any]: + return self._request("GET", f"/brands/{brand_code}")["brand"] + + def get_supported_brands(self): + brands = self.list_brands() + # There are 1536 brands. Nobody wants a CorePower Yoga gift card. Start with these + names = {"visa", "amazon", "apple", "starbucks", "uber", "walmart", "google"} + brands = [b for b in brands if any(name in b["name"].lower() for name in names)] + cms = [] + for b in brands: + b = self.get_brand(b["brand_code"]) + cm = self.format_brand_to_cashout_method(b) + if cm is not None: + cms.append(cm) + return cms + + def format_brand_to_cashout_method(self, c: dict): + if c["fund_currencyisocode"] not in Currency: + logger.info(f"Unsupported currency: {c['fund_currencyisocode']}") + return None + currency = Currency(c["fund_currencyisocode"]) + countries = None + if c["regions"] == [3]: + # Global + pass + else: + countries = [region_to_country_iso.get(x) for x in c["regions"]] + if not all(countries): + logger.info(f"Unsupported countries: {c['regions']})") + return None + if c["variable_price"]: + min_value = c["min_price_in_cents"] + max_value = c["max_price_in_cents"] + else: + # todo: support multiple fixed prices + price = sorted(c["allowed_prices_in_cents"])[0] + min_value, max_value = price, price + + return CashoutMethod( + name=c["name"], + description=c["description"], + id=uuid4().hex, + currency=Currency.USD, + original_currency=currency, + image_url=c["image_url"], + min_value=min_value, + max_value=max_value, + data=GiftbitCashoutMethodData( + brand_code=c["brand_code"], + variable_price=c["variable_price"], + min_price_in_cents=c.get("min_price_in_cents"), + max_price_in_cents=c.get("max_price_in_cents"), + allowed_prices_in_cents=c.get("allowed_prices_in_cents"), + countries=countries, + disclaimer=c.get("disclaimer", ""), + ), + type=PayoutType.GIFTBIT, + user=None, + ext_id=c["brand_code"], + ) + + def get_order(self, order_id: str) -> dict[str, Any]: + res = self._request("GET", f"/campaign/{order_id}") + d = res["campaign"] + + try: + res = self._request("GET", f"/links/{order_id}") + d["direct_link"] = res["directlinks"][0]["link"] + except GiftbitError as e: + if "Links no longer available" in e.args[0]: + # Can't retrieve the link once it has been claimed + d["direct_link"] = None + else: + raise + return d + + def get_order_if_exists(self, order_id: str) -> dict[str, Any] | None: + try: + return self.get_order(order_id) + except GiftbitError as e: + if "Campaign not found" not in e.args[0]: + raise + return None + + def create_order(self, cashout_request_id: str, brand_code: str, amount: Decimal): + """ + + :param cashout_request_id: unique identifier of the cashout request + :param brand_code: brand_code of the brand (from Giftbit) + :param amount: amount of the cashout (in the noted currency) + :return: + """ + assert type(amount) is Decimal + data = { + "id": cashout_request_id, + "price_in_cents": round(amount * 100), + "brand_codes": [brand_code], + "link_count": 1, + "expiry": (datetime.now(tz=UTC) + timedelta(days=364)).strftime("%Y-%m-%d"), + } + + res = self._request("POST", "/direct_links", json=data) + assert len(res["direct_links"]) == 1 + campaign = res["campaign"] + campaign["direct_link"] = res["direct_links"][0] + return campaign + + +class GiftbitManager(PostgresManagerWithRedis): + def __init__( + self, + *, + giftbit_token: str, + pg_config: PostgresConfig, + redis_config: RedisConfig, + permissions: Collection[Permission] | None = None, + cache_prefix: str | None = None, + ): + self.client = GiftbitClient(token=giftbit_token) + self.cashout_method_manager = CashoutMethodManager(pg_config=pg_config) + super().__init__( + pg_config=pg_config, + permissions=permissions, + redis_config=redis_config, + cache_prefix=cache_prefix, + ) + + def make_request( + self, amount: USDCent, cashout_method: CashoutMethod, payout_event_id: str + ) -> GiftbitCashoutMethodRequestData: + """Build the data needed to place an order.""" + assert type(amount) is USDCent + brand_code = cashout_method.data.brand_code + currency = cashout_method.original_currency + assert currency == Currency.USD + amount_in_currency = Decimal(float(amount) / 100).quantize(Decimal("0.01")) + return GiftbitCashoutMethodRequestData.model_validate( + { + "brand_code": brand_code, + "amount": amount_in_currency, + "currency": currency, + "cashout_request_id": payout_event_id, + } + ) + + def complete_order( + self, + user: User, + payout_event: UserPayoutEvent, + user_payout_event_manager: UserPayoutEventManager, + ledger_manager: ThlLedgerManager, + ): + """ + We approved the card redemption. Actually request the card. + """ + assert payout_event.status in { + PayoutStatus.PENDING, + PayoutStatus.FAILED, + }, ( + "attempting to manage payout that is not pending (you can also retry a failed order)" + ) + request = GiftbitCashoutMethodRequestData.model_validate( + payout_event.request_data + ) + + # Note: we'll use the cashout_request_id to uniquify orders + assert request.currency == Currency.USD + try: + order = self.client.create_order( + cashout_request_id=request.cashout_request_id, + brand_code=request.brand_code, + amount=request.amount, + ) + + 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... + user_payout_event_manager.update(payout_event, status=PayoutStatus.FAILED) + return payout_event + + # update Payout Event with the order data + user_payout_event_manager.update( + payout_event, + status=PayoutStatus.COMPLETE, + ext_ref_id=order["uuid"], + order_data=order, + ) + + ledger_manager.create_tx_user_payout_complete( + user, + payout_event=payout_event, + ) + + return payout_event + + def update_cashout_method_db(self): + # This only should be run once, or when brands/cards offered change. + cms = self.client.get_supported_brands() + + existing = self.cashout_method_manager.filter(payout_types=[PayoutType.GIFTBIT]) + existing_id = {cm.ext_id: cm.id for cm in existing} + for cm in cms: + if cm.ext_id in existing_id: + cm.id = existing_id[cm.ext_id] + + data = [] + for cm in cms: + d = cm.model_dump(mode="json") + d["data"] = cm.model_dump_json() + d["provider"] = cm.type.value + data.append(d) + + query = """ + INSERT INTO accounting_cashoutmethod ( + id, last_updated, is_live, provider, ext_id, name, data + ) VALUES ( + %(id)s, %(last_updated)s, %(is_live)s, %(provider)s, + %(ext_id)s, %(name)s, %(data)s + ) ON CONFLICT (id) DO UPDATE + SET last_updated = NOW(), + name = %(name)s, is_live=TRUE, + data = %(data)s + """ + with self.pg_config.make_connection() as conn: + with conn.cursor() as c: + c.executemany(query, data) + conn.commit() diff --git a/generalresearch/managers/thl/giftronaut.py b/generalresearch/managers/thl/giftronaut.py new file mode 100644 index 0000000..de311e0 --- /dev/null +++ b/generalresearch/managers/thl/giftronaut.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import logging +import threading +from collections.abc import Collection +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import requests + +from generalresearch.currency import USDCent +from generalresearch.locales import Localelator +from generalresearch.managers.base import Permission, PostgresManagerWithRedis +from generalresearch.managers.thl.cashout_method import CashoutMethodManager +from generalresearch.managers.thl.ledger_manager.thl_ledger import ThlLedgerManager +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 ( + CashoutMethod, + GiftronautCashoutMethodData, + GiftronautCashoutMethodRequestData, +) +from generalresearch.models.thl.wallet.definitions import Currency, PayoutType +from generalresearch.pg_helper import PostgresConfig +from generalresearch.redis_helper import RedisConfig + +if TYPE_CHECKING: + from generalresearch.managers.thl.wallet.user_payout import UserPayoutEventManager + +logging.basicConfig() +logger = logging.getLogger() +logger.setLevel("INFO") + +BASE_URL = "https://api.giftronaut.com" + +locales = Localelator() + + +class GiftronautError(RuntimeError): + pass + + +class GiftronautClient: + def __init__( + self, + *, + client_id: str, + secret: str, + ) -> None: + self.base_url = BASE_URL.rstrip("/") + self.timeout = 5 + self.client_id = client_id + self.secret = secret + + self.access_token = None + self.session = None + self.expires_at = None + + threading.Thread(target=self.get_access_token, daemon=True).start() + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> Any: + assert self.session is not None, "get_access_token didn't finish or failed" + # Refresh access token if it is expired (production tokens expire after 1 hour) + if datetime.now(tz=UTC) > self.expires_at: + self.get_access_token() + + response = self.session.request( + method=method, + url=f"{self.base_url}{path}", + params=params, + json=json, + timeout=self.timeout, + ) + + if response.ok: + if not response.content: + return None + return response.json() + + try: + body = response.json() + except ValueError: + body = response.text + + raise GiftronautError( + f"Giftronaut API error {response.status_code}" + f": {body}" + ) + + def get_access_token(self): + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.secret, + "scope": "orders.read orders.write orders.cancel catalog.read balance.read balance.write webhooks.read webhooks.write", + } + res = requests.post( + "https://api.giftronaut.com/oauth2/token", data=data, timeout=5 + ) + res.raise_for_status() + d = res.json() + self.access_token = d["access_token"] + self.expires_at = datetime.now(tz=UTC) + timedelta(seconds=d["expires_in"] - 30) + self.session = requests.Session() + self.session.headers.update( + { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}", + } + ) + + @staticmethod + def format_card_to_cashout_method(c: dict): + if c["currencyCode"] not in Currency: + logger.info(f"unsupported currency {c['currencyCode']}") + return None + assert c["priceType"] in {"RANGE", "FIXED"}, c["priceType"] + if not c["prices"]: + logger.info(f"invalid prices: {c['prices']}") + return None + if c["priceType"] == "RANGE": + if c["priceStep"] > 1: + logger.info(f"unsupported priceStep {c['priceStep']}") + return None + min_value = round(c["prices"][0] * 100) # cents + max_value = round(c["prices"][1] * 100) # cents + else: + min_value = round(min(c["prices"]) * 100) + max_value = round(min(c["prices"]) * 100) + country_iso = locales.get_country_iso(c["countryCode"].lower()) + + return CashoutMethod( + name=c["name"], + description=c["name"], + id=uuid4().hex, + currency=Currency.USD, + original_currency=Currency(c["currencyCode"]), + image_url=c["imageUrl"], + min_value=min_value, + max_value=max_value, + data=GiftronautCashoutMethodData( + productId=c["productId"], + terms=c["terms"] or "", + redemptionInstructions=c["redemptionInstructions"] or "", + country_iso=country_iso, + priceStep=c["priceStep"], + ), + type=PayoutType.GIFTRONAUT, + user=None, + ext_id=c["productId"], + ) + + def list_brands_page(self, page: int = 1, size: int = 20) -> dict[str, Any]: + params = {"page": page, "pageSize": size} + _res = self._request("GET", "/api/v1/catalog/branded-cards", params=params) + return _res + + def list_brands(self) -> list[dict[str, Any]]: + page = 1 + size = 100 + res = [] + while True: + _res = self.list_brands_page(page=page, size=size) + logger.info(f"list_brands {page=} {size=} total={_res['totalCount']}") + res.extend(_res["data"]) + if _res["totalCount"] == len(res): + break + page += 1 + return res + + def list_supported_brands(self) -> list[CashoutMethod]: + res = self.list_brands() + cms = [self.format_card_to_cashout_method(b) for b in res] + return [x for x in cms if x is not None] + + def get_order(self, order_id: str): + res = self._request("GET", f"/api/v1/orders/{order_id}") + d = res["data"] + assert len(d["recipients"]) == 1 + d.update(d.pop("recipients")[0]) + return d + + def create_order( + self, cashout_request_id: str, card_product_id: str, amount: Decimal + ): + # amazon.com: G00000020361 + data = { + "idempotencyKey": cashout_request_id, + "cardType": "BRANDED_CARD", + "productId": card_product_id, + "quantity": 1, + "amountPerLink": str(amount), + "refundOption": True, + "refundOptionDays": 90, + } + res = self._request("POST", "/api/v1/orders/reward-links", json=data) + """ + {'data': {'orderId': 'LO20261030149296', + 'idempotencyKey': 'test', + 'cardType': 'BRANDED_CARD', + 'productId': 'G00000020361', + 'quantity': 1, + 'amountPerLink': {'amount': 5.0, 'currency': 'USD'}, + 'totalCost': {'amount': 5.0, 'currency': 'USD'}, + 'status': 'PENDING', + 'createdAt': '2026-10-29T18:59:05.781813622Z'}} + """ + return res + + def get_balance(self): + return self._request("GET", "/api/v1/balance")['data'] + + +class GiftronautManager(PostgresManagerWithRedis): + def __init__( + self, + *, + client_id: str, + secret: str, + pg_config: PostgresConfig, + redis_config: RedisConfig, + permissions: Collection[Permission] | None = None, + cache_prefix: str | None = None, + ): + self.client = GiftronautClient(client_id=client_id, secret=secret) + self.cashout_method_manager = CashoutMethodManager(pg_config=pg_config) + super().__init__( + pg_config=pg_config, + permissions=permissions, + redis_config=redis_config, + cache_prefix=cache_prefix, + ) + + def make_request( + self, amount: USDCent, cashout_method: CashoutMethod, payout_event_id: str + ) -> GiftronautCashoutMethodRequestData: + """Build the data needed to place an order.""" + assert type(amount) is USDCent + brand_code = cashout_method.data.brand_code + currency = cashout_method.original_currency + assert currency == Currency.USD + amount_in_currency = Decimal(float(amount) / 100).quantize(Decimal("0.01")) + return GiftronautCashoutMethodRequestData.model_validate( + { + "brand_code": brand_code, + "amount": amount_in_currency, + "currency": currency, + "cashout_request_id": payout_event_id, + } + ) + + def complete_order( + self, + user: User, + payout_event: UserPayoutEvent, + user_payout_event_manager: UserPayoutEventManager, + ledger_manager: ThlLedgerManager, + ): + """ + We approved the card redemption. Actually request the card. + """ + assert payout_event.status in { + PayoutStatus.PENDING, + PayoutStatus.FAILED, + }, ( + "attempting to manage payout that is not pending (you can also retry a failed order)" + ) + request = GiftronautCashoutMethodRequestData.model_validate( + payout_event.request_data + ) + + # Note: we'll use the cashout_request_id to uniquify orders + assert request.currency == Currency.USD + try: + order = self.client.create_order( + cashout_request_id=request.cashout_request_id, + brand_code=request.brand_code, + amount=request.amount, + ) + + 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... + user_payout_event_manager.update(payout_event, status=PayoutStatus.FAILED) + return payout_event + + # update Payout Event with the order data + user_payout_event_manager.update( + payout_event, + status=order["status"], + ext_ref_id=order["number"], + order_data=order, + ) + + ledger_manager.create_tx_user_payout_complete( + user, + payout_event=payout_event, + ) + + return payout_event + + def update_cashout_method_db(self): + # This only should be run once, or when brands/cards offered change. + cms = self.client.list_supported_brands() + + existing = self.cashout_method_manager.filter( + payout_types=[PayoutType.GIFTRONAUT] + ) + existing_id = {cm.ext_id: cm.id for cm in existing} + for cm in cms: + if cm.ext_id in existing_id: + cm.id = existing_id[cm.ext_id] + + data = [] + for cm in cms: + d = cm.model_dump(mode="json") + d["data"] = cm.model_dump_json() + d["provider"] = cm.type.value + data.append(d) + + query = """ + INSERT INTO accounting_cashoutmethod ( + id, last_updated, is_live, provider, ext_id, name, data + ) VALUES ( + %(id)s, %(last_updated)s, %(is_live)s, %(provider)s, + %(ext_id)s, %(name)s, %(data)s + ) ON CONFLICT (id) DO UPDATE + SET last_updated = NOW(), + name = %(name)s, is_live=TRUE, + data = %(data)s + """ + with self.pg_config.make_connection() as conn: + with conn.cursor() as c: + c.executemany(query, data) + conn.commit() diff --git a/generalresearch/managers/thl/virtual_incentives_api.py b/generalresearch/managers/thl/virtual_incentives_api.py index 0516233..5da84b7 100644 --- a/generalresearch/managers/thl/virtual_incentives_api.py +++ b/generalresearch/managers/thl/virtual_incentives_api.py @@ -163,6 +163,21 @@ class VirtualIncentivesClient: raise return None + @staticmethod + def format_order(d: dict): + order = d["order"] + assert len(order["accounts"]) == 1 + acct = order.pop("accounts")[0] + order["sku"] = acct["sku"] + order["currency"] = acct["currency"] + order["amount"] = acct["amount"] + order["link"] = acct["link"] + return order + + def get_order_detail(self, order_number: int): + res = self._request("GET", f"/orders/{order_number}") + return self.format_order(res) + def create_order(self, cashout_request_id: str, sku: str, amount: Decimal): """ @@ -188,7 +203,8 @@ class VirtualIncentivesClient: ], } - return self._request("POST", "/orders", json={"order": data}) + res = self._request("POST", "/orders", json={"order": data}) + return self.format_order(res) class VirtualIncentivesManager(PostgresManagerWithRedis): diff --git a/generalresearch/managers/thl/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py index fe25a13..e41a501 100644 --- a/generalresearch/managers/thl/wallet/__init__.py +++ b/generalresearch/managers/thl/wallet/__init__.py @@ -1,6 +1,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Any +from generalresearch.managers.thl.giftbit import GiftbitManager from generalresearch.managers.thl.ipinfo import GeoIpInfoManager from generalresearch.managers.thl.virtual_incentives_api import VirtualIncentivesManager from generalresearch.managers.thl.wallet.approve import ( @@ -38,7 +39,8 @@ def manage_pending_cashout( order_data: dict[str, Any] | CashMailOrderData | None = None, tango_client: TangoClient | None = None, paypal_client: PayPalPayoutManager | None = None, - virtual_incentives_manager: VirtualIncentivesManager | None = None + virtual_incentives_manager: VirtualIncentivesManager | None = None, + giftbit_manager: GiftbitManager | None = None ) -> UserPayoutEvent: """ Called by a UI actions performed by Todd. This rejects/approves/cancels @@ -95,6 +97,16 @@ def manage_pending_cashout( ledger_manager=ledger_manager, ) + elif pe.payout_type == PayoutType.GIFTBIT: + assert giftbit_manager is not None + + giftbit_manager.complete_order( + user=user, + payout_event=pe, + user_payout_event_manager=user_payout_event_manager, + ledger_manager=ledger_manager, + ) + elif pe.payout_type == PayoutType.PAYPAL: assert paypal_client is not None diff --git a/generalresearch/managers/thl/wallet/user_payout.py b/generalresearch/managers/thl/wallet/user_payout.py index ce1a3c5..56daead 100644 --- a/generalresearch/managers/thl/wallet/user_payout.py +++ b/generalresearch/managers/thl/wallet/user_payout.py @@ -12,6 +12,7 @@ from redis import Redis from generalresearch.currency import USDCent, format_usd_cent from generalresearch.managers.thl.cashout_method import CashoutMethodManager +from generalresearch.managers.thl.giftbit import GiftbitManager from generalresearch.managers.thl.ledger_manager.exceptions import ( LedgerTransactionCreateError, ) @@ -31,6 +32,7 @@ from generalresearch.models.thl.wallet.cashout_method import ( CashoutMethod, CashoutMethodOut, CashoutRequestDetail, + GiftbitCashoutMethodRequestData, PaypalCashoutMethodRequestData, TangoCashoutMethodRequestData, VICashoutMethodRequestData, @@ -494,6 +496,7 @@ class UserPayoutEventManager(PayoutEventManager): redis_client: Redis, slack_client: slack.WebClient | None = None, virtual_incentives_manager: VirtualIncentivesManager | None = None, + giftbit_manager: GiftbitManager | None = None, ) -> tuple[UserPayoutEvent | None, str | None]: try: return self.user_request_redeem( @@ -506,7 +509,8 @@ class UserPayoutEventManager(PayoutEventManager): user_ip_history_manager=user_ip_history_manager, redis_client=redis_client, slack_client=slack_client, - virtual_incentives_manager=virtual_incentives_manager + virtual_incentives_manager=virtual_incentives_manager, + giftbit_manager=giftbit_manager, ), None except (AssertionError, LedgerTransactionCreateError) as e: return None, str(e) @@ -523,6 +527,7 @@ class UserPayoutEventManager(PayoutEventManager): redis_client: Redis, slack_client: slack.WebClient | None = None, virtual_incentives_manager: VirtualIncentivesManager | None = None, + giftbit_manager: GiftbitManager | None = None, ) -> UserPayoutEvent: """ A user has requested to be paid from their wallet balance. @@ -624,6 +629,10 @@ class UserPayoutEventManager(PayoutEventManager): request_data: VICashoutMethodRequestData = ( virtual_incentives_manager.make_request(amount, cashout_method, pe_uuid) ) + elif payout_type == PayoutType.GIFTBIT: + request_data: GiftbitCashoutMethodRequestData = ( + giftbit_manager.make_request(amount, cashout_method, pe_uuid) + ) else: raise ValueError(f"unknown {payout_type=}") diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py index d135f22..8ad02f5 100644 --- a/generalresearch/models/thl/wallet/cashout_method.py +++ b/generalresearch/models/thl/wallet/cashout_method.py @@ -84,6 +84,8 @@ class CashoutMethodBase(BaseModel): | CashMailCashoutMethodData | AmtCashoutMethodData | VICashoutMethodData + | GiftbitCashoutMethodData + | GiftronautCashoutMethodData ) = Field(discriminator="type") description: str = Field( description="The description of the cashout method.", default="" @@ -341,6 +343,55 @@ class VICashoutMethodRequestData(BaseModel): ) +class GiftbitCashoutMethodData(BaseModel): + type: Literal[PayoutType.GIFTBIT] = Field(default=PayoutType.GIFTBIT) + brand_code: str = Field(description="giftbit brand_code") + variable_price: bool = Field() + min_price_in_cents: int | None = Field(default=None) + max_price_in_cents: int | None = Field(default=None) + allowed_prices_in_cents: list[int] | None = Field(default=None) + countries: list[str] | None= Field(description="None means global") + disclaimer: str = Field(default="") + + +class GiftbitCashoutMethodRequestData(BaseModel): + brand_code: str = Field(description="giftbit brand_code") + # This is not necessarily in USD. It is in whatever currency the card is in + amount: Decimal = Field(description="Amount to be paid out (in USD, EUR, etc)") + currency: Currency = Field() + cashout_request_id: str = Field(description="An external deduplication identifier.") + + +class GiftronautCashoutMethodData(BaseModel): + type: Literal[PayoutType.GIFTRONAUT] = Field(default=PayoutType.GIFTRONAUT) + productId: str = Field(description="Giftronaut productId") + terms: str = Field() + redemptionInstructions: str = Field() + country_iso: str = Field() + priceStep: float = Field() + + +class GiftronautCashoutMethodRequestData(BaseModel): + productId: str = Field(description="Giftronaut productId") + # This is not necessarily in USD. It is in whatever currency the card is in + amount: Decimal = Field(description="Amount to be paid out (in USD, EUR, etc)") + currency: Currency = Field() + cashout_request_id: str = Field(description="An external deduplication identifier.") + + +class GiftedCashoutMethodData(BaseModel): + type: Literal[PayoutType.GIFTED] = Field(default=PayoutType.GIFTED) + card_id: str = Field(description="Gifted card") + + +class GiftedCashoutMethodRequestData(BaseModel): + card_id: str = Field(description="Gifted card") + # This is not necessarily in USD. It is in whatever currency the card is in + amount: Decimal = Field(description="Amount to be paid out (in USD, EUR, etc)") + currency: Currency = Field() + cashout_request_id: str = Field(description="An external deduplication identifier.") + + class AmtCashoutMethodData(BaseModel): type: Literal[PayoutType.AMT] = Field(default=PayoutType.AMT) diff --git a/generalresearch/models/thl/wallet/definitions.py b/generalresearch/models/thl/wallet/definitions.py index 981752c..63b189d 100644 --- a/generalresearch/models/thl/wallet/definitions.py +++ b/generalresearch/models/thl/wallet/definitions.py @@ -34,6 +34,12 @@ class PayoutType(StrEnum, metaclass=ReprEnumMeta): TREMENDOUS = "TREMENDOUS" # Virtual Incentives Gift Card VIRTUAL_INCENTIVES = "VI" + # Giftbit + GIFTBIT = "GIFTBIT" + # Gifted.co + GIFTED = "GIFTED" + # giftronaut.com + GIFTRONAUT = "GIFTRONAUT" # DWOLLA DWOLLA = "DWOLLA" # A payment is made to a bank account using ACH @@ -59,6 +65,9 @@ PAYOUT_TYPE_TO_CATEGORY = { PayoutType.TANGO: PayoutCategory.GIFT_CARD, PayoutType.TREMENDOUS: PayoutCategory.GIFT_CARD, PayoutType.VIRTUAL_INCENTIVES: PayoutCategory.GIFT_CARD, + PayoutType.GIFTBIT: PayoutCategory.GIFT_CARD, + PayoutType.GIFTED: PayoutCategory.GIFT_CARD, + PayoutType.GIFTRONAUT: PayoutCategory.GIFT_CARD, PayoutType.DWOLLA: PayoutCategory.CASH, PayoutType.ACH: PayoutCategory.CASH, PayoutType.WIRE: PayoutCategory.CASH, diff --git a/pyproject.toml b/pyproject.toml index 5be5740..1e3acad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "generalresearch" -version = "3.9.7" +version = "3.9.8" description = "Python Utilities for General Research" readme = "README.md" requires-python = ">=3.14" |
