diff options
| author | stuppie | 2026-09-07 13:08:27 -0600 |
|---|---|---|
| committer | stuppie | 2026-09-07 13:08:27 -0600 |
| commit | 17234dd26679b1b8a2c89e26c81fb27b4f553455 (patch) | |
| tree | c2d78bb2c19be43836fffc5cbcb690f8e2cb7d83 | |
| parent | 8c4913295b7b7bc1754cbb1a364a17bb24e796b7 (diff) | |
| download | generalresearch-17234dd26679b1b8a2c89e26c81fb27b4f553455.tar.gz generalresearch-17234dd26679b1b8a2c89e26c81fb27b4f553455.zip | |
add user attempt credit tests. Fix a bug in make tx with description not being set under a certain condition. Add auto-creation of user wallets needed for get_wallets
3 files changed, 368 insertions, 12 deletions
diff --git a/generalresearch/managers/thl/ledger_manager/conditions.py b/generalresearch/managers/thl/ledger_manager/conditions.py index 38398b1..21a99b5 100644 --- a/generalresearch/managers/thl/ledger_manager/conditions.py +++ b/generalresearch/managers/thl/ledger_manager/conditions.py @@ -10,7 +10,6 @@ from generalresearch.currency import USDCent from generalresearch.models.custom_types import UUIDStr if TYPE_CHECKING: - from generalresearch.managers.thl.ledger_manager.ledger import ( LedgerManager, ) @@ -26,44 +25,44 @@ logger = logging.getLogger("LedgerManager") logger.setLevel(logging.INFO) -def generate_condition_mp_payment(wall: Wall) -> Callable[..., bool]: +def generate_condition_mp_payment(wall: Wall) -> Callable[..., tuple[bool, str]]: """This returns a function that checks if the payment for this wall event exists already. This function gets run after we acquire a lock. It should return True if we want to continue (create a tx). """ wall_uuid = wall.uuid - def _condition(lm: LedgerManager) -> bool: + def _condition(lm: LedgerManager) -> tuple[bool, str]: tag = f"{lm.currency.value}:mp_payment:{wall_uuid}" txs = lm.get_tx_ids_by_tag(tag=tag) - return len(txs) == 0 + return len(txs) == 0, "duplicate tag" return _condition -def generate_condition_bp_payment(session: Session) -> Callable[..., bool]: +def generate_condition_bp_payment(session: Session) -> Callable[..., tuple[bool, str]]: """This returns a function that checks if the payment for this Session exists already. This function gets run after we acquire a lock. It should return True if we want to continue (create a tx). """ session_uuid = session.uuid - def _condition(lm: LedgerManager) -> bool: + def _condition(lm: LedgerManager) -> tuple[bool, str]: tag = f"{lm.currency.value}:bp_payment:{session_uuid}" txs_ids = lm.get_tx_ids_by_tag(tag=tag) - return len(txs_ids) == 0 + return len(txs_ids) == 0, "duplicate tag" return _condition -def generate_condition_tag_exists(tag: str) -> Callable[..., bool]: +def generate_condition_tag_exists(tag: str) -> Callable[..., tuple[bool, str]]: """This returns a function that checks if a tx with this tag already exists. It should return True if we want to continue (create a tx). """ - def _condition(lm: LedgerManager) -> bool: + def _condition(lm: LedgerManager) -> tuple[bool, str]: txs_ids = lm.get_tx_ids_by_tag(tag=tag) - return len(txs_ids) == 0 + return len(txs_ids) == 0, "duplicate tag" return _condition diff --git a/generalresearch/managers/thl/ledger_manager/thl_ledger.py b/generalresearch/managers/thl/ledger_manager/thl_ledger.py index b6a6f5b..eccc99f 100644 --- a/generalresearch/managers/thl/ledger_manager/thl_ledger.py +++ b/generalresearch/managers/thl/ledger_manager/thl_ledger.py @@ -460,6 +460,7 @@ class ThlLedgerManager(LedgerManager): if user.product.user_wallet_enabled: bp_pay -= user_pay user_account = self.get_account_or_create_user_wallet(user) + ext_description = f"BP & User Payment {session.uuid}" if bp_pay: entries.append( @@ -502,8 +503,6 @@ class ThlLedgerManager(LedgerManager): ), ] ) - ext_description = f"BP & User Payment {session.uuid}" - else: entries.append( LedgerEntry( @@ -2084,8 +2083,14 @@ class ThlLedgerManager(LedgerManager): """Return every ledger wallet owned by a user, across currencies.""" assert user.user_id, "User must be saved" user.prefetch_product(self.pg_config) + assert user.product.user_wallet_enabled, "only user_wallet managed" payout_format = user.product.payout_config.payout_format assert payout_format is not None, "Product must have a payout format" + + self.get_account_or_create_user_wallet(user) + if user.product.user_wallet_config.failed_attempt_credit_enabled: + self.get_account_or_create_user_attempt_credit(user) + user_account_types = { AccountType.USER_WALLET.value, AccountType.USER_ATTEMPT_CREDIT.value, diff --git a/tests/managers/thl/test_ledger/test_thl_lm_tx.py b/tests/managers/thl/test_ledger/test_thl_lm_tx.py index aa3b378..96ea0d6 100644 --- a/tests/managers/thl/test_ledger/test_thl_lm_tx.py +++ b/tests/managers/thl/test_ledger/test_thl_lm_tx.py @@ -22,7 +22,10 @@ from generalresearch.models.thl.definitions import ( WALL_ALLOWED_STATUS_STATUS_CODE, ) from generalresearch.models.thl.ledger import ( + AccountType, Direction, + LedgerAccount, + LedgerEntry, TransactionType, ) from generalresearch.models.thl.payout import UserPayoutEvent @@ -765,6 +768,355 @@ class TestThlLedgerTxManager: # Assert the balance came out of their user wallet assert ledger_manager.get_account_balance(account=user_account) == rand_amount + def test_create_tx_attempt_credit( + self, + bare_session_factory: Callable[..., Session], + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + session = bare_session_factory(user=user, started=utc_hour_ago) + session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + + tx = thl_ledger_manager.create_tx_attempt_credit(session) + + assert tx.tag == ( + f"{thl_ledger_manager.currency.value}:" + f"{TransactionType.USER_ATTEMPT_CREDIT.value}:{session.uuid}" + ) + assert tx.ext_description == f"Attempt Credit {session.uuid}" + assert [entry.amount for entry in tx.entries] == [5, 5] + assert [entry.direction for entry in tx.entries] == [ + Direction.DEBIT, + Direction.CREDIT, + ] + assert thl_ledger_manager.get_session_attempt_credit(session.uuid) == 5 + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 5 + + with pytest.raises( + LedgerTransactionConditionFailedError, match=r"^duplicate tag$" + ): + thl_ledger_manager.create_tx_attempt_credit(session, skip_flag_check=True) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 5 + + def test_create_tx_attempt_credit_rejects_ineligible_session( + self, + bare_session_factory: Callable[..., Session], + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + session = bare_session_factory(user=user, started=utc_hour_ago) + session.update( + status=Status.FAIL, + status_code_1=StatusCode1.SESSION_START_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + + with pytest.raises(AssertionError, match="not eligible"): + thl_ledger_manager.create_tx_attempt_credit(session) + + assert thl_ledger_manager.get_session_attempt_credit(session.uuid) is None + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 0 + + def test_bp_payment_settles_attempt_credit( + self, + bare_session_factory: Callable[..., Session], + session_factory: Callable[..., Session], + session_manager: SessionManager, + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + failed_session = bare_session_factory(user=user, started=utc_hour_ago) + failed_session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + thl_ledger_manager.create_tx_attempt_credit(failed_session) + + completed_session = session_factory(user=user) + _, status_code_1 = completed_session.determine_session_status() + _, _, bp_pay, user_pay = completed_session.determine_payments() + assert user_pay is not None and user_pay >= Decimal("0.05") + session_manager.finish_with_status( + session=completed_session, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=completed_session.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + + thl_ledger_manager.create_tx_bp_payment(completed_session) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 0 + assert thl_ledger_manager.get_user_wallet_balance(user) == round(user_pay * 100) + assert thl_ledger_manager.check_ledger_balanced() + + def test_bp_payment_with_zero_attempt_credit_balance( + self, + session_factory: Callable[..., Session], + session_manager: SessionManager, + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + completed_session = session_factory(user=user) + _, status_code_1 = completed_session.determine_session_status() + _, _, bp_pay, user_pay = completed_session.determine_payments() + assert user_pay is not None + session_manager.finish_with_status( + session=completed_session, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=completed_session.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + + thl_ledger_manager.create_tx_bp_payment(completed_session) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == 0 + assert thl_ledger_manager.get_user_wallet_balance(user) == round(user_pay * 100) + assert thl_ledger_manager.check_ledger_balanced() + + def test_bp_payment_partially_settles_attempt_credit( + self, + bare_session_factory: Callable[..., Session], + session_factory: Callable[..., Session], + session_manager: SessionManager, + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + thl_ledger_manager: ThlLedgerManager, + utc_hour_ago: datetime, + ): + attempt_credit = Decimal("1.00") + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=attempt_credit + ), + ) + user = user_factory(product=product) + failed_session = bare_session_factory(user=user, started=utc_hour_ago) + failed_session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + thl_ledger_manager.create_tx_attempt_credit(failed_session) + + completed_session = session_factory(user=user) + _, status_code_1 = completed_session.determine_session_status() + _, _, bp_pay, user_pay = completed_session.determine_payments() + assert user_pay is not None + attempt_credit_cents = round(attempt_credit * 100) + user_pay_cents = round(user_pay * 100) + assert 0 < user_pay_cents < attempt_credit_cents + session_manager.finish_with_status( + session=completed_session, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=completed_session.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + + thl_ledger_manager.create_tx_bp_payment(completed_session) + + assert thl_ledger_manager.get_user_attempt_credit_balance(user) == ( + attempt_credit_cents - user_pay_cents + ) + assert thl_ledger_manager.get_user_wallet_balance(user) == user_pay_cents + assert thl_ledger_manager.check_ledger_balanced() + + def test_get_user_wallets_with_attempt_credit_and_custom_currency( + self, + bare_session_factory: Callable[..., Session], + product_factory: Callable[..., Product], + user_factory: Callable[..., User], + payout_config: PayoutConfig, + ledger_manager: LedgerManager, + thl_ledger_manager: ThlLedgerManager, + currency: LedgerCurrency, + utc_hour_ago: datetime, + session_factory, + session_manager, + ): + product = product_factory( + payout_config=payout_config, + user_wallet_config=UserWalletConfig( + enabled=True, failed_attempt_credit=Decimal("0.05") + ), + ) + user = user_factory(product=product) + + wallets = thl_ledger_manager.get_user_wallets(user) + + assert wallets.wallets[0].amount == 0 + assert wallets.displayed_balances[0].amount == 0 + + failed_session = bare_session_factory(user=user, started=utc_hour_ago) + failed_session.update( + status=Status.FAIL, + status_code_1=StatusCode1.BUYER_FAIL, + finished=utc_hour_ago + timedelta(minutes=5), + ) + thl_ledger_manager.create_tx_attempt_credit(failed_session) + + wallets = thl_ledger_manager.get_user_wallets(user) + assert wallets.wallets[0].amount == 5 + assert wallets.wallets[0].redeemable_amount == 0 + assert wallets.displayed_balances[0].amount == 5 + assert wallets.displayed_balances[0].amount_string == "$0.05" + + gold_stars_currency = uuid4().hex + gold_stars_wallet = ledger_manager.create_account( + LedgerAccount( + display_name="Gold Stars", + qualified_name=( + f"{gold_stars_currency}:{AccountType.USER_WALLET.value}:{user.uuid}" + ), + normal_balance=Direction.CREDIT, + account_type=AccountType.USER_WALLET, + reference_type="user", + reference_uuid=user.uuid, + currency=gold_stars_currency, + ) + ) + gold_stars_source = ledger_manager.create_account( + LedgerAccount( + display_name="Gold Stars Source", + qualified_name=( + f"{gold_stars_currency}:" + f"{AccountType.BP_WALLET.value}:{product.uuid}" + ), + normal_balance=Direction.CREDIT, + account_type=AccountType.BP_WALLET, + reference_type="bp", + reference_uuid=product.uuid, + currency=gold_stars_currency, + ) + ) + ledger_manager.create_tx( + entries=[ + LedgerEntry( + direction=Direction.DEBIT, + account_uuid=gold_stars_source.uuid, + amount=25, + ), + LedgerEntry( + direction=Direction.CREDIT, + account_uuid=gold_stars_wallet.uuid, + amount=25, + ), + ], + tag=f"{gold_stars_currency}:gold_stars:{user.uuid}", + ) + + wallets = thl_ledger_manager.get_user_wallets(user) + wallets_by_type = { + (wallet.account_type, wallet.currency): wallet for wallet in wallets.wallets + } + displayed_by_currency = { + balance.currency: balance for balance in wallets.displayed_balances + } + + assert ( + wallets_by_type[ + (AccountType.USER_ATTEMPT_CREDIT, thl_ledger_manager.currency) + ].amount + == 5 + ) + assert ( + wallets_by_type[ + (AccountType.USER_WALLET, thl_ledger_manager.currency) + ].amount + == 0 + ) + assert displayed_by_currency[currency.value].amount == 5 + assert displayed_by_currency[currency.value].amount_string == "$0.05" + assert displayed_by_currency[gold_stars_currency].amount == 25 + assert displayed_by_currency[gold_stars_currency].amount_string is None + + s = session_factory(user=user, wall_req_cpi=Decimal(1)) + _, status_code_1 = s.determine_session_status() + _, _, bp_pay, user_pay = s.determine_payments() + assert user_pay is not None + session_manager.finish_with_status( + session=s, + status=Status.COMPLETE, + status_code_1=status_code_1, + finished=s.wall_events[-1].finished, + payout=bp_pay, + user_payout=user_pay, + ) + thl_ledger_manager.create_tx_bp_payment(s) + + wallets = thl_ledger_manager.get_user_wallets(user) + wallets_by_type = { + (wallet.account_type, wallet.currency): wallet for wallet in wallets.wallets + } + displayed_by_currency = { + balance.currency: balance for balance in wallets.displayed_balances + } + assert displayed_by_currency[currency.value].amount == 0 + 38 + assert ( + wallets_by_type[ + (AccountType.USER_ATTEMPT_CREDIT, thl_ledger_manager.currency) + ].amount + == 0 + ) + assert ( + wallets_by_type[ + (AccountType.USER_WALLET, thl_ledger_manager.currency) + ].amount + == 38 + ) + class TestThlLedgerTxManagerFlows: """Combine the various THL_LM methods to create actual "real world" |
