aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorstuppie2026-10-22 11:57:53 -0600
committerstuppie2026-10-22 11:57:53 -0600
commited4cf78c782d669aa554422f3cbbaf24361e1c8a (patch)
treea92795fd36bdd4f11c402ddf970339a806f70716
parent292860a9569e08f9f58c49802ee1f25cd39f9be1 (diff)
downloadgeneralresearch-ed4cf78c782d669aa554422f3cbbaf24361e1c8a.tar.gz
generalresearch-ed4cf78c782d669aa554422f3cbbaf24361e1c8a.zip
working on virtual incentives api and manager. mostly working, needs more tests
-rw-r--r--generalresearch/managers/thl/ledger_manager/thl_ledger.py1
-rw-r--r--generalresearch/managers/thl/virtual_incentives_api.py309
-rw-r--r--generalresearch/managers/thl/wallet/__init__.py12
-rw-r--r--generalresearch/managers/thl/wallet/user_payout.py9
-rw-r--r--generalresearch/models/thl/payout.py4
-rw-r--r--generalresearch/models/thl/wallet/cashout_method.py20
-rw-r--r--generalresearch/models/thl/wallet/definitions.py2
-rw-r--r--tests/managers/thl/test_virtual_incentives_api.py158
8 files changed, 513 insertions, 2 deletions
diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py
index cd82d4a..13a79ec 100644
--- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py
+++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py
@@ -1069,6 +1069,7 @@ class ThlLedgerManager(LedgerManager):
PayoutType.PAYPAL: f"User Payout Paypal Request {amt_str}",
PayoutType.CASH_IN_MAIL: f"User Payout Cash Request {amt_str}",
PayoutType.TANGO: f"User Payout Tango Request {amt_str}",
+ PayoutType.VIRTUAL_INCENTIVES: f"User Payout VI Request {amt_str}",
}
description = descriptions[payout_event.payout_type]
diff --git a/generalresearch/managers/thl/virtual_incentives_api.py b/generalresearch/managers/thl/virtual_incentives_api.py
new file mode 100644
index 0000000..25d9d5f
--- /dev/null
+++ b/generalresearch/managers/thl/virtual_incentives_api.py
@@ -0,0 +1,309 @@
+from __future__ import annotations
+
+from collections.abc import Collection
+from decimal import Decimal
+from typing import TYPE_CHECKING, Any
+from uuid import uuid4
+
+import requests
+
+from generalresearch.currency import USDCent
+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,
+ VICashoutMethodData,
+ VICashoutMethodRequestData,
+)
+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
+
+BASE_URL = "https://rest.virtualincentives.com/v6/json"
+
+
+class VirtualIncentivesError(RuntimeError):
+ pass
+
+
+class VirtualIncentivesClient:
+ PROGRAM_ID = 61929
+
+ 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"Basic {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 VirtualIncentivesError(
+ f"VirtualIncentives API error {response.status_code}" + f": {body}"
+ )
+
+ def get_program_brands(self) -> list[dict[str, Any]]:
+ return self._request(
+ "GET",
+ f"/programs/{self.PROGRAM_ID}/products",
+ )["products"]
+
+ def get_brand_marketing(self, sku: str) -> Any:
+ return self._request(
+ "GET",
+ f"/products/{sku}/marketing",
+ )
+
+ def get_brand_terms(self, sku: str) -> Any:
+ return self._request(
+ "GET",
+ f"/products/{sku}/terms",
+ )
+
+ # def get_all_brands(self):
+ # return self._request(
+ # "GET",
+ # "/products",
+ # )
+ #
+ # def get_brand(self, sku: str):
+ # return self._request(
+ # "GET",
+ # f"/products/{sku}",
+ # )
+
+ @staticmethod
+ def format_brand_to_cashout_method(c: dict):
+ assert c["currency"] == "USD"
+ assert c["country"] == "US"
+ assert not c["denomination"]["fixed"]
+ denomination_range = c["denomination"]["range"]
+ assert len(denomination_range) == 1
+ range = denomination_range[0]
+ return CashoutMethod(
+ name=c["name"],
+ description=c["name"],
+ id=uuid4().hex,
+ currency=Currency.USD,
+ original_currency=Currency.USD,
+ image_url=c["faceplate"],
+ min_value=round(range["min"] * 100), # $1.00
+ max_value=round(min(250.00, range["max"]) * 100), # $250.00
+ data=VICashoutMethodData(
+ sku=c["sku"],
+ value_type="variable",
+ marketing=c["marketing"],
+ terms=c["terms"],
+ ),
+ type=PayoutType.VIRTUAL_INCENTIVES,
+ user=None,
+ ext_id=c["sku"],
+ )
+
+ def get_order(self, cashout_request_id: str) -> dict[str, Any]:
+ params = {"clientid": cashout_request_id}
+ res = self._request("GET", "/orders", params=params)
+ order = res["orders"]["order"]
+ if not order:
+ raise VirtualIncentivesError("Order not found")
+ return order[0]
+
+ def get_order_if_exists(self, cashout_request_id: str) -> dict[str, Any] | None:
+ try:
+ return self.get_order(cashout_request_id)
+ except VirtualIncentivesError as e:
+ if "Order not found" not in e.args[0]:
+ raise
+ return None
+
+ def create_order(self, cashout_request_id: str, sku: str, amount: Decimal):
+ """
+
+ :param cashout_request_id: unique identifier of the cashout request
+ :param sku: sku of the brand (from VI)
+ :param amount: amount of the cashout (in the noted currency)
+ :return:
+ """
+ assert type(amount) is Decimal
+ data = {
+ "programid": self.PROGRAM_ID,
+ "clientid": cashout_request_id,
+ "fulfillment": "bulk",
+ "currency": "USD",
+ "accounts": [
+ {
+ "firstname": "Recipient",
+ "lastname": "Recipient",
+ "email": "support@g-r-l.com",
+ "sku": sku,
+ "amount": str(amount),
+ }
+ ],
+ }
+
+ return self._request("POST", "/orders", json={"order": data})
+
+
+class VirtualIncentivesManager(PostgresManagerWithRedis):
+ def __init__(
+ self,
+ *,
+ virtual_incentives_token: str,
+ pg_config: PostgresConfig,
+ redis_config: RedisConfig,
+ permissions: Collection[Permission] | None = None,
+ cache_prefix: str | None = None,
+ ):
+ self.client = VirtualIncentivesClient(token=virtual_incentives_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
+ ) -> VICashoutMethodRequestData:
+ """Build the data needed to place a VI order."""
+ assert type(amount) is USDCent
+ sku = cashout_method.data.sku
+ currency = cashout_method.original_currency
+ assert currency == Currency.USD
+ amount_in_currency = Decimal(float(amount) / 100).quantize(Decimal("0.01"))
+ return VICashoutMethodRequestData.model_validate(
+ {
+ "sku": sku,
+ "amount": amount_in_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 VI 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 = VICashoutMethodRequestData.model_validate(payout_event.request_data)
+
+ # Note: VI uses the cashout_request_id to uniquify orders
+ try:
+ order = self.client.create_order(
+ cashout_request_id=request.cashout_request_id,
+ sku=request.sku,
+ 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,
+ )
+
+ # todo: What is the fee??
+ ledger_manager.create_tx_user_payout_complete(
+ user, payout_event=payout_event,
+ fee_amount=Decimal('0.10'),
+ )
+
+ return payout_event
+
+ def update_cashout_method_db(self):
+ # This only should be run once, or when brands/cards offered by VI change.
+ cards = self.client.get_program_brands()
+ cms = []
+ for c in cards:
+ cms.append(self.client.format_brand_to_cashout_method(c))
+
+ existing = self.cashout_method_manager.filter(
+ payout_types=[PayoutType.VIRTUAL_INCENTIVES]
+ )
+ 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/wallet/__init__.py b/generalresearch/managers/thl/wallet/__init__.py
index 1e80397..fe25a13 100644
--- a/generalresearch/managers/thl/wallet/__init__.py
+++ b/generalresearch/managers/thl/wallet/__init__.py
@@ -2,6 +2,7 @@ from decimal import Decimal
from typing import TYPE_CHECKING, Any
from generalresearch.managers.thl.ipinfo import GeoIpInfoManager
+from generalresearch.managers.thl.virtual_incentives_api import VirtualIncentivesManager
from generalresearch.managers.thl.wallet.approve import (
approve_amt_cashout,
approve_paypal_order,
@@ -37,6 +38,7 @@ 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
) -> UserPayoutEvent:
"""
Called by a UI actions performed by Todd. This rejects/approves/cancels
@@ -83,6 +85,16 @@ def manage_pending_cashout(
tango_client=tango_client,
)
+ elif pe.payout_type == PayoutType.VIRTUAL_INCENTIVES:
+ assert virtual_incentives_manager is not None
+
+ virtual_incentives_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 2e9e7df..ce1a3c5 100644
--- a/generalresearch/managers/thl/wallet/user_payout.py
+++ b/generalresearch/managers/thl/wallet/user_payout.py
@@ -18,6 +18,7 @@ from generalresearch.managers.thl.ledger_manager.exceptions import (
from generalresearch.managers.thl.ledger_manager.thl_ledger import ThlLedgerManager
from generalresearch.managers.thl.payout import PayoutEventManager
from generalresearch.managers.thl.userhealth import UserIpHistoryManager
+from generalresearch.managers.thl.virtual_incentives_api import VirtualIncentivesManager
from generalresearch.managers.thl.wallet.tango import TangoManager
from generalresearch.managers.utils import parse_order_by
from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr
@@ -32,6 +33,7 @@ from generalresearch.models.thl.wallet.cashout_method import (
CashoutRequestDetail,
PaypalCashoutMethodRequestData,
TangoCashoutMethodRequestData,
+ VICashoutMethodRequestData,
)
from generalresearch.models.thl.wallet.definitions import PayoutType
@@ -491,6 +493,7 @@ class UserPayoutEventManager(PayoutEventManager):
user_ip_history_manager: UserIpHistoryManager,
redis_client: Redis,
slack_client: slack.WebClient | None = None,
+ virtual_incentives_manager: VirtualIncentivesManager | None = None,
) -> tuple[UserPayoutEvent | None, str | None]:
try:
return self.user_request_redeem(
@@ -503,6 +506,7 @@ 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
), None
except (AssertionError, LedgerTransactionCreateError) as e:
return None, str(e)
@@ -518,6 +522,7 @@ class UserPayoutEventManager(PayoutEventManager):
user_ip_history_manager: UserIpHistoryManager,
redis_client: Redis,
slack_client: slack.WebClient | None = None,
+ virtual_incentives_manager: VirtualIncentivesManager | None = None,
) -> UserPayoutEvent:
"""
A user has requested to be paid from their wallet balance.
@@ -615,6 +620,10 @@ class UserPayoutEventManager(PayoutEventManager):
cashout_method.data.model_dump()
)
)
+ elif payout_type == PayoutType.VIRTUAL_INCENTIVES:
+ request_data: VICashoutMethodRequestData = (
+ virtual_incentives_manager.make_request(amount, cashout_method, pe_uuid)
+ )
else:
raise ValueError(f"unknown {payout_type=}")
diff --git a/generalresearch/models/thl/payout.py b/generalresearch/models/thl/payout.py
index 2b15ea4..b2c3ca7 100644
--- a/generalresearch/models/thl/payout.py
+++ b/generalresearch/models/thl/payout.py
@@ -203,9 +203,9 @@ class UserPayoutEvent(PayoutEvent):
@field_validator("payout_type", mode="before")
@classmethod
def normalize_enum(cls, v):
- if isinstance(v, str):
+ if type(v) is str:
try:
- return PayoutType[v.upper()]
+ return PayoutType(v.upper())
except KeyError:
raise ValueError(f"Invalid payout_type: {v}")
return v
diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py
index 6357f9f..d135f22 100644
--- a/generalresearch/models/thl/wallet/cashout_method.py
+++ b/generalresearch/models/thl/wallet/cashout_method.py
@@ -83,6 +83,7 @@ class CashoutMethodBase(BaseModel):
| TangoCashoutMethodData
| CashMailCashoutMethodData
| AmtCashoutMethodData
+ | VICashoutMethodData
) = Field(discriminator="type")
description: str = Field(
description="The description of the cashout method.", default=""
@@ -321,6 +322,25 @@ class TangoCashoutMethodData(BaseModel):
return [x.lower() for x in countries]
+class VICashoutMethodData(BaseModel):
+ type: Literal[PayoutType.VIRTUAL_INCENTIVES] = Field(
+ default=PayoutType.VIRTUAL_INCENTIVES
+ )
+ sku: str = Field(description="VI sku")
+ value_type: Literal["variable", "fixed"] = Field()
+ marketing: str = Field(default="")
+ terms: str = Field(default="")
+
+
+class VICashoutMethodRequestData(BaseModel):
+ sku: str = Field(description="VI sku")
+ # 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)")
+ cashout_request_id: str = Field(
+ description="An external deduplication identifier. VI calls this the clientid"
+ )
+
+
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 96b639c..26d15d1 100644
--- a/generalresearch/models/thl/wallet/definitions.py
+++ b/generalresearch/models/thl/wallet/definitions.py
@@ -32,6 +32,8 @@ class PayoutType(StrEnum, metaclass=ReprEnumMeta):
TANGO = "TANGO"
# Tremendous Gift Card
TREMENDOUS = "TREMENDOUS"
+ # Virtual Incentives Gift Card
+ VIRTUAL_INCENTIVES = "VI"
# DWOLLA
DWOLLA = "DWOLLA"
# A payment is made to a bank account using ACH
diff --git a/tests/managers/thl/test_virtual_incentives_api.py b/tests/managers/thl/test_virtual_incentives_api.py
new file mode 100644
index 0000000..0d1f233
--- /dev/null
+++ b/tests/managers/thl/test_virtual_incentives_api.py
@@ -0,0 +1,158 @@
+from decimal import Decimal
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+from generalresearch.currency import USDCent
+from generalresearch.managers.thl.virtual_incentives_api import (
+ VirtualIncentivesClient,
+ VirtualIncentivesManager,
+)
+from generalresearch.models.thl.definitions import PayoutStatus
+
+
+@pytest.fixture
+def client() -> VirtualIncentivesClient:
+ return VirtualIncentivesClient(token="secret-token")
+
+
+@pytest.fixture
+def variable_brand() -> dict:
+ return {
+ "sku": "AMAZON-US",
+ "name": "Amazon.com Gift Card",
+ "currency": "USD",
+ "country": "US",
+ "faceplate": "https://example.com/amazon.png",
+ "marketing": "Amazon marketing copy",
+ "terms": "Amazon terms",
+ "denomination": {
+ "fixed": False,
+ "range": [{"min": 5.0, "max": 500.0}],
+ },
+ }
+
+
+class TestVirtualIncentivesClient:
+
+ def test_create_order_builds_vi_payload(self, client):
+ client._request = Mock(return_value={"number": "order-1"})
+
+ result = client.create_order("request-1", "AMAZON-US", Decimal("12.34"))
+
+ assert result == {"number": "order-1"}
+ client._request.assert_called_once_with(
+ "POST",
+ "/orders",
+ json={
+ "order": {
+ "programid": client.PROGRAM_ID,
+ "clientid": "request-1",
+ "fulfillment": "bulk",
+ "currency": "USD",
+ "accounts": [
+ {
+ "firstname": "Recipient",
+ "lastname": "Recipient",
+ "email": "support@g-r-l.com",
+ "sku": "AMAZON-US",
+ "amount": "12.34",
+ }
+ ],
+ }
+ },
+ )
+
+
+
+class TestVirtualIncentivesManager:
+ @pytest.fixture
+ def manager(self):
+ manager = VirtualIncentivesManager.__new__(VirtualIncentivesManager)
+ manager.client = Mock(spec=VirtualIncentivesClient)
+ return manager
+
+ def test_make_request(
+ self, manager, client, variable_brand
+ ):
+ cashout_method = client.format_brand_to_cashout_method(variable_brand)
+
+ request = manager.make_request(USDCent(1_234), cashout_method, "payout-1")
+
+ assert request.sku == "AMAZON-US"
+ assert request.amount == Decimal("12.34")
+ assert request.cashout_request_id == "payout-1"
+
+ def test_complete_order_updates_payout_and_ledger(self, manager):
+ payout_event = SimpleNamespace(
+ status=PayoutStatus.PENDING,
+ request_data={
+ "sku": "AMAZON-US",
+ "amount": "12.34",
+ "cashout_request_id": "payout-1",
+ },
+ )
+ order = {"status": PayoutStatus.COMPLETE, "number": "order-1"}
+ manager.client.create_order.return_value = order
+ payout_manager = Mock()
+ ledger_manager = Mock()
+ user = Mock()
+
+ result = manager.complete_order(
+ user, payout_event, payout_manager, ledger_manager
+ )
+
+ assert result is payout_event
+ manager.client.create_order.assert_called_once_with(
+ cashout_request_id="payout-1",
+ sku="AMAZON-US",
+ amount=Decimal("12.34"),
+ )
+ payout_manager.update.assert_called_once_with(
+ payout_event,
+ status=PayoutStatus.COMPLETE,
+ ext_ref_id="order-1",
+ order_data=order,
+ )
+ ledger_manager.create_tx_user_payout_complete.assert_called_once_with(
+ user, payout_event=payout_event
+ )
+
+ def test_complete_order_marks_assertion_failure_as_failed(self, manager):
+ payout_event = SimpleNamespace(
+ status=PayoutStatus.FAILED,
+ request_data={
+ "sku": "AMAZON-US",
+ "amount": "12.34",
+ "cashout_request_id": "payout-1",
+ },
+ )
+ manager.client.create_order.side_effect = AssertionError("bad order")
+ payout_manager = Mock()
+ ledger_manager = Mock()
+
+ result = manager.complete_order(
+ Mock(), payout_event, payout_manager, ledger_manager
+ )
+
+ assert result is payout_event
+ payout_manager.update.assert_called_once_with(
+ payout_event, status=PayoutStatus.FAILED
+ )
+ ledger_manager.create_tx_user_payout_complete.assert_not_called()
+
+ def test_complete_order_rejects_final_payout(self, manager):
+ payout_event = SimpleNamespace(
+ status=PayoutStatus.COMPLETE,
+ request_data={
+ "sku": "AMAZON-US",
+ "amount": "12.34",
+ "cashout_request_id": "payout-1",
+ },
+ )
+
+ with pytest.raises(AssertionError, match="not pending"):
+ manager.complete_order(Mock(), payout_event, Mock(), Mock())
+
+ manager.client.create_order.assert_not_called()