diff options
| -rw-r--r-- | generalresearch/managers/thl/ledger_manager/ledger.py | 2 | ||||
| -rw-r--r-- | generalresearch/managers/thl/ledger_manager/thl_ledger.py | 41 | ||||
| -rw-r--r-- | generalresearch/managers/thl/virtual_incentives_api.py | 9 | ||||
| -rw-r--r-- | generalresearch/models/thl/ledger.py | 195 |
4 files changed, 245 insertions, 2 deletions
diff --git a/generalresearch/managers/thl/ledger_manager/ledger.py b/generalresearch/managers/thl/ledger_manager/ledger.py index d027bd5..1372d68 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 diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index 85e60d1..3920cda 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -2326,3 +2326,44 @@ class ThlLedgerManager(LedgerManager): exclude_txs_before=exclude_txs_before, ) return utx + + def get_bp_txs( + self, + product_id: str, + time_start: datetime | None = None, + time_end: datetime | None = None, + page: int = 1, + size: int = 50, + order_by: str | None = "created,tag", + ): + 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 + ) + txs, total = self.get_tx_filtered_by_account_paginated( + bp_account.uuid, + time_start=time_start, + time_end=time_end, + page=page, + size=size, + order_by=order_by, + ) + txs = [tx.to_brokerage_product_tx(bp_account) for tx in txs] + return txs, total
\ No newline at end of file diff --git a/generalresearch/managers/thl/virtual_incentives_api.py b/generalresearch/managers/thl/virtual_incentives_api.py index 56127a1..0516233 100644 --- a/generalresearch/managers/thl/virtual_incentives_api.py +++ b/generalresearch/managers/thl/virtual_incentives_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from collections.abc import Collection from decimal import Decimal from typing import TYPE_CHECKING, Any @@ -53,6 +54,11 @@ class VirtualIncentivesClient: } ) + @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, @@ -263,7 +269,8 @@ class VirtualIncentivesManager(PostgresManagerWithRedis): ) ledger_manager.create_tx_user_payout_complete( - user, payout_event=payout_event, + user, + payout_event=payout_event, ) return payout_event 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( |
