aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--generalresearch/managers/thl/giftbit.py419
-rw-r--r--generalresearch/managers/thl/giftronaut.py346
-rw-r--r--generalresearch/managers/thl/virtual_incentives_api.py18
-rw-r--r--generalresearch/managers/thl/wallet/__init__.py14
-rw-r--r--generalresearch/managers/thl/wallet/user_payout.py11
-rw-r--r--generalresearch/models/thl/wallet/cashout_method.py51
-rw-r--r--generalresearch/models/thl/wallet/definitions.py9
-rw-r--r--pyproject.toml2
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"