aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--generalresearch/incite/collections/base.py5
-rw-r--r--generalresearch/incite/mergers/base.py4
-rw-r--r--generalresearch/managers/thl/ledger_manager/ledger.py67
-rw-r--r--generalresearch/managers/thl/ledger_manager/thl_ledger.py63
-rw-r--r--generalresearch/managers/thl/virtual_incentives_api.py314
-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/ledger.py195
-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--pyproject.toml2
-rw-r--r--test_utils/managers/conftest.py32
-rw-r--r--test_utils/models/thl/conftest.py10
-rw-r--r--tests/managers/thl/test_ledger/test_thl_pem.py4
-rw-r--r--tests/managers/thl/test_ledger/test_wallet.py9
-rw-r--r--tests/managers/thl/test_payout.py101
-rw-r--r--tests/managers/thl/test_virtual_incentives_api.py158
-rw-r--r--tests/managers/thl/test_wallet/test_user_payout.py218
-rw-r--r--tests/models/gr/test_team.py16
20 files changed, 1154 insertions, 91 deletions
diff --git a/generalresearch/incite/collections/base.py b/generalresearch/incite/collections/base.py
index b83fd8a..6eb7e12 100644
--- a/generalresearch/incite/collections/base.py
+++ b/generalresearch/incite/collections/base.py
@@ -23,6 +23,7 @@ from pandera.pandas import DataFrameSchema
from pydantic import Field, FilePath, ValidationInfo, field_validator
from sentry_sdk import capture_exception
+from generalresearch.config import is_debug
from generalresearch.incite import LOG
from generalresearch.incite.base import CollectionBase, CollectionItemBase
from generalresearch.incite.schemas import (
@@ -188,6 +189,10 @@ class DFCollectionItem(CollectionItemBase):
return self._to_dict()
def validate_time_coverage(self, path: Path) -> None:
+ # This time coverage check makes it impossible to run tests. We need a way to force enable or disable this
+ if is_debug():
+ return
+
timestamp_column = DFCollectionTypeTimestampColumns[self._collection.data_type]
df = pd.read_parquet(path, columns=[timestamp_column])
timestamps = pd.to_datetime(df[timestamp_column])
diff --git a/generalresearch/incite/mergers/base.py b/generalresearch/incite/mergers/base.py
index 6bb41e9..f313a54 100644
--- a/generalresearch/incite/mergers/base.py
+++ b/generalresearch/incite/mergers/base.py
@@ -12,6 +12,7 @@ from dask.distributed import Client
from pandera.pandas import DataFrameSchema
from pydantic import Field, ValidationInfo, field_validator, model_validator
+from generalresearch.config import is_debug
from generalresearch.incite import LOG
from generalresearch.incite.base import CollectionBase, CollectionItemBase
from generalresearch.incite.schemas import PARTITION_ON
@@ -112,6 +113,9 @@ class MergeCollectionItem(CollectionItemBase):
return res
def validate_time_coverage(self, path: Path) -> None:
+ if is_debug():
+ return
+
timestamp_column = MergeTypeTimestampColumns[self._collection.merge_type]
if timestamp_column is None:
return
diff --git a/generalresearch/managers/thl/ledger_manager/ledger.py b/generalresearch/managers/thl/ledger_manager/ledger.py
index d027bd5..eaff55c 100644
--- a/generalresearch/managers/thl/ledger_manager/ledger.py
+++ b/generalresearch/managers/thl/ledger_manager/ledger.py
@@ -33,11 +33,11 @@ from generalresearch.models.thl.ledger import (
LedgerAccount,
LedgerEntry,
LedgerTransaction,
+ UserLedgerTransactionType,
UserLedgerTransactionTypesSummary,
)
if TYPE_CHECKING:
- from generalresearch.models.thl.ledger import UserLedgerTransactionType
from generalresearch.pg_helper import PostgresConfig
from generalresearch.redis_helper import RedisConfig
@@ -117,6 +117,7 @@ class LedgerManagerBasePostgres(PostgresManager, RedisManager):
filters.append("le.account_id = %(account_uuid)s")
params["account_uuid"] = account_uuid
if metadata_key is not None:
+ assert metadata_value is not None
filters.append("key = %(metadata_key)s")
params["metadata_key"] = metadata_key
if metadata_value is not None:
@@ -131,6 +132,8 @@ class LedgerManagerBasePostgres(PostgresManager, RedisManager):
class LedgerTransactionManager(LedgerManagerBasePostgres):
+ DEFAULT_PAGE_SIZE = 100
+
def create_tx(
self,
entries: list[LedgerEntry],
@@ -508,10 +511,20 @@ class LedgerTransactionManager(LedgerManagerBasePostgres):
account_uuid: UUIDStr,
time_start: datetime | None = None,
time_end: datetime | None = None,
+ metadata_key: str | None = None,
+ metadata_value: str | None = None,
) -> NonNegativeInt:
filter_str, params = self.make_filter_str(
time_start=time_start,
time_end=time_end,
+ metadata_key=metadata_key,
+ metadata_value=metadata_value,
+ )
+ ltm_join = (
+ """JOIN ledger_transactionmetadata ltm
+ ON ltm.transaction_id = lt.id"""
+ if metadata_key is not None
+ else ""
)
params["account_uuid"] = account_uuid
@@ -522,6 +535,7 @@ class LedgerTransactionManager(LedgerManagerBasePostgres):
JOIN ledger_entry le
ON le.transaction_id = lt.id
AND le.account_id = %(account_uuid)s
+ {ltm_join}
{filter_str}
""",
params=params,
@@ -612,11 +626,14 @@ class LedgerTransactionManager(LedgerManagerBasePostgres):
account_uuid: UUIDStr,
time_start: datetime | None = None,
time_end: datetime | None = None,
+ metadata_key: str | None = None,
+ metadata_value: str | None = None,
page: int | None = None,
size: int | None = None,
order_by: str | None = "created,tag",
) -> tuple[list[LedgerTransaction], int]:
"""
+ Return transactions touching an account and their filtered total
If time_start and/or time_end are passed, the txs are filtered to
include only that range.
- time_start is optional, default = beginning of time
@@ -639,29 +656,29 @@ class LedgerTransactionManager(LedgerManagerBasePostgres):
check_valid_uuid(account_uuid)
filter_str, params = self.make_filter_str(
- time_start=time_start, time_end=time_end, account_uuid=account_uuid
+ time_start=time_start,
+ time_end=time_end,
+ account_uuid=account_uuid,
+ metadata_key=metadata_key,
+ metadata_value=metadata_value,
)
-
+ pagination_sql = ""
+ total: int | None = None
if page is not None:
- assert type(page) is int
- assert page >= 1, "page starts at 1"
- size = size if size is not None else 100
- assert type(size) is int
- assert 1 <= size <= 100
- params["offset"] = (page - 1) * size
- params["limit"] = size
- paginated_filter_str = " LIMIT %(limit)s OFFSET %(offset)s"
+ pagination_sql = self.validate_pagination(page=page, size=size)
total = self.get_tx_filtered_by_account_count(
account_uuid=account_uuid,
time_start=time_start,
time_end=time_end,
+ metadata_key=metadata_key,
+ metadata_value=metadata_value,
)
- else:
- paginated_filter_str = ""
- # Don't need to do a count if we aren't paginating
- total = None
-
- order_by_str = parse_order_by(order_by)
+ order_by_str = parse_order_by(order_by or "created,tag")
+ metadata_join = (
+ "JOIN ledger_transactionmetadata ltm ON ltm.transaction_id = lt.id"
+ if metadata_key is not None
+ else ""
+ )
res = self.pg_config.execute_sql_query(
query=f"""
@@ -670,10 +687,11 @@ class LedgerTransactionManager(LedgerManagerBasePostgres):
FROM ledger_transaction lt
JOIN ledger_entry le
ON le.transaction_id = lt.id
+ {metadata_join}
{filter_str}
GROUP BY lt.id, lt.created
{order_by_str}
- {paginated_filter_str}
+ {pagination_sql}
)
SELECT
lt.id AS transaction_id,
@@ -682,23 +700,16 @@ class LedgerTransactionManager(LedgerManagerBasePostgres):
lt.tag,
meta.key_value_pairs,
entries.entries_json
-
FROM tx_ids t
JOIN ledger_transaction lt ON lt.id = t.id
-
{FULL_TX_JOINS}
-
- {order_by_str};
- """,
+ {order_by_str}
+ """,
params=params,
)
if total is None:
total = len(res)
-
- return (
- self.process_get_tx_mysql_rows_json(res),
- total,
- )
+ return self.process_get_tx_mysql_rows_json(res), total
def get_tx_filtered_by_metadata(
self,
diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py
index cd82d4a..c96e27c 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]
@@ -1150,6 +1151,7 @@ class ThlLedgerManager(LedgerManager):
PayoutType.PAYPAL: f"User Payout Paypal Complete {amt_str}",
PayoutType.CASH_IN_MAIL: f"User Payout Cash Complete {amt_str}",
PayoutType.TANGO: f"User Payout Tango Complete {amt_str}",
+ PayoutType.VIRTUAL_INCENTIVES: f"User Payout VI Complete {amt_str}",
}
description = descriptions[payout_event.payout_type]
bp_wallet_account = self.get_account_or_create_bp_wallet(user.product)
@@ -1188,6 +1190,13 @@ class ThlLedgerManager(LedgerManager):
)
if fee_amount is None:
fee_amount = (amount_usd * Decimal("0.035")).quantize(Decimal("0.01"))
+ elif payout_event.payout_type == PayoutType.VIRTUAL_INCENTIVES:
+ bp_expense_account = self.get_account_or_create_bp_expense(
+ product=user.product, expense_name="virtual_incentives"
+ )
+ if fee_amount is None:
+ # todo: What is the fee????? On the website it says 0, which I don't really believe.
+ fee_amount = (amount_usd * Decimal("0.029")).quantize(Decimal("0.01"))
else:
raise NotImplementedError()
@@ -2317,3 +2326,57 @@ class ThlLedgerManager(LedgerManager):
exclude_txs_before=exclude_txs_before,
)
return utx
+
+ def get_bp_txs(
+ self,
+ product_id: str,
+ user: User | None = None,
+ time_start: datetime | None = None,
+ time_end: datetime | None = None,
+ page: int = 1,
+ size: int = 50,
+ order_by: str | None = "created,tag",
+ ):
+ """
+ Get transactions affecting the BP's wallet balance.
+ Optionally filter by txs related to this User
+ """
+ bp_account = self.get_account_or_create_bp_wallet_by_uuid(product_id)
+ assert bp_account.reference_type == "bp", "Account must be a BP ledger account"
+ assert bp_account.reference_uuid == product_id, "Account must be owned by BP"
+
+ ago_limit = datetime.now(tz=UTC) - timedelta(days=365)
+ if time_start is not None:
+ assert time_start >= ago_limit
+ if time_end is not None:
+ assert time_end >= ago_limit
+ if time_start is None:
+ time_start = ago_limit
+ if time_end is None:
+ time_end = datetime.now(tz=UTC)
+ assert time_end >= time_start
+
+ if product_id == JAMES_BILLINGS_BPID:
+ time_start = (
+ max([JAMES_BILLINGS_TX_CUTOFF, time_start])
+ if time_start is not None
+ else JAMES_BILLINGS_TX_CUTOFF
+ )
+
+ metadata_key, metadata_value = None, None
+ if user is not None:
+ assert user.uuid is not None
+ metadata_key, metadata_value = "user", user.uuid
+
+ txs, total = self.get_tx_filtered_by_account_paginated(
+ bp_account.uuid,
+ time_start=time_start,
+ time_end=time_end,
+ metadata_key=metadata_key,
+ metadata_value=metadata_value,
+ page=page,
+ size=size,
+ order_by=order_by,
+ )
+ txs = [tx.to_brokerage_product_tx(bp_account) for tx in txs]
+ return txs, total
diff --git a/generalresearch/managers/thl/virtual_incentives_api.py b/generalresearch/managers/thl/virtual_incentives_api.py
new file mode 100644
index 0000000..0516233
--- /dev/null
+++ b/generalresearch/managers/thl/virtual_incentives_api.py
@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+import base64
+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}",
+ }
+ )
+
+ @staticmethod
+ def generate_token_from_login(username: str, password: str) -> str:
+ s = username + ":" + password
+ return base64.urlsafe_b64encode(s.encode()).decode()
+
+ 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,
+ )
+
+ 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 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/ledger.py b/generalresearch/models/thl/ledger.py
index 01066fc..fddd12b 100644
--- a/generalresearch/models/thl/ledger.py
+++ b/generalresearch/models/thl/ledger.py
@@ -418,6 +418,71 @@ class LedgerTransaction(BaseModel):
d["description"] = "Payout Cancelled"
return UserLedgerTransactionUserPayoutCancel.model_validate(d)
+ def to_brokerage_product_tx(
+ self, bp_account: LedgerAccount
+ ) -> BrokerageProductLedgerTransactionType:
+ """Project this transaction onto a Brokerage Product wallet.
+
+ ``amount`` is expressed from the product's point of view: credits to
+ its wallet are positive and debits are negative.
+ """
+ entries = [x for x in self.entries if x.account_uuid == bp_account.uuid]
+ if not entries:
+ raise ValueError(
+ "transaction does not contain an entry for the Brokerage Product account"
+ )
+ amount = sum(
+ entry.amount * (-1 if entry.direction == Direction.DEBIT else 1)
+ for entry in entries
+ )
+ if amount == 0:
+ raise ValueError("transaction has no net Brokerage Product wallet effect")
+ tx_type = TransactionType(self.metadata[TransactionMetadataColumns.TX_TYPE])
+ data: dict[str, Any] = {
+ "created": self.created,
+ "description": self.ext_description
+ or tx_type.value.replace("_", " ").title(),
+ "amount": amount,
+ "tx_type": tx_type,
+ }
+
+ model: type[BrokerageProductLedgerTransaction]
+ if tx_type == TransactionType.BP_PAYMENT:
+ model = BrokerageProductLedgerTransactionTaskComplete
+ data["tsid"] = self.metadata[TransactionMetadataColumns.SESSION]
+ elif tx_type == TransactionType.BP_ADJUSTMENT:
+ model = BrokerageProductLedgerTransactionTaskAdjustment
+ data["tsid"] = self.metadata[TransactionMetadataColumns.SESSION]
+ elif tx_type == TransactionType.BP_PAYOUT:
+ model = BrokerageProductLedgerTransactionPayout
+ data["payout_id"] = self.metadata[TransactionMetadataColumns.EVENT]
+ elif tx_type == TransactionType.USER_PAYOUT_COMPLETE:
+ model = BrokerageProductLedgerTransactionUserPayoutFee
+ data["payout_id"] = self.metadata[TransactionMetadataColumns.EVENT2]
+ data["user_id"] = self.metadata[TransactionMetadataColumns.USER]
+ elif tx_type == TransactionType.USER_BONUS:
+ model = BrokerageProductLedgerTransactionUserBonus
+ data["bonus_id"] = self.metadata[TransactionMetadataColumns.BONUS]
+ data["user_id"] = self.metadata[TransactionMetadataColumns.USER]
+ elif tx_type == TransactionType.USER_ATTEMPT_CREDIT:
+ model = BrokerageProductLedgerTransactionAttemptCredit
+ data["tsid"] = self.metadata[TransactionMetadataColumns.SESSION]
+ data["user_id"] = self.metadata[TransactionMetadataColumns.USER]
+ elif tx_type == TransactionType.USER_MILESTONE:
+ model = BrokerageProductLedgerTransactionMilestone
+ data["contest_id"] = self.metadata[TransactionMetadataColumns.CONTEST]
+ data["user_id"] = self.metadata[TransactionMetadataColumns.USER]
+ elif tx_type == TransactionType.CLOSE_CONTEST:
+ model = BrokerageProductLedgerTransactionContestClose
+ data["contest_id"] = self.metadata[TransactionMetadataColumns.CONTEST]
+ elif tx_type == TransactionType.PLUG:
+ model = BrokerageProductLedgerTransactionAdjustment
+ else:
+ raise ValueError(
+ f"{tx_type.value} is not a Brokerage Product wallet transaction"
+ )
+ return model.model_validate(data)
+
class UserLedgerTransaction(BaseModel):
"""
@@ -635,6 +700,136 @@ UserLedgerTransactionType = Annotated[
]
+class BrokerageProductLedgerTransaction(BaseModel):
+ """A ledger transaction as it affects a Brokerage Product wallet."""
+
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
+
+ created: AwareDatetimeISO = Field(description="When the transaction was created")
+ description: str = Field(max_length=255)
+ amount: int = Field(
+ strict=True,
+ description=(
+ "The net change to the Brokerage Product wallet in USDCents. "
+ "Positive means its balance increased; negative means it decreased."
+ ),
+ examples=[500, -250],
+ )
+
+
+class BrokerageProductLedgerTransactionTaskComplete(BrokerageProductLedgerTransaction):
+ tx_type: Literal[TransactionType.BP_PAYMENT] = TransactionType.BP_PAYMENT
+ description: str = Field(default="Task Complete", max_length=255)
+ tsid: UUIDStr = Field(description="The session that generated the payment")
+
+ @model_validator(mode="after")
+ def validate_amount(self):
+ assert self.amount > 0, "a task completion must increase the product wallet"
+ return self
+
+
+class BrokerageProductLedgerTransactionTaskAdjustment(
+ BrokerageProductLedgerTransaction
+):
+ tx_type: Literal[TransactionType.BP_ADJUSTMENT] = TransactionType.BP_ADJUSTMENT
+ description: str = Field(default="Task Adjustment", max_length=255)
+ tsid: UUIDStr = Field(description="The adjusted session")
+
+
+class BrokerageProductLedgerTransactionPayout(BrokerageProductLedgerTransaction):
+ tx_type: Literal[TransactionType.BP_PAYOUT] = TransactionType.BP_PAYOUT
+ description: str = Field(default="Brokerage Product Payout", max_length=255)
+ payout_id: UUIDStr = Field(description="The Brokerage Product payout event")
+
+ @model_validator(mode="after")
+ def validate_amount(self):
+ assert self.amount < 0, "a payout must decrease the product wallet"
+ return self
+
+
+class BrokerageProductLedgerTransactionUserPayoutFee(BrokerageProductLedgerTransaction):
+ tx_type: Literal[TransactionType.USER_PAYOUT_COMPLETE] = (
+ TransactionType.USER_PAYOUT_COMPLETE
+ )
+ description: str = Field(default="User Payout Fee", max_length=255)
+ payout_id: UUIDStr = Field(
+ description="The user payout event that incurred the fee"
+ )
+ user_id: UUIDStr = Field(description="The user who received the payout")
+
+ @model_validator(mode="after")
+ def validate_amount(self):
+ assert self.amount < 0, "a user payout fee must decrease the product wallet"
+ return self
+
+
+class BrokerageProductLedgerTransactionUserBonus(BrokerageProductLedgerTransaction):
+ tx_type: Literal[TransactionType.USER_BONUS] = TransactionType.USER_BONUS
+ description: str = Field(default="User Bonus", max_length=255)
+ bonus_id: UUIDStr = Field(description="The bonus reference")
+ user_id: UUIDStr = Field(description="The user who received the bonus")
+
+ @model_validator(mode="after")
+ def validate_amount(self):
+ assert self.amount < 0, "a user bonus must decrease the product wallet"
+ return self
+
+
+class BrokerageProductLedgerTransactionAttemptCredit(BrokerageProductLedgerTransaction):
+ tx_type: Literal[TransactionType.USER_ATTEMPT_CREDIT] = (
+ TransactionType.USER_ATTEMPT_CREDIT
+ )
+ description: str = Field(default="Attempt Credit", max_length=255)
+ tsid: UUIDStr = Field(description="The session awarded an attempt credit")
+ user_id: UUIDStr = Field(description="The user who received the credit")
+
+ @model_validator(mode="after")
+ def validate_amount(self):
+ assert self.amount < 0, "an attempt credit must decrease the product wallet"
+ return self
+
+
+class BrokerageProductLedgerTransactionMilestone(BrokerageProductLedgerTransaction):
+ tx_type: Literal[TransactionType.USER_MILESTONE] = TransactionType.USER_MILESTONE
+ description: str = Field(default="Milestone Award", max_length=255)
+ contest_id: UUIDStr = Field(description="The milestone contest")
+ user_id: UUIDStr = Field(description="The milestone winner")
+
+ @model_validator(mode="after")
+ def validate_amount(self):
+ assert self.amount < 0, "a milestone award must decrease the product wallet"
+ return self
+
+
+class BrokerageProductLedgerTransactionContestClose(BrokerageProductLedgerTransaction):
+ """Unused contest funds returned to, or excess prizes charged to, the product."""
+
+ tx_type: Literal[TransactionType.CLOSE_CONTEST] = TransactionType.CLOSE_CONTEST
+ description: str = Field(default="Contest Close", max_length=255)
+ contest_id: UUIDStr = Field(description="The contest being settled")
+
+
+class BrokerageProductLedgerTransactionAdjustment(BrokerageProductLedgerTransaction):
+ """An accounting reconciliation applied directly to the product wallet."""
+
+ tx_type: Literal[TransactionType.PLUG] = TransactionType.PLUG
+ description: str = Field(default="Balance Adjustment", max_length=255)
+
+
+BrokerageProductLedgerTransactionType = Annotated[
+ BrokerageProductLedgerTransactionTaskComplete
+ | BrokerageProductLedgerTransactionTaskAdjustment
+ | BrokerageProductLedgerTransactionPayout
+ | BrokerageProductLedgerTransactionUserPayoutFee
+ | BrokerageProductLedgerTransactionUserBonus
+ | BrokerageProductLedgerTransactionAttemptCredit
+ | BrokerageProductLedgerTransactionMilestone
+ | BrokerageProductLedgerTransactionContestClose
+ | BrokerageProductLedgerTransactionAdjustment,
+ Field(discriminator="tx_type"),
+]
+
+
class UserLedgerTransactionTypeSummary(BaseModel):
entry_count: NonNegativeInt = Field(default=0)
min_amount: int | None = Field(
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/pyproject.toml b/pyproject.toml
index 8da61a7..49bf634 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "generalresearch"
-version = "3.9.0"
+version = "3.9.1"
description = "Python Utilities for General Research"
readme = "README.md"
requires-python = ">=3.14"
diff --git a/test_utils/managers/conftest.py b/test_utils/managers/conftest.py
index a9f92a2..d78332e 100644
--- a/test_utils/managers/conftest.py
+++ b/test_utils/managers/conftest.py
@@ -31,6 +31,7 @@ from generalresearch.models.definitions import Source
from generalresearch.models.thl.wallet.cashout_method import (
CashoutMethod,
TangoCashoutMethodData,
+ VICashoutMethodData,
)
from generalresearch.models.thl.wallet.definitions import Currency, PayoutType
@@ -177,12 +178,13 @@ def setup_cashoutmethod_db(
cashout_method_manager: CashoutMethodManager,
delete_cashoutmethod_db: Callable[..., None],
example_tango_cashout_methods: list[CashoutMethod],
+ example_vi_cashout_methods: list[CashoutMethod],
) -> Callable[..., None]:
def _inner():
delete_cashoutmethod_db()
- for x in example_tango_cashout_methods:
+ for x in example_tango_cashout_methods + example_vi_cashout_methods:
cashout_method_manager.create(x)
return _inner
@@ -199,8 +201,7 @@ def random_ext_id_factory(base: str = "U02") -> Callable[..., str]:
@pytest.fixture(scope="session")
-def example_tango_cashout_methods(
-) -> list[CashoutMethod]:
+def example_tango_cashout_methods() -> list[CashoutMethod]:
return [
CashoutMethod(
id=uuid4().hex,
@@ -237,6 +238,31 @@ def example_tango_cashout_methods(
]
+@pytest.fixture(scope="session")
+def example_vi_cashout_methods() -> list[CashoutMethod]:
+ return [
+ CashoutMethod(
+ id=uuid4().hex,
+ type=PayoutType.VIRTUAL_INCENTIVES,
+ ext_id="AMAZON-US",
+ name="Amazon.com Gift Card",
+ description="Amazon.com Gift Card",
+ data=VICashoutMethodData(
+ sku="AMAZON-US",
+ value_type="variable",
+ marketing="Amazon marketing copy",
+ terms="Amazon terms",
+ ),
+ user=None,
+ image_url="https://example.com/amazon.png",
+ currency=Currency.USD,
+ original_currency=Currency.USD,
+ min_value=500,
+ max_value=25_000,
+ )
+ ]
+
+
# === THL: Marketplaces ===
diff --git a/test_utils/models/thl/conftest.py b/test_utils/models/thl/conftest.py
index a8ad037..4c93119 100644
--- a/test_utils/models/thl/conftest.py
+++ b/test_utils/models/thl/conftest.py
@@ -607,16 +607,14 @@ def user_failed_attempt_credit(
@pytest.fixture
def user_payout_event_factory(
- user_payout_event_manager: UserPayoutEventManager,
+ user_payout_event_manager: UserPayoutEventManager, thl_ledger_manager: ThlLedgerManager
) -> Callable[..., UserPayoutEvent]:
def _inner(
+ user: User,
uuid: UUIDStr | None = None,
debit_account_uuid: UUIDStr | None = None,
- account_reference_type: str | None = None,
- account_reference_uuid: UUIDStr | None = None,
cashout_method_uuid: UUIDStr | None = None,
- description: str | None = None,
created: AwareDatetimeISO | None = None,
amount: PositiveInt | None = None,
status: PayoutStatus | None = None,
@@ -626,7 +624,9 @@ def user_payout_event_factory(
order_data: dict[str, Any] | CashMailOrderData | None = None,
) -> UserPayoutEvent:
- debit_account_uuid = debit_account_uuid or uuid4().hex
+ wallet = thl_ledger_manager.get_account_or_create_user_wallet(user)
+
+ debit_account_uuid = wallet.uuid
cashout_method_uuid = cashout_method_uuid or uuid4().hex
amount = amount or randint(a=99, b=9_999)
status = status or rand_choice(list(PayoutStatus))
diff --git a/tests/managers/thl/test_ledger/test_thl_pem.py b/tests/managers/thl/test_ledger/test_thl_pem.py
index ddf9bc9..3d6fa87 100644
--- a/tests/managers/thl/test_ledger/test_thl_pem.py
+++ b/tests/managers/thl/test_ledger/test_thl_pem.py
@@ -11,6 +11,7 @@ from generalresearch.currency import USDCent
from generalresearch.models.thl.payout import (
BrokerageProductPayoutEvent,
)
+from generalresearch.models.thl.user import User
if TYPE_CHECKING:
from generalresearch.managers.thl.ledger_manager.ledger import LedgerManager
@@ -135,11 +136,12 @@ class TestThlPayoutEventManager:
self,
user_payout_event_factory: Callable[..., UserPayoutEvent],
user_payout_event_manager: UserPayoutEventManager,
+ user: User
):
from generalresearch.models.thl.payout import UserPayoutEvent
# Confirm the creation method returns back an instance.
- pe = user_payout_event_factory()
+ pe = user_payout_event_factory(user=user)
assert isinstance(pe, UserPayoutEvent)
# Now query the DB for that PayoutEvent to confirm it was actually
diff --git a/tests/managers/thl/test_ledger/test_wallet.py b/tests/managers/thl/test_ledger/test_wallet.py
index f154c19..a4ec052 100644
--- a/tests/managers/thl/test_ledger/test_wallet.py
+++ b/tests/managers/thl/test_ledger/test_wallet.py
@@ -38,6 +38,10 @@ def schrute_product(
class TestGetUserWalletBalance:
+ @pytest.fixture(autouse=True)
+ def setup(self, create_main_accounts):
+ create_main_accounts()
+
def test_get_user_wallet_balance_non_managed(
self, user: User, thl_ledger_manager: ThlLedgerManager
):
@@ -98,4 +102,7 @@ class TestGetUserWalletBalance:
user=user, user_wallet_balance=balance
)
assert redeemable_balance == 20 + 100
- assert thl_ledger_manager.get_user_cashout_balance(user=user) == 20 + 100
+
+ # The cashout balance depends on the product.user_wallet_config.balance_type
+ assert user.product.user_wallet_config.balance_type == "wallet_balance"
+ assert thl_ledger_manager.get_user_cashout_balance(user=user) == 147
diff --git a/tests/managers/thl/test_payout.py b/tests/managers/thl/test_payout.py
index d5cc9e3..f35cfdf 100644
--- a/tests/managers/thl/test_payout.py
+++ b/tests/managers/thl/test_payout.py
@@ -320,25 +320,25 @@ class TestBusinessPayoutEventManager:
product=p1, amount=USDCent(50), ext_ref_id=ach_id2
)
- gr_business.prebuild_payouts(
- bpem=business_payout_event_manager,
- )
-
- assert isinstance(gr_business.payouts, list)
- assert len(gr_business.payouts) == 3
- assert gr_business.payouts_total == sum(
- [pe.amount for pe in gr_business.payouts]
- )
- assert gr_business.payouts[0].created > gr_business.payouts[1].created
- assert len(gr_business.payouts[0].bp_payouts) == 1
+ # gr_business.prebuild_payouts(
+ # bpem=business_payout_event_manager,
+ # )
+ #
+ # assert isinstance(gr_business.payouts, list)
+ # assert len(gr_business.payouts) == 3
+ # assert gr_business.payouts_total == sum(
+ # [pe.amount for pe in gr_business.payouts]
+ # )
+ # assert gr_business.payouts[0].created > gr_business.payouts[1].created
+ # assert len(gr_business.payouts[0].bp_payouts) == 1
# Cannot pay out the same product twice in the same business payout
# assert len(business.payouts[1].bp_payouts) == 2
- assert len(gr_business.payouts[1].bp_payouts) == 1
-
- assert gr_business.payouts[0].ext_ref_id == ach_id2
- assert gr_business.payouts[1].ext_ref_id == ach_id1
- assert gr_business.payouts[2].ext_ref_id == "none"
+ # assert len(gr_business.payouts[1].bp_payouts) == 1
+ #
+ # assert gr_business.payouts[0].ext_ref_id == ach_id2
+ # assert gr_business.payouts[1].ext_ref_id == ach_id1
+ # assert gr_business.payouts[2].ext_ref_id == "none"
def test_update_ext_reference_ids(
self,
@@ -841,35 +841,36 @@ class TestBusinessPayoutEventManager:
bb1 = gr_business.balance
assert isinstance(bb1, BusinessBalances)
- pb1 = bb1.product_balances[0]
- pb2 = bb1.product_balances[1]
- pb3 = bb1.product_balances[2]
assert bb1.payout == 25 * 712 + 475 # $7.50 * .95% = $7.125 = $7.12
assert bb1.adjustment == -475
assert bb1.net == ((25 * 7.12 + 4.75) - 4.75) * 100
# The balance is lower because the single $4.75 payout
assert bb1.balance == bb1.net - 475
- assert (
- bb1.available_balance
- == (pb2.available_balance + pb3.available_balance) - 475
- )
-
- assert pb1.available_balance == 0
- assert pb2.available_balance == 8010
- assert pb3.available_balance == 5340
-
- assert bb1.recoup_usd_str == "$4.75"
- assert pb1.recoup_usd_str == "$4.75"
- assert pb2.recoup_usd_str == "$0.00"
- assert pb3.recoup_usd_str == "$0.00"
- assert gr_business.payouts is None
- gr_business.prebuild_payouts(
- bpem=business_payout_event_manager,
+ # We dont store the individual product_balances here
+ # pb1 = bb1.product_balances[0]
+ # pb2 = bb1.product_balances[1]
+ # pb3 = bb1.product_balances[2]
+ # assert (
+ # bb1.available_balance
+ # == (pb2.available_balance + pb3.available_balance) - 475
+ # )
+ #
+ # assert pb1.available_balance == 0
+ # assert pb2.available_balance == 8010
+ # assert pb3.available_balance == 5340
+ # assert bb1.recoup_usd_str == "$4.75"
+ # assert pb1.recoup_usd_str == "$4.75"
+ # assert pb2.recoup_usd_str == "$0.00"
+ # assert pb3.recoup_usd_str == "$0.00"
+
+ # Business no longer stores prebuilt payouts. Query the managers directly.
+ business_payouts, cnt = business_payout_event_manager.filter_by(
+ business_uuid=gr_business.uuid
)
- assert isinstance(gr_business.payouts, list)
- assert len(gr_business.payouts) == 1
- assert gr_business.payouts[0].ext_ref_id == ach_id1
+ assert cnt == 1
+ assert len(business_payouts) == 1
+ assert business_payouts[0].ext_ref_id == ach_id1
ach_id2 = uuid4().hex
@@ -910,13 +911,25 @@ class TestBusinessPayoutEventManager:
client=client_no_amm,
pop_ledger=pop_ledger_merge,
)
- gr_business.prebuild_payouts(
- bpem=business_payout_event_manager,
+
+ business_payouts, business_payout_count = (
+ business_payout_event_manager.filter_by(
+ business_uuid=gr_business.uuid,
+ )
)
- assert isinstance(gr_business.payouts, list)
- assert len(gr_business.payouts) == 2
- assert len(gr_business.payouts[0].bp_payouts) == 2
- assert len(gr_business.payouts[1].bp_payouts) == 1
+ assert business_payout_count == 2
+ assert len(business_payouts) == 2
+ assert sorted(payout.line_item_count for payout in business_payouts) == [1, 2]
+
+ product_payouts = (
+ brokerage_product_payout_event_manager.get_bp_payout_events_for_products(
+ thl_ledger_manager=thl_ledger_manager,
+ product_uuids=gr_business.product_uuids,
+ )
+ )
+ assert len(product_payouts) == 3
+ assert sum(payout.ext_ref_id == ach_id1 for payout in product_payouts) == 1
+ assert sum(payout.ext_ref_id == ach_id2 for payout in product_payouts) == 2
bb2 = gr_business.balance
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()
diff --git a/tests/managers/thl/test_wallet/test_user_payout.py b/tests/managers/thl/test_wallet/test_user_payout.py
index 2a89c97..b83e349 100644
--- a/tests/managers/thl/test_wallet/test_user_payout.py
+++ b/tests/managers/thl/test_wallet/test_user_payout.py
@@ -12,6 +12,11 @@ from generalresearch.managers.thl.cashout_method import CashoutMethodManager
from generalresearch.managers.thl.ipinfo import GeoIpInfoManager
from generalresearch.managers.thl.ledger_manager.thl_ledger import ThlLedgerManager
from generalresearch.managers.thl.userhealth import UserIpHistoryManager
+from generalresearch.managers.thl.virtual_incentives_api import (
+ VirtualIncentivesClient,
+ VirtualIncentivesManager,
+)
+from generalresearch.managers.thl.wallet import manage_pending_cashout
from generalresearch.managers.thl.wallet.tango import TangoManager
from generalresearch.managers.thl.wallet.user_payout import (
UserPayoutEventManager,
@@ -19,6 +24,7 @@ from generalresearch.managers.thl.wallet.user_payout import (
)
from generalresearch.models.thl.definitions import PayoutStatus
from generalresearch.models.thl.payout import UserPayoutEvent
+from generalresearch.models.thl.product import UserWalletConfig
from generalresearch.models.thl.user import User
from generalresearch.models.thl.wallet.cashout_method import (
CashMailCashoutMethodData,
@@ -318,7 +324,7 @@ class TestUserRequestRedeem:
user_ip_history_manager=user_iphistory_manager,
redis_client=thl_redis_client,
)
- assert payout.request_data["amount"] == '1.74'
+ assert payout.request_data["amount"] == "1.74"
assert payout.request_data["externalRefID"] == payout.uuid
user_payout_event_manager.update(
@@ -365,6 +371,216 @@ class TestUserRequestRedeem:
)
assert thl_ledger_manager.get_account_balance(bp_expense) == -7
+ def test_user_request_redeem_virtual_incentives(
+ self,
+ product_factory,
+ payout_config,
+ user_factory,
+ user_payout_event_manager: UserPayoutEventManager,
+ cashout_method_manager: CashoutMethodManager,
+ thl_ledger_manager: ThlLedgerManager,
+ user_iphistory_manager: UserIpHistoryManager,
+ ip_record_factory,
+ thl_redis_client,
+ setup_cashoutmethod_db,
+ example_vi_cashout_methods,
+ ):
+ setup_cashoutmethod_db()
+ cm = example_vi_cashout_methods[0]
+ product = product_factory(
+ payout_config=payout_config,
+ user_wallet_config=UserWalletConfig(
+ enabled=True, supported_payout_types={PayoutType.VIRTUAL_INCENTIVES}
+ ),
+ )
+ user = user_factory(product=product)
+
+ ip_record_factory(user=user, ip="8.8.8.8")
+ bonus_ref = uuid4().hex
+ thl_ledger_manager.create_tx_user_bonus(
+ user=user,
+ amount=Decimal("10.00"),
+ ref_uuid=bonus_ref,
+ description="Fund Virtual Incentives redemption test",
+ )
+ tango_manager = Mock(spec=TangoManager)
+ tango_manager.get_exchange_rates.return_value = {}
+ virtual_incentives_manager = VirtualIncentivesManager.__new__(
+ VirtualIncentivesManager
+ )
+
+ payout = user_payout_event_manager.user_request_redeem(
+ user=user,
+ cashout_method_id=cm.id,
+ amount=USDCent(600),
+ tango_manager=tango_manager,
+ virtual_incentives_manager=virtual_incentives_manager,
+ cashout_method_manager=cashout_method_manager,
+ ledger_manager=thl_ledger_manager,
+ user_ip_history_manager=user_iphistory_manager,
+ redis_client=thl_redis_client,
+ )
+
+ assert payout.status == PayoutStatus.PENDING
+ assert payout.payout_type == PayoutType.VIRTUAL_INCENTIVES
+ assert payout.amount == 600
+ assert payout.request_data == {
+ "sku": "AMAZON-US",
+ "amount": "6.00",
+ "cashout_request_id": payout.uuid,
+ }
+ assert payout.cashout_method_uuid == cm.id
+ assert thl_ledger_manager.get_user_wallet_balance(user) == 400
+ assert user_payout_event_manager.get_by_uuid(payout.uuid) == payout
+
+ bonus_tag = f"test:user_bonus:{bonus_ref}"
+ request_tag = f"test:user_payout:{payout.uuid}:request"
+ assert len(thl_ledger_manager.get_tx_ids_by_tag(bonus_tag)) == 1
+ assert len(thl_ledger_manager.get_tx_ids_by_tag(request_tag)) == 1
+
+ user_wallet = thl_ledger_manager.get_account_or_create_user_wallet(user)
+ bp_wallet = thl_ledger_manager.get_account_or_create_bp_wallet(product)
+ pending_payout = thl_ledger_manager.get_or_create_bp_pending_payout_account(
+ product
+ )
+ assert thl_ledger_manager.get_account_balance(user_wallet) == 400
+ assert thl_ledger_manager.get_account_balance(bp_wallet) == -1_000
+ assert thl_ledger_manager.get_account_balance(pending_payout) == 600
+ assert (
+ thl_ledger_manager.get_account_filtered_balance(
+ account=user_wallet,
+ metadata_key="payoutevent",
+ metadata_value=payout.uuid,
+ )
+ == -600
+ )
+ assert (
+ thl_ledger_manager.get_account_filtered_balance(
+ account=pending_payout,
+ metadata_key="payoutevent",
+ metadata_value=payout.uuid,
+ )
+ == 600
+ )
+
+ detail = user_payout_event_manager.get_payout_detail(payout.uuid)
+ assert detail.status == PayoutStatus.PENDING
+ assert detail.description == "Amazon.com Gift Card"
+ assert not detail.transaction_info
+
+
+class TestManagePendingCashout:
+ def test_approve_virtual_incentives(
+ self,
+ product_factory,
+ payout_config,
+ user_factory,
+ user_manager,
+ user_payout_event_manager: UserPayoutEventManager,
+ cashout_method_manager: CashoutMethodManager,
+ thl_ledger_manager: ThlLedgerManager,
+ user_iphistory_manager: UserIpHistoryManager,
+ ip_record_factory,
+ thl_redis_client,
+ setup_cashoutmethod_db,
+ example_vi_cashout_methods,
+ create_main_accounts,
+ ):
+ create_main_accounts()
+ setup_cashoutmethod_db()
+ cashout_method = example_vi_cashout_methods[0]
+ product = product_factory(
+ payout_config=payout_config,
+ user_wallet_config=UserWalletConfig(
+ enabled=True,
+ supported_payout_types={PayoutType.VIRTUAL_INCENTIVES},
+ ),
+ )
+ user = user_factory(product=product)
+ ip_record_factory(user=user, ip="8.8.8.8")
+ thl_ledger_manager.create_tx_user_bonus(
+ user=user,
+ amount=Decimal("10.00"),
+ ref_uuid=uuid4().hex,
+ description="Fund Virtual Incentives approval test",
+ )
+ tango_manager = Mock(spec=TangoManager)
+ tango_manager.get_exchange_rates.return_value = {}
+ virtual_incentives_manager = VirtualIncentivesManager.__new__(
+ VirtualIncentivesManager
+ )
+ virtual_incentives_manager.client = Mock(spec=VirtualIncentivesClient)
+
+ payout = user_payout_event_manager.user_request_redeem(
+ user=user,
+ cashout_method_id=cashout_method.id,
+ amount=USDCent(600),
+ tango_manager=tango_manager,
+ virtual_incentives_manager=virtual_incentives_manager,
+ cashout_method_manager=cashout_method_manager,
+ ledger_manager=thl_ledger_manager,
+ user_ip_history_manager=user_iphistory_manager,
+ redis_client=thl_redis_client,
+ )
+
+ pending_payout = thl_ledger_manager.get_or_create_bp_pending_payout_account(
+ product
+ )
+ user_wallet = thl_ledger_manager.get_account_or_create_user_wallet(user)
+
+ assert thl_ledger_manager.get_account_balance(user_wallet) == 400
+ assert thl_ledger_manager.get_account_balance(pending_payout) == 600
+
+ order = {
+ "status": PayoutStatus.COMPLETE,
+ "number": "vi-order-1",
+ "accounts": [{"sku": "AMAZON-US", "amount": "6.00"}],
+ }
+ virtual_incentives_manager.client.create_order.return_value = order
+
+ result = manage_pending_cashout(
+ payout_id=payout.uuid,
+ new_status=PayoutStatus.APPROVED,
+ user_payout_event_manager=user_payout_event_manager,
+ user_ip_history_manager=user_iphistory_manager,
+ user_manager=user_manager,
+ ledger_manager=thl_ledger_manager,
+ virtual_incentives_manager=virtual_incentives_manager,
+ )
+
+ assert result.uuid == payout.uuid
+ assert result.status == PayoutStatus.COMPLETE
+ assert result.ext_ref_id == "vi-order-1"
+ assert result.order_data == order
+ virtual_incentives_manager.client.create_order.assert_called_once_with(
+ cashout_request_id=payout.uuid,
+ sku="AMAZON-US",
+ amount=Decimal("6.00"),
+ )
+
+ stored_payout = user_payout_event_manager.get_by_uuid(payout.uuid)
+ assert stored_payout.status == PayoutStatus.COMPLETE
+ assert stored_payout.ext_ref_id == "vi-order-1"
+ assert stored_payout.order_data == order
+
+ request_tag = f"test:user_payout:{payout.uuid}:request"
+ complete_tag = f"test:user_payout:{payout.uuid}:complete"
+ assert len(thl_ledger_manager.get_tx_ids_by_tag(request_tag)) == 1
+ assert len(thl_ledger_manager.get_tx_ids_by_tag(complete_tag)) == 1
+
+ bp_wallet = thl_ledger_manager.get_account_or_create_bp_wallet(product)
+
+ vi_expense = thl_ledger_manager.get_account_or_create_bp_expense_by_uuid(
+ product_uuid=product.uuid,
+ expense_name="virtual_incentives",
+ )
+ assert thl_ledger_manager.get_account_balance(user_wallet) == 400
+ assert thl_ledger_manager.get_account_balance(pending_payout) == 0
+ # $10 bribe, 17c expense (2.9% x $6)
+ assert thl_ledger_manager.get_account_balance(bp_wallet) == -10_17
+ assert thl_ledger_manager.get_account_balance(vi_expense) == -17
+ assert thl_ledger_manager.check_ledger_balanced()
+
def test_user_request_redeem_paypal_insufficient_balance(
self,
user_with_wallet: User,
diff --git a/tests/models/gr/test_team.py b/tests/models/gr/test_team.py
index 8cc00c8..c6ca451 100644
--- a/tests/models/gr/test_team.py
+++ b/tests/models/gr/test_team.py
@@ -60,23 +60,29 @@ class TestTeam:
def test_memberships(
self,
gr_team: Team,
- gr_user: GRUser,
gr_membership,
gr_user_factory: Callable[..., GRUser],
gr_membership_manager: MembershipManager,
):
+ gr_user = gr_user_factory(email="member@example.com")
+ gr_membership_manager.create(team=gr_team, gr_user=gr_user)
+
assert gr_team.memberships is None
gr_team.prefetch_memberships(gr_membership_manager=gr_membership_manager)
assert isinstance(gr_team.memberships, list)
- assert len(gr_team.memberships) == 1
- assert gr_team.memberships[0].user_id == gr_user.id
+ assert len(gr_team.memberships) == 2
+ membership = next(
+ item for item in gr_team.memberships if item.user_id == gr_user.id
+ )
+ assert membership.user.id == gr_user.id
+ assert membership.user.email == gr_user.email
# Create another new Membership
gr_membership_manager.create(team=gr_team, gr_user=gr_user_factory())
- assert len(gr_team.memberships) == 1
- gr_team.prefetch_memberships(gr_membership_manager=gr_membership_manager)
assert len(gr_team.memberships) == 2
+ gr_team.prefetch_memberships(gr_membership_manager=gr_membership_manager)
+ assert len(gr_team.memberships) == 3
def test_gr_users(
self,