diff options
| author | stuppie | 2026-10-22 11:57:53 -0600 |
|---|---|---|
| committer | stuppie | 2026-10-22 11:57:53 -0600 |
| commit | ed4cf78c782d669aa554422f3cbbaf24361e1c8a (patch) | |
| tree | a92795fd36bdd4f11c402ddf970339a806f70716 | |
| parent | 292860a9569e08f9f58c49802ee1f25cd39f9be1 (diff) | |
| download | generalresearch-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.py | 1 | ||||
| -rw-r--r-- | generalresearch/managers/thl/virtual_incentives_api.py | 309 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/__init__.py | 12 | ||||
| -rw-r--r-- | generalresearch/managers/thl/wallet/user_payout.py | 9 | ||||
| -rw-r--r-- | generalresearch/models/thl/payout.py | 4 | ||||
| -rw-r--r-- | generalresearch/models/thl/wallet/cashout_method.py | 20 | ||||
| -rw-r--r-- | generalresearch/models/thl/wallet/definitions.py | 2 | ||||
| -rw-r--r-- | tests/managers/thl/test_virtual_incentives_api.py | 158 |
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() |
