diff options
| author | Greg Stupp | 2026-10-23 03:20:59 +0000 |
|---|---|---|
| committer | Greg Stupp | 2026-10-23 03:20:59 +0000 |
| commit | af5c3e97a8afb31f5219d4cdd9d752b7f2b2aa3b (patch) | |
| tree | cd182fdd65de736cb9c93a59bc40f1e1e2360dde | |
| parent | 292860a9569e08f9f58c49802ee1f25cd39f9be1 (diff) | |
| parent | 45d66cd40d409a8a05c8dde7193f1e8469058fff (diff) | |
| download | generalresearch-af5c3e97a8afb31f5219d4cdd9d752b7f2b2aa3b.tar.gz generalresearch-af5c3e97a8afb31f5219d4cdd9d752b7f2b2aa3b.zip | |
virtual incentives and BrokerageProductLedgerTransaction
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, |
